├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── graphql-rails-resolver.gemspec └── lib └── graphql ├── rails.rb └── rails └── resolver.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.rbc 2 | capybara-*.html 3 | .rspec 4 | /log 5 | /tmp 6 | /db/*.sqlite3 7 | /db/*.sqlite3-journal 8 | /public/system 9 | /coverage/ 10 | /spec/tmp 11 | **.orig 12 | rerun.txt 13 | pickle-email-*.html 14 | 15 | # TODO Comment out these rules if you are OK with secrets being uploaded to the repo 16 | config/initializers/secret_token.rb 17 | config/secrets.yml 18 | 19 | # dotenv 20 | # TODO Comment out this rule if environment variables can be committed 21 | .env 22 | 23 | ## Environment normalization: 24 | /.bundle 25 | /vendor/bundle 26 | 27 | # these should all be checked in to normalize the environment: 28 | # Gemfile.lock, .ruby-version, .ruby-gemset 29 | 30 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 31 | .rvmrc 32 | 33 | # if using bower-rails ignore default bower_components path bower.json files 34 | /vendor/assets/bower_components 35 | *.bowerrc 36 | bower.json 37 | 38 | # Ignore pow environment settings 39 | .powenv 40 | 41 | # Ignore Byebug command history file. 42 | .byebug_history 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # GraphQL::Rails::Resolver 2 | ## CHANGELOG 3 | 4 | ### Version 0.2.9 5 | Accept the non-null ID type (!ID) as an ID type 6 | 7 | ### Version 0.2.8 8 | Added argument value preprocessing using `:map` option 9 | 10 | ### Version 0.2.7 11 | Added conditional resolution by means of `:if` and `:unless` options 12 | 13 | ### Version 0.2.6 14 | Fixes issue where resolution may skip over :where 15 | 16 | ### Version 0.2.5 17 | Adds heirarchal resolution strategy 18 | 19 | The base resolver will now check for the field's resolver method on the object. If resolving `Child` on `Parent` it will now default to `Parent.child` instead of `Child.all` 20 | 21 | ### Version 0.2.4 22 | Adds ID resolution for non-primary ID field arguments 23 | Adds `get_field_args` to get type declarations for arguments 24 | Adds `get_arg_type`, `is_field_id_type?`, and `is_arg_id_type?` 25 | 26 | ### Version 0.2.2 27 | Fixed arguments to :scope resolution 28 | 29 | ### Version 0.2.2 30 | Changed dependency version to optimistic. 31 | 32 | ### Version 0.2.1 33 | Added `resolve_id` that resolves a single or list of ID type objects. 34 | 35 | ### Version 0.2.0 36 | Update to support GraphQL 0.19.0 37 | 38 | Removes `to_model_id` and `lookup_id` functions. This functionality should be decided on the application level via `Schema.object_from_id` 39 | Uses schema functions to resolve objects. 40 | 41 | ### Version 0.1.5 42 | Fixed `where` method resolving superseding attribute. 43 | 44 | ### Version 0.1.4 45 | Added `resolve` with parameters 46 | Deprecates `resolve_where` and `resolve_scope` 47 | 48 | ### Version 0.1.3 49 | Added `resolve_scope` for resolving model scopes. 50 | Fixed `resolve_where` not being called and reworked class inheritance. 51 | 52 | 53 | ### Version 0.1.2 54 | Initial release. Took a couple tries to figure out how to import to a new namespace on an existing gem. 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Cole Turner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL::Rails::Resolver (graphql-rails-resolver) 2 | A utility to ease graphql-ruby integration into a Rails project. This resolver offers a declarative approach to resolving Field arguments in a Rails environment. 3 | 4 | # How it works 5 | `GraphQL::Rails::Resolver` serves as a base class for your GraphQL Ruby schema. When a resolver inherits from this base class, you can easily map arguments in a GraphQL Field to an attribute on an ActiveRecord model or a custom method. 6 | 7 | ## Why? 8 | **tl;dr; To achieves three goals: maintainable query type, code re-use, and a declarative integration with Ruby on Rails.** 9 | 10 | Take for example the following Rails model: 11 | 12 | ```ruby 13 | class Post < ApplicationRecord 14 | belongs_to :author 15 | has_many :comments 16 | 17 | scope :is_public, -> { where(is_public: true) } 18 | scope :is_private, -> { where(is_public: false) } 19 | scope :featured, -> (value) { where(created_at: value) } 20 | 21 | def tags 22 | ["hello", "world"] 23 | end 24 | 25 | end 26 | ``` 27 | 28 | The standard implementation for resolving a `Post` is as follows: 29 | 30 | ```ruby 31 | field :post, PostType do 32 | argument :is_public, types.Boolean, default_value: true 33 | resolve -> (obj, args, ctx) { 34 | post.is_public if args[:is_public] 35 | post.is_private unless args[:is_public] 36 | } 37 | end 38 | ``` 39 | 40 | This implementation is cumbersome and when your application grows it will become unmanageable. In [GraphQL Ruby: Clean Up your Query Type](https://m.alphasights.com/graphql-ruby-clean-up-your-query-type-d7ab05a47084) we see a better pattern emerge for building resolvers that can be re-used. 41 | 42 | Using the pattern from this article, our Field becomes much simpler: 43 | 44 | **/app/graph/types/query_type.rb** 45 | 46 | ```ruby 47 | field :post, PostType do 48 | argument :is_public, types.Boolean, default_value: true 49 | resolve Resolvers::Post.new 50 | end 51 | ``` 52 | 53 | **/app/graph/resolvers/post.rb** 54 | 55 | ```ruby 56 | module Resolvers 57 | class Post 58 | def call(_, arguments, _) 59 | if arguments[:ids] 60 | ::Post.where(id: arguments[:ids]) 61 | elsif arguments.key? :is_public 62 | ::Post.is_public if arguments[:is_public] 63 | ::Post.is_private unless arguments[:is_public] 64 | else 65 | ::Post.all 66 | end 67 | end 68 | end 69 | end 70 | ``` 71 | 72 | This solution addresses code re-use, but these series of conditionals do not allow you to resolve more than one argument, and it may become difficult to maintain this imperative approach. 73 | 74 | 75 | ## Hello "Active" Resolver 76 | **Out with imperative, in with declarative.** 77 | 78 | To begin, we install the gem by adding it to our `Gemfile`: 79 | 80 | ```ruby 81 | gem 'graphql-rails-resolver' 82 | ``` 83 | 84 | This will load a class by the name of `GraphQL::Rails::Resolver` 85 | 86 | Take the Resolver from the previous example. Using `GraphQL::Rails::Resolver`, we inherit and use declarations for arguments and how they will be resolved. These declarations will be mapped to the attributes on the resolved model. 87 | 88 | ```ruby 89 | # Class name must match the Rails model name exactly. 90 | 91 | class Post < GraphQL::Rails::Resolver 92 | # ID argument is resolved in base class 93 | 94 | # Resolve :title, :created_at, :updated_at to Post.where() arguments 95 | resolve :title 96 | resolve :createdAt, :where => :created_at 97 | resolve :updatedAt, :where => :updated_at 98 | 99 | # Condition resolution on title being present using the `unless` option 100 | resolve :title, unless: -> (value) { value.blank? } 101 | 102 | # Resolve :title but preprocess the argument value first (strip leading/trailing spaces) 103 | resolve :title, map: -> (value) { value.strip } 104 | 105 | # Resolve :featured argument with default test: if argument `featured` is present 106 | resolve :featured, :scope => :featured 107 | 108 | # Same resolution as the line above, but send the value to the scope function 109 | resolve :featured, :scope => :featured, :with_value => true 110 | 111 | # Resolve :featured scope to a dynamic scope name 112 | resolve :is_public, :scope => -> (value) { value == true ? :is_public : :is_private} 113 | 114 | # Resolve :is_public to a class method 115 | resolve :custom_arg, :custom_resolve_method 116 | 117 | def custom_resolve_method(value) 118 | ... 119 | end 120 | 121 | # Resolve :is_public to a method on the model object 122 | resolve :custom_arg, :model_obj_method 123 | 124 | end 125 | ``` 126 | 127 | In the examples above, the three primary arguments to `resolve` are: 128 | 129 | `resolve :argument_name, ...` 130 | 131 | `where` to specify another attribute. 132 | 133 | `scope` to specify a scope on the model: 134 | - `scope` accepts string/symbol "scope name" or a closure that returns a scope name or `nil` 135 | - Use `with_value` to send the argument value to the scope closure. 136 | 137 | Alternatively you can specify a symbol representing a method name: (ie: `resolve :arg_1, :custom_method`). The resolver will use it's own method if it exists, or else it will call the method on the object itself. 138 | 139 | ### Conditional resolution 140 | Sometimes it is necessary to condition resolution of an argument on its value. For instance, by default 141 | an empty string as an argument matches only records whose corresponding field is an empty string as well. 142 | However, you may want an empty argument to mean that this argument should be ignored and all records shall 143 | be matched. To achieve this, you would condition resolution of that argument on it being not empty. 144 | 145 | You can condition resolution by passing the `:if` or `:unless` option to the `resolve` method. This option 146 | can take a method name (as a symbol or a string), or a `Proc` (or lambda expression for that matter), which 147 | will be called with the argument's value: 148 | 149 | ```ruby 150 | resolve :tagline, unless: -> (value) { value.blank? } 151 | 152 | resolve :tagline, if: -> (value) { value.present? } 153 | 154 | resolve :tagline, if: :check_value 155 | 156 | def check_value(value) 157 | value.present? 158 | end 159 | ``` 160 | 161 | ### Preprocessing argument values 162 | You can alter an argument's value before it is being resolved. To do this, pass a method 163 | name (as a symbol or a string), or a `Proc` (or lambda expression) to the `:map` option 164 | of `resolve`. The method or `Proc` you specify is then passed the original argument value 165 | and expected to return the value that shall be used for resolution. 166 | 167 | This comes in handy in various cases, for instance when you need to make sure that an 168 | argument value is well-defined: 169 | 170 | ```ruby 171 | resolve :offset, map: -> (value) { [value, 0].max } 172 | resolve :limit, map: -> (value) { [value, 100].min } 173 | ``` 174 | 175 | The above example guarantees that the offset is never negative and that the limit is 176 | capped at a reasonable value (for [security reasons](https://rmosolgo.github.io/graphql-ruby/queries/security)). 177 | 178 | ### Detecting the Model 179 | The resolver will automatically resolve to a Rails model with the same name. This behavior can be overridden by defining a `Post#model` which returns the appropriate model. 180 | 181 | ```ruby 182 | def model 183 | ::AnotherModel 184 | end 185 | ``` 186 | 187 | ### Find Model by ID 188 | `GraphQL::Rails::Resolver` includes the ability to resolve an object by ID (or a list of ID types). Using the following method, by default the resolver will find a model by **Schema.object_from_id(value)**. 189 | 190 | ```ruby 191 | def object_from_id(value=...) 192 | ... 193 | end 194 | ``` 195 | 196 | 197 | ### Override Default Scope 198 | The default behavior is to use `Model.all` to scope the resolution. This scope can be changed by providing a block or lambda to the class instance: 199 | 200 | ```ruby 201 | Resolvers::Post.new(Proc.new { 202 | ::Post.where(:created_at => ...) 203 | }) 204 | ``` 205 | 206 | # Needs Help 207 | I wanted to release this utility for the hopes of sparking interest in Rails integration with `graphql-ruby`. If you wish to contribute to this project, any pull request is warmly welcomed. 208 | 209 | # Credits 210 | - Cole Turner ([@colepatrickturner](https://github.com/colepatrickturner)) 211 | - Peter Salanki ([@salanki](https://github.com/salanki)) 212 | - Jonas Schwertfeger ([@jschwertfeger](https://github.com/jschwertfeger)) 213 | - Joshua Coffee ([@joshualcoffee](https://github.com/joshualcoffee)) 214 | -------------------------------------------------------------------------------- /graphql-rails-resolver.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path("../lib", __FILE__) 2 | require 'graphql/rails/resolver' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'graphql-rails-resolver' 6 | s.version = GraphQL::Rails::Resolver::VERSION 7 | s.date = Date.today.to_s 8 | s.summary = "GraphQL + Rails integration for Field Resolvers." 9 | s.description = "A utility for ease graphql-ruby integration into a Rails project." 10 | s.authors = ["Cole Turner"] 11 | s.email = 'turner.cole@gmail.com' 12 | s.files = Dir["{lib}/**/*", "LICENSE", "README.md", "CHANGELOG.md"] 13 | s.homepage = 'http://rubygems.org/gems/graphql-rails-resolver' 14 | s.license = 'MIT' 15 | 16 | s.add_runtime_dependency "graphql", ['>= 1.5.0', '< 2.0'] 17 | s.add_development_dependency "activerecord" 18 | s.required_ruby_version = '>= 2.3.0' 19 | end 20 | -------------------------------------------------------------------------------- /lib/graphql/rails.rb: -------------------------------------------------------------------------------- 1 | require 'graphql' 2 | require 'graphql/rails/resolver' 3 | -------------------------------------------------------------------------------- /lib/graphql/rails/resolver.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Rails 3 | class Resolver 4 | VERSION = '0.3.0' 5 | 6 | attr_accessor :resolvers 7 | 8 | def initialize(callable=nil) 9 | unless callable.nil? 10 | raise ArgumentError, "Resolver requires a callable type or nil" unless callable.respond_to? :call 11 | end 12 | 13 | @callable = 14 | if callable.present? 15 | callable 16 | else 17 | Proc.new { |obj| 18 | subfield = model.name.underscore.pluralize 19 | if obj.respond_to? subfield 20 | obj.send(subfield) 21 | else 22 | model.all 23 | end 24 | } 25 | end 26 | 27 | @obj = nil 28 | @args = nil 29 | @ctx = nil 30 | @resolvers = self.class.resolvers 31 | @id_field = self.class.id_field 32 | end 33 | 34 | def call(obj, args, ctx) 35 | @obj = obj 36 | @args = args 37 | @ctx = ctx 38 | 39 | @result = @callable.call(obj, args, ctx) 40 | 41 | # If there's an ID type, offer ID resolution_strategy 42 | if has_id_argument? and args.key? @id_field 43 | @result = resolve_id(args[@id_field]) 44 | end 45 | 46 | @resolvers.each do |arg, resolvers| 47 | if args.key? arg 48 | original_value = args[arg] 49 | 50 | resolvers.each do |method, params| 51 | next unless condition_met?(params.fetch(:if, nil), true, original_value) 52 | next unless condition_met?(params.fetch(:unless, nil), false, original_value) 53 | value = map_value(params.fetch(:map, nil), original_value) 54 | 55 | # Match scopes 56 | if params.key? :scope 57 | scope_name = params[:scope] 58 | scope_name = scope_name.call(value) if scope_name.respond_to? :call 59 | 60 | scope_args = [] 61 | scope_args.push(value) if params.key? :with_value and params[:with_value] == true 62 | 63 | @result = @result.send(scope_name, *scope_args) unless scope_name.nil? 64 | # Match custom methods 65 | elsif params.key? :method 66 | @result = send(params[:method], value) 67 | elsif method.present? 68 | # Match first param 69 | if method.respond_to? :call 70 | # Match implicit blocks 71 | @result = method.call(value) 72 | elsif self.respond_to? method 73 | # Match method name to current resolver class 74 | @result = send(method, value) 75 | elsif @result.respond_to? method 76 | # Match method name to object 77 | @result = @result.send(method, value) 78 | else 79 | raise ArgumentError, "Unable to resolve parameter of type #{method.class} in #{self}" 80 | end 81 | else 82 | # Resolve ID arguments 83 | if is_arg_id_type? arg 84 | value = resolve_id(value) 85 | end 86 | 87 | if self.respond_to? arg and params[:where].present? == false 88 | @result = send(arg, value) 89 | elsif @result.respond_to? arg and params[:where].present? == false 90 | @result = @result.send(arg, value) 91 | elsif @result.respond_to? :where 92 | attribute = 93 | if params[:where].present? 94 | params[:where] 95 | else 96 | arg 97 | end 98 | 99 | unless @result.has_attribute?(attribute) 100 | raise ArgumentError, "Unable to resolve attribute #{attribute} on #{@result}" 101 | end 102 | 103 | hash = {} 104 | hash[attribute] = value 105 | @result = @result.where(hash) 106 | else 107 | raise ArgumentError, "Unable to resolve argument #{arg} in #{self}" 108 | end 109 | end 110 | end 111 | end 112 | end 113 | 114 | result = payload 115 | 116 | @obj = nil 117 | @args = nil 118 | @ctx = nil 119 | 120 | result 121 | end 122 | 123 | def payload 124 | # Return all results if it's a list or a connection 125 | if connection? or list? 126 | @result 127 | else 128 | @result.first 129 | end 130 | end 131 | 132 | def field_name 133 | @ctx.ast_node.name 134 | end 135 | 136 | def has_id_argument? 137 | @ctx.irep_node.definitions.any? do |field_defn| 138 | if field_defn.name === field_name 139 | field_defn.arguments.any? do |k,v| 140 | is_field_id_type?(v.type) 141 | end 142 | else 143 | false 144 | end 145 | end 146 | end 147 | 148 | def has_id_argument 149 | warn "[DEPRECATION] `has_id_argument` is deprecated. Please use `has_id_argument?` instead." 150 | has_id_argument? 151 | end 152 | 153 | def has_id_field 154 | warn "[DEPRECATION] `has_id_field` is deprecated. Please use `has_id_argument` instead." 155 | has_id_argument? 156 | end 157 | 158 | def connection? 159 | @ctx.irep_node.definitions.all? { |field_defn| field_defn.resolve_proc.is_a?(GraphQL::Relay::ConnectionResolve) } 160 | end 161 | 162 | def list? 163 | @ctx.irep_node.definitions.all? { |field_defn| field_defn.type.kind.eql?(GraphQL::TypeKinds::LIST) } 164 | end 165 | 166 | def get_field_args 167 | @ctx.irep_node.parent.return_type.get_field(@ctx.irep_node.definition_name).arguments 168 | end 169 | 170 | def get_arg_type(key) 171 | args = get_field_args 172 | args[key].type 173 | end 174 | 175 | def is_field_id_type?(field) 176 | field == ::GraphQL::ID_TYPE || 177 | (field.kind == ::GraphQL::TypeKinds::LIST && field.of_type == ::GraphQL::ID_TYPE) || 178 | (field.kind == ::GraphQL::TypeKinds::NON_NULL && field.of_type == ::GraphQL::ID_TYPE) 179 | end 180 | 181 | def is_arg_id_type?(key) 182 | is_field_id_type?(get_arg_type(key)) 183 | end 184 | 185 | def model 186 | unless self.class < Resolvers::Base 187 | raise ArgumentError, "Cannot call `model` on BaseResolver" 188 | end 189 | 190 | "::#{self.class.name.demodulize}".constantize 191 | end 192 | 193 | def resolve_id(value) 194 | if value.kind_of? Array 195 | value.compact.map { |v| @ctx.schema.object_from_id(v, @ctx) }.compact 196 | else 197 | @ctx.schema.object_from_id(value, @ctx) 198 | end 199 | end 200 | 201 | def condition_met?(conditional, expectation, value) 202 | if conditional.respond_to? :call 203 | conditional.call(value) == expectation 204 | elsif (conditional.is_a?(Symbol) || conditional.is_a?(String)) && self.respond_to?(conditional) 205 | self.send(conditional, value) == expectation 206 | else 207 | true 208 | end 209 | end 210 | 211 | def map_value(mapper, value) 212 | if mapper.respond_to? :call 213 | mapper.call(value) 214 | elsif (mapper.is_a?(Symbol) || mapper.is_a?(String)) && self.respond_to?(mapper) 215 | self.send(mapper, value) 216 | else 217 | value 218 | end 219 | end 220 | 221 | class << self 222 | @@id_field = :id 223 | 224 | def id_field(value=nil) 225 | @@id_field = value if value.present? 226 | @@id_field 227 | end 228 | 229 | def resolvers 230 | @resolvers ||= {} 231 | @resolvers 232 | end 233 | 234 | def resolve(arg, definition=nil, **otherArgs) 235 | @resolvers ||= {} 236 | @resolvers[arg] ||= [] 237 | @resolvers[arg].push([definition, otherArgs]) 238 | end 239 | 240 | def resolve_where(arg) 241 | warn "[DEPRECATION] `resolve_where` is deprecated. Please use `resolve` instead." 242 | resolve(arg) 243 | end 244 | 245 | def resolve_scope(arg, test=nil, scope_name: nil, with_value: false) 246 | warn "[DEPRECATION] `resolve_scope` is deprecated. Please use `resolve` instead." 247 | test = lambda { |value| value.present? } if test.nil? 248 | scope_name = arg if scope_name.nil? 249 | 250 | resolve(arg, :scope => -> (value) { test.call(value) ? scope_name : nil }, :with_value => with_value) 251 | end 252 | 253 | def resolve_method(arg) 254 | warn "[DEPRECATION] `resolve_method` is deprecated. Please use `resolve` instead." 255 | resolve(arg) 256 | end 257 | end 258 | end 259 | end 260 | end 261 | --------------------------------------------------------------------------------