├── .gitignore ├── .rspec ├── .travis.yml ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── gemfiles ├── activerecord_3.gemfile ├── activerecord_3.gemfile.lock ├── activerecord_4.gemfile ├── activerecord_4.gemfile.lock ├── activerecord_5.gemfile └── activerecord_5.gemfile.lock ├── graphql-query-resolver.gemspec ├── lib └── graphql │ ├── query_resolver.rb │ └── query_resolver │ └── version.rb └── spec ├── graphql └── query_resolver_spec.rb ├── spec_helper.rb └── support ├── activerecord_models.rb ├── factories.rb ├── graphql_schema.rb └── schema.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .byebug_history 11 | .idea 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.2 4 | before_install: gem install bundler -v 1.11.2 5 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "activerecord-3" do 2 | gem "activerecord", "3.2.14" 3 | gem "activesupport", "3.2.14" 4 | end 5 | 6 | appraise "activerecord-4" do 7 | gem "activerecord", "4.2.0" 8 | end 9 | 10 | appraise "activerecord-5" do 11 | gem "activerecord", "5.0.0" 12 | end 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.2.0 2 | - Add support to Relay style pagination 3 | 4 | # 0.1.2 5 | - Acceptany version of graphql-ruby over 1.0.0 6 | 7 | # 0.1.1 8 | - Initial Release 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at nettofarah@gmail.com. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in graphql-query-resolver.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 nettofarah 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::QueryResolver 2 | [![Build Status](https://travis-ci.org/nettofarah/graphql-query-resolver.svg?branch=master)](https://travis-ci.org/nettofarah/graphql-query-resolver) 3 | 4 | GraphQL::QueryResolver is an add-on to [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) 5 | that allows your field resolvers to minimize N+1 SELECTS issued by ActiveRecord. 6 | 7 | GraphQL::QueryResolver will analyze the AST from incoming GraphQL queries and 8 | try to match query selections to `ActiveRecord::Reflections` present in your 9 | `ActiveRecord` models. 10 | 11 | Every matched selection will be then passed on to 12 | `ActiveRecord::Associations::Preloader.new` so your queries now only issue 13 | one `SELECT` statement for every level of the GraphQL AST. 14 | 15 | ## Installation 16 | 17 | Add this line to your application's Gemfile: 18 | 19 | ```ruby 20 | gem 'graphql-query-resolver' 21 | ``` 22 | 23 | And then execute: 24 | ```bash 25 | $ bundle 26 | ``` 27 | 28 | Or install it yourself as: 29 | ```bash 30 | $ gem install graphql-query-resolver 31 | ``` 32 | 33 | ## Usage 34 | ```ruby 35 | require 'graphql/query_resolver' 36 | 37 | # In your field resolver 38 | # Assuming `Project < ActiveRecord::Base` and a `ProjectType` GraphQL type: 39 | # 40 | field :projects do 41 | type types[ProjectType] 42 | 43 | resolve -> (obj, args, ctx) { 44 | # Wrap your field resolve operation with `GraphQL::QueryResolver` 45 | GraphQL::QueryResolver.run(Project, ctx, ProjectType) do 46 | Project.all 47 | end 48 | } 49 | end 50 | 51 | # QueryResolver works the same way for single objects 52 | 53 | field :comment do 54 | type CommentType 55 | argument :id, !types.ID 56 | 57 | resolve -> (obj, args, ctx) { 58 | GraphQL::QueryResolver.run(Comment, ctx, CommentType) do 59 | Comment.find(args['id']) 60 | end 61 | } 62 | end 63 | ``` 64 | 65 | ## Development 66 | 67 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 68 | 69 | To install this gem onto your local machine, run `bundle exec rake install`. 70 | 71 | ## Contributing 72 | 73 | Bug reports and pull requests are welcome on GitHub at https://github.com/nettofarah/graphql-query-resolver. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 74 | 75 | To run the specs across all supported versions of ActiveRecord, check out the repo and follow these steps: 76 | ```bash 77 | $ bundle install 78 | $ bundle exec appraisal install 79 | $ bundle exec appraisal rake 80 | ``` 81 | 82 | ## License 83 | 84 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 85 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "graphql/activerecord" 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 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 | -------------------------------------------------------------------------------- /gemfiles/activerecord_3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "3.2.14" 6 | gem "activesupport", "3.2.14" 7 | 8 | gemspec :path => "../" 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord_3.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../ 3 | specs: 4 | graphql-query-resolver (0.1.0) 5 | graphql (~> 1.0.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (3.2.14) 11 | activesupport (= 3.2.14) 12 | builder (~> 3.0.0) 13 | activerecord (3.2.14) 14 | activemodel (= 3.2.14) 15 | activesupport (= 3.2.14) 16 | arel (~> 3.0.2) 17 | tzinfo (~> 0.3.29) 18 | activesupport (3.2.14) 19 | i18n (~> 0.6, >= 0.6.4) 20 | multi_json (~> 1.0) 21 | appraisal (2.1.0) 22 | bundler 23 | rake 24 | thor (>= 0.14.0) 25 | arel (3.0.3) 26 | builder (3.0.4) 27 | byebug (9.0.6) 28 | diff-lcs (1.3) 29 | graphql (1.0.0) 30 | i18n (0.8.1) 31 | multi_json (1.12.1) 32 | rake (10.5.0) 33 | rspec (3.5.0) 34 | rspec-core (~> 3.5.0) 35 | rspec-expectations (~> 3.5.0) 36 | rspec-mocks (~> 3.5.0) 37 | rspec-core (3.5.4) 38 | rspec-support (~> 3.5.0) 39 | rspec-expectations (3.5.0) 40 | diff-lcs (>= 1.2.0, < 2.0) 41 | rspec-support (~> 3.5.0) 42 | rspec-mocks (3.5.0) 43 | diff-lcs (>= 1.2.0, < 2.0) 44 | rspec-support (~> 3.5.0) 45 | rspec-support (3.5.0) 46 | sqlite3 (1.3.13) 47 | thor (0.19.4) 48 | tzinfo (0.3.52) 49 | 50 | PLATFORMS 51 | ruby 52 | 53 | DEPENDENCIES 54 | activerecord (= 3.2.14) 55 | activesupport (= 3.2.14) 56 | appraisal (~> 2.1.0, >= 2.1.0) 57 | bundler (~> 1.11) 58 | byebug (~> 9.0.6, >= 9.0.6) 59 | graphql-query-resolver! 60 | rake (~> 10.0) 61 | rspec (~> 3.0) 62 | sqlite3 (~> 1.3, >= 1.3.12) 63 | 64 | BUNDLED WITH 65 | 1.13.7 66 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "4.2.0" 6 | 7 | gemspec :path => "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../ 3 | specs: 4 | graphql-query-resolver (0.1.0) 5 | graphql (~> 1.0.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (4.2.0) 11 | activesupport (= 4.2.0) 12 | builder (~> 3.1) 13 | activerecord (4.2.0) 14 | activemodel (= 4.2.0) 15 | activesupport (= 4.2.0) 16 | arel (~> 6.0) 17 | activesupport (4.2.0) 18 | i18n (~> 0.7) 19 | json (~> 1.7, >= 1.7.7) 20 | minitest (~> 5.1) 21 | thread_safe (~> 0.3, >= 0.3.4) 22 | tzinfo (~> 1.1) 23 | appraisal (2.1.0) 24 | bundler 25 | rake 26 | thor (>= 0.14.0) 27 | arel (6.0.4) 28 | builder (3.2.3) 29 | byebug (9.0.6) 30 | diff-lcs (1.3) 31 | graphql (1.0.0) 32 | i18n (0.8.1) 33 | json (1.8.6) 34 | minitest (5.10.1) 35 | rake (10.5.0) 36 | rspec (3.5.0) 37 | rspec-core (~> 3.5.0) 38 | rspec-expectations (~> 3.5.0) 39 | rspec-mocks (~> 3.5.0) 40 | rspec-core (3.5.4) 41 | rspec-support (~> 3.5.0) 42 | rspec-expectations (3.5.0) 43 | diff-lcs (>= 1.2.0, < 2.0) 44 | rspec-support (~> 3.5.0) 45 | rspec-mocks (3.5.0) 46 | diff-lcs (>= 1.2.0, < 2.0) 47 | rspec-support (~> 3.5.0) 48 | rspec-support (3.5.0) 49 | sqlite3 (1.3.13) 50 | thor (0.19.4) 51 | thread_safe (0.3.6) 52 | tzinfo (1.2.2) 53 | thread_safe (~> 0.1) 54 | 55 | PLATFORMS 56 | ruby 57 | 58 | DEPENDENCIES 59 | activerecord (= 4.2.0) 60 | appraisal (~> 2.1.0, >= 2.1.0) 61 | bundler (~> 1.11) 62 | byebug (~> 9.0.6, >= 9.0.6) 63 | graphql-query-resolver! 64 | rake (~> 10.0) 65 | rspec (~> 3.0) 66 | sqlite3 (~> 1.3, >= 1.3.12) 67 | 68 | BUNDLED WITH 69 | 1.13.7 70 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "5.0.0" 6 | 7 | gemspec :path => "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../ 3 | specs: 4 | graphql-query-resolver (0.1.0) 5 | graphql (~> 1.0.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (5.0.0) 11 | activesupport (= 5.0.0) 12 | activerecord (5.0.0) 13 | activemodel (= 5.0.0) 14 | activesupport (= 5.0.0) 15 | arel (~> 7.0) 16 | activesupport (5.0.0) 17 | concurrent-ruby (~> 1.0, >= 1.0.2) 18 | i18n (~> 0.7) 19 | minitest (~> 5.1) 20 | tzinfo (~> 1.1) 21 | appraisal (2.1.0) 22 | bundler 23 | rake 24 | thor (>= 0.14.0) 25 | arel (7.1.4) 26 | byebug (9.0.6) 27 | concurrent-ruby (1.0.5) 28 | diff-lcs (1.3) 29 | graphql (1.0.0) 30 | i18n (0.8.1) 31 | minitest (5.10.1) 32 | rake (10.5.0) 33 | rspec (3.5.0) 34 | rspec-core (~> 3.5.0) 35 | rspec-expectations (~> 3.5.0) 36 | rspec-mocks (~> 3.5.0) 37 | rspec-core (3.5.4) 38 | rspec-support (~> 3.5.0) 39 | rspec-expectations (3.5.0) 40 | diff-lcs (>= 1.2.0, < 2.0) 41 | rspec-support (~> 3.5.0) 42 | rspec-mocks (3.5.0) 43 | diff-lcs (>= 1.2.0, < 2.0) 44 | rspec-support (~> 3.5.0) 45 | rspec-support (3.5.0) 46 | sqlite3 (1.3.13) 47 | thor (0.19.4) 48 | thread_safe (0.3.6) 49 | tzinfo (1.2.2) 50 | thread_safe (~> 0.1) 51 | 52 | PLATFORMS 53 | ruby 54 | 55 | DEPENDENCIES 56 | activerecord (= 5.0.0) 57 | appraisal (~> 2.1.0, >= 2.1.0) 58 | bundler (~> 1.11) 59 | byebug (~> 9.0.6, >= 9.0.6) 60 | graphql-query-resolver! 61 | rake (~> 10.0) 62 | rspec (~> 3.0) 63 | sqlite3 (~> 1.3, >= 1.3.12) 64 | 65 | BUNDLED WITH 66 | 1.13.7 67 | -------------------------------------------------------------------------------- /graphql-query-resolver.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'graphql/query_resolver/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "graphql-query-resolver" 8 | spec.version = GraphQL::QueryResolver::VERSION 9 | spec.authors = ["nettofarah"] 10 | spec.email = ["nettofarah@gmail.com"] 11 | 12 | spec.summary = %q{Minimize N+1 queries generated by GraphQL} 13 | spec.description = %q{GraphQL::QueryResolver is an add-on to graphql-ruby that allows your field resolvers to minimize N+1 SELECTS issued by ActiveRecord. } 14 | spec.homepage = "https://github.com/nettofarah" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_runtime_dependency "graphql", '~> 1.0', '>= 1.0.0' 23 | 24 | spec.add_development_dependency "bundler", "~> 1.11" 25 | spec.add_development_dependency "sqlite3", "~> 1.3", ">= 1.3.12" 26 | spec.add_development_dependency "appraisal", "~> 2.1.0", ">= 2.1.0" 27 | spec.add_development_dependency "byebug", "~> 9.0.6", ">= 9.0.6" 28 | spec.add_development_dependency "activerecord", "~> 5.0" 29 | 30 | spec.add_development_dependency "rake", "~> 10.0" 31 | spec.add_development_dependency "rspec", "~> 3.0" 32 | end 33 | -------------------------------------------------------------------------------- /lib/graphql/query_resolver.rb: -------------------------------------------------------------------------------- 1 | require "graphql" 2 | require "graphql/query_resolver/version" 3 | 4 | module GraphQL 5 | module QueryResolver 6 | 7 | def self.run(model_class, context, return_type) 8 | to_load = yield 9 | dependencies = {} 10 | 11 | reflection_dependencies = map_dependencies(model_class, context.ast_node) 12 | dependencies = reflection_dependencies.merge(dependencies) 13 | 14 | if dependencies.any? && to_load.present? 15 | if ActiveRecord::VERSION::MAJOR < 4 16 | ActiveRecord::Associations::Preloader.new(to_load, dependencies).run 17 | else 18 | ActiveRecord::Associations::Preloader.new.preload(to_load, dependencies) 19 | end 20 | end 21 | 22 | to_load 23 | end 24 | 25 | def self.using_relay_pagination?(selection) 26 | selection.name == 'edges' 27 | end 28 | 29 | def self.using_nodes_pagination?(selection) 30 | selection.name == 'nodes' 31 | end 32 | 33 | def self.map_relay_pagination_depencies(class_name, selection, dependencies) 34 | node_selection = selection.selections.find { |sel| sel.name == 'node' } 35 | 36 | if node_selection.present? 37 | map_dependencies(class_name, node_selection, dependencies) 38 | else 39 | dependencies 40 | end 41 | end 42 | 43 | def self.has_reflection_with_name?(class_name, selection_name) 44 | class_name.reflections.with_indifferent_access[selection_name].present? 45 | end 46 | 47 | def self.map_dependencies(class_name, ast_node, dependencies={}) 48 | ast_node.selections.each do |selection| 49 | name = selection.name 50 | 51 | if using_relay_pagination?(selection) 52 | map_relay_pagination_depencies(class_name, selection, dependencies) 53 | next 54 | end 55 | 56 | if using_nodes_pagination?(selection) 57 | map_dependencies(class_name, selection, dependencies) 58 | next 59 | end 60 | 61 | if has_reflection_with_name?(class_name, name) 62 | begin 63 | current_class_name = selection.name.singularize.classify.constantize 64 | dependencies[name] = map_dependencies(current_class_name, selection) 65 | rescue NameError 66 | selection_name = class_name.reflections.with_indifferent_access[selection.name].options[:class_name] 67 | current_class_name = selection_name.singularize.classify.constantize 68 | dependencies[selection.name.to_sym] = map_dependencies(current_class_name, selection) 69 | next 70 | end 71 | end 72 | end 73 | 74 | dependencies 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/graphql/query_resolver/version.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module QueryResolver 3 | VERSION = "0.2.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/graphql/query_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe GraphQL::QueryResolver do 4 | 5 | before(:each) do 6 | TestData.create_netto 7 | end 8 | 9 | it 'groups queries' do 10 | data = nil 11 | 12 | queries = track_queries do 13 | data = GQL.query(%{ 14 | query { 15 | recipes { 16 | title 17 | ingredients { name } 18 | } 19 | } 20 | }) 21 | end 22 | 23 | expect(queries.size).to eq(2) 24 | expect(queries.first).to eq('SELECT "recipes".* FROM "recipes"') 25 | expect(queries.last).to eq('SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."recipe_id" IN (1, 2, 3, 4)') 26 | end 27 | 28 | it 'works with multiple levels of nesting' do 29 | data = nil 30 | 31 | queries = track_queries do 32 | data = GQL.query(%{ 33 | query { 34 | recipes { 35 | title 36 | ingredients { 37 | name, quantity 38 | vendor { name } 39 | } 40 | } 41 | } 42 | }) 43 | end 44 | 45 | expect(queries.size).to eq(3) 46 | expect(queries[0]).to eq('SELECT "recipes".* FROM "recipes"') 47 | expect(queries[1]).to eq('SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."recipe_id" IN (1, 2, 3, 4)') 48 | expect(queries[2]).to eq('SELECT "vendors".* FROM "vendors" WHERE "vendors"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)') 49 | end 50 | 51 | it 'issues one SELECT call per level' do 52 | data = nil 53 | 54 | queries = track_queries do 55 | data = GQL.query(%{ 56 | query { 57 | restaurant(id: 1) { 58 | name 59 | owner { 60 | name 61 | recipes { 62 | title 63 | ingredients { 64 | name, quantity 65 | vendor { 66 | name 67 | } 68 | } 69 | } 70 | } 71 | } 72 | } 73 | }) 74 | end 75 | 76 | 77 | expect(queries[0]).to include('SELECT "restaurants".* FROM "restaurants" WHERE "restaurants"."id" = ?') 78 | expect(queries[1]).to include('SELECT "chefs".* FROM "chefs" WHERE "chefs"."id"') # AR 4 will use id IN (1), AR 5 will use id = 1 79 | expect(queries[2]).to include('SELECT "recipes".* FROM "recipes" WHERE "recipes"."chef_id"') # AR 4 will use id IN (1), AR 5 will use id = 1 80 | expect(queries[3]).to include('SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."recipe_id" IN (1, 2, 3, 4)') 81 | expect(queries[4]).to include('SELECT "vendors".* FROM "vendors" WHERE "vendors"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)') 82 | end 83 | 84 | it 'works with alias reflections' do 85 | # Owner is an instance of Chef 86 | query = %{ 87 | query { 88 | restaurant(id: 1) { 89 | name 90 | owner { name } 91 | } 92 | } 93 | } 94 | 95 | queries = track_queries do 96 | GQL.query(query) 97 | end 98 | 99 | expect(queries.size).to eq(2) 100 | end 101 | 102 | it 'works with pagination' do 103 | query = %{ 104 | query { 105 | vendors(first: 5) { 106 | edges { 107 | node { 108 | id 109 | name 110 | ingredients { 111 | name 112 | quantity 113 | } 114 | } 115 | cursor 116 | } 117 | pageInfo { 118 | hasNextPage 119 | } 120 | } 121 | } 122 | } 123 | queries = track_queries do 124 | GQL.query(query) 125 | end 126 | 127 | expect(queries.size).to eq(2) 128 | end 129 | 130 | it 'works with paginations and "nodes" selection' do 131 | query = %{ 132 | query { 133 | vendors(first: 5) { 134 | nodes { 135 | id 136 | name 137 | ingredients { 138 | name 139 | quantity 140 | } 141 | } 142 | pageInfo { 143 | endCursor 144 | hasNextPage 145 | } 146 | } 147 | } 148 | } 149 | queries = track_queries do 150 | GQL.query(query) 151 | end 152 | 153 | expect(queries.size).to eq(2) 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'graphql/query_resolver' 3 | require 'active_record' 4 | require 'byebug' 5 | 6 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 7 | 8 | require 'support/schema' 9 | require 'support/activerecord_models' 10 | require 'support/factories' 11 | require 'support/graphql_schema' 12 | 13 | RSpec.configure do |c| 14 | c.around(:example) do |example| 15 | ActiveRecord::Base.transaction do 16 | example.run 17 | raise ActiveRecord::Rollback 18 | end 19 | end 20 | end 21 | 22 | def track_queries 23 | selects = [] 24 | queries_collector = lambda do |name, start, finish, id, payload| 25 | selects << payload 26 | end 27 | 28 | ActiveRecord::Base.connection.clear_query_cache 29 | ActiveSupport::Notifications.subscribed(queries_collector, 'sql.active_record') do 30 | yield 31 | end 32 | 33 | selects.map { |sel| sel[:sql].strip.gsub(" ", " ") } 34 | end 35 | -------------------------------------------------------------------------------- /spec/support/activerecord_models.rb: -------------------------------------------------------------------------------- 1 | class Recipe < ActiveRecord::Base 2 | belongs_to :chef 3 | has_many :ingredients #, through: :recipes_ingredients 4 | 5 | serialize :metadata, JSON 6 | end 7 | 8 | class Ingredient < ActiveRecord::Base 9 | belongs_to :vendor 10 | end 11 | 12 | class Vendor < ActiveRecord::Base 13 | has_many :ingredients 14 | end 15 | 16 | class Restaurant < ActiveRecord::Base 17 | belongs_to :owner, class_name: 'Chef' 18 | has_one :rating 19 | end 20 | 21 | class Rating < ActiveRecord::Base 22 | belongs_to :restaurant 23 | end 24 | 25 | class Chef < ActiveRecord::Base 26 | has_many :recipes 27 | has_many :ingredients, through: :recipes 28 | has_one :restaurant, foreign_key: 'owner_id' 29 | end 30 | 31 | class Person < ActiveRecord::Base 32 | self.primary_key = :ssn 33 | end 34 | -------------------------------------------------------------------------------- /spec/support/factories.rb: -------------------------------------------------------------------------------- 1 | module TestData 2 | 3 | def self.nv(name) 4 | Vendor.new(name: name) 5 | end 6 | 7 | def self.create_netto 8 | Chef.create(name: 'Netto', email: 'nettofarah@gmail.com').tap do |netto| 9 | 10 | Recipe.create(title: 'Turkey Sandwich', chef: netto).tap do |r| 11 | r.ingredients.create(name: 'Turkey', quantity: 'a lot', vendor: nv('Turkey Farm')) 12 | r.ingredients.create(name: 'Cheese', quantity: '1 slice', vendor: nv('Dairy Farm')) 13 | r.ingredients.create(name: 'Bread', quantity: '1 loaf', vendor: nv('Bakery')) 14 | r.ingredients.create(name: 'Mayo', quantity: '1 spoon', vendor: nv('Costco')) 15 | end 16 | 17 | Restaurant.create(name: "Netto's Joint", owner: netto) 18 | 19 | Recipe.create(title: 'Cheese Burger', chef: netto).tap do |r| 20 | r.ingredients.create(name: 'Patty', quantity: '1', vendor: nv('Butcher')) 21 | r.ingredients.create(name: 'Cheese', quantity: '2 slices', vendor: nv('Berkeley Farms')) 22 | end 23 | 24 | Recipe.create(title: 'Bacon Cheese Burger', chef: netto).tap do |r| 25 | r.ingredients.create(name: 'Patty', quantity: '1', vendor: nv('Butcher')) 26 | r.ingredients.create(name: 'Cheese', quantity: '2 slices', vendor: nv('Somewhere')) 27 | r.ingredients.create(name: 'Bacon', quantity: '2 slices', vendor: nv('Heaven')) 28 | end 29 | 30 | Recipe.create(title: 'BBQ Burger', chef: netto).tap do |r| 31 | r.ingredients.create(name: 'Patty', quantity: '1', vendor: nv('Costco')) 32 | r.ingredients.create(name: 'BBQ Sauce', quantity: '1 spoon', vendor: nv('Somewhere in SC')) 33 | end 34 | end 35 | 36 | Person.create(name: 'John Doe') 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/graphql_schema.rb: -------------------------------------------------------------------------------- 1 | require 'graphql' 2 | require 'graphql/query_resolver' 3 | 4 | GraphQL::Relay::ConnectionType.default_nodes_field = true 5 | 6 | RestaurantType = GraphQL::ObjectType.define do 7 | name "restaurant" 8 | 9 | field :id, types.ID 10 | field :name, types.String 11 | 12 | field :owner do 13 | type ChefType 14 | 15 | resolve -> (obj, args, ctx) { 16 | obj.owner 17 | } 18 | end 19 | 20 | field :recipes do 21 | type types[RecipeType] 22 | 23 | resolve -> (obj, args, ctx) { 24 | obj.owner.recipes 25 | } 26 | end 27 | end 28 | 29 | ChefType = GraphQL::ObjectType.define do 30 | name "chef" 31 | 32 | field :id, types.ID 33 | field :name, types.String 34 | field :email, types.String 35 | 36 | field :recipes do 37 | type types[RecipeType] 38 | 39 | resolve -> (obj, args, ctx) { 40 | obj.recipes 41 | } 42 | end 43 | end 44 | 45 | RecipeType = GraphQL::ObjectType.define do 46 | name "recipe" 47 | 48 | field :id, types.ID 49 | field :title, types.String 50 | 51 | field :ingredients do 52 | type types[IngredientType] 53 | 54 | resolve -> (obj, args, ctx) { 55 | obj.ingredients 56 | } 57 | end 58 | end 59 | 60 | VendorType = GraphQL::ObjectType.define do 61 | name "vendor" 62 | 63 | field :id, types.ID 64 | field :name, types.String 65 | 66 | field :ingredients do 67 | type types[IngredientType] 68 | 69 | resolve -> (obj, args, ctx) { 70 | obj.ingredients 71 | } 72 | end 73 | end 74 | 75 | IngredientType = GraphQL::ObjectType.define do 76 | name "ingredient" 77 | 78 | field :id, types.ID 79 | field :name, types.String 80 | field :quantity, types.Int 81 | field :vendor, VendorType 82 | end 83 | 84 | QueryRoot = GraphQL::ObjectType.define do 85 | name "query" 86 | description "The top level query" 87 | 88 | field :recipes do 89 | type types[RecipeType] 90 | 91 | resolve -> (obj, args, ctx) { 92 | GraphQL::QueryResolver::run(Recipe, ctx, RecipeType) do 93 | Recipe.all 94 | end 95 | } 96 | end 97 | 98 | field :restaurant do 99 | type RestaurantType 100 | argument :id, !types.Int 101 | 102 | resolve -> (obj, args, ctx) { 103 | id = args['id'] 104 | 105 | GraphQL::QueryResolver::run(Restaurant, ctx, RestaurantType) do 106 | Restaurant.find(id) 107 | end 108 | } 109 | end 110 | 111 | connection :vendors, VendorType.connection_type, max_page_size: 50 do 112 | resolve -> (obj, args, ctx) { 113 | GraphQL::QueryResolver::run(Vendor, ctx, VendorType) do 114 | Vendor.all.to_a 115 | end 116 | } 117 | end 118 | end 119 | 120 | Schema = GraphQL::Schema.define do 121 | query QueryRoot 122 | end 123 | 124 | class GQL 125 | 126 | class QueryError < StandardError; end 127 | 128 | def self.query(query_string) 129 | document = GraphQL.parse(query_string) 130 | 131 | result = Schema.execute({ 132 | document: document 133 | }) 134 | 135 | if result['errors'].present? 136 | raise QueryError.new(result['errors']) 137 | end 138 | 139 | result['data'] 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :recipes, force: true do |t| 3 | t.column :title, :string 4 | t.column :num_steps, :integer 5 | t.column :chef_id, :integer 6 | t.column :metadata, :text 7 | end 8 | 9 | create_table :ingredients, force: true do |t| 10 | t.column :name, :string 11 | t.column :quantity, :string 12 | t.column :recipe_id, :integer 13 | t.column :vendor_id, :integer 14 | end 15 | 16 | create_table :vendors, force: true do |t| 17 | t.column :name, :string 18 | end 19 | 20 | create_table :chefs, force: true do |t| 21 | t.column :name, :string 22 | t.column :email, :string 23 | end 24 | 25 | create_table :restaurants, force: true do |t| 26 | t.column :name, :string 27 | t.column :owner_id, :integer 28 | t.column :current_customer_count, :integer 29 | end 30 | 31 | create_table :ratings, force: true do |t| 32 | t.column :value, :string 33 | t.column :restaurant_id, :integer 34 | end 35 | 36 | create_table :people, primary_key: :ssn, force: true do |t| 37 | t.column :name, :string 38 | end 39 | end 40 | --------------------------------------------------------------------------------