├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── graphql_model_mapper.gemspec ├── lib ├── graphql_model_mapper.rb └── graphql_model_mapper │ ├── custom_type.rb │ ├── encryption.rb │ ├── mapper_type.rb │ ├── mutation.rb │ ├── query.rb │ ├── railtie.rb │ ├── resolve.rb │ ├── schema.rb │ ├── utility.rb │ └── version.rb └── test ├── graphql_model_mapper_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.2.8 5 | before_install: gem install bundler -v 1.16.0 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at geblack@hotmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rake' 4 | gem 'rails', '~>3.2.22.5' 5 | gem 'activerecord', '~>3.2.22.5', :require => 'active_record' 6 | gem 'activemodel', '~>3.2.22.5', :require => 'active_model' 7 | gem 'activesupport', '~>3.2.22.5', :require => 'active_support' 8 | gem 'graphql', '~>1.7.5' 9 | gem 'graphql-errors', '~>0.1.0' 10 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 11 | 12 | # Specify your gem's dependencies in graphql_model_mapper.gemspec 13 | gemspec 14 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | graphql_model_mapper (0.1.1) 5 | activemodel (>= 3.2.22.5) 6 | activerecord (>= 3.2.22.5) 7 | activesupport (>= 3.2.22.5) 8 | graphql (>= 1.7.5) 9 | graphql-errors (>= 0.1.0) 10 | rails (>= 3.2.22.5) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actionmailer (3.2.22.5) 16 | actionpack (= 3.2.22.5) 17 | mail (~> 2.5.4) 18 | actionpack (3.2.22.5) 19 | activemodel (= 3.2.22.5) 20 | activesupport (= 3.2.22.5) 21 | builder (~> 3.0.0) 22 | erubis (~> 2.7.0) 23 | journey (~> 1.0.4) 24 | rack (~> 1.4.5) 25 | rack-cache (~> 1.2) 26 | rack-test (~> 0.6.1) 27 | sprockets (~> 2.2.1) 28 | activemodel (3.2.22.5) 29 | activesupport (= 3.2.22.5) 30 | builder (~> 3.0.0) 31 | activerecord (3.2.22.5) 32 | activemodel (= 3.2.22.5) 33 | activesupport (= 3.2.22.5) 34 | arel (~> 3.0.2) 35 | tzinfo (~> 0.3.29) 36 | activeresource (3.2.22.5) 37 | activemodel (= 3.2.22.5) 38 | activesupport (= 3.2.22.5) 39 | activesupport (3.2.22.5) 40 | i18n (~> 0.6, >= 0.6.4) 41 | multi_json (~> 1.0) 42 | arel (3.0.3) 43 | builder (3.0.4) 44 | concurrent-ruby (1.0.5) 45 | erubis (2.7.0) 46 | graphql (1.7.5) 47 | graphql-errors (0.1.0) 48 | graphql (>= 1.6.0, < 2) 49 | hike (1.2.3) 50 | i18n (0.9.1) 51 | concurrent-ruby (~> 1.0) 52 | journey (1.0.4) 53 | json (1.8.6) 54 | mail (2.5.5) 55 | mime-types (~> 1.16) 56 | treetop (~> 1.4.8) 57 | mime-types (1.25.1) 58 | minitest (5.10.3) 59 | multi_json (1.12.2) 60 | polyglot (0.3.5) 61 | rack (1.4.7) 62 | rack-cache (1.7.1) 63 | rack (>= 0.4) 64 | rack-ssl (1.3.4) 65 | rack 66 | rack-test (0.6.3) 67 | rack (>= 1.0) 68 | rails (3.2.22.5) 69 | actionmailer (= 3.2.22.5) 70 | actionpack (= 3.2.22.5) 71 | activerecord (= 3.2.22.5) 72 | activeresource (= 3.2.22.5) 73 | activesupport (= 3.2.22.5) 74 | bundler (~> 1.0) 75 | railties (= 3.2.22.5) 76 | railties (3.2.22.5) 77 | actionpack (= 3.2.22.5) 78 | activesupport (= 3.2.22.5) 79 | rack-ssl (~> 1.3.2) 80 | rake (>= 0.8.7) 81 | rdoc (~> 3.4) 82 | thor (>= 0.14.6, < 2.0) 83 | rake (12.2.1) 84 | rdoc (3.12.2) 85 | json (~> 1.4) 86 | sprockets (2.2.3) 87 | hike (~> 1.2) 88 | multi_json (~> 1.0) 89 | rack (~> 1.0) 90 | tilt (~> 1.1, != 1.3.0) 91 | thor (0.20.0) 92 | tilt (1.4.1) 93 | treetop (1.4.15) 94 | polyglot 95 | polyglot (>= 0.3.1) 96 | tzinfo (0.3.53) 97 | 98 | PLATFORMS 99 | ruby 100 | 101 | DEPENDENCIES 102 | activemodel (~> 3.2.22.5) 103 | activerecord (~> 3.2.22.5) 104 | activesupport (~> 3.2.22.5) 105 | bundler (~> 1.16) 106 | graphql (~> 1.7.5) 107 | graphql-errors (~> 0.1.0) 108 | graphql_model_mapper! 109 | minitest (~> 5.0) 110 | rails (~> 3.2.22.5) 111 | rake 112 | 113 | BUNDLED WITH 114 | 1.16.0 115 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Gene Black 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql_model_mapper 2 | This project is a work in progress and is in a pre-alpha state. Many thanks to @AndyKriger https://github.com/AndyKriger who initiated and shared the original idea on the GraphQL issue thread https://github.com/rmosolgo/graphql-ruby/issues/945. 3 | 4 | The graphql_model_mapper gem facilitates the generation of GraphQL objects based on the definition of your existing ActiveRecord models. 5 | 6 | It has been tested on Rails 3.2, 4.1 and 5.0 using Ruby 2.1.10 and 2.2.8 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem 'graphql_model_mapper' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install graphql_model_mapper 23 | 24 | ## Usage 25 | 26 | Initially, you will not have any models exposed as GraphQL types. To expose a model you can add any/all of the following macro attributes to your model definition: 27 | 28 | ```ruby 29 | graphql_query # to generate a GraphQL query object (and associated GraphQL input/output types) for the model 30 | graphql_create # to generate a GraphQL create mutation object (and its associated GraphQL input/output types) for the model 31 | graphql_delete # to generate a GraphQL delete mutation object (and its associated GraphQL input/output types) for the model 32 | graphql_update # to generate a GraphQL update mutation object (and its associated GraphQL input/output types) for the model 33 | ``` 34 | 35 | ## Type options 36 | The default input/output types generated for the model are based on the default settings (which may be overriden by initializing GraphqlModelMapper::GRAPHQL_DEFAULT_TYPES in your own initializer 37 | 38 | ```ruby 39 | #config/initializers/grapqhql_model_mapper_init.rb 40 | GraphqlModelMapper::CustomType::GRAPHQL_DEFAULT_TYPES = { 41 | input_type: { 42 | required_attributes: [], 43 | excluded_attributes: [], 44 | allowed_attributes: [], 45 | foreign_keys: true, 46 | primary_keys: true, 47 | validation_keys: false, 48 | association_macro: nil, 49 | source_nulls: false 50 | }, 51 | output_type: { 52 | required_attributes: [], 53 | excluded_attributes: [], 54 | allowed_attributes: [], 55 | foreign_keys: true, 56 | primary_keys: true, 57 | validation_keys: false, 58 | association_macro: nil, 59 | source_nulls: false 60 | } 61 | } 62 | ``` 63 | 64 | or individually by using the 65 | 66 | ```ruby 67 | graphql_types 68 | ``` 69 | 70 | macro attribute on the model, passing the individual settings that differ from the defaults. These will be merged into the default values. i.e. 71 | 72 | ```ruby 73 | graphql_types output_type: { 74 | excluded_attributes: [:crypted_password, :secret, :username], 75 | association_macro: :none, 76 | foreign_keys: false, 77 | primary_keys: false 78 | }, 79 | input_type: { 80 | excluded_attributes: [:crypted_password, :secret, :username], 81 | association_macro: :none, 82 | foreign_keys: false, 83 | primary_keys: false 84 | } 85 | ``` 86 | 87 | or you can initialize your own GRAPHQL_DEFAULT_TYPES constant for the model in an initializer, these settings will not be merged into the default settings, so you will need to fully elucidate the types 88 | 89 | ```ruby 90 | #config/initializers/grapqhql_model_mapper_init.rb 91 | GraphqlModelMapper::CustomType::[YOUR_MODEL_NAME_CLASSIFIED_AND_CAPITALIZED]_GRAPHQL_DEFAULT_TYPES = { 92 | input_type: { 93 | required_attributes: [], 94 | excluded_attributes: [:crypted_password, :secret, :username], 95 | allowed_attributes: [], 96 | foreign_keys: false, 97 | primary_keys: false, 98 | validation_keys: false, 99 | association_macro: :none, 100 | source_nulls: false 101 | }, 102 | output_type: { 103 | required_attributes: [], 104 | excluded_attributes: [:crypted_password, :secret, :username], 105 | allowed_attributes: [], 106 | foreign_keys: false, 107 | primary_keys: false, 108 | validation_keys: false, 109 | association_macro: :none, 110 | source_nulls: false 111 | } 112 | } 113 | ``` 114 | 115 | ## Resolver Options 116 | 117 | The query and mutation objects have a default resolver defined that may be sufficient for your needs (with the exception of the create mutation which simply validates the input and does not actually create the record). 118 | 119 | ```ruby 120 | def self.create_resolver(obj, inputs, ctx, model_name) 121 | if !GraphqlModelMapper.authorized?(ctx, model_name, :create) 122 | raise GraphQL::ExecutionError.new("error: unauthorized access: create '#{model_name.classify}'") 123 | end 124 | model = model_name.classify.constantize 125 | item = model.new(inputs[model_name.downcase].to_h) 126 | begin 127 | if !item.valid? 128 | raise GraphQL::ExecutionError.new(item.errors.full_messages.join("; ")) 129 | else 130 | raise GraphQL::ExecutionError.new("error: WIP, item not saved but is a valid '#{model_name.classify}'") 131 | #item.save! 132 | end 133 | end 134 | item 135 | end 136 | ``` 137 | 138 | If you want to assign your own resolvers for your type you can override the default resolver for the type on the macro attribute in the following way: 139 | 140 | ```ruby 141 | graphql_query resolver: -> (obj, inputs, ctx){ GraphqlModelMapper.log_resolve(ctx, args, generate_error: true) ) } 142 | ``` 143 | 144 | or create named methods on your model which will override the resolver (takes precedence over the default resolver AND the macro assigned resolver) 145 | 146 | ```ruby 147 | def self.graphql_query_resolver(obj,args,ctx) 148 | # this method will log the info for the inputs(arguments)/outputs(fields) to the Rails logger as well as optionally generate an error containing the information 149 | # it can be called from any resolve method 150 | GraphqlModelMapper.log_resolve(ctx, args, generate_error: true) 151 | end 152 | 153 | def self.graphql_create_resolver(obj,args,ctx) 154 | end 155 | 156 | def self.graphql_update_resolver(obj,args,ctx) 157 | end 158 | 159 | def self.graphql_delete_resolver(obj,args,ctx) 160 | end 161 | ``` 162 | 163 | The method that you assign to the resolver should either be self contained or call a class method that accepts and orchestrates the parameters passed from GraphQL in the resolve. In this example the query resolver is calling a GraphqlModelMapper utility function to log the input parameters (args) and output type(s) (context.fields). 164 | 165 | Another resolver option is to provide a resolver wrapper. This will wrap the inner resolves for queries and mutations with a wrapper method that you can use to accomplish global methodologies or to format results before or after your resolve method is called. They inherit from GraphqlModelMapper::Resolve::ResolveWrapper and can be declared in your initializer in the following manner: 166 | 167 | ```ruby 168 | class GraphqlModelMapper::CustomType::QueryResolveWrapper < GraphqlModelMapper::Resolve::ResolveWrapper 169 | # @resolve_func is original resolve, either default resolve or overriden from model 170 | # you can insert other custom functionality required before or after the resolver is called 171 | def call(obj, args, ctx) 172 | puts "overidden query resolve wrapper" 173 | 174 | # custom methods to call before the resolve 175 | 176 | ret = @resolve_func.call(obj, args, ctx) 177 | 178 | # custom methods to call after the resolve 179 | 180 | # always return the result from the resolve or your custom formatted methods (complying with the expected return type) at the end of the wrapper call 181 | ret 182 | end 183 | end 184 | 185 | class GraphqlModelMapper::CustomType::MutationResolveWrapper < GraphqlModelMapper::Resolve::ResolveWrapper 186 | def call(obj, args, ctx) 187 | puts "overidden mutation resolve wrapper" 188 | @resolve_func.call(obj, args, ctx) 189 | end 190 | end 191 | ``` 192 | 193 | These are then passed to your Schema arguments 194 | 195 | ```ruby 196 | GraphqlModelMapper.Schema(mutation_resolve_wrapper: GraphqlModelMapper::CustomType::MutationResolveWrapper, query_resolve_wrapper: GraphqlModelMapper::CustomType::QueryResolveWrapper) 197 | ``` 198 | 199 | Some other attributes that you can set on the macro functions in addition to the input/output types and resolver are 200 | 201 | ## graphql_query 202 | 203 | ```ruby 204 | description: # a short description of the query 205 | scope_methods: # scope methods available to be used in the query, these can be parameterized (must not be named parameters, must be accepted as string arguments and coerced in the method if needed) and must be written so that they valid in the presence of other tables which may be included in the associations 206 | arguments: # a list of argument definitions to override the default GraphQL arguments, if using your own arguments you will need to override the query resolver to act on those arguments 207 | ``` 208 | 209 | Arguments should be a list of objects with the following attributes (*required) 210 | 211 | *name - displayed name of the attribute 212 | *type - GraphQL type of the attribute 213 | default - default argument value 214 | authorization - authorization level for the attribute (if GraphqlModelMapper.use_authorize = true this authorization will be compared to the authorized ability for the user on the model to which this attribute applies) 215 | 216 | The default arguments handled by the default resolver and exposed on the query and delete mutations are: 217 | 218 | ```ruby 219 | default_arguments = 220 | [{:name=>:explain, :type=>GraphQL::BOOLEAN_TYPE, :default=>nil, :authorization=>:manage}, # handled by the default resolver, outputs the top level sql for the operation 221 | {:name=>:id, :type=>GraphQL::INT_TYPE, :default=>nil}, # allows input of an global id for top level record selection for the model 222 | {:name=>:ids, :type=>GraphQL::INT_TYPE.to_list_type, :default=>nil}, # allows input of an array of global ids for top level records selection for the model 223 | {:name=>:item_id, :type=>GraphQL::INT_TYPE, :default=>nil}, # allows input of a record id for top level record selection for the model 224 | {:name=>:item_ids, :type=>GraphQL::INT_TYPE.to_list_type, :default=>nil} # allows input of an array of record ids for top level records selection for the model 225 | {:name=>:limit, :type=>GraphQL::INT_TYPE, :default=>50}, # limits the number of records retuurned (defaults to 50 records) 226 | {:name=>:offset, :type=>GraphQL::INT_TYPE, :default=>nil}, # specifies an offset for the start of the records returned 227 | {:name=>:order, :type=>GraphQL::STRING_TYPE, :default=>nil, :authorization=>:manage}, # a string value that is passed to ActiveRecord query specifying the output order 228 | {:name=>:where, :type=>GraphQL::STRING_TYPE.to_list_type, :default=>nil, :authorization=>:manage}] # a string array for use in ActiveRecord query, can be a string or a query/value array to be used by the query ["model.id =? and model.date is not nul]", "1"] 229 | {:name=>:scopes, :type=>ModelScopeList, :default=>nil, :authorization=>:manage}] # a list of ModelScopeEnums exposed on the graphql_query/graphql_delete macro, :allowed_scopes and their optional arguments string array 230 | ``` 231 | 232 | ## graphql_delete 233 | 234 | description: 235 | scope_methods: 236 | arguments: 237 | resolver: 238 | 239 | ## graphql_update 240 | 241 | description: 242 | resolver: 243 | 244 | ## graphql_create 245 | 246 | description: 247 | resolver: 248 | 249 | ## Optional Authorization 250 | 251 | The schema has the capability to use the cancancan gem to enable authorized access to the query and mutation fields based on the models, if implemented it also will control the availability of the associations assigned to the model based on their underlying model authorization. This is an optional setup and is not required. 252 | 253 | ```ruby 254 | gem "cancancan", "~> 1.10" 255 | ``` 256 | 257 | Follow the setup for cancancan and create an app/model/ability.rb file to setup your access rights 258 | 259 | ```ruby 260 | class Ability 261 | include CanCan::Ability 262 | 263 | def initialize(user) 264 | # Define abilities for the passed in user here. For example: 265 | # 266 | # user ||= User.new # guest user (not logged in) 267 | # if user.admin? 268 | # can :manage, :all 269 | # else 270 | # can :read, :all 271 | # end 272 | # 273 | # The first argument to `can` is the action you are giving the user 274 | # permission to do. 275 | # If you pass :manage it will apply to every action. Other common actions 276 | # here are :read, :create, :update and :destroy. 277 | # 278 | # The second argument is the resource the user can perform the action on. 279 | # If you pass :all it will apply to every resource. Otherwise pass a Ruby 280 | # class of the resource. 281 | # 282 | # The third argument is an optional hash of conditions to further filter the 283 | # objects. 284 | # For example, here the user can only update published articles. 285 | # 286 | # can :update, Article, :published => true 287 | # 288 | # See the wiki for details: 289 | # https://github.com/CanCanCommunity/cancancan/wiki/Defining-Abilities 290 | 291 | 292 | user ||= User.new # guest user (not logged in) 293 | if user.is_admin? 294 | can :manage, :all 295 | else 296 | can :manage, [YourModelA] # this will allow access to :query, :create, :update, :delete GraphQL methods for defined models 297 | can :read, [YourModelB] # this will allow access to :query GraphQL methods for defined models as well as allow read access to associations of that type 298 | can :create, [YourModelC] # this will allow access to :create GraphQL methods for defined models 299 | can :update, [YourModelD] # this will allow access to :update GraphQL methods for defined models 300 | can :delete, [YourModelE] # this will allow access to :delete GraphQL methods for defined models 301 | 302 | end 303 | 304 | end 305 | end 306 | ``` 307 | 308 | GraphqlModelMapper requires an ability method on your current_user in order to check the context current_user's authorization to access a GraphQL objects model implementation. 309 | 310 | ```ruby 311 | class User < ActiveRecord::Base 312 | def ability 313 | @ability ||= Ability.new(self) 314 | end 315 | 316 | ... 317 | end 318 | ``` 319 | 320 | ## Schema implementation 321 | 322 | Once you have your models decorated with the graphql_query/graphql_update/graphql_create/graphql_delete attributes the next step is implementing your schema and adding it to your controller. For this example I am using a schema definition located at app/graphql/graphql_model_mapper_schema.rb. I have used https://github.com/exAspArk/graphql-errors to handle errors generated from the resolve methods. It is not required but it provides an easy way to setup error handling. 323 | 324 | ```ruby 325 | #app/graphql/graphql_model_mapper_schema.rb 326 | require 'graphql_model_mapper' 327 | 328 | # these are options that can be passed to the schema initiation to enable query logging or for authorization setup 329 | # 330 | # nesting_strategy: can be :flat, :shallow or :deep 331 | # type_case: can be :camelize, :underscore or :classify 332 | # scan_for_polymorphic_associations: when true will automatically scan your tables for the types to use when it encounters polymorphic associations, this defaults to **false** because it is a high cost operation. It is recommended that you setup custom types to handle the polymorphic associations to avoid table scans during the schema build process. See the custom types section for additional guidance on this topic. 333 | 334 | # default values are shown here 335 | default_schema_options = {log_query_depth: false, log_query_complexity: false, use_backtrace: false, use_authorize: false, nesting_strategy: :deep, type_case: :camelize, max_page_size: 100, scan_for_polymorphic_associations: false, mutation_resolve_wrapper: nil, query_resolve_wrapper: nil, bidirectional_pagination: false, default_nodes_field: false} 336 | 337 | GraphqlModelMapperSchema = GraphqlModelMapper.Schema(default_schema_options) 338 | GraphQL::Errors.configure(GraphqlModelMapperSchema) do 339 | 340 | rescue_from ActiveRecord::StatementInvalid do |exception| 341 | GraphQL::ExecutionError.new(exception.message) 342 | end 343 | 344 | rescue_from ActiveRecord::RecordNotFound do |exception| 345 | GraphQL::ExecutionError.new(exception.message) 346 | end 347 | 348 | 349 | rescue_from ActiveRecord::RecordInvalid do |exception| 350 | GraphQL::ExecutionError.new(exception.message) 351 | end 352 | 353 | rescue_from StandardError do |exception| 354 | GraphQL::ExecutionError.new(exception.message) 355 | end 356 | end 357 | ``` 358 | 359 | ## Graphiql controller setup 360 | 361 | I recommend that you install 362 | 363 | ```ruby 364 | gem "graphiql-rails" 365 | ``` 366 | 367 | so you may access and test your GraphQL queries. It is located at https://github.com/rmosolgo/graphiql-rails. Once you have graphiql-rails you can setup the route 368 | 369 | ```ruby 370 | #config/routes.rb 371 | [YourApp]::Application.routes.draw do 372 | if Rails.env.development? || Rails.env.staging? # you can restrict access to graphiql to specific environments here 373 | mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" 374 | end 375 | 376 | post "/graphql", to: "graphql#execute" 377 | 378 | .... 379 | end 380 | ``` 381 | 382 | you can then reference your previously assigned schema in `app/controllers/graphql_controller.rb` 383 | 384 | ```ruby 385 | #app/controllers/graphql_controller.rb 386 | class GraphqlController < ApplicationController 387 | def execute 388 | variables = ensure_hash(params[:variables]) 389 | query = params[:query] 390 | operation_name = params[:operationName] 391 | context = { 392 | # Query context goes here, for example: 393 | current_user: current_user 394 | } 395 | 396 | begin 397 | if (logged_in?)# && current_user.is_admin?) 398 | Ability.new(current_user) if GraphqlModelMapper.use_authorize # set on GraphqlModelMapper.Schema initialization 399 | elsif Rails.env != "development" 400 | query = nil 401 | end 402 | result = GraphqlModelMapperSchema.execute(query, variables: variables, context: context, operation_name: operation_name, except: ExceptFilter) 403 | 404 | end 405 | render json: result 406 | end 407 | 408 | private 409 | # this class is exercised when use_authorize is true 410 | class ExceptFilter 411 | def self.call(schema_member, context) 412 | return false unless GraphqlModelMapper.use_authorize 413 | # true if field should be excluded, false if it should be included 414 | return false unless authorized_proc = schema_member.metadata[:authorized_proc] 415 | model_name = schema_member.metadata[:model_name] 416 | access_type = schema_member.metadata[:access_type] 417 | !authorized_proc.call(context, model_name, access_type) 418 | end 419 | end 420 | 421 | def ensure_hash(query_variables) 422 | if query_variables.blank? 423 | {} 424 | elsif query_variables.is_a?(String) 425 | JSON.parse(query_variables) 426 | else 427 | query_variables 428 | end 429 | end 430 | end 431 | ``` 432 | 433 | ## Custom attribute types 434 | The functionality included in the type generation uses the base type reported by ActiveRecord for the definition of the Input/Output model field/argument types. These base types include: 435 | 436 | :integer -> GraphQL::INT_TYPE 437 | :decimal, :float -> GraphQL::FLOAT_TYPE 438 | :boolean -> GraphQL::BOOLEAN_TYPE 439 | :date, :datetime -> GraphqlModelMapper::DATE_TYPE 440 | :geometry, :multipolygon, :polygon -> GraphqlModelMapper::GEOMETRY_OBJECT_TYPE 441 | :string -> GraphQL::STRING_TYPE 442 | 443 | In some cases this is not sufficient. In the case that you are using ActiveRecord Enums (Rails >= 4.1) or you have stuffed formatted data into a field that you would like to display in a custom way there is an option for you to define a custom type for input/output of that specialized data. 444 | 445 | In order to support this functionality you will need to create an initializer for creation of your custom types. The naming convention will allow the GraphqlModelMapper to pickup your custom types for use in the generated schema in place of the default ActiveRecord db type. 446 | 447 | Use the form "#{model_name.classified}#{db_column_name.classified}Attribute#{Input/Output}" to name your custom type in the following manner. 448 | 449 | If your model name is "Job" and the attribute that you want to override the type is named "status", you will want to create a GraphQL object constant like the following: 450 | 451 | ```ruby 452 | GraphqlModelMapper::CustomType::JobStatusAttributeInput 453 | GraphqlModelMapper::CustomType::JobStatusAttributeOutput 454 | ``` 455 | 456 | in the following example I will show you how to create an override type for a Rails >=4.1 Enum value 457 | 458 | given the following definition in a model named 'Job' with an enum type mapped to the 'status' attribute 459 | 460 | ```ruby 461 | class Job < ApplicationRecord 462 | enum status: { applied:0, enrolled: 100, accepted: 200, rejected: 300, cancelled: 400} 463 | end 464 | ``` 465 | 466 | to enable application of a custom type to handle the input/output of the AR enum value you would need to create custom types in an initilizer. In this case we will use config/initializers/graphql_model_mapper_init.rb to create those types. 467 | 468 | If you do not need to intercept the values when the custom type is used in input/output you can simply assign a GraphQL enum to the custom type. (take note of the naming convention used in the last statement, since the custom type will be picked up by convention when the model types are built it is important that you follow the naming convention **exactly** to ensure your custom type is used, custom types should be defined and reside in the GraphqlModelMapper::CustomType namespace). Since we do not need to intercept the field/argument resolver/prepare for this type, both input and output can be directly assigned to the GraphQL enum type. **(this case is already handled by default in Rails >=4.1 so you will not need to establish a custom type for this built in support for Rails enums)** 469 | 470 | **config/initializers/graphql_model_mapper_init.rb** 471 | 472 | ```ruby 473 | #config/initializers/graphql_model_mapper_init.rb 474 | 475 | GraphqlModelMapper::CustomType::JobStatusAttributeEnum = GraphQL::EnumType.define do 476 | name "JobStatusAttributeEnum" 477 | value("Applied", "", value: 'applied') 478 | value("Enrolled", "", value: 'enrolled') 479 | value("Accepted", "", value: 'accepted') 480 | value("Rejectd", "", value: 'rejected') 481 | value("Cancelled", "", value: 'cancelled') 482 | end 483 | 484 | GraphqlModelMapper::CustomType::JobStatusAttributeOutputType = GraphqlModelMapper::CustomType::JobStatusAttributeInputType = GraphqlModelMapper::CustomType::JobStatusAttributeEnumType 485 | ``` 486 | 487 | In the event that you need to customize the way in which your custom types are used at runtime you will need to fully declare the field and argument that will be used with your custom type. In this example I am declaring the Input and Output fully so that I can use additional functionality in the prepare/resolve methods. 488 | 489 | **config/initializers/graphql_model_mapper_init.rb** 490 | 491 | ```ruby 492 | #config/initializers/graphql_model_mapper_init.rb 493 | GraphqlModelMapper::CustomType::JobStatusAttributeEnum = GraphQL::EnumType.define do 494 | name "JobStatusAttributeEnum" 495 | value("Applied", "", value: 'applied') 496 | value("Enrolled", "", value: 'enrolled') 497 | value("Accepted", "", value: 'accepted') 498 | value("Rejectd", "", value: 'rejected') 499 | value("Cancelled", "", value: 'cancelled') 500 | end 501 | 502 | GraphqlModelMapper::CustomType::JobStatusAttributeOutput = GraphQL::Field.define do 503 | name "JobStatusAttributeOutput" 504 | type(GraphqlModelMapper::CustomType::JobStatusAttributeEnum) 505 | description("testing") 506 | resolve ->(object, arguments, context) { 507 | object.status 508 | } 509 | end 510 | 511 | GraphqlModelMapper::CustomType::JobStatusAttributeInput = GraphQL::Argument.define do 512 | name "JobStatusAttributeInput" 513 | type (GraphqlModelMapper::CustomType::JobStatusAttributeEnum) 514 | prepare ->(value, ctx) do 515 | value 516 | end 517 | end 518 | ``` 519 | 520 | once you have these types defined and have restarted your server you should be able to see the mapping to the custom type in your __schema__ view and be able to use the GraphQL enums for query and update. 521 | 522 | To establish a custom type for a polymorphic association attribute on your model you will follow the same naming convention, but establish a GraphQL UnionType with interfaces that match the possible types that the polymorphic relation represent. (UnionTypes are not valid on input types, so they are only applicable to the output type) 523 | 524 | Assuming you have a relation in your models resembling: 525 | 526 | ```ruby 527 | class Car < ActiveRecord::Base 528 | belongs_to :parent, :polymorphic => true 529 | end 530 | 531 | class Ford < ActiveRecord::Base 532 | has_many :cars, :as => :parent 533 | end 534 | 535 | class Chevy < ActiveRecord::Base 536 | has_many :cars, :as => :parent 537 | end 538 | ``` 539 | 540 | you will then add the following to your initialization file for the custom type: 541 | 542 | ```ruby 543 | GraphqlModelMapper::CustomType::CarParentUnionOutput = GraphQL::UnionType.define do 544 | name "CarParentUnionOutput" 545 | description "UnionType for polymorphic association parent on Car" 546 | possible_types [GraphqlModelMapper::CHEVYOUTPUT, GraphqlModelMapper::FORDOUTPUT] 547 | resolve_type ->(obj, ctx) { 548 | #the field resolve_type will dereference the correct type when queried using the GraphqlModelMapper::MapperType.graph_object utility method to return the correct type mapped to the model (this method could also be used in the possible_types declaration if prefferred over the use of the assigned contant) 549 | 550 | GraphqlModelMapper::MapperType.graph_object(obj.class.name) 551 | } 552 | end 553 | ``` 554 | 555 | when resolving the parent attribute in a query you will need to write the query to late resolve the type when the data is fetched: 556 | 557 | query { 558 | car{ 559 | items { 560 | nodes { 561 | parent { 562 | ... on FordOutput{ 563 | id 564 | model 565 | ford_specific_attribute 566 | } 567 | 568 | ... on ChevyOutput{ 569 | id 570 | model 571 | chevy_specific_attribute 572 | } 573 | } 574 | } 575 | } 576 | } 577 | } 578 | 579 | **Note: when querying the model, you will still use the underlying database field value for any custom type when using it in a 'where' argument since the query is sent directly to the db and has no knowlege of the Rails enum or other GraphQL custom types.** 580 | 581 | ## Development 582 | 583 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 584 | 585 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 586 | 587 | ## Contributing 588 | 589 | Bug reports and pull requests are welcome on GitHub at https://github.com/geneeblack/graphql_model_mapper. 590 | 591 | ## License 592 | 593 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 594 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "graphql_model_mapper" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /graphql_model_mapper.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "graphql_model_mapper/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "graphql_model_mapper" 8 | spec.version = GraphqlModelMapper::VERSION 9 | spec.authors = ["Gene Black"] 10 | spec.email = ["geblack@hotmail.com"] 11 | 12 | spec.summary = %q{Adds GraphQL object generation based on your ActiveRecord models.} 13 | spec.description = %q{This gem extends ActiveRecord::Base to add automatic generation of GraphQL objects based on your models.} 14 | spec.homepage = "https://github.com/geneeblack/graphql_model_mapper" 15 | spec.license = "MIT" 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 21 | else 22 | raise "RubyGems 2.0 or newer is required to protect against " \ 23 | "public gem pushes." 24 | end 25 | 26 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 27 | f.match(%r{^(test|spec|features)/}) 28 | end 29 | spec.bindir = "exe" 30 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 31 | spec.require_paths = ["lib"] 32 | 33 | spec.required_ruby_version = '>= 2.1' 34 | spec.add_runtime_dependency "graphql", ['>= 1.7.5'] 35 | spec.add_runtime_dependency "graphql-errors", ['>= 0.1.0'] 36 | spec.add_runtime_dependency "activesupport", ['>= 3.2.22.5'] 37 | spec.add_runtime_dependency "activemodel", ['>= 3.2.22.5'] 38 | spec.add_runtime_dependency "activerecord", ['>= 3.2.22.5'] 39 | spec.add_runtime_dependency "rails", ['>= 3.2.22.5'] 40 | spec.add_development_dependency "graphql", [">= 1.7.5"] 41 | spec.add_development_dependency "graphql-errors", ['>= 0.1.0'] 42 | spec.add_development_dependency "bundler", "~> 1.16" 43 | spec.add_development_dependency "rake", "~> 10.0" 44 | spec.add_development_dependency "minitest", "~> 5.0" 45 | end 46 | -------------------------------------------------------------------------------- /lib/graphql_model_mapper.rb: -------------------------------------------------------------------------------- 1 | require "graphql" 2 | require "graphql_model_mapper/mapper_type" 3 | require "graphql_model_mapper/custom_type" 4 | require "graphql_model_mapper/mutation" 5 | require "graphql_model_mapper/query" 6 | require "graphql_model_mapper/resolve" 7 | require "graphql_model_mapper/schema" 8 | require "graphql_model_mapper/utility" 9 | require "graphql_model_mapper/encryption" 10 | require "graphql_model_mapper/version" 11 | require 'graphql_model_mapper/railtie' if defined?(Rails) 12 | 13 | module GraphqlModelMapper 14 | mattr_accessor :query_resolve_wrapper 15 | mattr_accessor :mutation_resolve_wrapper 16 | mattr_accessor :resolve_wrapper 17 | mattr_accessor :type_case 18 | mattr_accessor :nesting_strategy 19 | mattr_accessor :use_authorize 20 | mattr_accessor :max_page_size 21 | mattr_accessor :scan_for_polymorphic_associations 22 | mattr_accessor :default_nodes_field 23 | mattr_accessor :bidirectional_pagination 24 | mattr_accessor :handle_errors 25 | mattr_accessor :secret_token 26 | 27 | 28 | 29 | @@type_case = :camelize 30 | @@nesting_strategy = :shallow 31 | @@use_authorize = false 32 | @@max_page_size = 100 33 | @@scan_for_polymorphic_associations = false 34 | @@query_resolve_wrapper = nil 35 | @@mutation_resolve_wrapper = nil 36 | @@default_nodes_field = false 37 | @@bidirectional_pagination = false 38 | @@handle_errors = true 39 | @@secret_token = '3eb6db5a9026c547c72708438d496d942e976b252138db7e4e0ee5edd7539457d3ed0fa02ee5e7179420ce5290462018591adaf5f42adcf855da04877827def2' 40 | 41 | 42 | class << self 43 | attr_writer :logger 44 | 45 | def logger 46 | @logger ||= Logger.new($stdout).tap do |log| 47 | log.progname = self.name 48 | end 49 | end 50 | end 51 | 52 | 53 | def self.included(klazz) 54 | klazz.extend GraphqlModelMapper_Macros 55 | end 56 | 57 | module GraphqlModelMapper_Macros 58 | 59 | protected 60 | 61 | def graphql_types(input_type:{}, output_type:{}) 62 | name = self.name 63 | define_singleton_method(:graphql_types) do 64 | GraphqlModelMapper::MapperType.graphql_types(name: name, input_type: input_type, output_type: output_type) 65 | end 66 | end 67 | 68 | def graphql_update(description:"", resolver: -> (obj, inputs, ctx){ 69 | item = GraphqlModelMapper::Resolve.update_resolver(obj, inputs, ctx, name) 70 | { 71 | item: item 72 | } 73 | }) 74 | name = self.name 75 | define_singleton_method(:graphql_update) do 76 | GraphqlModelMapper::Mutation.graphql_update(name: name, description: description, resolver: resolver) 77 | end 78 | end 79 | 80 | def graphql_delete(description:"", resolver: -> (obj, inputs, ctx){ 81 | items = GraphqlModelMapper::Resolve.delete_resolver(obj, inputs, ctx, name) 82 | { 83 | items: items 84 | } 85 | }, arguments: [], scope_methods: []) 86 | name = self.name 87 | define_singleton_method(:graphql_delete) do 88 | GraphqlModelMapper::Mutation.graphql_delete(name: name, description: description, resolver: resolver, scope_methods: scope_methods) 89 | end 90 | end 91 | 92 | def graphql_create(description:"", resolver: -> (obj, args, ctx){ 93 | item = GraphqlModelMapper::Resolve.create_resolver(obj, args, ctx, name) 94 | { 95 | item: item 96 | } 97 | }) 98 | name = self.name 99 | define_singleton_method(:graphql_create) do 100 | GraphqlModelMapper::Mutation.graphql_create(name: name, description: description, resolver: resolver) 101 | end 102 | end 103 | 104 | def graphql_query(description: "", resolver: -> (obj, args, ctx) { 105 | items = GraphqlModelMapper::Resolve.query_resolver(obj, args, ctx, name) 106 | }, arguments: [], scope_methods: []) 107 | name = self.name 108 | define_singleton_method(:graphql_query) do 109 | GraphqlModelMapper::Query.graphql_query(name: name, description: description, resolver: resolver, scope_methods: scope_methods, arguments: arguments) 110 | end 111 | end 112 | end 113 | end 114 | 115 | ActiveRecord::Base.send(:include, GraphqlModelMapper) if defined?(ActiveRecord) -------------------------------------------------------------------------------- /lib/graphql_model_mapper/custom_type.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | module CustomType 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /lib/graphql_model_mapper/encryption.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | module Encryption 3 | def self.key 4 | Digest::SHA256.digest(ENV['GRAPHQL_SECRET_TOKEN'] || ENV['SECRET_TOKEN'] || GraphqlModelMapper.secret_token) 5 | end 6 | 7 | def self.aes(m,t) 8 | (aes = OpenSSL::Cipher::Cipher.new('aes-256-cbc').send(m)).key = Digest::SHA256.digest(self.key) 9 | aes.update(t) << aes.final 10 | end 11 | 12 | def self.encode(text) 13 | Base64.encode64(aes(:encrypt, text)).strip 14 | end 15 | 16 | def self.decode(text) 17 | aes(:decrypt, Base64.decode64(text)) 18 | end 19 | end 20 | end -------------------------------------------------------------------------------- /lib/graphql_model_mapper/mapper_type.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | module MapperType 3 | def self.graphql_types( 4 | name: self.name, 5 | input_type: {}, 6 | output_type: {} 7 | ) 8 | return GraphqlModelMapper::CustomType.const_get("#{name.upcase}_GRAPHQL_DEFAULT_TYPES") if GraphqlModelMapper::CustomType.const_defined?("#{name.upcase}_GRAPHQL_DEFAULT_TYPES") 9 | graphql_type = {} 10 | graphql_type[:input_type] = input_type 11 | graphql_type[:input_type][:type_sub_key] = :input_type 12 | graphql_type[:output_type] = output_type 13 | graphql_type[:output_type][:type_sub_key] = :output_type 14 | merged_graphql_type = self.graphql_default_types.deep_merge(graphql_type) 15 | GraphqlModelMapper::CustomType.const_set("#{name.upcase}_GRAPHQL_DEFAULT_TYPES", merged_graphql_type) 16 | 17 | merged_graphql_type 18 | end 19 | 20 | 21 | def self.graph_object(name) 22 | if GraphqlModelMapper.implementations.map(&:to_s).include?(name.classify) 23 | self.get_ar_object_with_params(name) 24 | else 25 | name.classify.constantize 26 | end 27 | end 28 | 29 | def self.get_ar_object_with_params(name, type_sub_key: :output_type) 30 | self.get_ar_object(name, self.get_type_params(name, type_sub_key: type_sub_key)) 31 | end 32 | 33 | def self.get_ar_object(name, 34 | required_attributes: [], 35 | excluded_attributes: [], 36 | allowed_attributes: [], 37 | foreign_keys: false, 38 | primary_keys: false, 39 | validation_keys: false, 40 | association_macro: nil, 41 | source_nulls: true, 42 | type_sub_key: nil) 43 | 44 | 45 | #typesuffix = method(__method__).parameters.map { |arg| eval arg[1].to_s }.hash.abs.to_i.to_s 46 | #typesuffix = "#{type_key.to_s.classify}#{GraphqlModelMapper.underscore(type_sub_key.to_s)}".camelize 47 | typesuffix = "#{GraphqlModelMapper.underscore(type_sub_key.to_s).sub("_Type", "")}".camelize 48 | #typesuffix = "#{type_sub_key == :input_type ? '_i' : '_o'}" 49 | typename = "#{GraphqlModelMapper.get_type_name(name)}#{typesuffix}" 50 | 51 | return GraphqlModelMapper.get_constant(typename) if GraphqlModelMapper.defined_constant?(typename) 52 | 53 | model = name.classify.constantize 54 | 55 | required_attributes = required_attributes.map(&:to_sym) | (validation_keys ? self.model_validation_keys(name) : []) 56 | 57 | columns = model.columns_hash 58 | 59 | # figure out which association fields we are exposing 60 | association_includes = (model.reflect_on_all_associations(association_macro).map(&:name)).map(&:to_sym) - excluded_attributes 61 | 62 | # find all relations for this model, skip ones where the association klass is invalid, be cognizant of include/exclude arrays similar to dbfields 63 | associations = model.reflect_on_all_associations(association_macro).select{|t| begin t.klass rescue next end}.select{|t| association_includes.include?(t.name.to_sym) } 64 | # now include polymorphic relations whose association klass is invalid, but is correct 65 | associations = associations + model.reflect_on_all_associations(association_macro).select{|t| t.options[:polymorphic]}.select{|t| association_includes.include?(t.name.to_sym) } 66 | # never show foreign keys for defined associations 67 | db_fields_never = foreign_keys ? [] : ( model.reflect_on_all_associations.map(&:association_foreign_key) + model.reflect_on_all_associations.map(&:options).select{|v| v.key?(:foreign_key) }.map {|x| x[:foreign_key]} ).uniq.map(&:to_sym) 68 | 69 | # figure out which database fields we are exposing 70 | allowed_attributes = allowed_attributes.count > 0 ? allowed_attributes.map(&:to_sym) : associations.map(&:name) + columns.keys.map(&:to_sym) 71 | allowed_associations = (associations.map(&:name) - excluded_attributes - db_fields_never) & allowed_attributes 72 | db_fields = (columns.keys.map(&:to_sym) - excluded_attributes - db_fields_never) & allowed_attributes 73 | associations = associations.select{|m| allowed_associations.include?(m.name)} 74 | enums = (Rails.version.split(".").first.to_i >= 4 && Rails.version.split(".").second.to_i >= 1) || (Rails.version.split(".").first.to_i >= 5) ? model.defined_enums.keys : [] 75 | enum_values = (Rails.version.split(".").first.to_i >= 4 && Rails.version.split(".").second.to_i >= 1) || (Rails.version.split(".").first.to_i >= 5) ? model.defined_enums : [] 76 | 77 | ret_type = GraphQL::InputObjectType.define do 78 | #ensure type name is unique so it does not collide with known types 79 | name typename 80 | description "an input interface for the #{name} ActiveRecord model" 81 | # create GraphQL fields for each association 82 | associations.sort_by(&:name).each do |reflection| 83 | begin 84 | klass = reflection.klass if !reflection.options[:polymorphic] 85 | rescue 86 | GraphqlModelMapper.logger.info("invalid relation #{reflection.name} specified on the #{name} model, the relation class does not exist") 87 | next # most likely an invalid association without a class name, skip if other errors are encountered 88 | end 89 | if reflection.macro == :has_many 90 | argument reflection.name.to_sym, -> {GraphqlModelMapper::MapperType.get_ar_object_with_params(klass.name, type_sub_key: type_sub_key).to_list_type} do 91 | if GraphqlModelMapper.use_authorize 92 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 93 | model_name klass.name 94 | access_type :read 95 | end 96 | end 97 | else 98 | if reflection.options[:polymorphic] #not currently supported as an input type 99 | #if GraphqlModelMapper.scan_for_polymorphic_associations 100 | # argument reflection.name.to_sym, -> {GraphqlModelMapper::MapperType.get_polymorphic_type(reflection, name, type_sub_key: type_sub_key)} 101 | #end 102 | else 103 | argument reflection.name.to_sym, -> {GraphqlModelMapper::MapperType.get_ar_object_with_params(klass.name, type_sub_key: type_sub_key)} do 104 | if GraphqlModelMapper.use_authorize 105 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 106 | model_name klass.name 107 | access_type :read 108 | end 109 | end 110 | end 111 | end 112 | end 113 | 114 | # create GraphQL fields for each exposed database field 115 | #if primary keys were requested 116 | db_fields.select{|s| (primary_keys && s.to_sym == :id)}.each do |f| 117 | argument "item_id".to_sym, -> {GraphqlModelMapper::MapperType.convert_type(columns[f.to_s].type, columns[f.to_s].sql_type, (source_nulls ? columns[f.to_s].null : true))} 118 | end 119 | argument :id, GraphQL::ID_TYPE 120 | # force required_attributes to be non-null 121 | db_fields.select{|s| required_attributes.include?(s)}.each do |f| 122 | argument f.to_sym, -> {GraphqlModelMapper.convert_type(columns[f.to_s].type, columns[f.to_s].sql_type, false)} 123 | end 124 | #get the rest of the fields that are not primary keys or required fields 125 | db_fields.reject{|s| (s.to_sym == :id) || required_attributes.include?(s)}.sort.each do |f| 126 | custom_type_name = "#{name.classify}#{f.to_s.classify}AttributeInput" 127 | if GraphqlModelMapper::CustomType.const_defined?(custom_type_name) 128 | argument f.to_sym, GraphqlModelMapper::CustomType.const_get(custom_type_name) 129 | else 130 | if enums.include?(f.to_s) 131 | argument f.to_sym, -> {GraphqlModelMapper::MapperType.get_enum_object(name, enum_values[f.to_s], f.to_s)} 132 | else 133 | argument f.to_sym, -> {GraphqlModelMapper::MapperType.convert_type(columns[f.to_s].type, columns[f.to_s].sql_type, (source_nulls ? columns[f.to_s].null : true))} 134 | end 135 | end 136 | end 137 | end if type_sub_key == :input_type 138 | 139 | ret_type = GraphQL::ObjectType.define do 140 | #ensure type name is unique so it does not collide with known types 141 | name typename 142 | model_name name 143 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 144 | access_type :read.to_s 145 | 146 | description "an output interface for the #{name} ActiveRecord model" 147 | # create GraphQL fields for each association 148 | associations.sort_by(&:name).each do |reflection| 149 | begin 150 | klass = reflection.klass if !reflection.options[:polymorphic] 151 | rescue 152 | GraphqlModelMapper.logger.info("invalid relation #{reflection.name} specified on the #{name} model, the relation class does not exist") 153 | next # most likely an invalid association without a class name, skip if other errors are encountered 154 | end 155 | if reflection.macro == :has_many 156 | if [:deep].include?(GraphqlModelMapper.nesting_strategy) 157 | connection reflection.name.to_sym, -> {GraphqlModelMapper::MapperType.get_connection_type(klass.name, GraphqlModelMapper::MapperType.get_ar_object_with_params(klass.name, type_sub_key: type_sub_key))}, property: reflection.name.to_sym, max_page_size: GraphqlModelMapper.max_page_size do 158 | if GraphqlModelMapper.use_authorize 159 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 160 | model_name klass.name 161 | access_type :read.to_s 162 | end 163 | end 164 | else 165 | field reflection.name.to_sym, -> {GraphqlModelMapper::MapperType.get_list_type(klass.name, GraphqlModelMapper::MapperType.get_ar_object_with_params(klass.name, type_sub_key: type_sub_key))}, property: reflection.name.to_sym do 166 | if GraphqlModelMapper.use_authorize 167 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 168 | model_name klass.name 169 | access_type :read.to_s 170 | end 171 | end 172 | end 173 | else 174 | if reflection.options[:polymorphic] 175 | # if a union type is defined in custom types use it, otherwise generate a union type from the association definition (requires a table scan) 176 | custom_type_name = "#{name.classify}#{reflection.name.to_s.classify}Union" 177 | if GraphqlModelMapper::CustomType.const_defined?(custom_type_name) 178 | field reflection.name.to_sym, -> {GraphqlModelMapper::CustomType.const_get(custom_type_name)} 179 | elsif GraphqlModelMapper.scan_for_polymorphic_associations 180 | field reflection.name.to_sym, -> {GraphqlModelMapper::MapperType.get_polymorphic_type(reflection, name)}, property: reflection.name.to_sym 181 | end 182 | else 183 | field reflection.name.to_sym, -> {GraphqlModelMapper::MapperType.get_ar_object_with_params(klass.name, type_sub_key: type_sub_key)}, property: reflection.name.to_sym do 184 | if GraphqlModelMapper.use_authorize 185 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 186 | model_name klass.name 187 | access_type :read.to_s 188 | end 189 | end 190 | end 191 | end 192 | end 193 | # create GraphQL fields for each exposed database field 194 | # get primary keys if requested 195 | implements GraphQL::Relay::Node.interface 196 | global_id_field :id 197 | db_fields.select{|s| (primary_keys && s.to_sym == :id)}.each do |f| 198 | field "item_id".to_sym, -> {GraphqlModelMapper::MapperType.convert_type(columns[f.to_s].type, columns[f.to_s].sql_type, (source_nulls ? columns[f.to_s].null : true))}, property: :id do 199 | resolve -> (obj, args, ctx) { 200 | obj[:id] 201 | } 202 | end 203 | end 204 | # force required attributes to be non-null 205 | db_fields.select{|s| required_attributes.include?(s)}.sort.each do |f| 206 | field f.to_sym, -> {GraphqlModelMapper::MapperType.convert_type(columns[f.to_s].type, columns[f.to_s].sql_type, false)}, property: f.to_sym 207 | end 208 | # get the remaining fields and apply custom type if defined 209 | db_fields.reject{|s| (s.to_sym == :id) || required_attributes.include?(s)}.sort.each do |f| 210 | custom_type_name = "#{name.classify}#{f.to_s.classify}AttributeOutput" 211 | if GraphqlModelMapper::CustomType.const_defined?(custom_type_name) 212 | field f.to_sym, GraphqlModelMapper::CustomType.const_get(custom_type_name) 213 | else 214 | if enums.include?(f.to_s) 215 | field f.to_sym, -> {GraphqlModelMapper::MapperType.get_enum_object(name, enum_values[f.to_s], f.to_s)}, property: f.to_sym 216 | else 217 | field f.to_sym, -> {GraphqlModelMapper::MapperType.convert_type(columns[f.to_s].type, columns[f.to_s].sql_type, (source_nulls ? columns[f.to_s].null : true))}, property: f.to_sym 218 | end 219 | end 220 | end 221 | end if type_sub_key == :output_type 222 | GraphqlModelMapper.set_constant(typename, ret_type) if !GraphqlModelMapper.defined_constant?(typename) 223 | ret_type 224 | end 225 | 226 | 227 | def self.get_polymorphic_type(reflection, model_name) 228 | type_name = "#{model_name}#{reflection.name.to_s.classify}UnionOutput" 229 | 230 | return GraphqlModelMapper.get_constant(type_name) if GraphqlModelMapper.defined_constant?(type_name) 231 | 232 | model = model_name.classify.constantize 233 | has_with_deleted = model.public_methods.include?(:with_deleted) 234 | parent_name = "#{reflection.name}_type" 235 | parent_classes = has_with_deleted ? model.with_deleted.select("distinct #{parent_name}").map{|m| m.send("#{parent_name}".to_sym)} : model.select("distinct #{parent_name}").map{|m| m.send("#{parent_name}".to_sym)} 236 | types = [] 237 | parent_classes.each do |p| 238 | types << self.get_ar_object_with_params(p, type_sub_key: :output_type) 239 | end 240 | 241 | ret_type = GraphQL::UnionType.define do 242 | name type_name 243 | description "UnionType for polymorphic association #{reflection.name} on #{model_name}" 244 | possible_types types 245 | resolve_type ->(obj, ctx) { 246 | GraphqlModelMapper::MapperType.get_ar_object_with_params(obj.class.name) 247 | } 248 | end 249 | GraphqlModelMapper.set_constant(type_name, ret_type) 250 | GraphqlModelMapper.get_constant(type_name) 251 | end 252 | 253 | def self.get_type_params(name, type_sub_key: :output_type) 254 | model = name.classify.constantize 255 | if model.public_methods.include?(:graphql_types) 256 | params = model.graphql_types 257 | else 258 | params = self.graphql_default_types 259 | end 260 | if !type_sub_key.nil? 261 | if params.keys.include?(type_sub_key.to_sym) 262 | params = params[type_sub_key.to_sym] 263 | else 264 | params = nil 265 | end 266 | else 267 | params = nil 268 | end 269 | params[:type_sub_key] = type_sub_key 270 | params 271 | end 272 | 273 | 274 | def self.get_connection_type(model_name, output_type) 275 | connection_type_name = "#{GraphqlModelMapper.get_type_case(GraphqlModelMapper.get_type_name(model_name))}Connection" 276 | if GraphqlModelMapper.defined_constant?(connection_type_name) 277 | connection_type = GraphqlModelMapper.get_constant(connection_type_name) 278 | else 279 | connection_type = output_type.define_connection do 280 | name connection_type_name 281 | field :total, hash_key: :total do 282 | type types.Int 283 | resolve ->(obj, args, ctx) { 284 | obj.nodes.limit(nil).count 285 | #obj.nodes.length 286 | } 287 | end 288 | end 289 | GraphqlModelMapper.set_constant(connection_type_name, connection_type) 290 | end 291 | return GraphqlModelMapper.get_constant(connection_type_name) 292 | end 293 | 294 | def self.get_list_type(model_name, output_type) 295 | list_type_name = "#{GraphqlModelMapper.get_type_case(GraphqlModelMapper.get_type_name(model_name))}List" 296 | if GraphqlModelMapper.defined_constant?(list_type_name) 297 | list_type = GraphqlModelMapper.get_constant(list_type_name) 298 | else 299 | list_type = GraphQL::ObjectType.define do 300 | name(list_type_name) 301 | 302 | field :items, -> {output_type.to_list_type}, hash_key: :items do 303 | argument :per_page, GraphQL::INT_TYPE 304 | argument :page, GraphQL::INT_TYPE 305 | resolve -> (obj,args,ctx){ GraphqlModelMapper::MapperType.resolve_list(obj,args,ctx) } 306 | end 307 | field :total, -> {GraphQL::INT_TYPE}, hash_key: :total do 308 | resolve->(obj,args, ctx){ 309 | obj.count 310 | } 311 | end 312 | end 313 | GraphqlModelMapper.set_constant(list_type_name, list_type) 314 | end 315 | GraphqlModelMapper.get_constant(list_type_name) 316 | end 317 | 318 | 319 | def self.resolve_list(obj, args, ctx) 320 | first_rec = nil 321 | last_rec = nil 322 | limit = GraphqlModelMapper.max_page_size.to_i 323 | 324 | if args[:per_page] 325 | per_page = args[:per_page].to_i 326 | raise GraphQL::ExecutionError.new("per_page must be greater than 0") if per_page < 1 327 | raise GraphQL::ExecutionError.new("you requested more items than the maximum page size #{limit}, please reduce your requested per_page entry") if per_page > limit 328 | limit = [per_page,limit].min 329 | end 330 | if args[:page] 331 | page = args[:page].to_i 332 | raise GraphQL::ExecutionError.new("page must be greater than 0") if page < 1 333 | max_page = (obj.count/limit).ceil 334 | raise GraphQL::ExecutionError.new("you requested page #{page} which is greater than the maximum number of pages #{max_page}") if page > max_page 335 | obj = obj.offset((page-1)*limit) 336 | end 337 | obj = obj.limit(limit) 338 | obj 339 | end 340 | 341 | def self.graphql_default_types( 342 | input_type: { 343 | required_attributes: [], 344 | excluded_attributes: [], 345 | allowed_attributes: [], 346 | foreign_keys: true, 347 | primary_keys: true, 348 | validation_keys: false, 349 | association_macro: nil, 350 | source_nulls: false 351 | }, 352 | output_type: { 353 | required_attributes: [], 354 | excluded_attributes: [], 355 | allowed_attributes: [], 356 | foreign_keys: true, 357 | primary_keys: true, 358 | validation_keys: false, 359 | association_macro: nil, 360 | source_nulls: false 361 | } 362 | ) 363 | return GraphqlModelMapper::CustomType.const_get("GRAPHQL_DEFAULT_TYPES") if GraphqlModelMapper::CustomType.const_defined?("GRAPHQL_DEFAULT_TYPES") 364 | 365 | graphql_type = {} 366 | graphql_type[:input_type] = input_type 367 | graphql_type[:output_type] = output_type 368 | 369 | GraphqlModelMapper::CustomType.const_set("GRAPHQL_DEFAULT_TYPES", graphql_type) 370 | graphql_type 371 | end 372 | 373 | def self.model_validation_keys(name) 374 | model = name.classify.constantize 375 | validation_attributes = model.validators.select{|m| m.is_a?(ActiveModel::Validations::PresenceValidator) && !m.options[:if]}.map(&:attributes).flatten 376 | model.reflect_on_all_associations.select{|p| validation_attributes.include?(p.name) }.map(&:foreign_key).map(&:to_sym) | validation_attributes & model.columns_hash.keys.map(&:to_sym) 377 | end 378 | 379 | def self.get_enum_object(model_name, enum, enum_name) 380 | enum_values = enum.keys 381 | type_name = "#{model_name.classify}#{enum_name.classify}Enum" 382 | return GraphqlModelMapper.get_constant(type_name) if GraphqlModelMapper.defined_constant?(type_name) 383 | ret_type = GraphQL::EnumType.define do 384 | name type_name 385 | description "generated GraphQL enum for ActiveRecord enum #{enum_name} on model #{model_name}" 386 | enum_values.each do |v| 387 | value(v.classify, "", value: v) 388 | end 389 | end 390 | GraphqlModelMapper.set_constant(type_name, ret_type) 391 | GraphqlModelMapper.get_constant(type_name) 392 | end 393 | # convert a database type to a GraphQL type 394 | # @param db_type [Symbol] the type returned by columns_hash[column_name].type 395 | # @param db_sql_type [String] the sql_type returned by columns_hash[column_name].sql_type 396 | # @return [GraphQL::ScalarType] a GraphQL type 397 | def self.convert_type db_type, db_sql_type="", nullable=true 398 | # because we are outside of a GraphQL define block we cannot use the types helper 399 | # we must refer directly to the built-in GraphQL scalar types 400 | case db_type 401 | when :integer 402 | nullable ? GraphQL::INT_TYPE : !GraphQL::INT_TYPE 403 | when :decimal, :float 404 | nullable ? GraphQL::FLOAT_TYPE : !GraphQL::FLOAT_TYPE 405 | when :boolean 406 | nullable ? GraphQL::BOOLEAN_TYPE : !GraphQL::BOOLEAN_TYPE 407 | when :date, :datetime 408 | nullable ? GraphqlModelMapper::DATE_TYPE : !GraphqlModelMapper::DATE_TYPE 409 | else 410 | case db_sql_type.to_sym #these are strings not symbols 411 | when :geometry, :multipolygon, :polygon 412 | case db_type 413 | when :string 414 | nullable ? GraphqlModelMapper::GEOMETRY_OBJECT_TYPE : !GraphqlModelMapper::GEOMETRY_OBJECT_TYPE 415 | else 416 | nullable ? GraphqlModelMapper::GEOMETRY_STRING_TYPE : !GraphqlModelMapper::GEOMETRY_STRING_TYPE 417 | end 418 | else 419 | nullable ? GraphQL::STRING_TYPE : !GraphQL::STRING_TYPE 420 | end 421 | end 422 | end 423 | end 424 | end -------------------------------------------------------------------------------- /lib/graphql_model_mapper/mutation.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | module Mutation 3 | def self.graphql_update(name: "",description:"", 4 | resolver: nil) 5 | 6 | 7 | input_type = GraphqlModelMapper::MapperType.get_ar_object_with_params(name, type_sub_key: :input_type) 8 | output_type = GraphqlModelMapper::MapperType.get_ar_object_with_params(name, type_sub_key: :output_type) 9 | 10 | self.get_mutation(name, description, "Update", resolver, input_type, output_type, name.downcase, "item") 11 | end 12 | 13 | def self.graphql_delete(name: "", description:"", 14 | resolver: nil, 15 | arguments: [], 16 | scope_methods: []) 17 | 18 | input_type = GraphqlModelMapper::MapperType.get_ar_object_with_params(name, type_sub_key: :input_type) 19 | output_type = GraphqlModelMapper::MapperType.get_ar_object_with_params(name, type_sub_key: :output_type).to_list_type 20 | self.get_delete_mutation(name, description, "Delete", resolver, arguments, scope_methods, input_type, output_type) 21 | end 22 | 23 | def self.graphql_create(name: "", description:"", 24 | resolver: nil) 25 | 26 | input_type = GraphqlModelMapper::MapperType.get_ar_object_with_params(name, type_sub_key: :input_type) 27 | output_type = GraphqlModelMapper::MapperType.get_ar_object_with_params(name, type_sub_key: :output_type) 28 | 29 | self.get_mutation(name, description, "Create", resolver, input_type, output_type, name.downcase, "item") 30 | end 31 | 32 | def self.get_mutation(name, description, operation_name, resolver, input_type, output_type, input_name, output_name) 33 | model = name.classify.constantize 34 | mutation_type_name = GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(name)}#{operation_name}") 35 | return GraphqlModelMapper.get_constant(mutation_type_name) if GraphqlModelMapper.defined_constant?(mutation_type_name) 36 | mutation_type = GraphQL::Relay::Mutation.define do 37 | name mutation_type_name 38 | description description 39 | input_field input_name.to_sym, -> {input_type} 40 | return_field output_name.to_sym, -> {output_type} 41 | 42 | resolve GraphqlModelMapper::Mutation.get_resolver(resolver, model, operation_name.downcase.to_sym) 43 | end 44 | 45 | GraphqlModelMapper.set_constant(mutation_type_name, mutation_type.field) 46 | GraphqlModelMapper.get_constant(mutation_type_name) 47 | end 48 | 49 | def self.get_delete_mutation(name, description, operation_name, resolver, arguments, scope_methods, input_type, output_type) 50 | query_type_name = GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(name)}#{operation_name}") 51 | return GraphqlModelMapper.get_constant(query_type_name) if GraphqlModelMapper.defined_constant?(query_type_name) 52 | 53 | model = name.classify.constantize 54 | default_arguments = arguments ? (arguments.length > 0 ? arguments : self.get_default_select_arguments(model, scope_methods)) : [] 55 | 56 | select_input_type_name = GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(name)}SelectInput") 57 | if GraphqlModelMapper.defined_constant?(select_input_type_name) 58 | query_input_object_type = GraphqlModelMapper.get_constant(select_input_type_name) 59 | else 60 | query_input_object_type = GraphQL::InputObjectType.define do 61 | name select_input_type_name 62 | default_arguments.each do |k| 63 | argument k[:name].to_sym, k[:type], k[:description], default_value: k[:default] do 64 | if k[:authorization] && GraphqlModelMapper.use_authorize 65 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 66 | model_name name 67 | access_type k[:authorization] 68 | end 69 | end 70 | end 71 | end 72 | GraphqlModelMapper.set_constant(select_input_type_name, query_input_object_type) 73 | end 74 | 75 | total_result_type_name = GraphqlModelMapper.get_type_case("TotalResult") 76 | if GraphqlModelMapper.defined_constant?(total_result_type_name) 77 | total_result_type = GraphqlModelMapper.get_constant(total_result_type_name) 78 | else 79 | total_result_type = GraphQL::InterfaceType.define do 80 | name total_result_type_name 81 | field :total, -> {GraphQL::INT_TYPE} do 82 | resolve -> (obj, args, ctx) { 83 | obj.items.length 84 | } 85 | end 86 | end 87 | GraphqlModelMapper.set_constant(total_result_type_name, total_result_type) 88 | end 89 | 90 | 91 | ret_type = GraphQL::Relay::Mutation.define do 92 | name query_type_name 93 | return_field :items, output_type 94 | return_interfaces [total_result_type] 95 | input_field :select, -> {!query_input_object_type} 96 | 97 | resolve GraphqlModelMapper::Mutation.get_resolver(resolver, model, :delete) 98 | end 99 | GraphqlModelMapper.set_constant(query_type_name, ret_type.field) 100 | GraphqlModelMapper.get_constant(query_type_name) 101 | end 102 | 103 | def self.get_default_select_arguments(model, scope_methods) 104 | default_arguments = [ 105 | {:name=>:id, :type=>GraphQL::ID_TYPE, :default=>nil}, 106 | {:name=>:ids, :type=>GraphQL::ID_TYPE.to_list_type, :default=>nil}, 107 | ] 108 | 109 | default_arguments = default_arguments + [ 110 | {:name=>:item_id, :type=>GraphQL::INT_TYPE, :default=>nil}, 111 | {:name=>:item_ids, :type=>GraphQL::INT_TYPE.to_list_type, :default=>nil} 112 | ] if GraphqlModelMapper::MapperType.get_type_params(model.name, type_sub_key: :input_type)[:primary_keys] 113 | 114 | default_arguments = default_arguments + [ 115 | {:name=>:explain, :type=>GraphQL::BOOLEAN_TYPE, :default=>nil, :authorization=>:manage}, 116 | {:name=>:order, :type=>GraphQL::STRING_TYPE, :default=>nil, :authorization=>:manage}, 117 | {:name=>:where, :type=>GraphQL::STRING_TYPE.to_list_type, :default=>nil, :authorization=>:manage } 118 | ] 119 | 120 | scope_methods = scope_methods.map(&:to_sym) 121 | #.select{|m| model.method(m.to_sym).arity == 0} 122 | if (model.public_methods - model.instance_methods - Object.methods - ActiveRecord::Base.methods).include?(:with_deleted) 123 | default_arguments << {:name=>:with_deleted, :type=>GraphQL::BOOLEAN_TYPE, :default=>false, :authorization=>:manage} 124 | end 125 | allowed_scope_methods = [] 126 | if scope_methods.count > 0 127 | scope_methods.each do |s| 128 | #.select{|m| model.method(m.to_sym).arity == 0} 129 | allowed_scope_methods << s if (model.public_methods - model.instance_methods - Object.methods - ActiveRecord::Base.methods).include?(s) 130 | end 131 | if allowed_scope_methods.count > 0 132 | scope_enum_type_name = GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(model.name)}Scope_Enum") 133 | if !GraphqlModelMapper.defined_constant?(scope_enum_type_name) 134 | enum_type = GraphQL::EnumType.define do 135 | name scope_enum_type_name 136 | description "scope enum for #{GraphqlModelMapper.get_type_name(model.name)}" 137 | allowed_scope_methods.sort.each do |s| 138 | value(s, "") 139 | end 140 | end 141 | GraphqlModelMapper.set_constant scope_enum_type_name, enum_type 142 | end 143 | #default_arguments << {:name=>:scope, :type=>GraphqlModelMapper.get_constant(typename), :default=>nil, :authorization=>:manage} 144 | 145 | scope_list_type_name = GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(model.name)}Scope_List") 146 | if !GraphqlModelMapper.defined_constant?(scope_list_type_name) 147 | scope_list_type = GraphQL::InputObjectType.define do 148 | name scope_list_type_name 149 | description "scope list for #{GraphqlModelMapper.get_type_name(model.name)}" 150 | argument :scope, !GraphqlModelMapper.get_constant(scope_enum_type_name) 151 | argument :arguments, GraphQL::STRING_TYPE.to_list_type 152 | end 153 | GraphqlModelMapper.set_constant scope_list_type_name, scope_list_type 154 | end 155 | default_arguments << {:name=>:scopes, :type=>GraphqlModelMapper.get_constant(scope_list_type_name).to_list_type , :default=>nil, :authorization=>:manage} 156 | end 157 | end 158 | default_arguments 159 | end 160 | 161 | def self.get_resolver(resolver, model, operation) 162 | if model.public_methods.include?("graphql_#{operation}_resolver".to_sym) 163 | case operation 164 | when :create 165 | resolver = -> (obj, args, ctx) {model.graphql_create_resolver(obj,args,ctx) } if model.public_methods.include?(:graphql_create_resolver) 166 | when :delete 167 | resolver = -> (obj, args, ctx) {model.graphql_delete_resolver(obj,args,ctx) } if model.public_methods.include?(:graphql_delete_resolver) 168 | when :update 169 | resolver = -> (obj, args, ctx) {model.graphql_update_resolver(obj,args,ctx) } if model.public_methods.include?(:graphql_update_resolver) 170 | end 171 | end 172 | if GraphqlModelMapper.mutation_resolve_wrapper && GraphqlModelMapper.mutation_resolve_wrapper < GraphqlModelMapper::Resolve::ResolveWrapper 173 | return GraphqlModelMapper.mutation_resolve_wrapper.new(resolver) 174 | else 175 | return GraphqlModelMapper::Resolve::ResolveWrapper.new(resolver) 176 | end 177 | end 178 | 179 | end 180 | end -------------------------------------------------------------------------------- /lib/graphql_model_mapper/query.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | module Query 3 | def self.graphql_query(name: "", 4 | description: "", 5 | resolver: nil, 6 | arguments: [], 7 | scope_methods: [] 8 | ) 9 | 10 | input_type = GraphqlModelMapper::MapperType.get_ar_object_with_params(name, type_sub_key: :input_type) 11 | output_type = GraphqlModelMapper::MapperType.get_ar_object_with_params(name, type_sub_key: :output_type) 12 | self.get_query(name, description, "Query", resolver, arguments, scope_methods, input_type, output_type) 13 | end 14 | 15 | def self.get_default_select_arguments(model, scope_methods) 16 | default_arguments = [ 17 | {:name=>:id, :type=>GraphQL::ID_TYPE, :default=>nil}, 18 | {:name=>:ids, :type=>GraphQL::ID_TYPE.to_list_type, :default=>nil}, 19 | ] 20 | 21 | default_arguments = default_arguments + [ 22 | {:name=>:item_id, :type=>GraphQL::INT_TYPE, :default=>nil}, 23 | {:name=>:item_ids, :type=>GraphQL::INT_TYPE.to_list_type, :default=>nil} 24 | ] if GraphqlModelMapper::MapperType.get_type_params(model.name, type_sub_key: :input_type)[:primary_keys] 25 | 26 | 27 | default_arguments = default_arguments + [ 28 | {:name=>:explain, :type=>GraphQL::BOOLEAN_TYPE, :default=>nil, :authorization=>:manage}, 29 | {:name=>:order, :type=>GraphQL::STRING_TYPE, :default=>nil, :authorization=>:manage}, 30 | {:name=>:where, :type=>GraphQL::STRING_TYPE.to_list_type, :default=>nil, :authorization=>:manage } 31 | ] 32 | =begin 33 | default_arguments = default_arguments + [ 34 | # {:name=>:limit, :type=>GraphQL::INT_TYPE, :default=>GraphqlModelMapper.max_page_size}, 35 | {:name=>:first, :type=>GraphQL::INT_TYPE, :default=>nil}, 36 | {:name=>:last, :type=>GraphQL::INT_TYPE, :default=>nil}, 37 | {:name=>:offset, :type=>GraphQL::INT_TYPE, :default=>nil}] if ![:deep, :shallow].include?(GraphqlModelMapper.nesting_strategy) 38 | =end 39 | scope_methods = scope_methods.map(&:to_sym) 40 | #.select{|m| model.method(m.to_sym).arity == 0} 41 | if (model.public_methods - model.instance_methods - Object.methods - ActiveRecord::Base.methods).include?(:with_deleted) 42 | default_arguments << {:name=>:with_deleted, :type=>GraphQL::BOOLEAN_TYPE, :default=>false, :authorization=>:manage} 43 | end 44 | allowed_scope_methods = [] 45 | if scope_methods.count > 0 46 | scope_methods.each do |s| 47 | #.select{|m| model.method(m.to_sym).arity == 0} 48 | allowed_scope_methods << s if (model.public_methods - model.instance_methods - Object.methods - ActiveRecord::Base.methods).include?(s) 49 | end 50 | if allowed_scope_methods.count > 0 51 | scope_enum_type_name = GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(model.name)}Scope_Enum") 52 | if !GraphqlModelMapper.defined_constant?(scope_enum_type_name) 53 | enum_type = GraphQL::EnumType.define do 54 | name scope_enum_type_name 55 | description "scope enum for #{GraphqlModelMapper.get_type_name(model.name)}" 56 | allowed_scope_methods.sort.each do |s| 57 | value(s, "") 58 | end 59 | end 60 | GraphqlModelMapper.set_constant scope_enum_type_name, enum_type 61 | end 62 | #default_arguments << {:name=>:scope, :type=>GraphqlModelMapper.get_constant(typename), :default=>nil, :authorization=>:manage} 63 | 64 | scope_list_type_name = GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(model.name)}Scope_List") 65 | if !GraphqlModelMapper.defined_constant?(scope_list_type_name) 66 | scope_list_type = GraphQL::InputObjectType.define do 67 | name scope_list_type_name 68 | description "scope list for #{GraphqlModelMapper.get_type_name(model.name)}" 69 | argument :scope, !GraphqlModelMapper.get_constant(scope_enum_type_name) 70 | argument :arguments, GraphQL::STRING_TYPE.to_list_type 71 | end 72 | GraphqlModelMapper.set_constant scope_list_type_name, scope_list_type 73 | end 74 | default_arguments << {:name=>:scopes, :type=>GraphqlModelMapper.get_constant(scope_list_type_name).to_list_type , :default=>nil, :authorization=>:manage} 75 | end 76 | end 77 | default_arguments 78 | end 79 | 80 | def self.get_query(name, description, operation_name, resolver, arguments, scope_methods, input_type, output_type) 81 | 82 | query_type_name = GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(name)}#{operation_name}") 83 | return GraphqlModelMapper.get_constant(query_type_name) if GraphqlModelMapper.defined_constant?(query_type_name) 84 | 85 | model = name.classify.constantize 86 | 87 | default_arguments = arguments ? (arguments.length > 0 ? arguments : self.get_default_select_arguments(model, scope_methods)) : [] 88 | 89 | 90 | =begin 91 | select_input_type_name = "#{GraphqlModelMapper.get_type_case(GraphqlModelMapper.get_type_name(name))}QueryInput" 92 | 93 | if GraphqlModelMapper.defined_constant?(select_input_type_name) 94 | select_input_type = GraphqlModelMapper.get_constant(select_input_type_name) 95 | else 96 | select_input_type = GraphQL::InputObjectType.define do 97 | name select_input_type_name 98 | default_arguments.each do |k| 99 | argument k[:name].to_sym, k[:type], k[:description], default_value: k[:default] 100 | end 101 | end 102 | GraphqlModelMapper.set_constant(select_input_type_name, select_input_type) 103 | end 104 | =end 105 | =begin 106 | page_info_type_name = "FlatPageInfo" 107 | if GraphqlModelMapper.defined_constant?(page_info_type_name) 108 | page_info_type = GraphqlModelMapper.get_constant(page_info_type_name) 109 | else 110 | page_info_type = GraphQL::ObjectType.define do 111 | name("FlatPageInfo") 112 | description("Information about pagination in a query.") 113 | field :hasNextPage, !types.Boolean, "When paginating forwards, are there more items?", property: :has_next_page 114 | field :hasPreviousPage, !types.Boolean, "When paginating backwards, are there more items?", property: :has_previous_page 115 | field :startCursor, types.String, "When paginating backwards, the cursor to continue.", property: :start_cursor 116 | field :endCursor, types.String, "When paginating forwards, the cursor to continue.", property: :end_cursor 117 | end 118 | GraphqlModelMapper.set_constant(page_info_type_name, page_info_type) 119 | end 120 | =end 121 | 122 | 123 | 124 | total_output_type_name = "#{GraphqlModelMapper.get_type_name(name)}QueryPayload" 125 | if GraphqlModelMapper.defined_constant?(total_output_type_name) 126 | total_output_type = GraphqlModelMapper.get_constant(total_output_type_name) 127 | else 128 | if [:deep, :shallow].include?(GraphqlModelMapper.nesting_strategy) 129 | connection_type = GraphqlModelMapper::MapperType.get_connection_type(name, output_type) 130 | total_output_type = GraphQL::ObjectType.define do 131 | name total_output_type_name 132 | connection :items, -> {connection_type}, max_page_size: GraphqlModelMapper.max_page_size do 133 | resolve -> (obj, args, ctx) { 134 | limit = GraphqlModelMapper.max_page_size 135 | raise GraphQL::ExecutionError.new("you have exceeded the maximum requested page size #{limit}") if args[:first].to_i > limit || args[:last].to_i > limit 136 | obj 137 | } 138 | end 139 | end 140 | else 141 | total_output_type = GraphqlModelMapper::MapperType.get_list_type(name, output_type) 142 | end 143 | GraphqlModelMapper.set_constant(total_output_type_name, total_output_type) 144 | end 145 | 146 | 147 | 148 | ret_type = GraphQL::Field.define do 149 | name query_type_name 150 | type total_output_type 151 | default_arguments.each do |k| 152 | argument k[:name].to_sym, k[:type], k[:description], default_value: k[:default] do 153 | if k[:authorization] && GraphqlModelMapper.use_authorize 154 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 155 | model_name name 156 | access_type k[:authorization] 157 | end 158 | end 159 | end 160 | resolve GraphqlModelMapper::Query.get_resolver(resolver, model) 161 | end 162 | GraphqlModelMapper.set_constant(query_type_name, ret_type) 163 | GraphqlModelMapper.get_constant(query_type_name) 164 | end 165 | 166 | 167 | def self.get_resolver(resolver, model) 168 | 169 | resolver = -> (obj,args,ctx){ model.graphql_query_resolver(obj, args, ctx) } if model.public_methods.include?(:graphql_query_resolver) 170 | 171 | if GraphqlModelMapper.query_resolve_wrapper && GraphqlModelMapper.query_resolve_wrapper < GraphqlModelMapper::Resolve::ResolveWrapper 172 | return GraphqlModelMapper.query_resolve_wrapper.new(resolver) 173 | else 174 | return GraphqlModelMapper::Resolve::ResolveWrapper.new(resolver) 175 | end 176 | end 177 | end 178 | end -------------------------------------------------------------------------------- /lib/graphql_model_mapper/railtie.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | class Railtie < Rails::Railtie 3 | initializer 'Rails logger' do 4 | GraphqlModelMapper.logger = Rails.logger 5 | end 6 | end 7 | end 8 | 9 | -------------------------------------------------------------------------------- /lib/graphql_model_mapper/resolve.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | module Resolve 3 | def self.query_resolver(obj, args, ctx, name) 4 | #binding.pry 5 | obj_context = obj || name.classify.constantize 6 | model = name.classify.constantize 7 | select_args = args[:select] || args 8 | 9 | if !GraphqlModelMapper.authorized?(ctx, obj_context.name, :query) 10 | raise GraphQL::ExecutionError.new("error: unauthorized access: #{:query} '#{obj_context.class_name.classify}'") 11 | end 12 | classmethods = [] 13 | scope_allowed = false 14 | with_deleted_allowed = false 15 | test_query = false 16 | 17 | if select_args[:scopes] 18 | input_scopes = select_args[:scopes] 19 | allowed_scopes = [] 20 | input_scopes.each do |s| 21 | if model.public_methods.include?(s[:scope].to_sym) 22 | allowed_scopes << {method: s[:scope].to_sym, args: s[:arguments] } 23 | else 24 | next 25 | end 26 | end 27 | errors = [] 28 | allowed_scopes.each do |a| 29 | begin 30 | obj_context = obj_context.send(a[:method], *a[:args]) 31 | rescue => e 32 | errors << "scope method: #{a[:method]} arguments: #{a[:args] || []} error: #{e.message}" 33 | end 34 | end 35 | if errors.length > 0 36 | raise GraphQL::ExecutionError.new(errors.join("; ")) 37 | end 38 | test_query = true 39 | end 40 | if select_args[:scope] 41 | scope_allowed = model.public_methods.include?(select_args[:scope].to_sym) 42 | raise GraphQL::ExecutionError.new("error: invalid scope '#{select_args[:scope]}' specified, '#{select_args[:scope]}' method does not exist on '#{obj_context.class_name.classify}'") unless scope_allowed 43 | test_query = true 44 | end 45 | if select_args[:with_deleted] 46 | with_deleted_allowed = model.public_methods.include?(:with_deleted) 47 | raise GraphQL::ExecutionError.new("error: invalid usage of 'with_deleted', 'with_deleted' method does not exist on '#{obj_context.class_name.classify}'") unless with_deleted_allowed 48 | end 49 | if with_deleted_allowed && select_args[:with_deleted] 50 | obj_context = obj_context.send(:with_deleted) 51 | test_query = true 52 | end 53 | 54 | implied_includes = self.get_implied_includes(obj_context.name.classify.constantize, ctx.ast_node) 55 | if !implied_includes.empty? 56 | obj_context = obj_context.includes(implied_includes) 57 | if Rails.version.split(".").first.to_i > 3 58 | obj_context = obj_context.references(implied_includes) 59 | end 60 | end 61 | if select_args[:id] 62 | type_name, item_id = nil 63 | begin 64 | type_name, item_id = GraphQL::Schema::UniqueWithinType.decode(GraphqlModelMapper::Encryption.decode(select_args[:id])) 65 | rescue => e 66 | raise GraphQL::ExecutionError.new("incorrect global id: unable to resolve id: #{e.message}") 67 | end 68 | raise GraphQL::ExecutionError.new("incorrect global id: unable to resolve type for id:'#{select_args[:id]}'") if type_name.nil? 69 | model_name = GraphqlModelMapper.get_constant(type_name.upcase).metadata[:model_name].to_s.classify 70 | raise GraphQL::ExecutionError.new("incorrect global id '#{select_args[:id]}': expected global id for '#{name}', received global id for '#{model_name}'") if model_name != name 71 | obj_context = obj_context.where(["#{obj_context.model_name.plural}.id = ?", item_id.to_i]) 72 | test_query = true 73 | end 74 | if select_args[:ids] 75 | finder_array = [] 76 | errors = [] 77 | select_args[:ids].each do |id| 78 | type_name, item_id = GraphQL::Schema::UniqueWithinType.decode(GraphqlModelMapper::Encryption.decode(id)) 79 | if type_name.nil? 80 | errors << "incorrect global id: unable to resolve type for id:'#{id}'" 81 | next 82 | end 83 | model_name = GraphqlModelMapper.get_constant(type_name.upcase).metadata[:model_name].to_s.classify 84 | if model_name != name 85 | errors << "incorrect global id '#{id}': expected global id for '#{name}', received global id for '#{model_name}'" 86 | next 87 | end 88 | finder_array << item_id.to_i 89 | end 90 | if errors.length > 0 91 | raise GraphQL::ExecutionError.new(errors.join(";")) 92 | end 93 | obj_context = obj_context.where(["`#{obj_context.model_name.plural}`.id in (?)", finder_array]) 94 | test_query = true 95 | end 96 | if select_args[:item_ids] 97 | obj_context = obj_context.where(["`#{obj_context.model_name.plural}`.id in (?)", select_args[:item_ids]]) 98 | test_query = true 99 | end 100 | if select_args[:item_id] 101 | obj_context = obj_context.where(["`#{obj_context.model_name.plural}`.id = ?", select_args[:item_id].to_i]) 102 | test_query = true 103 | end 104 | if select_args[:where] 105 | obj_context = obj_context.where(select_args[:where]) 106 | test_query = true 107 | else 108 | obj_context = obj_context.where("1=1") 109 | end 110 | if scope_allowed 111 | obj_context = obj_context.send(select_args[:scope].to_sym) 112 | test_query = true 113 | end 114 | if select_args[:order] 115 | obj_context = obj_context.order(select_args[:order]) 116 | test_query = true 117 | end 118 | #check for sql errors 119 | begin 120 | GraphqlModelMapper.logger.info "GraphqlModelMapper: ****** testing query for validity" 121 | test_statement = obj_context.includes(implied_includes) 122 | if Rails.version.split(".").first.to_i > 3 123 | test_statement = test_statement.references(implied_includes) 124 | end 125 | test_result = test_statement.limit(0).to_a 126 | 127 | rescue ActiveRecord::StatementInvalid => e 128 | raise GraphQL::ExecutionError.new(e.message.sub(" AND (1=1)", "").sub(" LIMIT 0", "")) 129 | end if test_query 130 | if select_args[:explain] 131 | obj_context = obj_context.limit(1) 132 | obj_context = obj_context.eager_load(implied_includes) 133 | raise GraphQL::ExecutionError.new(obj_context.explain.split("\n").first.sub("EXPLAIN for: ", "").sub(" LIMIT 1", !select_args[:limit].nil? && select_args[:limit].to_f > 0 ? "LIMIT #{select_args[:limit]}" : "").sub(" AND (1=1)","").sub(" WHERE (1=1)","")) 134 | end 135 | #if select_args[:limit].nil? 136 | # obj_context = obj_context.limit(GraphqlModelMapper.max_page_size+1) 137 | #end 138 | 139 | obj_context 140 | end 141 | 142 | def self.update_resolver(obj, inputs, ctx, name) 143 | item = GraphqlModelMapper::Resolve.nested_update(ctx, name, inputs) 144 | item 145 | end 146 | 147 | def self.delete_resolver(obj, inputs, ctx, model_name) 148 | model = model_name.classify.constantize 149 | items = self.query_resolver(obj, inputs, ctx, model_name) 150 | ids = items.collect(&:id) 151 | if !GraphqlModelMapper.authorized?(ctx, model_name, :update) 152 | raise GraphQL::ExecutionError.new("error: unauthorized access: delete '#{model_name.classify}', transaction cancelled") 153 | end 154 | begin 155 | deleted_items = model.delete(ids) 156 | rescue => e 157 | raise e #GraphQL::ExecutionError.new("error: delete") 158 | end 159 | if model.public_methods.include?(:with_deleted) 160 | items.with_deleted 161 | else 162 | items 163 | end 164 | end 165 | 166 | def self.create_resolver(obj, inputs, ctx, model_name) 167 | if !GraphqlModelMapper.authorized?(ctx, model_name, :create) 168 | raise GraphQL::ExecutionError.new("error: unauthorized access: create '#{model_name.classify}'") 169 | end 170 | model = model_name.classify.constantize 171 | item = model.new(inputs[model_name.downcase].to_h) 172 | begin 173 | if !item.valid? 174 | raise GraphQL::ExecutionError.new(item.errors.full_messages.join("; ")) 175 | else 176 | raise GraphQL::ExecutionError.new("error: WIP, item not saved but is a valid '#{model_name.classify}'") 177 | #item.save! 178 | end 179 | end 180 | item 181 | end 182 | 183 | def self.using_relay_pagination?(selection) 184 | selection.name == 'edges' 185 | end 186 | 187 | def self.using_is_items_collection?(selection) 188 | selection.name == 'items' 189 | end 190 | 191 | def self.using_nodes_pagination?(selection) 192 | selection.name == 'nodes' 193 | end 194 | 195 | def self.using_items_pagination?(selection) 196 | selection.name == 'items' 197 | end 198 | 199 | def self.has_reflection_with_name?(class_name, selection_name) 200 | class_name.reflect_on_all_associations.select{|m|m.name == selection_name.to_sym}.present? 201 | end 202 | 203 | def self.map_relay_pagination_depencies(class_name, selection, dependencies) 204 | node_selection = selection.selections.find { |sel| sel.name == 'node' } 205 | 206 | if node_selection.present? 207 | get_implied_includes(class_name, node_selection, dependencies) 208 | else 209 | dependencies 210 | end 211 | end 212 | 213 | def self.get_implied_includes(class_name, ast_node, dependencies={}) 214 | ast_node.selections.each do |selection| 215 | name = selection.name 216 | 217 | if using_relay_pagination?(selection) 218 | map_relay_pagination_depencies(class_name, selection, dependencies) 219 | next 220 | end 221 | 222 | if using_nodes_pagination?(selection) 223 | get_implied_includes(class_name, selection, dependencies) 224 | next 225 | end 226 | 227 | if using_items_pagination?(selection) 228 | get_implied_includes(class_name, selection, dependencies) 229 | next 230 | end 231 | 232 | if using_is_items_collection?(selection) 233 | get_implied_includes(class_name, selection, dependencies) 234 | next 235 | end 236 | 237 | if has_reflection_with_name?(class_name, name) 238 | begin 239 | current_class_name = selection.name.singularize.classify.constantize 240 | dependencies[name] = get_implied_includes(current_class_name, selection) 241 | rescue NameError 242 | selection_name = class_name.reflections.with_indifferent_access[selection.name].class_name 243 | begin 244 | current_class_name = selection_name.singularize.classify.constantize 245 | dependencies[selection.name.to_sym] = get_implied_includes(current_class_name, selection) 246 | rescue 247 | # this will occur if the relation is polymorphic, since polymorphic associations do not have a class_name 248 | GraphqlModelMapper.logger.info "implied_includes: #{class_name} could not resolve a class for relation #{selection.name}" 249 | end 250 | next 251 | end 252 | end 253 | end 254 | dependencies 255 | end 256 | 257 | 258 | def self.nested_update(ctx, model_name, inputs, child_name=nil, child_id=nil, parent_name=nil, parent_id=nil, klass_name=nil) 259 | model = model_name.classify.constantize 260 | 261 | if !child_name.nil? && !child_id.nil? # has_many && has_one 262 | inputs_root = inputs 263 | #puts "inputs_root[:item_id] #{inputs_root[:item_id]} #{inputs_root}" 264 | if model.public_methods.include?(:with_deleted) 265 | item = model.with_deleted.where("id = ? and #{child_name.downcase}_id = ?", inputs_root[:item_id], child_id).first 266 | else 267 | item = model.where("id = ? and #{child_name.downcase}_id = ?", inputs_root[:item_id], child_id).first 268 | end 269 | raise GraphQL::ExecutionError.new("error: #{model.name} record not found for #{model.name}.id = #{inputs_root[:item_id]} and #{model.name}.#{child_name.downcase}_id = #{child_id}") if item.nil? 270 | elsif !parent_name.nil? && !parent_id.nil? # belongs_to 271 | inputs_root = inputs 272 | #puts "parent_id #{parent_id} parent_name #{parent_name} #{model_name} model.with_deleted.find(#{parent_id}).send(#{parent_name}.to_sym).id} inputs_root[:item_id] #{inputs_root[:item_id]} #{inputs_root}" 273 | if model.public_methods.include?(:with_deleted) 274 | item = model.with_deleted.find(parent_id).public_send(parent_name.to_sym) if model.with_deleted.find(parent_id).public_send(parent_name.to_sym) && model.with_deleted.find(parent_id).public_send(parent_name.to_sym).id == inputs_root[:item_id] 275 | else 276 | item = model.find(parent_id).public_send(parent_name.to_sym) if model.find(parent_id).public_send(parent_name.to_sym) && model.with_deleted.find(parent_id).public_send(parent_name.to_sym).id == inputs_root[:item_id] 277 | end 278 | raise GraphQL::ExecutionError.new("error: #{model.name}.#{parent_name} record not found for #{model.name}.with_deleted.find(#{parent_id}).#{parent_name}_id = #{inputs_root[:item_id]}") if item.nil? 279 | model_name = klass_name 280 | model = klass_name.classify.constantize 281 | else #root query always single record, need to offeset property for object_input_type 282 | inputs_root = inputs[model_name.downcase] 283 | #puts "inputs_root[:item_id] #{inputs_root[:item_id]} #{inputs_root}" 284 | if model.public_methods.include?(:with_deleted) 285 | item = model.with_deleted.find(inputs_root[:item_id]) 286 | else 287 | item = model.find(inputs_root[:item_id]) 288 | end 289 | raise GraphQL::ExecutionError.new("error: #{model.name} record not found for #{model.name}.id=#{inputs[model_name.downcase][:item_id]}") if item.nil? 290 | end 291 | if !GraphqlModelMapper.authorized?(ctx, model.name, :update) 292 | raise GraphQL::ExecutionError.new("error: unauthorized access: #{:update} '#{model}', transaction cancelled") 293 | end 294 | 295 | item_associations = model.reflect_on_all_associations.select{|t| begin t.klass rescue next end}.select{|t| !t.options[:polymorphic]} 296 | item_association_names = item_associations.map{|m| m.name.to_s} 297 | input_association_names = item_association_names & inputs_root.to_h.keys 298 | 299 | item.transaction do 300 | #puts "***********item.update_attributes(#{inputs_root.to_h.except('id').except!(*item_association_names)})" 301 | #puts "***********ctx[current_user.to_sym].is_admin?(#{ctx[:current_user].is_admin?})" 302 | item.update_attributes(inputs_root.to_h.except('id').except('item_id').except!(*item_association_names)) 303 | input_association_names.each do |ia| 304 | lclinput = inputs_root[ia] 305 | ass = item_associations.select{|a| a.name.to_s == ia}.first 306 | klass = ass.klass 307 | is_collection = ass.collection? 308 | belongs_to = ass.belongs_to? 309 | #puts "#{ass.name} #{ass.collection?} #{ass.belongs_to?}" 310 | #puts "#{ass.association_foreign_key} #{ass.association_primary_key} #{ass.active_record_primary_key}" 311 | 312 | if is_collection 313 | #puts "is_collection" 314 | lclinput.each do |i| 315 | #puts "#{klass.name} #{i.to_h} #{model_name.downcase} #{inputs_root[:item_id]}" 316 | GraphqlModelMapper::Resolve.nested_update(ctx, klass.name, i, model_name.downcase, inputs_root[:item_id]) 317 | end 318 | elsif !is_collection && belongs_to 319 | #puts "belongs_to" 320 | #puts "self.nested_update(#{ctx}, #{model.name}, #{lclinput.to_h}, nil, nil, #{ass.name}, #{inputs_root[:item_id]}, #{klass.name})" 321 | GraphqlModelMapper::Resolve.nested_update(ctx, model.name, lclinput, nil, nil, ass.name, inputs_root[:item_id], klass.name) 322 | elsif !is_collection && !belongs_to #has_one 323 | #puts "has_one" 324 | #puts "self.nested_update(#{ctx}, #{klass.name}, #{lclinput.to_h}, #{model_name.downcase}, #{inputs_root[:item_id]})" 325 | GraphqlModelMapper::Resolve.nested_update(ctx, model.name, lclinput, nil, nil, ass.name, inputs_root[:item_id], klass.name) 326 | end 327 | end 328 | end 329 | item 330 | end 331 | 332 | class ResolveWrapper 333 | def initialize(resolve_func) 334 | @resolve_func = resolve_func 335 | end 336 | 337 | def call(obj, args, ctx) 338 | @resolve_func.call(obj, args, ctx) 339 | end 340 | end 341 | end 342 | end -------------------------------------------------------------------------------- /lib/graphql_model_mapper/schema.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | def self.Schema(log_query_depth: false, log_query_complexity: false, use_backtrace: false, use_authorize: false, nesting_strategy: :deep, type_case: :camelize, max_page_size: 100, scan_for_polymorphic_associations: false, mutation_resolve_wrapper: nil, query_resolve_wrapper: nil, bidirectional_pagination: false, default_nodes_field: false, handle_errors: false, secret_token: nil) 3 | 4 | return GraphqlModelMapper.get_constant("GraphqlModelMapperSchema".upcase) if GraphqlModelMapper.defined_constant?("GraphqlModelMapperSchema".upcase) 5 | GraphqlModelMapper.use_authorize = use_authorize 6 | GraphqlModelMapper.nesting_strategy = nesting_strategy 7 | GraphqlModelMapper.type_case = type_case 8 | GraphqlModelMapper.max_page_size = max_page_size 9 | GraphqlModelMapper.scan_for_polymorphic_associations = scan_for_polymorphic_associations 10 | GraphqlModelMapper.default_nodes_field = default_nodes_field 11 | GraphqlModelMapper.bidirectional_pagination = bidirectional_pagination 12 | GraphqlModelMapper.handle_errors = handle_errors 13 | 14 | if query_resolve_wrapper && query_resolve_wrapper < GraphqlModelMapper::Resolve::ResolveWrapper 15 | GraphqlModelMapper.query_resolve_wrapper = query_resolve_wrapper 16 | else 17 | GraphqlModelMapper.query_resolve_wrapper = GraphqlModelMapper::Resolve::ResolveWrapper 18 | end 19 | 20 | if mutation_resolve_wrapper && mutation_resolve_wrapper < GraphqlModelMapper::Resolve::ResolveWrapper 21 | GraphqlModelMapper.mutation_resolve_wrapper = mutation_resolve_wrapper 22 | else 23 | GraphqlModelMapper.mutation_resolve_wrapper = GraphqlModelMapper::Resolve::ResolveWrapper 24 | end 25 | 26 | if secret_token 27 | GraphqlModelMapper.secret_token = secret_token 28 | end 29 | 30 | 31 | GraphQL::Relay::ConnectionType.bidirectional_pagination = GraphqlModelMapper.bidirectional_pagination 32 | GraphQL::Relay::ConnectionType.default_nodes_field = GraphqlModelMapper.default_nodes_field 33 | 34 | #if GraphqlModelMapper.use_authorize 35 | metadata_definitions = { 36 | authorized: ->(field, authorized_proc) { field.metadata[:authorized_proc] = authorized_proc }, 37 | model_name: GraphQL::Define.assign_metadata_key(:model_name), 38 | access_type: GraphQL::Define.assign_metadata_key(:access_type) 39 | } 40 | GraphQL::Field.accepts_definitions(metadata_definitions) 41 | GraphQL::Argument.accepts_definitions(metadata_definitions) 42 | GraphQL::ObjectType.accepts_definitions(metadata_definitions) 43 | #end 44 | 45 | schema = GraphQL::Schema.define do 46 | use GraphQL::Backtrace if use_backtrace 47 | default_max_page_size max_page_size.to_i 48 | mutation GraphqlModelMapper.MutationType 49 | query GraphqlModelMapper.QueryType 50 | 51 | resolve_type ->(type, obj, ctx) { 52 | raise GraphQL::ExecutionError.new("unauthorized access: #{obj.class.name}") if !GraphqlModelMapper.authorized?(ctx, obj.class.name) 53 | GraphqlModelMapper.get_constant("#{obj.class.name}Output".upcase) 54 | } 55 | 56 | # Create UUIDs by joining the type name & ID, then base64-encoding it 57 | id_from_object ->(object, type_definition, context) { 58 | GraphqlModelMapper::Encryption.encode(GraphQL::Schema::UniqueWithinType.encode(type_definition.name, object.id)) 59 | } 60 | 61 | object_from_id ->(id, context) { 62 | type_name, item_id = nil 63 | begin 64 | type_name, item_id = GraphQL::Schema::UniqueWithinType.decode(GraphqlModelMapper::Encryption.decode(id)) 65 | rescue => e 66 | raise GraphQL::ExecutionError.new("incorrect global id: unable to resolve id: #{e.message}") 67 | end 68 | 69 | type = GraphqlModelMapper.get_constant(type_name.upcase) 70 | raise GraphQL::ExecutionError.new("unknown type for id: #{id}") if type.nil? 71 | authorized_proc = type.metadata[:authorized_proc] 72 | model_name = type.metadata[:model_name] 73 | access_type = type.metadata[:access_type] 74 | 75 | 76 | raise GraphQL::ExecutionError.new("unauthorized access for id: #{id}") if !authorized_proc.call(context, model_name, access_type) 77 | model = model_name.to_s.classify.constantize 78 | model.unscoped.find(item_id) 79 | } 80 | end 81 | 82 | 83 | schema.query_analyzers << GraphQL::Analysis::QueryDepth.new { |query, depth| Rails.logger.info("[******GraphqlModelMapper Query Depth] #{depth}") } if log_query_depth 84 | schema.query_analyzers << GraphQL::Analysis::QueryComplexity.new { |query, complexity| Rails.logger.info("[******GraphqlModelMapper Query Complexity] #{complexity}")} if log_query_complexity 85 | GraphQL::Errors.configure(schema) do 86 | rescue_from ActiveRecord::RecordNotFound do |exception| 87 | nil 88 | end 89 | 90 | rescue_from ActiveRecord::StatementInvalid do |exception| 91 | GraphQL::ExecutionError.new(exception.message) 92 | end 93 | 94 | rescue_from ActiveRecord::RecordInvalid do |exception| 95 | GraphQL::ExecutionError.new(exception.record.errors.full_messages.join("\n")) 96 | end 97 | 98 | rescue_from StandardError do |exception| 99 | GraphQL::ExecutionError.new(exception.message) 100 | end 101 | 102 | rescue_from do |exception| 103 | GraphQL::ExecutionError.new(exception.message) 104 | end 105 | end if GraphqlModelMapper.handle_errors && GraphQL.const_defined?("Errors") 106 | 107 | GraphqlModelMapper.set_constant("GraphqlModelMapperSchema".upcase, schema) 108 | GraphqlModelMapper.get_constant("GraphqlModelMapperSchema".upcase) 109 | end 110 | 111 | 112 | def self.QueryType 113 | return GraphQL::ObjectType.define do 114 | name 'Query' 115 | # create queries for each AR model object 116 | field :node, GraphQL::Relay::Node.field do 117 | description "Fetches an object given its globally unique ID" 118 | end 119 | 120 | field :nodes, GraphQL::Relay::Node.plural_field do 121 | description "Fetches a list of objects given a list of globally unique IDs" 122 | end 123 | 124 | GraphqlModelMapper.schema_queries.each do |f| 125 | field f[:name], f[:field] do 126 | if GraphqlModelMapper.use_authorize 127 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 128 | model_name f[:model_name] 129 | access_type f[:access_type].to_s 130 | end 131 | end 132 | end 133 | end 134 | end 135 | 136 | def self.MutationType 137 | return GraphQL::ObjectType.define do 138 | name 'Mutation' 139 | 140 | field :login, GraphqlModelMapper::LOGIN.field 141 | field :logout, GraphqlModelMapper::LOGOUT.field 142 | 143 | GraphqlModelMapper.schema_mutations.each do |f| 144 | field f[:name], f[:field] do 145 | if GraphqlModelMapper.use_authorize 146 | authorized ->(ctx, model_name, access_type) { GraphqlModelMapper.authorized?(ctx, model_name, access_type.to_sym) } 147 | model_name f[:model_name] 148 | access_type f[:access_type].to_s 149 | end 150 | end 151 | end 152 | end 153 | end 154 | end 155 | 156 | GraphqlModelMapper::LOGIN = GraphQL::Relay::Mutation.define do 157 | name 'Login' 158 | description '' 159 | input_field :username, !GraphQL::STRING_TYPE 160 | input_field :password, !GraphQL::STRING_TYPE 161 | return_field :success, GraphQL::BOOLEAN_TYPE 162 | 163 | resolve -> (obj, args, ctx){ 164 | { 165 | success: true 166 | } 167 | } 168 | end 169 | 170 | GraphqlModelMapper::LOGOUT = GraphQL::Relay::Mutation.define do 171 | name 'Logout' 172 | description '' 173 | return_field :success, GraphQL::BOOLEAN_TYPE 174 | resolve -> (obj, args, ctx){ 175 | { 176 | success: true 177 | } 178 | } 179 | end 180 | 181 | GraphqlModelMapper::GEOMETRY_OBJECT_TYPE = GraphQL::ScalarType.define do 182 | name "GeometryObject" 183 | description "The Geometry scalar type enables the serialization of Geometry data" 184 | require 'geo_ruby/geojson' if !defined?(GeoRuby).nil? 185 | 186 | coerce_input ->(value, ctx) do 187 | begin 188 | if value.nil? 189 | nil 190 | elsif !defined?(GeoRuby::GeojsonParser).nil? 191 | GeoRuby::SimpleFeatures::Geometry.from_geojson(value) 192 | elsif !defined?(RGeo::GeoJSON).nil? 193 | RGeo::GeoJSON.decode(value, json_parser: :json) 194 | else 195 | raise ArgumentError 196 | end 197 | rescue ArgumentError 198 | raise GraphQL::CoercionError, "cannot coerce `#{value.inspect}` to json" 199 | end 200 | end 201 | coerce_result ->(value, ctx) { (value.nil? ? "" : (defined?(GeoRuby) == "constant" && value.kind_of?(GeoRuby::SimpleFeatures::Geometry) ? value.to_json : (defined?(RGeo) == "constant" && defined?(RGeo::GeoJSON) == "constant" && RGeo::Geos.is_capi_geos?(value) ? RGeo::GeoJSON.encode(value).to_json : value))) } 202 | end 203 | 204 | GraphqlModelMapper::GEOMETRY_STRING_TYPE = GraphQL::ScalarType.define do 205 | name "GeometryString" 206 | description "The Geometry scalar type enables the serialization of Geometry data" 207 | require 'geo_ruby/geojson' if !defined?(GeoRuby).nil? 208 | 209 | coerce_input ->(value, ctx) do 210 | begin 211 | if value.nil? 212 | nil 213 | elsif !defined?(GeoRuby::GeojsonParser).nil? 214 | GeoRuby::SimpleFeatures::Geometry.from_geojson(value).as_wkt 215 | elsif !defined?(RGeo::GeoJSON).nil? 216 | RGeo::GeoJSON.decode(value, json_parser: :json).as_text 217 | else 218 | raise ArgumentError 219 | end 220 | rescue ArgumentError 221 | raise GraphQL::CoercionError, "cannot coerce `#{value.inspect}` to json" 222 | end 223 | end 224 | coerce_result ->(value, ctx) { (value.nil? ? "" : (defined?(GeoRuby) == "constant" && value.kind_of?(GeoRuby::SimpleFeatures::Geometry) ? value.to_json : (defined?(RGeo) == "constant" && defined?(RGeo::GeoJSON) == "constant" && RGeo::Geos.is_capi_geos?(value) ? RGeo::GeoJSON.encode(value).to_json : value))) } 225 | end 226 | 227 | GraphqlModelMapper::DATE_TYPE = GraphQL::ScalarType.define do 228 | name "Date" 229 | description "The Date scalar type enables the serialization of date data to/from iso8601" 230 | 231 | coerce_input ->(value, ctx) do 232 | begin 233 | value.nil? ? nil : Date.iso8601(value) 234 | rescue ArgumentError 235 | raise GraphQL::CoercionError, "cannot coerce `#{value.inspect}` to date" 236 | end 237 | end 238 | coerce_result ->(value, ctx) { value.nil? ? nil : value.iso8601 } 239 | end 240 | -------------------------------------------------------------------------------- /lib/graphql_model_mapper/utility.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | 3 | def self.implementations 4 | Rails.application.eager_load! 5 | ActiveRecord::Base.descendants.each.select do |clz| 6 | begin 7 | clz.included_modules.include?(GraphqlModelMapper) && (clz.public_methods.include?(:graphql_query) || clz.public_methods.include?(:graphql_update) || clz.public_methods.include?(:graphql_delete) || clz.public_methods.include?(:graphql_create) || clz.public_methods.include?(:graphql_types)) 8 | rescue 9 | # it is okay that this is empty - just covering the possibility 10 | end 11 | end 12 | end 13 | 14 | def self.schema_queries 15 | fields = [] 16 | GraphqlModelMapper.implementations.select{|t| t.public_methods.include?(:graphql_query)}.each { |t| 17 | fields << { :name =>GraphqlModelMapper.get_type_case(t.name, false).to_sym, :field => t.graphql_query, :model_name=>t.name, :access_type=>:query } 18 | } 19 | fields 20 | end 21 | 22 | def self.generate_secret_token 23 | "secret_token: '#{SecureRandom.hex(64)}'" 24 | end 25 | 26 | def self.schema_mutations 27 | fields = [] 28 | GraphqlModelMapper.implementations.select{|t| t.public_methods.include?(:graphql_create)}.each { |t| 29 | fields << {:name => GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(t.name)}Create", false).to_sym, :field=> t.graphql_create, :model_name=>t.name, :access_type=>:create } 30 | } 31 | GraphqlModelMapper.implementations.select{|t| t.public_methods.include?(:graphql_update)}.each { |t| 32 | fields << {:name =>GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(t.name)}Update", false).to_sym, :field=>t.graphql_update, :model_name=>t.name, :access_type=>:update } 33 | } 34 | GraphqlModelMapper.implementations.select{|t| t.public_methods.include?(:graphql_delete)}.each { |t| 35 | fields << {:name =>GraphqlModelMapper.get_type_case("#{GraphqlModelMapper.get_type_name(t.name)}Delete", false).to_sym, :field=>t.graphql_delete, :model_name=>t.name, :access_type=>:delete } 36 | } 37 | fields 38 | end 39 | 40 | def self.select_list(model_name, classes=[]) 41 | model = model_name.classify.constantize 42 | output = [] 43 | columns = model.columns_hash.keys.map{|m| "#{model.name.underscore.pluralize}.#{m}"} 44 | relation_includes = model.reflect_on_all_associations.select{|t| begin t.klass rescue next end}.select{|t| !t.options[:polymorphic]}.map{|m| "#{model.name.underscore.pluralize}.#{m.name}"} 45 | relations = model.reflect_on_all_associations.select{|t| begin t.klass rescue next end}.select{|t| !t.options[:polymorphic]} 46 | relations.each do |a| 47 | if !classes.include?(a.klass.name) 48 | classes << a.klass.name 49 | output = output + GraphqlModelMapper.select_list(a.klass.name, classes) 50 | end 51 | end 52 | output << relation_includes + columns 53 | output.sort 54 | end 55 | 56 | def self.log_resolve(ctx, args, generate_error: false) 57 | ret_info = {} 58 | ret_info[:return_type] = ctx.type.to_s 59 | ret_info[:return_fields] = [] 60 | ctx.type.fields.keys.each do |f| 61 | ret_info[:return_fields] << {field: f, field_type: ctx.type.fields[f].type.to_s} 62 | end 63 | ret_wrap = {} 64 | ret_wrap[:input] = args.to_h 65 | ret_wrap[:output] = ret_info 66 | GraphqlModelMapper.logger.info "***GraphqlModelMapper_resolver_info: #{{resolver_data: ret_wrap}}" 67 | GraphQL::ExecutionError.new("resolver info", options: {resolver_data: ret_wrap}) if generate_error 68 | end 69 | 70 | def self.authorized?(ctx, model_name, access=:read, roles=nil) 71 | 72 | model = model_name.classify.constantize 73 | access = access.to_sym 74 | #here it is checking to see if public methods are exposed on items based on the operation being performed 75 | if (access && access == :read) || (access && access == :query) 76 | access = :read 77 | if !model.public_methods.include?(:graphql_query) 78 | return false 79 | end 80 | elsif access && access == :create 81 | if !model.public_methods.include?(:graphql_create) 82 | return false 83 | end 84 | elsif access && access == :update 85 | if !model.public_methods.include?(:graphql_update) 86 | return false 87 | end 88 | elsif access && access == :delete 89 | if !model.public_methods.include?(:graphql_delete) 90 | return false 91 | end 92 | end 93 | if roles && roles.length > 0 94 | roles.each do |r| 95 | if !ctx[:current_user].hash_role?(role) 96 | return false 97 | end 98 | end 99 | end 100 | if !GraphqlModelMapper.use_authorize 101 | return true 102 | end 103 | #implementation specific, here it is using an ability method on the user class plugged into cancan 104 | if ctx && ctx[:current_user].public_methods.include?(:ability) 105 | if !ctx[:current_user].ability.can? access, model 106 | return false 107 | end 108 | end 109 | true 110 | end 111 | 112 | def self.get_type_name(classname, lowercase_first_letter=false) 113 | str = "#{classname.classify.demodulize}" 114 | if lowercase_first_letter && str.length > 0 115 | str = str[0].downcase + str[1..-1] 116 | end 117 | str 118 | end 119 | 120 | def self.get_type_case(str, uppercase=true) 121 | if @@type_case == :camelize 122 | if uppercase 123 | str.to_s.camelize(:upper) 124 | else 125 | str.to_s.camelize(:lower) 126 | end 127 | elsif @@type_case == :underscore 128 | if uppercase 129 | self.underscore(str) 130 | else 131 | str.underscore 132 | end 133 | elsif @@type_case == :classify 134 | str 135 | else 136 | str 137 | end 138 | end 139 | 140 | def self.underscore(str, upcase=true) 141 | if upcase 142 | str.split('_').map {|w| w.capitalize}.join('_') 143 | else 144 | str.underscore 145 | end 146 | end 147 | 148 | def self.get_constant(type_name) 149 | GraphqlModelMapper.const_get(type_name.upcase) 150 | end 151 | 152 | def self.set_constant(type_name, type) 153 | GraphqlModelMapper.const_set(type_name.upcase, type) 154 | end 155 | 156 | def self.defined_constant?(type_name) 157 | GraphqlModelMapper.const_defined?(type_name.upcase) 158 | end 159 | end -------------------------------------------------------------------------------- /lib/graphql_model_mapper/version.rb: -------------------------------------------------------------------------------- 1 | module GraphqlModelMapper 2 | VERSION = "0.1.1" 3 | end 4 | -------------------------------------------------------------------------------- /test/graphql_model_mapper_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class GraphqlModelMapperTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::GraphqlModelMapper::VERSION 6 | end 7 | 8 | def test_it_does_something_useful 9 | assert false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 2 | require "graphql_model_mapper" 3 | 4 | require "minitest/autorun" 5 | --------------------------------------------------------------------------------