├── .github
└── workflows
│ ├── build.yml
│ └── publish.yml
├── .gitignore
├── .rspec
├── Gemfile
├── Gemfile.lock
├── MIT-LICENSE
├── README.md
├── Rakefile
├── bin
├── console
└── setup
├── graphql-extras.gemspec
├── lib
└── graphql
│ ├── extras.rb
│ └── extras
│ ├── controller.rb
│ ├── preload.rb
│ ├── test.rb
│ ├── test
│ ├── loader.rb
│ ├── response.rb
│ └── schema.rb
│ ├── types.rb
│ └── version.rb
└── spec
├── fixtures
├── files
│ └── image.jpg
└── graphql
│ ├── explode.graphql
│ ├── hello.graphql
│ ├── people.graphql
│ ├── person.graphql
│ └── upload_image.graphql
├── graphql
├── extras
│ ├── controller_spec.rb
│ ├── preload_spec.rb
│ ├── test
│ │ ├── loader_spec.rb
│ │ └── schema_spec.rb
│ └── types_spec.rb
└── extras_spec.rb
├── spec_helper.rb
└── support
├── database.rb
├── rails.rb
└── schema.rb
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on: [push]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - name: Checkout
8 | uses: actions/checkout@v2
9 |
10 | - name: Setup Ruby
11 | uses: ruby/setup-ruby@v1
12 | with:
13 | ruby-version: 3.4.1
14 |
15 | - name: Install packages
16 | run: sudo apt-get install libsqlite3-dev
17 |
18 | - name: Install bundler
19 | run: gem install bundler
20 |
21 | - name: Install dependencies
22 | run: bundle install
23 |
24 | - name: Test
25 | run: bundle exec rspec
26 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on:
3 | release:
4 | types: [published]
5 | jobs:
6 | publish:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Checkout
10 | uses: actions/checkout@v2
11 |
12 | - name: Setup Ruby
13 | uses: ruby/setup-ruby@v1
14 | with:
15 | ruby-version: 3.4.1
16 |
17 | - name: Install packages
18 | run: sudo apt-get install libsqlite3-dev
19 |
20 | - name: Install bundler
21 | run: gem install bundler
22 |
23 | - name: Install dependencies
24 | run: bundle install
25 |
26 | - name: Test
27 | run: bundle exec rspec
28 |
29 | - name: Set version
30 | run: perl -pi -e "s/0\.0\.0/${GITHUB_REF:11}/" lib/graphql/extras/version.rb
31 |
32 | - name: Publish
33 | run: |
34 | mkdir -p $HOME/.gem
35 | touch $HOME/.gem/credentials
36 | chmod 0600 $HOME/.gem/credentials
37 | printf -- "---\n:rubygems_api_key: ${RUBYGEMS_TOKEN}\n" > $HOME/.gem/credentials
38 | gem build *.gemspec
39 | gem push *.gem
40 | env:
41 | RUBYGEMS_TOKEN: ${{ secrets.RUBYGEMS_TOKEN }}
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /tmp/
9 |
10 | # rspec failure tracking
11 | .rspec_status
12 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --format documentation
2 | --color
3 | --require spec_helper
4 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | # Specify your gem's dependencies in graphql-extras.gemspec
4 | gemspec
5 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | graphql-extras (0.0.0)
5 | activesupport (>= 5.2)
6 | graphql (>= 1.12, < 3)
7 |
8 | GEM
9 | remote: https://rubygems.org/
10 | specs:
11 | actionpack (8.0.1)
12 | actionview (= 8.0.1)
13 | activesupport (= 8.0.1)
14 | nokogiri (>= 1.8.5)
15 | rack (>= 2.2.4)
16 | rack-session (>= 1.0.1)
17 | rack-test (>= 0.6.3)
18 | rails-dom-testing (~> 2.2)
19 | rails-html-sanitizer (~> 1.6)
20 | useragent (~> 0.16)
21 | actionview (8.0.1)
22 | activesupport (= 8.0.1)
23 | builder (~> 3.1)
24 | erubi (~> 1.11)
25 | rails-dom-testing (~> 2.2)
26 | rails-html-sanitizer (~> 1.6)
27 | activemodel (8.0.1)
28 | activesupport (= 8.0.1)
29 | activerecord (8.0.1)
30 | activemodel (= 8.0.1)
31 | activesupport (= 8.0.1)
32 | timeout (>= 0.4.0)
33 | activesupport (8.0.1)
34 | base64
35 | benchmark (>= 0.3)
36 | bigdecimal
37 | concurrent-ruby (~> 1.0, >= 1.3.1)
38 | connection_pool (>= 2.2.5)
39 | drb
40 | i18n (>= 1.6, < 2)
41 | logger (>= 1.4.2)
42 | minitest (>= 5.1)
43 | securerandom (>= 0.3)
44 | tzinfo (~> 2.0, >= 2.0.5)
45 | uri (>= 0.13.1)
46 | base64 (0.2.0)
47 | benchmark (0.4.0)
48 | bigdecimal (3.1.9)
49 | builder (3.3.0)
50 | concurrent-ruby (1.3.5)
51 | connection_pool (2.5.0)
52 | crass (1.0.6)
53 | date (3.4.1)
54 | diff-lcs (1.5.1)
55 | drb (2.2.1)
56 | erubi (1.13.1)
57 | fiber-storage (1.0.0)
58 | graphql (2.4.9)
59 | base64
60 | fiber-storage
61 | logger
62 | i18n (1.14.7)
63 | concurrent-ruby (~> 1.0)
64 | io-console (0.8.0)
65 | irb (1.15.1)
66 | pp (>= 0.6.0)
67 | rdoc (>= 4.0.0)
68 | reline (>= 0.4.2)
69 | logger (1.6.5)
70 | loofah (2.24.0)
71 | crass (~> 1.0.2)
72 | nokogiri (>= 1.12.0)
73 | minitest (5.25.4)
74 | nokogiri (1.18.2-aarch64-linux-gnu)
75 | racc (~> 1.4)
76 | nokogiri (1.18.2-aarch64-linux-musl)
77 | racc (~> 1.4)
78 | nokogiri (1.18.2-arm-linux-gnu)
79 | racc (~> 1.4)
80 | nokogiri (1.18.2-arm-linux-musl)
81 | racc (~> 1.4)
82 | nokogiri (1.18.2-arm64-darwin)
83 | racc (~> 1.4)
84 | nokogiri (1.18.2-x86_64-darwin)
85 | racc (~> 1.4)
86 | nokogiri (1.18.2-x86_64-linux-gnu)
87 | racc (~> 1.4)
88 | nokogiri (1.18.2-x86_64-linux-musl)
89 | racc (~> 1.4)
90 | pp (0.6.2)
91 | prettyprint
92 | prettyprint (0.2.0)
93 | psych (5.2.3)
94 | date
95 | stringio
96 | racc (1.8.1)
97 | rack (3.1.9)
98 | rack-session (2.1.0)
99 | base64 (>= 0.1.0)
100 | rack (>= 3.0.0)
101 | rack-test (2.2.0)
102 | rack (>= 1.3)
103 | rackup (2.2.1)
104 | rack (>= 3)
105 | rails-dom-testing (2.2.0)
106 | activesupport (>= 5.0.0)
107 | minitest
108 | nokogiri (>= 1.6)
109 | rails-html-sanitizer (1.6.2)
110 | loofah (~> 2.21)
111 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
112 | railties (8.0.1)
113 | actionpack (= 8.0.1)
114 | activesupport (= 8.0.1)
115 | irb (~> 1.13)
116 | rackup (>= 1.0.0)
117 | rake (>= 12.2)
118 | thor (~> 1.0, >= 1.2.2)
119 | zeitwerk (~> 2.6)
120 | rake (13.2.1)
121 | rdoc (6.11.0)
122 | psych (>= 4.0.0)
123 | reline (0.6.0)
124 | io-console (~> 0.5)
125 | rspec (3.13.0)
126 | rspec-core (~> 3.13.0)
127 | rspec-expectations (~> 3.13.0)
128 | rspec-mocks (~> 3.13.0)
129 | rspec-core (3.13.2)
130 | rspec-support (~> 3.13.0)
131 | rspec-expectations (3.13.3)
132 | diff-lcs (>= 1.2.0, < 2.0)
133 | rspec-support (~> 3.13.0)
134 | rspec-mocks (3.13.2)
135 | diff-lcs (>= 1.2.0, < 2.0)
136 | rspec-support (~> 3.13.0)
137 | rspec-rails (7.1.0)
138 | actionpack (>= 7.0)
139 | activesupport (>= 7.0)
140 | railties (>= 7.0)
141 | rspec-core (~> 3.13)
142 | rspec-expectations (~> 3.13)
143 | rspec-mocks (~> 3.13)
144 | rspec-support (~> 3.13)
145 | rspec-support (3.13.2)
146 | securerandom (0.4.1)
147 | sqlite3 (2.5.0-aarch64-linux-gnu)
148 | sqlite3 (2.5.0-aarch64-linux-musl)
149 | sqlite3 (2.5.0-arm-linux-gnu)
150 | sqlite3 (2.5.0-arm-linux-musl)
151 | sqlite3 (2.5.0-arm64-darwin)
152 | sqlite3 (2.5.0-x86_64-darwin)
153 | sqlite3 (2.5.0-x86_64-linux-gnu)
154 | sqlite3 (2.5.0-x86_64-linux-musl)
155 | stringio (3.1.2)
156 | thor (1.3.2)
157 | timeout (0.4.3)
158 | tzinfo (2.0.6)
159 | concurrent-ruby (~> 1.0)
160 | uri (1.0.2)
161 | useragent (0.16.11)
162 | zeitwerk (2.7.1)
163 |
164 | PLATFORMS
165 | aarch64-linux
166 | aarch64-linux-gnu
167 | aarch64-linux-musl
168 | arm-linux
169 | arm-linux-gnu
170 | arm-linux-musl
171 | arm64-darwin
172 | x86_64-darwin
173 | x86_64-linux
174 | x86_64-linux-gnu
175 | x86_64-linux-musl
176 |
177 | DEPENDENCIES
178 | actionpack
179 | activerecord
180 | bundler
181 | graphql-extras!
182 | rake
183 | rspec
184 | rspec-rails
185 | sqlite3
186 |
187 | BUNDLED WITH
188 | 2.6.3
189 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2019-2025 Ray Zane
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
GraphQL::Extras
2 |
3 |
4 |
5 | 
6 | 
7 |
8 |
9 |
10 | A collection of utilities for building GraphQL APIs.
11 |
12 | **Table of Contents**
13 |
14 | - [Installation](#installation)
15 | - [Usage](#usage)
16 | - [GraphQL::Extras::Controller](#graphqlextrascontroller)
17 | - [GraphQL::Extras::Preload](#graphqlextraspreload)
18 | - [GraphQL::Extras::PreloadSource](#graphqlextraspreloadsource)
19 | - [GraphQL::Extras::Types](#graphqlextrastypes)
20 | - [Date](#date)
21 | - [DateTime](#datetime)
22 | - [Decimal](#decimal)
23 | - [Upload](#upload)
24 | - [GraphQL::Extras::Test](#graphqlextrastest)
25 | - [Development](#development)
26 | - [Contributing](#contributing)
27 |
28 | ## Installation
29 |
30 | Add this line to your application's Gemfile:
31 |
32 | ```ruby
33 | gem 'graphql-extras'
34 | ```
35 |
36 | And then execute:
37 |
38 | $ bundle
39 |
40 | ## Usage
41 |
42 | ### GraphQL::Extras::Controller
43 |
44 | The [`graphql` gem](https://github.com/rmosolgo/graphql-ruby) will generate a controller for you with a bunch of boilerplate. This module will encapsulate that boilerplate:
45 |
46 | ```ruby
47 | class GraphqlController < ApplicationController
48 | include GraphQL::Extras::Controller
49 |
50 | def execute
51 | graphql(schema: MySchema, context: { current_user: current_user })
52 | end
53 | end
54 | ```
55 |
56 | ### GraphQL::Extras::Preload
57 |
58 | This allows you to preload associations before resolving fields.
59 |
60 | ```ruby
61 | class Schema < GraphQL::Schema
62 | use GraphQL::Dataloader
63 | end
64 |
65 | class BaseField < GraphQL::Schema::Field
66 | prepend GraphQL::Extras::Preload
67 | end
68 |
69 | class BaseObject < GraphQL::Schema::Object
70 | field_class BaseField
71 | end
72 |
73 | class PostType < BaseObject
74 | field :author, AuthorType, preload: :author, null: false
75 | field :author_posts, [PostType], preload: {author: :posts}, null: false
76 | field :depends_on_author, Integer, preload: :author, null: false
77 |
78 | def author_posts
79 | object.author.posts
80 | end
81 | end
82 | ```
83 |
84 | ### GraphQL::Extras::PreloadSource
85 |
86 | This is a subclass of [`GraphQL::Dataloader::Source`](https://graphql-ruby.org/dataloader/overview.html) that performs eager loading of Active Record associations.
87 |
88 | ```ruby
89 | loader = dataloader.with(GraphQL::Extras::PreloadSource, :blog)
90 | loader.load(Post.first)
91 | loader.load_many(Post.all)
92 | ```
93 |
94 | ### GraphQL::Extras::Types
95 |
96 | In your base classes, you should include the `GraphQL::Extras::Types`.
97 |
98 | ```ruby
99 | class BaseObject < GraphQL::Schema::Object
100 | include GraphQL::Extras::Types
101 | end
102 |
103 | class BaseInputObject < GraphQL::Schema::InputObject
104 | include GraphQL::Extras::Types
105 | end
106 | ```
107 |
108 | #### Date
109 |
110 | This scalar takes a `Date` and transmits it as a string, using ISO 8601 format.
111 |
112 | ```ruby
113 | field :birthday, Date, required: true
114 | ```
115 |
116 | #### DateTime
117 |
118 | This scalar takes a `DateTime` and transmits it as a string, using ISO 8601 format.
119 |
120 | ```ruby
121 | field :created_at, DateTime, required: true
122 | ```
123 |
124 | _Note: This is just an alias for the `ISO8601DateTime` type that is included in the `graphql` gem._
125 |
126 | #### Decimal
127 |
128 | This scalar takes a `BigDecimal` and transmits it as a string.
129 |
130 | ```ruby
131 | field :weight, BigDecimal, required: true
132 | ```
133 |
134 | #### Upload
135 |
136 | This scalar is used for accepting file uploads.
137 |
138 | ```ruby
139 | field :image, Upload, required: true
140 | ```
141 |
142 | It achieves this by passing in all file upload parameters through context. This will work out of the box if you're using `GraphQL::Extras::Controller`.
143 |
144 | Here's an example using CURL:
145 |
146 | $ curl -X POST \
147 | -F query='mutation { uploadFile(image: "image") }' \
148 | -F image=@cats.png \
149 | localhost:3000/graphql
150 |
151 | Take note of the correspondence between the value `"image"` and the additional HTTP parameter called `-F image=@cats.png`.
152 |
153 | See [apollo-link-upload](https://github.com/rzane/apollo-link-upload) for the client-side implementation.
154 |
155 | ### GraphQL::Extras::Test
156 |
157 | This module makes it really easy to test your schema.
158 |
159 | First, create a test schema:
160 |
161 | ```ruby
162 | # spec/support/test_schema.rb
163 | require "graphql/extras/test"
164 |
165 | class TestSchema < GraphQL::Extras::Test::Schema
166 | configure schema: Schema, queries: "spec/**/*.graphql"
167 | end
168 | ```
169 |
170 | Now, you can run tests like so:
171 |
172 | ```ruby
173 | require "support/test_schema"
174 |
175 | RSpec.describe "hello" do
176 | let(:context) { { name: "Ray" } }
177 | let(:schema) { TestSchema.new(context) }
178 |
179 | it "allows easily executing queries" do
180 | query = schema.hello
181 |
182 | expect(query).to be_successful
183 | expect(query.data["hello"]).to eq("world")
184 | end
185 |
186 | it "parses errors" do
187 | query = schema.kaboom
188 |
189 | expect(query).not_to be_successful
190 | expect(query.errors[0].message).to eq("Invalid")
191 | expect(query.errors[0].code).to eq("VALIDATION_ERROR")
192 | end
193 | end
194 | ```
195 |
196 | ## Development
197 |
198 | To install dependencies:
199 |
200 | $ bundle install
201 |
202 | To run the test suite:
203 |
204 | $ bundle exec rspec
205 |
206 | ## Contributing
207 |
208 | Bug reports and pull requests are welcome on GitHub at https://github.com/rzane/graphql-extras.
209 |
210 |
211 | ## License
212 |
213 | GraphQL::Extras is released under the [MIT License](MIT-LICENSE).
214 |
--------------------------------------------------------------------------------
/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/extras"
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-extras.gemspec:
--------------------------------------------------------------------------------
1 | lib = File.expand_path("lib", __dir__)
2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3 | require "graphql/extras/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "graphql-extras"
7 | spec.version = GraphQL::Extras::VERSION
8 | spec.authors = ["Ray Zane"]
9 | spec.email = ["raymondzane@gmail.com"]
10 |
11 | spec.summary = %q{Utiltities for building GraphQL APIs.}
12 | spec.description = %q{A set of modules and types for buildign GraphQL APIs.}
13 | spec.homepage = "https://github.com/rzane/graphql-extras"
14 |
15 | spec.metadata["homepage_uri"] = spec.homepage
16 | spec.metadata["source_code_uri"] = "https://github.com/rzane/graphql-extras"
17 |
18 | # Specify which files should be added to the gem when it is released.
19 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
21 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22 | end
23 | spec.bindir = "exe"
24 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25 | spec.require_paths = ["lib"]
26 |
27 | spec.add_dependency "activesupport", ">= 5.2"
28 | spec.add_dependency "graphql", [">= 1.12", "< 3"]
29 |
30 | spec.add_development_dependency "bundler"
31 | spec.add_development_dependency "rake"
32 | spec.add_development_dependency "rspec"
33 | spec.add_development_dependency "rspec-rails"
34 | spec.add_development_dependency "actionpack"
35 | spec.add_development_dependency "activerecord"
36 | spec.add_development_dependency "sqlite3"
37 | end
38 |
--------------------------------------------------------------------------------
/lib/graphql/extras.rb:
--------------------------------------------------------------------------------
1 | require "graphql/extras/version"
2 | require "graphql/extras/types"
3 | require "graphql/extras/controller"
4 | require "graphql/extras/preload"
5 |
6 | module GraphQL
7 | module Extras
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/graphql/extras/controller.rb:
--------------------------------------------------------------------------------
1 | module GraphQL
2 | module Extras
3 | module Controller
4 | def graphql(schema:, context: {}, debug: Rails.env.development?)
5 | query = params[:query]
6 | operation = params[:operationName]
7 | variables = cast_graphql_params(params[:variables])
8 |
9 | uploads = params.to_unsafe_h.select do |_, value|
10 | value.is_a?(ActionDispatch::Http::UploadedFile)
11 | end
12 |
13 | result = schema.execute(
14 | query,
15 | operation_name: operation,
16 | variables: variables,
17 | context: context.merge(uploads: uploads)
18 | )
19 |
20 | render(status: 200, json: result)
21 | rescue => error
22 | raise error unless debug
23 |
24 | logger.error(error.message)
25 | logger.error(error.backtrace.join("\n"))
26 |
27 | render(
28 | status: 500,
29 | json: {
30 | data: {},
31 | error: {
32 | message: error.message,
33 | backtrace: error.backtrace
34 | }
35 | }
36 | )
37 | end
38 |
39 | private def cast_graphql_params(param)
40 | case param
41 | when String
42 | return {} if param.blank?
43 | cast_graphql_params(JSON.parse(param))
44 | when Hash, ActionController::Parameters
45 | param
46 | when nil
47 | {}
48 | else
49 | raise ArgumentError, "Unexpected parameter: #{param}"
50 | end
51 | end
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/graphql/extras/preload.rb:
--------------------------------------------------------------------------------
1 | module GraphQL
2 | module Extras
3 | class PreloadSource < GraphQL::Dataloader::Source
4 | def initialize(preload)
5 | @preload = preload
6 | end
7 |
8 | def fetch(records)
9 | if ActiveRecord::VERSION::MAJOR >= 7
10 | preloader = ActiveRecord::Associations::Preloader.new(
11 | records: records,
12 | associations: @preload
13 | )
14 | preloader.call
15 | else
16 | preloader = ActiveRecord::Associations::Preloader.new
17 | preloader.preload(records, @preload)
18 | end
19 |
20 | records
21 | end
22 | end
23 |
24 | module Preload
25 | # @override
26 | def initialize(*args, preload: nil, **opts, &block)
27 | @preload = preload
28 | super(*args, **opts, &block)
29 | end
30 |
31 | # @override
32 | def resolve(object, args, context)
33 | if @preload
34 | loader = context.dataloader.with(PreloadSource, @preload)
35 | loader.load(object.object)
36 | end
37 |
38 | super
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/graphql/extras/test.rb:
--------------------------------------------------------------------------------
1 | require "graphql/extras/test/schema"
2 |
--------------------------------------------------------------------------------
/lib/graphql/extras/test/loader.rb:
--------------------------------------------------------------------------------
1 | module GraphQL
2 | module Extras
3 | module Test
4 | class Loader
5 | FragmentNotFoundError = Class.new(StandardError)
6 |
7 | attr_reader :fragments
8 | attr_reader :operations
9 |
10 | def initialize
11 | @fragments = {}
12 | @operations = {}
13 | end
14 |
15 | def load(path)
16 | document = ::GraphQL.parse_file(path)
17 | document.definitions.each do |node|
18 | case node
19 | when Nodes::FragmentDefinition
20 | fragments[node.name] = node
21 | when Nodes::OperationDefinition
22 | operations[node.name] = node
23 | end
24 | end
25 | end
26 |
27 | def print(operation)
28 | printer = ::GraphQL::Language::Printer.new
29 | nodes = [operation, *resolve_fragments(operation)]
30 | nodes.map { |node| printer.print(node) }.join("\n")
31 | end
32 |
33 | private
34 |
35 | Nodes = ::GraphQL::Language::Nodes
36 |
37 | # Recursively iterate through the node's fields and find
38 | # resolve all of the fragment definitions that are needed.
39 | def resolve_fragments(node)
40 | result = node.selections.flat_map do |selection|
41 | case selection
42 | when Nodes::FragmentSpread
43 | fragment = fetch_fragment!(selection.name)
44 | [fragment, *resolve_fragments(fragment)]
45 | else
46 | resolve_fragments(selection)
47 | end
48 | end
49 |
50 | result.uniq
51 | end
52 |
53 | def fetch_fragment!(name)
54 | fragments.fetch(name) do
55 | raise FragmentNotFoundError, "Fragment `#{name}` is not defined"
56 | end
57 | end
58 | end
59 | end
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/lib/graphql/extras/test/response.rb:
--------------------------------------------------------------------------------
1 | module GraphQL
2 | module Extras
3 | module Test
4 | class Response
5 | attr_reader :data
6 | attr_reader :errors
7 |
8 | def initialize(payload)
9 | @data = payload["data"]
10 | @errors = payload.fetch("errors", []).map do |error|
11 | Error.new(error)
12 | end
13 | end
14 |
15 | def successful?
16 | errors.empty?
17 | end
18 |
19 | class Error
20 | attr_reader :message
21 | attr_reader :extensions
22 | attr_reader :code
23 | attr_reader :path
24 | attr_reader :locations
25 |
26 | def initialize(payload)
27 | @message = payload["message"]
28 | @path = payload["path"]
29 | @locations = payload["locations"]
30 | @extensions = payload["extensions"]
31 | @code = payload.dig("extensions", "code")
32 | end
33 | end
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/graphql/extras/test/schema.rb:
--------------------------------------------------------------------------------
1 | require "securerandom"
2 | require "active_support/inflector"
3 | require "active_support/core_ext/hash"
4 | require "graphql/extras/test/loader"
5 | require "graphql/extras/test/response"
6 |
7 | module GraphQL
8 | module Extras
9 | module Test
10 | class Schema
11 | def self.configure(schema:, queries:)
12 | loader = Loader.new
13 |
14 | Dir.glob(queries) do |path|
15 | loader.load(path)
16 | end
17 |
18 | loader.operations.each do |name, operation|
19 | query = loader.print(operation)
20 |
21 | define_method(name.underscore) do |variables = {}|
22 | __execute(schema, query, variables)
23 | end
24 | end
25 | end
26 |
27 | def initialize(context = {})
28 | @context = context
29 | end
30 |
31 | private
32 |
33 | def __execute(schema, query, variables)
34 | uploads = {}
35 |
36 | variables = variables.deep_transform_keys do |key|
37 | key.to_s.camelize(:lower)
38 | end
39 |
40 | variables = variables.deep_transform_values do |value|
41 | if value.respond_to? :tempfile
42 | id = SecureRandom.uuid
43 | uploads[id] = value
44 | id
45 | else
46 | value
47 | end
48 | end
49 |
50 | context = @context.merge(uploads: uploads)
51 | result = schema.execute(query, variables: variables, context: context)
52 | Response.new(result.to_h)
53 | end
54 | end
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/graphql/extras/types.rb:
--------------------------------------------------------------------------------
1 | require "date"
2 | require "bigdecimal"
3 | require "graphql"
4 |
5 | module GraphQL
6 | module Extras
7 | module Types
8 | class DateTime < GraphQL::Types::ISO8601DateTime
9 | description <<~DESC
10 | The `DateTime` scalar type represents a date and time in the UTC
11 | timezone. The DateTime appears in a JSON response as an ISO8601 formatted
12 | string, including UTC timezone ("Z"). The parsed date and time string will
13 | be converted to UTC and any UTC offset other than 0 will be rejected.
14 | DESC
15 | end
16 |
17 | class Date < GraphQL::Schema::Scalar
18 | description <<~DESC
19 | The `Date` scalar type represents a date. The Date appears in a JSON
20 | response as an ISO8601 formatted string.
21 | DESC
22 |
23 | def self.coerce_input(value, _context)
24 | ::Date.iso8601(value)
25 | rescue ArgumentError
26 | nil
27 | end
28 |
29 | def self.coerce_result(value, _context)
30 | value.iso8601
31 | end
32 | end
33 |
34 | class Decimal < GraphQL::Schema::Scalar
35 | description <<~DESC
36 | The `Decimal` scalar type represents signed double-precision fractional
37 | values. The Decimal appears in a JSON response as a string to preserve
38 | precision.
39 | DESC
40 |
41 | def self.coerce_input(value, _context)
42 | BigDecimal(value.to_s)
43 | rescue ArgumentError
44 | nil
45 | end
46 |
47 | def self.coerce_result(value, _context)
48 | value.to_s("F")
49 | end
50 | end
51 |
52 | class Upload < GraphQL::Schema::Scalar
53 | description "Represents an uploaded file."
54 |
55 | def self.coerce_input(value, context)
56 | return nil if value.nil?
57 |
58 | uploads = context.fetch(:uploads) {
59 | raise "Expected context to include a hash of uploads."
60 | }
61 |
62 | uploads.fetch(value) do
63 | raise GraphQL::CoercionError, "No upload named `#{value}` provided."
64 | end
65 | end
66 | end
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/graphql/extras/version.rb:
--------------------------------------------------------------------------------
1 | module GraphQL
2 | module Extras
3 | VERSION = "0.0.0"
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/spec/fixtures/files/image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rzane/graphql-extras/d8144c89c0f1e16067133a98b5c728494a4a9493/spec/fixtures/files/image.jpg
--------------------------------------------------------------------------------
/spec/fixtures/graphql/explode.graphql:
--------------------------------------------------------------------------------
1 | query Explode {
2 | explode
3 | }
4 |
--------------------------------------------------------------------------------
/spec/fixtures/graphql/hello.graphql:
--------------------------------------------------------------------------------
1 | query HelloWorld {
2 | hello: helloWorld
3 | }
4 |
5 | query HelloName($name: String!) {
6 | hello: helloName(name: $name)
7 | }
8 |
9 | query HelloContext {
10 | hello: helloContext
11 | }
12 |
--------------------------------------------------------------------------------
/spec/fixtures/graphql/people.graphql:
--------------------------------------------------------------------------------
1 | query ListPeople {
2 | people {
3 | ...Person
4 | }
5 | allPeople: people {
6 | ...Person
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/spec/fixtures/graphql/person.graphql:
--------------------------------------------------------------------------------
1 | fragment Person on Person {
2 | name
3 | }
4 |
--------------------------------------------------------------------------------
/spec/fixtures/graphql/upload_image.graphql:
--------------------------------------------------------------------------------
1 | mutation UploadImage($image: Upload!) {
2 | image: uploadImage(image: $image)
3 | }
4 |
--------------------------------------------------------------------------------
/spec/graphql/extras/controller_spec.rb:
--------------------------------------------------------------------------------
1 | require "support/rails"
2 | require "support/schema"
3 | require "graphql/extras/controller"
4 |
5 | RSpec.describe GraphQL::Extras::Controller, type: :controller do
6 | let(:json) { JSON.parse(response.body) }
7 | let(:upload) { build_upload("files/image.jpg") }
8 |
9 | controller ActionController::Base do
10 | include GraphQL::Extras::Controller
11 |
12 | def index
13 | graphql(schema: Support::Schema)
14 | end
15 | end
16 |
17 | it "executes a query against the schema" do
18 | post :index, params: { query: "{ helloWorld }" }
19 | expect(json).to eq("data" => { "helloWorld" => "Hello, world" })
20 | end
21 |
22 | it "handles file uploads" do
23 | query = <<~GRAPHQL
24 | mutation UploadImage($image: Upload!) {
25 | uploadImage(image: $image)
26 | }
27 | GRAPHQL
28 |
29 | post :index, params: {
30 | query: query,
31 | example: upload,
32 | variables: {
33 | image: "example"
34 | }
35 | }
36 |
37 | expect(json).to eq("data" => { "uploadImage" => "image.jpg" })
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/spec/graphql/extras/preload_spec.rb:
--------------------------------------------------------------------------------
1 | require "support/database"
2 |
3 | RSpec.describe GraphQL::Extras::Preload do
4 | class Foo < ActiveRecord::Base
5 | end
6 |
7 | class Bar < ActiveRecord::Base
8 | belongs_to :foo
9 | end
10 |
11 | class BaseField < GraphQL::Schema::Field
12 | prepend GraphQL::Extras::Preload
13 | end
14 |
15 | class BaseObject < GraphQL::Schema::Object
16 | field_class BaseField
17 | field :id, ID, null: false
18 | end
19 |
20 | class BarType < BaseObject
21 | field :foo, BaseObject, null: false
22 | end
23 |
24 | class BatchedBarType < BaseObject
25 | field :foo, BaseObject, null: false, preload: :foo
26 | end
27 |
28 | class BatchQueryType < BaseObject
29 | field :bars, [BarType], null: false
30 | field :bars_batched, [BatchedBarType], null: false
31 | def bars; Bar.all; end
32 | def bars_batched; Bar.all; end
33 | end
34 |
35 | class BatchSchema < GraphQL::Schema
36 | query BatchQueryType
37 | use GraphQL::Dataloader
38 | end
39 |
40 | before :all do
41 | Database.setup do
42 | create_table(:foos, force: true)
43 | create_table(:bars, force: true) do |t|
44 | t.belongs_to :foo
45 | end
46 | end
47 | end
48 |
49 | before do
50 | 5.times { Bar.create!(foo: Foo.create!) }
51 | end
52 |
53 | it "fires N+1 queries without batching" do
54 | query = <<~GRAPHQL
55 | query {
56 | bars {
57 | id
58 | foo { id }
59 | }
60 | }
61 | GRAPHQL
62 |
63 | count = Database.count_queries(/SELECT.*foos/i) do
64 | BatchSchema.execute(query)
65 | end
66 |
67 | expect(count).to eq(5)
68 | end
69 |
70 | it "fires 1 query with batching" do
71 | query = <<~GRAPHQL
72 | query {
73 | barsBatched {
74 | id
75 | foo { id }
76 | }
77 | }
78 | GRAPHQL
79 |
80 | count = Database.count_queries(/SELECT.*foos/i) do
81 | BatchSchema.execute(query)
82 | end
83 |
84 | expect(count).to eq(1)
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/spec/graphql/extras/test/loader_spec.rb:
--------------------------------------------------------------------------------
1 | require "graphql/extras/test/loader"
2 |
3 | RSpec.describe GraphQL::Extras::Test::Loader do
4 | Loader = GraphQL::Extras::Test::Loader
5 |
6 | it "initializes" do
7 | loader = Loader.new
8 | expect(loader.fragments).to eq({})
9 | expect(loader.operations).to eq({})
10 | end
11 |
12 | it "loads a query" do
13 | loader = Loader.new
14 | loader.load "spec/fixtures/graphql/people.graphql"
15 | expect(loader.operations).to have_key("ListPeople")
16 | end
17 |
18 | it "loads a fragment" do
19 | loader = Loader.new
20 | loader.load "spec/fixtures/graphql/person.graphql"
21 | expect(loader.fragments).to have_key("Person")
22 | end
23 |
24 | it "raises when a fragment is not found" do
25 | loader = Loader.new
26 | loader.load "spec/fixtures/graphql/people.graphql"
27 |
28 | operation = loader.operations["ListPeople"]
29 | expect { loader.print(operation) }.to raise_error(Loader::FragmentNotFoundError)
30 | end
31 |
32 | it "prints an operation, including fragments, with no duplicates" do
33 | loader = Loader.new
34 | loader.load "spec/fixtures/graphql/person.graphql"
35 | loader.load "spec/fixtures/graphql/people.graphql"
36 |
37 | operation = loader.operations["ListPeople"]
38 | graphql = loader.print(operation)
39 |
40 | expect(graphql).to include("query ListPeople")
41 | expect(graphql.scan("fragment Person").count).to eq 1
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/spec/graphql/extras/test/schema_spec.rb:
--------------------------------------------------------------------------------
1 | require "support/schema"
2 | require "graphql/extras/test/schema"
3 |
4 | class TestSchema < GraphQL::Extras::Test::Schema
5 | configure(
6 | schema: Support::Schema,
7 | queries: "spec/fixtures/graphql/*.graphql"
8 | )
9 | end
10 |
11 | RSpec.describe GraphQL::Extras::Test::Schema do
12 | let(:schema) { TestSchema.new }
13 |
14 | it "executes a query" do
15 | query = schema.hello_world
16 |
17 | expect(query).to be_successful
18 | expect(query.data["hello"]).to eq("Hello, world")
19 | end
20 |
21 | it "executes a query with variables" do
22 | query = schema.hello_name(name: "argument")
23 |
24 | expect(query).to be_successful
25 | expect(query.data["hello"]).to eq("Hello, argument")
26 | end
27 |
28 | it "executes a query with context" do
29 | schema = TestSchema.new(name: "context")
30 | query = schema.hello_context
31 |
32 | expect(query).to be_successful
33 | expect(query.data["hello"]).to eq("Hello, context")
34 | end
35 |
36 | it "executes a query with uploads" do
37 | upload = build_upload("files/image.jpg")
38 | query = schema.upload_image(image: upload)
39 |
40 | expect(query).to be_successful
41 | expect(query.data["image"]).to eq("image.jpg")
42 | end
43 |
44 | it "executes a query with errors" do
45 | query = schema.explode
46 |
47 | expect(query).not_to be_successful
48 | expect(query.errors[0].message).to eq("Boom!")
49 | expect(query.errors[0].code).to eq("BOOM")
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/spec/graphql/extras/types_spec.rb:
--------------------------------------------------------------------------------
1 | require "graphql/extras/types"
2 | require "action_dispatch/http/upload"
3 |
4 | RSpec.describe GraphQL::Extras::Types do
5 | describe GraphQL::Extras::Types::Decimal do
6 | it "parses a decimal" do
7 | value = described_class.coerce_input("5.5", {})
8 | expect(value).to eq(BigDecimal("5.5"))
9 | end
10 |
11 | it "translates an invalid value to nil" do
12 | value = described_class.coerce_input("", {})
13 | expect(value).to eq(nil)
14 | end
15 |
16 | it "converts a decimal to a string" do
17 | value = described_class.coerce_result(BigDecimal("5.5"), {})
18 | expect(value).to eq("5.5")
19 | end
20 | end
21 |
22 | describe GraphQL::Extras::Types::Date do
23 | it "parses a valid date" do
24 | value = described_class.coerce_input("2019-01-01", {})
25 | expect(value).to eq(Date.new(2019, 1, 1))
26 | end
27 |
28 | it "translates an invalid value to nil" do
29 | value = described_class.coerce_input("1/1/2019", {})
30 | expect(value).to eq(nil)
31 | end
32 |
33 | it "converts a date to a string" do
34 | value = described_class.coerce_result(Date.new(2019, 1, 1), {})
35 | expect(value).to eq("2019-01-01")
36 | end
37 | end
38 |
39 | describe GraphQL::Extras::Types::Upload do
40 | let(:upload) {
41 | ActionDispatch::Http::UploadedFile.new(
42 | filename: "image.jpg",
43 | tempfile: "/tmp/image.jpg"
44 | )
45 | }
46 |
47 | it "extracts an upload from context" do
48 | context = { uploads: { "foo" => upload } }
49 | value = described_class.coerce_input("foo", context)
50 | expect(value).to eq(upload)
51 | end
52 |
53 | it "raises an error when uploads are not passed into context" do
54 | expect {
55 | described_class.coerce_input("foo", {})
56 | }.to raise_error(RuntimeError, /hash of uploads/)
57 | end
58 |
59 | it "raises an error when upload does not exist in context" do
60 | context = { uploads: {} }
61 |
62 | expect {
63 | described_class.coerce_input("foo", context)
64 | }.to raise_error(GraphQL::CoercionError, "No upload named `foo` provided.")
65 | end
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/spec/graphql/extras_spec.rb:
--------------------------------------------------------------------------------
1 | RSpec.describe GraphQL::Extras do
2 | it "has a version number" do
3 | expect(GraphQL::Extras::VERSION).not_to be nil
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require "bundler/setup"
2 | require "rack/test"
3 | require "graphql/extras"
4 |
5 | module UploadHelpers
6 | def build_upload(fixture)
7 | path = File.join(__dir__, "fixtures", fixture)
8 | Rack::Test::UploadedFile.new(path)
9 | end
10 | end
11 |
12 | RSpec.configure do |config|
13 | # Enable flags like --only-failures and --next-failure
14 | config.example_status_persistence_file_path = ".rspec_status"
15 |
16 | # Disable RSpec exposing methods globally on `Module` and `main`
17 | config.disable_monkey_patching!
18 |
19 | config.expect_with :rspec do |c|
20 | c.syntax = :expect
21 | end
22 |
23 | config.include UploadHelpers
24 | end
25 |
--------------------------------------------------------------------------------
/spec/support/database.rb:
--------------------------------------------------------------------------------
1 | require "sqlite3"
2 | require "active_support/all"
3 | require "active_record"
4 |
5 | module Database
6 | def self.setup(&block)
7 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
8 | ActiveRecord::Migration.verbose = false
9 | ActiveRecord::Schema.define(&block)
10 | end
11 |
12 | def self.count_queries(matching)
13 | count = 0
14 | ActiveSupport::Notifications.subscribe('sql.active_record') do |_, _, _, _, values|
15 | count += 1 if values[:sql] && values[:sql] =~ matching
16 | end
17 | yield
18 | count
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/spec/support/rails.rb:
--------------------------------------------------------------------------------
1 | require "active_support/all"
2 | require "action_view"
3 | require "action_controller"
4 | require "action_dispatch"
5 | require "rspec/rails"
6 |
7 | module Rails
8 | # Stub out Rails in order to act like we're running a live Rails app.
9 |
10 | class Application
11 | def env_config
12 | {}
13 | end
14 |
15 | def routes
16 | ActionDispatch::Routing::RouteSet.new
17 | end
18 | end
19 |
20 | def self.application
21 | Application.new
22 | end
23 |
24 | def self.env
25 | ActiveSupport::StringInquirer.new('test')
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/spec/support/schema.rb:
--------------------------------------------------------------------------------
1 | module Support
2 | class Person < GraphQL::Schema::Object
3 | field :name, String, null: false
4 | end
5 |
6 | class Query < GraphQL::Schema::Object
7 | field :hello_world, String, null: false
8 | field :hello_context, String, null: false
9 | field :hello_name, String, null: false do
10 | argument :name, String, required: true
11 | end
12 |
13 | field :explode, String, null: false
14 | field :people, [Person], null: false
15 |
16 | def hello_world
17 | "Hello, world"
18 | end
19 |
20 | def hello_name(name:)
21 | "Hello, #{name}"
22 | end
23 |
24 | def hello_context
25 | "Hello, #{context[:name]}"
26 | end
27 |
28 | def explode
29 | raise GraphQL::ExecutionError.new("Boom!", extensions: {code: "BOOM"})
30 | end
31 |
32 | def people
33 | [{name: "Rick"}]
34 | end
35 | end
36 |
37 | class Mutation < GraphQL::Schema::Object
38 | include GraphQL::Extras::Types
39 |
40 | field :upload_image, String, null: false do
41 | argument :image, Upload, required: true
42 | end
43 |
44 | def upload_image(image:)
45 | image.original_filename
46 | end
47 | end
48 |
49 | class Schema < GraphQL::Schema
50 | query(Query)
51 | mutation(Mutation)
52 | end
53 | end
54 |
--------------------------------------------------------------------------------