Skip to content

Commit

Permalink
Create pagination query generators and resolvers hook (#23)
Browse files Browse the repository at this point in the history
## Summary

* Pagination: Adds a new Pagination module for paginated query results,
along with the corresponding paginated_type object for each model. The
default page size is now configurable through the PAGE_SIZE environment
variable.
* Custom resolvers: Allows users to define custom resolvers for the
find, filter, create, and update actions. This enables more granular
control over how objects are retrieved, created, and updated.
* Dynamic attribute handling: Properly handles dynamic attributes from
models that include Mongoid::Attributes::Dynamic. Also adds utility
functions for underscore and camelize transformations.
* Bug fixes: Fixes a bug where the _id field was being ignored in input
arguments, and another where the field names were not being properly
camelized.

# Overview 🔍

## How to use the new features in the GraphQL library

This guide will walk you through using the new features introduced in
the GraphQL library, including pagination, custom resolvers, and dynamic
attribute handling.

### Pagination

The new pagination feature allows you to fetch a specific page of
results with a specified number of items per page. To use it, follow
these steps:

1. In your GraphQL query, use the pluralized form of the model name,
which will return a paginated type.
2. Provide the `limit` and `skip` arguments to specify the number of
items per page and the number of items to skip, respectively.

Example query for fetching paginated results:

```graphql
query {
  products(limit: 10, skip: 20) {
    count
    pageSize
    pages
    skip
    data {
      id
      name
      price
    }
  }
}
```

### Custom Resolvers

You can now define custom resolvers for `find`, `filter`, `create`, and
`update` actions. Here's how to do it:

1. In your model, define a class method for the action you want to
customize, such as `resolve_find`, `resolve_filter`, `resolve_one`, or
`resolve_many`. The method should take the necessary arguments based on
the action.
2. Implement the custom logic for the resolver within the method.

Example of defining a custom `resolve_find` resolver in the `Product`
model:

```ruby
class Product
  # ...

  def self.resolve_find(context, id)
    # Custom logic for finding a product by ID
    find_by(custom_id: id)
  end
end
```

### Dynamic Attribute Handling

The library now properly handles dynamic attributes for models that
include `Mongoid::Attributes::Dynamic`. To work with dynamic attributes,
simply use them in your queries and mutations as you would with regular
attributes.

Example of querying a model with dynamic attributes:

```graphql
query {
  product(id: "123") {
    id
    name
    dynamicAttribute
  }
}
```

That's it! You're now ready to use the new features in the GraphQL
library. Enjoy the enhanced flexibility and customization options in
your GraphQL API.


# Checks ☑️

- Updates readme to reflect latest changes
- Query with pagination skip, limit, pages
- Generate grapho builds during initializer
- Enable dynamic defined fields in mongoid with dynamic enabled
- Enabling back updateMany and deleteMany
- Fix embeds many values transformation on update
- Allow to hook before creation
- Allow to filter objects before deletemany
- Fix dynamic attributes for mongoid criterias
- Allowing to hook data before create
- Enable lookahead feature for pagination data
- Converting input objects to hash
  • Loading branch information
niltonvasques authored May 9, 2023
1 parent 7ab4f9b commit f71c976
Show file tree
Hide file tree
Showing 26 changed files with 566 additions and 89 deletions.
7 changes: 5 additions & 2 deletions lib/graphoid/definitions/inputs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ def generate(model)
description("Generated model input for #{name}")

Attribute.fields_of(model).each do |field|
next if field.name.start_with?('_')
next if field.name.start_with?('_') and not field.name == '_id'

name = field.name
name = 'id' if name == '_id'

type = Graphoid::Mapper.convert(field)
name = Utils.camelize(field.name)
name = Utils.camelize(name)

argument(name, type, required: false)
end
Expand Down
2 changes: 2 additions & 0 deletions lib/graphoid/definitions/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ def initialize(*models)
Graphoid::Types::const_set(sorter_const, Class.new(GraphQL::Schema::InputObject))
#Graphoid::Types::const_set(input_const, Class.new(GraphQL::Schema::InputObject))
end

models.each { |model| Graphoid.build(model) }
end

def generate(model)
Expand Down
1 change: 1 addition & 0 deletions lib/graphoid/graphoid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
require 'graphoid/operators/inherited/many_to_many'

require 'graphoid/queries/queries'
require 'graphoid/queries/pagination'
require 'graphoid/queries/processor'
require 'graphoid/queries/operation'

Expand Down
1 change: 1 addition & 0 deletions lib/graphoid/mutations/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def self.build(model)
define_method :"#{name}" do |data: {}|
begin
user = context[:current_user]
data = model.before_resolve_create(self, data) if model.respond_to?(:before_resolve_create)
Graphoid::Mutations::Processor.execute(model, grapho, data, user)
rescue Exception => ex
GraphQL::ExecutionError.new(ex.message)
Expand Down
23 changes: 22 additions & 1 deletion lib/graphoid/mutations/delete.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,38 @@ def self.build(model)
argument :id, GraphQL::Types::ID, required: true
end

type.field(name: plural, type: [grapho.type], null: true) do
argument :where, grapho.filter, required: false
end

type.class_eval do
define_method :"#{name}" do |id:|
begin
result = model.find(id)
result = if model.respond_to?(:resolve_find)
model.resolve_find(self, id)
else
model.find(id)
end
result.destroy!
result
rescue Exception => ex
GraphQL::ExecutionError.new(ex.message)
end
end
end

type.class_eval do
define_method :"#{plural}" do |where: {}|
begin
objects = Graphoid::Queries::Processor.execute(model, where.to_h)
objects = model.resolve_filter(self, objects) if model.respond_to?(:resolve_filter)
objects.destroy_all
objects.all.to_a
rescue Exception => ex
GraphQL::ExecutionError.new(ex.message)
end
end
end
end
end
end
Expand Down
25 changes: 24 additions & 1 deletion lib/graphoid/mutations/update.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,42 @@ def self.build(model)
argument :data, grapho.input, required: false
end

type.field name: plural, type: [grapho.type], null: true do
argument :where, grapho.filter, required: false
argument :data, grapho.input, required: false
end

type.class_eval do
define_method :"#{name}" do |id:, data: {}|
attrs = Utils.build_update_attributes(data, model, context)

begin
object = model.find(id)
object = if model.respond_to?(:resolve_find)
model.resolve_find(self, id)
else
model.find(id)
end
object.update!(attrs)
object.reload
rescue Exception => ex
GraphQL::ExecutionError.new(ex.message)
end
end
end

type.class_eval do
define_method :"#{plural}" do |where: {}, data: {}|
attrs = Utils.build_update_attributes(data, model, context)

begin
objects = Graphoid::Queries::Processor.execute(model, where.to_h)
objects.update_all(attrs)
objects.all.to_a
rescue Exception => ex
GraphQL::ExecutionError.new(ex.message)
end
end
end
end
end
end
Expand Down
8 changes: 1 addition & 7 deletions lib/graphoid/operators/attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,8 @@ def forbidden_fields_of(model, action)
end

def correct(model, attributes)
result = {}
fieldnames = fieldnames_of(model)
attributes.each do |key, value|
key = key.to_s.camelize(:lower) if fieldnames.exclude?(key)
key = key.to_s.underscore if fieldnames.exclude?(key.to_s)
result[key] = value
end
result
Utils.underscore(attributes, fieldnames)
end
end
end
Expand Down
5 changes: 5 additions & 0 deletions lib/graphoid/queries/operation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ def build_operand(model, key)

relation = relations.find { |r| r.name == key.underscore.to_sym }
return Graphoid.driver.class_of(relation).new(relation) if relation

if model.include? Mongoid::Attributes::Dynamic or
(model.respond_to?(:klass) and model.klass.include? Mongoid::Attributes::Dynamic)
return Attribute.new(name: key, type: @value.class) unless @value.nil?
end
end
end
end
Expand Down
75 changes: 75 additions & 0 deletions lib/graphoid/queries/pagination.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

module Graphoid
module Queries::Pagination
PAGE_SIZE = (ENV['PAGE_SIZE'] || 100).to_i

def self.generate(*models)
models.each { |model| Graphoid::Queries::Pagination.build(model) }
end

def self.build(model)
Graphoid.initialize
grapho = Graphoid.build(model)
query_type = ::Types::QueryType

query_type.field name: grapho.name, type: grapho.type, null: true do
argument :id, GraphQL::Types::ID, required: false
argument :where, grapho.filter, required: false
end

paginated_type = Class.new(GraphQL::Schema::Object) do
graphql_name("#{model.name}Pagination")
description('Pagination Type')

# https://www.rubydoc.info/github/rmosolgo/graphql-ruby/GraphQL/Field
# Syntax => name, type, description
field :count, GraphQL::Types::Int, null: true
field :page_size, GraphQL::Types::Int, null: true
field :pages, GraphQL::Types::Int, null: true
field :skip, GraphQL::Types::Int, null: true
field :data, [grapho.type], null: true, extras: [:lookahead]

def data(lookahead:)
object ||= @object
# Mongoid::Criteria uses method_missing to send the method to the underlying Model
# Just implement def self.lookahead(object, lookahead) in your model to manage
# eager loading
obj = object.lookahead(object, lookahead) if object.respond_to? :lookahead
object = obj if obj
return object.eager_load if object.respond_to? :eager_load
object
end

def page_size
return PAGE_SIZE if object.options[:limit].nil? or object.options[:limit] > PAGE_SIZE

object.options[:limit]
end

def skip
object.options[:skip] || 0
end

def pages
return 1 if object.options[:limit].nil?

(object.count / object.options[:limit].to_f).ceil
end
end

query_type.field name: grapho.plural, type: paginated_type, null: true do
argument :where, grapho.filter, required: false
argument :order, grapho.order, required: false
argument :limit, GraphQL::Types::Int, required: false, default_value: PAGE_SIZE,
prepare: lambda { |limit, _ctx|
limit = PAGE_SIZE if limit > PAGE_SIZE
return limit
}
argument :skip, GraphQL::Types::Int, required: false
end

Graphoid::Queries.define_resolvers(query_type, model, grapho.name, grapho)
end
end
end
11 changes: 10 additions & 1 deletion lib/graphoid/queries/queries.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def self.build(model)
Graphoid::Argument.query_many(self, grapho.filter, grapho.order, required: false)
end

Graphoid::Queries.define_resolvers(query_type, model, grapho.name, grapho)

end

def self.define_resolvers(query_type, model, name, grapho)
query_type.class_eval do
# Dynamically defining a resolver method for queries:
# query {
Expand All @@ -29,6 +34,7 @@ def self.build(model)
# }
define_method :"#{grapho.name}" do |id: nil, where: nil|
begin
return model.resolve_one(self, id, where) if model.respond_to?(:resolve_one)
return model.find(id) if id
Processor.execute(model, where.to_h).first
rescue Exception => ex
Expand All @@ -51,6 +57,10 @@ def self.build(model)
# but the problem is that it is not the same
# model = Graphoid.driver.eager_load(context.irep_node, model)
# https://graphql-ruby.org/fields/introduction.html#extra-field-metadata
if model.respond_to?(:resolve_many)
return model.resolve_many(self, where, order, limit, skip)
end

result = Processor.execute(model, where.to_h)
order = Processor.parse_order(model, order.to_h)
result = result.order(order).limit(limit)
Expand All @@ -63,4 +73,3 @@ def self.build(model)
end
end
end

12 changes: 12 additions & 0 deletions lib/graphoid/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ def underscore(props, fields = [])
key = key.to_s if key.is_a? Symbol
key = key.camelize(:lower) if fields.exclude?(key)
key = key.underscore if fields.exclude?(key)
# embeds many is passing on update action an array of dynamic object like hash
if value.is_a? Array
value = value.map do |v|
transformed = v
transformed = v.to_h if v.respond_to?(:to_h)
transformed
end
end

if value.is_a? GraphQL::Schema::InputObject
value = value.to_h
end
attrs[key] = value
end
attrs
Expand Down
21 changes: 21 additions & 0 deletions spec/tester_mongo/app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,25 @@ def graphField2

embeds_one :value
embeds_many :snakes

def self.before_resolve_create(model, data)
if data[:string_field] == 'hook'
data = data.to_h
data[:string_field] = 'hook_changed'
return data
end

data
end

def self.resolve_find(resolver, id)
where.not(string_field: 'hook').find(id)
end

def self.resolve_one(resolver, id, filter)
result = where.not(string_field: 'hook')
result = Graphoid::Queries::Processor.execute(result, filter.to_h)
return result.find(id) if id
result.first
end
end
8 changes: 8 additions & 0 deletions spec/tester_mongo/app/models/level.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class Level
include Mongoid::Document
include Mongoid::Timestamps

field :name, type: String
end
19 changes: 19 additions & 0 deletions spec/tester_mongo/app/models/player.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

class Player
include Mongoid::Document
include Mongoid::Timestamps

field :name, type: String
field :email, type: String
field :active, type: Boolean, default: true

def self.resolve_many(resolver, where_args, order, limit, skip)
w = where_args.to_h
w[:active] = true
result = Graphoid::Queries::Processor.execute(self, w)
order = Graphoid::Queries::Processor.parse_order(self, order.to_h)
result = result.order(order).limit(limit)
Graphoid.driver.skip(result, skip)
end
end
1 change: 1 addition & 0 deletions spec/tester_mongo/app/models/value.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ class Value
field :text, type: String
field :name, type: String

embeds_many :value_nested
embedded_in :account
end
11 changes: 11 additions & 0 deletions spec/tester_mongo/app/models/value_nested.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class ValueNested
include Mongoid::Document
include Mongoid::Timestamps

field :text, type: String
field :name, type: String

embedded_in :value
end
8 changes: 5 additions & 3 deletions spec/tester_mongo/config/initializers/graphoid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
end

Graphoid.initialize
Graphoid::Types.initialize(User, House, Label, Snake, Value, Person, Account, Contract)
Graphoid::Queries.generate(User, House, Label, Snake, Value, Person, Account, Contract)
Graphoid::Mutations.generate(User, House, Label, Snake, Value, Person, Account, Contract)
Graphoid::Types.initialize(User, House, Label, Snake, ValueNested, Value, Person, Account, Contract, Level,
Player)
Graphoid::Queries.generate(User, House, Label, Snake, ValueNested, Value, Person, Account, Contract, Player)
Graphoid::Mutations.generate(User, House, Label, Snake, Value, ValueNested, Person, Account, Contract)
Graphoid::Queries::Pagination.generate(Level)
end
Loading

0 comments on commit f71c976

Please sign in to comment.