graphql-extras 0.2.6 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51f813e6dda5daf0c9c0b94a768b2dc339c19e45ae08277b7fa6e1f0b7a33361
4
- data.tar.gz: c0359145f282a0e86ea53e23a5240712c2cde97331daec88d2b754244fe5af4e
3
+ metadata.gz: 29213c20e07ca49b31f25ee4c66a1b60537c746ac3a66030eee023eb33d35f65
4
+ data.tar.gz: 45694df9fb4e00159e7394bcab9f9253de8dd949b891cc6b78a6e28fa548a99e
5
5
  SHA512:
6
- metadata.gz: 58851891def9df659c6effdc2633954ffdcafbfcad544196b32f1f502bacd8d6d227e3e3a95b78bf418859b2541a284f746368934d6fa3fd8000ae36a53b86a2
7
- data.tar.gz: 21517e559a3ab8835839b16b39689b780118838d016e9348b0c3189309ae6dfc57376eee89f920423b78ebd0f8577b18e2b796ce72f721dc8460fe00d62041b0
6
+ metadata.gz: 22ca80f894bdae011ee6707f35a43097b699534dfc4295e5369100f993dced3a6906df29518dff0729683320db7dd72d9186c0edc8d0378ffb2b5bee24941e0d
7
+ data.tar.gz: e6df88738196ecf8b02b15fcad0da085ee6cfca9762a27dc06942458797b73a624fa300252338e3f04c92aff73a52a59543eeee29bc5f9ee2631c27563223423
data/README.md CHANGED
@@ -2,13 +2,29 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- ![Build](https://github.com/rzane/graphql-extras/workflows/CI/badge.svg)
5
+ ![Build](https://github.com/rzane/graphql-extras/workflows/Build/badge.svg)
6
6
  ![Version](https://img.shields.io/gem/v/graphql-extras)
7
7
 
8
8
  </div>
9
9
 
10
10
  A collection of utilities for building GraphQL APIs.
11
11
 
12
+ **Table of Contents**
13
+
14
+ - [Installation](#installation)
15
+ - [Usage](#usage)
16
+ - [GraphQL::Extras::Controller](#graphqlextrascontroller)
17
+ - [GraphQL::Extras::AssociationLoader](#graphqlextrasassociationloader)
18
+ - [GraphQL::Extras::Preload](#graphqlextraspreload)
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
+
12
28
  ## Installation
13
29
 
14
30
  Add this line to your application's Gemfile:
@@ -37,30 +53,36 @@ class GraphqlController < ApplicationController
37
53
  end
38
54
  ```
39
55
 
40
- ### GraphQL::Extras::Batch::AssociationLoader
56
+ ### GraphQL::Extras::AssociationLoader
41
57
 
42
58
  This is a subclass of [`GraphQL::Batch::Loader`](https://github.com/Shopify/graphql-batch) that performs eager loading of Active Record associations.
43
59
 
44
60
  ```ruby
45
- loader = GraphQL::Extras::Batch::AssociationLoader.for(:blog)
61
+ loader = GraphQL::Extras::AssociationLoader.for(:blog)
46
62
  loader.load(Post.first)
47
63
  loader.load_many(Post.all)
48
64
  ```
49
65
 
50
- ### GraphQL::Extras::Batch::Resolvers
66
+ ### GraphQL::Extras::Preload
51
67
 
52
- This includes a set of convenience methods for query batching.
68
+ This allows you to preload associations before resolving fields.
53
69
 
54
70
  ```ruby
55
- class Post < GraphQL::Schema::Object
56
- include GraphQL::Extras::Batch::Resolver
71
+ class BaseField < GraphQL::Schema::Field
72
+ prepend GraphQL::Extras::Preload
73
+ end
57
74
 
58
- field :blog, BlogType, resolve: association(:blog), null: false
59
- field :comments, [CommentType], resolve: association(:comments, preload: { comments: :user }), null: false
60
- field :blog_title, String, null: false
75
+ class BaseObject < GraphQL::Schema::Object
76
+ field_class BaseField
77
+ end
61
78
 
62
- def blog_title
63
- association(object, :blog).then(&:title)
79
+ class PostType < BaseObject
80
+ field :author, AuthorType, preload: :author, null: false
81
+ field :author_posts, [PostType], preload: {author: :posts}, null: false
82
+ field :depends_on_author, Integer, preload: :author, null: false
83
+
84
+ def author_posts
85
+ object.author.posts
64
86
  end
65
87
  end
66
88
  ```
@@ -95,7 +117,7 @@ This scalar takes a `DateTime` and transmits it as a string, using ISO 8601 form
95
117
  field :created_at, DateTime, required: true
96
118
  ```
97
119
 
98
- *Note: This is just an alias for the `ISO8601DateTime` type that is included in the `graphql` gem.*
120
+ _Note: This is just an alias for the `ISO8601DateTime` type that is included in the `graphql` gem._
99
121
 
100
122
  #### Decimal
101
123
 
@@ -126,36 +148,44 @@ Take note of the correspondence between the value `"image"` and the additional H
126
148
 
127
149
  See [apollo-link-upload](https://github.com/rzane/apollo-link-upload) for the client-side implementation.
128
150
 
129
- ### RSpec integration
151
+ ### GraphQL::Extras::Test
152
+
153
+ This module makes it really easy to test your schema.
130
154
 
131
- Add the following to your `rails_helper.rb` (or `spec_helper.rb`).
155
+ First, create a test schema:
132
156
 
133
157
  ```ruby
134
- require "graphql/extras/rspec"
158
+ # spec/support/test_schema.rb
159
+ require "graphql/extras/test"
160
+
161
+ class TestSchema < GraphQL::Extras::Test::Schema
162
+ configure schema: Schema, queries: "spec/**/*.graphql"
163
+ end
135
164
  ```
136
165
 
137
166
  Now, you can run tests like so:
138
167
 
139
168
  ```ruby
169
+ require "support/test_schema"
170
+
140
171
  RSpec.describe "hello" do
141
172
  let(:context) { { name: "Ray" } }
142
- let(:schema) { use_schema(Schema, context: context) }
143
- let(:queries) { graphql_fixture("hello.graphql") }
173
+ let(:schema) { TestSchema.new(context) }
144
174
 
145
175
  it "allows easily executing queries" do
146
- result = schema.execute(queries.hello)
176
+ query = schema.hello
147
177
 
148
- expect(result).to be_successful_query
149
- expect(result['data']['hello']).to eq("world")
178
+ expect(query).to be_successful
179
+ expect(query.data["hello"]).to eq("world")
150
180
  end
151
- end
152
- ```
153
181
 
154
- The `graphql_fixture` method assumes that your queries live in `spec/fixtures/graphql`. You can change this assumption with the following configuration:
182
+ it "parses errors" do
183
+ query = schema.kaboom
155
184
 
156
- ```ruby
157
- RSpec.configure do |config|
158
- config.graphql_fixture_path = '/path/to/queries'
185
+ expect(query).not_to be_successful
186
+ expect(query.errors[0].message).to eq("Invalid")
187
+ expect(query.errors[0].code).to eq("VALIDATION_ERROR")
188
+ end
159
189
  end
160
190
  ```
161
191
 
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.require_paths = ["lib"]
26
26
 
27
27
  spec.add_dependency "activesupport", ">= 5.2"
28
- spec.add_dependency "graphql", "~> 1.9"
28
+ spec.add_dependency "graphql", "~> 1.12"
29
29
  spec.add_dependency "graphql-batch", "~> 0.4"
30
30
 
31
31
  spec.add_development_dependency "bundler", "~> 2.0"
@@ -1,7 +1,8 @@
1
1
  require "graphql/extras/version"
2
2
  require "graphql/extras/types"
3
3
  require "graphql/extras/controller"
4
- require "graphql/extras/batch"
4
+ require "graphql/extras/association_loader"
5
+ require "graphql/extras/preload"
5
6
 
6
7
  module GraphQL
7
8
  module Extras
@@ -0,0 +1,24 @@
1
+ require "graphql/batch"
2
+
3
+ module GraphQL
4
+ module Extras
5
+ class AssociationLoader < GraphQL::Batch::Loader
6
+ def initialize(preload)
7
+ @preload = preload
8
+ end
9
+
10
+ def cache_key(record)
11
+ record.object_id
12
+ end
13
+
14
+ def perform(records)
15
+ preloader = ActiveRecord::Associations::Preloader.new
16
+ preloader.preload(records, @preload)
17
+
18
+ records.each do |record|
19
+ fulfill(record, nil) unless fulfilled?(record)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ require "graphql/extras/association_loader"
2
+
3
+ module GraphQL
4
+ module Extras
5
+ module Preload
6
+ # @override
7
+ def initialize(*args, preload: nil, **opts, &block)
8
+ @preload = preload
9
+ super(*args, **opts, &block)
10
+ end
11
+
12
+ # @override
13
+ def resolve(object, args, ctx)
14
+ return super unless @preload
15
+
16
+ loader = AssociationLoader.for(@preload)
17
+ loader.load(object.object).then do
18
+ super(object, args, ctx)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1 @@
1
+ require "graphql/extras/test/schema"
@@ -0,0 +1,59 @@
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
+ node.selections.flat_map do |field|
41
+ case field
42
+ when Nodes::FragmentSpread
43
+ fragment = fetch_fragment!(field.name)
44
+ [fragment, *resolve_fragments(fragment)]
45
+ else
46
+ resolve_fragments(field)
47
+ end
48
+ end
49
+ end
50
+
51
+ def fetch_fragment!(name)
52
+ fragments.fetch(name) do
53
+ raise FragmentNotFoundError, "Fragment `#{name}` is not defined"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,53 @@
1
+ require "securerandom"
2
+ require "active_support/inflector"
3
+ require "active_support/core_ext/hash/deep_transform_values"
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_values do |value|
37
+ if value.respond_to? :tempfile
38
+ id = SecureRandom.uuid
39
+ uploads[id] = value
40
+ id
41
+ else
42
+ value
43
+ end
44
+ end
45
+
46
+ context = @context.merge(uploads: uploads)
47
+ result = schema.execute(query, variables: variables, context: context)
48
+ Response.new(result.to_h)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,5 +1,5 @@
1
1
  module GraphQL
2
2
  module Extras
3
- VERSION = "0.2.6"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-extras
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Zane
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-07-17 00:00:00.000000000 Z
11
+ date: 2021-02-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '1.9'
33
+ version: '1.12'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '1.9'
40
+ version: '1.12'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: graphql-batch
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -168,9 +168,13 @@ files:
168
168
  - bin/setup
169
169
  - graphql-extras.gemspec
170
170
  - lib/graphql/extras.rb
171
- - lib/graphql/extras/batch.rb
171
+ - lib/graphql/extras/association_loader.rb
172
172
  - lib/graphql/extras/controller.rb
173
- - lib/graphql/extras/rspec.rb
173
+ - lib/graphql/extras/preload.rb
174
+ - lib/graphql/extras/test.rb
175
+ - lib/graphql/extras/test/loader.rb
176
+ - lib/graphql/extras/test/response.rb
177
+ - lib/graphql/extras/test/schema.rb
174
178
  - lib/graphql/extras/types.rb
175
179
  - lib/graphql/extras/version.rb
176
180
  homepage: https://github.com/promptworks/graphql-extras
@@ -1,47 +0,0 @@
1
- require "graphql/batch"
2
-
3
- module GraphQL
4
- module Extras
5
- module Batch
6
- class AssociationLoader < GraphQL::Batch::Loader
7
- def initialize(name, preload: name)
8
- @name = name
9
- @preload = preload
10
- end
11
-
12
- def cache_key(record)
13
- record.object_id
14
- end
15
-
16
- def perform(records)
17
- preloader = ActiveRecord::Associations::Preloader.new
18
- preloader.preload(records, @preload)
19
-
20
- records.each do |record|
21
- fulfill(record, record.public_send(@name))
22
- end
23
- end
24
- end
25
-
26
- module Resolvers
27
- def self.included(base)
28
- base.extend ClassMethods
29
- end
30
-
31
- module ClassMethods
32
- def association(name)
33
- lambda do |record, _args, _ctx|
34
- loader = AssociationLoader.for(name)
35
- loader.load(record)
36
- end
37
- end
38
- end
39
-
40
- def association(record, name)
41
- loader = AssociationLoader.for(name)
42
- loader.load(record)
43
- end
44
- end
45
- end
46
- end
47
- end
@@ -1,133 +0,0 @@
1
- require "yaml"
2
- require "active_support/inflector"
3
- require "active_support/core_ext/hash"
4
-
5
- module GraphQL
6
- module Extras
7
- module RSpec
8
- class Queries
9
- def add(key, value)
10
- define_singleton_method(key) { value }
11
- end
12
- end
13
-
14
- class Schema
15
- def initialize(schema, context: {})
16
- @schema = schema
17
- @context = context
18
- end
19
-
20
- def execute(query, variables = {})
21
- variables = deep_camelize_keys(variables)
22
- variables, uploads = extract_uploads(variables)
23
- context = @context.merge(uploads: uploads)
24
-
25
- result = @schema.execute(query, variables: variables, context: context)
26
- result.to_h
27
- end
28
-
29
- private
30
-
31
- def extract_uploads(variables)
32
- uploads = {}
33
- variables = deep_transform_values(variables) { |value|
34
- if upload?(value)
35
- SecureRandom.hex.tap { |key| uploads.merge!(key => value) }
36
- else
37
- value
38
- end
39
- }
40
-
41
- [variables, uploads]
42
- end
43
-
44
- def deep_camelize_keys(variables)
45
- variables.deep_transform_keys { |key| key.to_s.camelize(:lower) }
46
- end
47
-
48
- def deep_transform_values(data, &block)
49
- case data
50
- when Array
51
- data.map { |v| deep_transform_values(v, &block) }
52
- when Hash
53
- data.transform_values { |v| deep_transform_values(v, &block) }
54
- else
55
- yield data
56
- end
57
- end
58
-
59
- def upload?(value)
60
- value.respond_to?(:tempfile) && value.respond_to?(:original_filename)
61
- end
62
- end
63
-
64
- class Parser
65
- include ::GraphQL::Language
66
-
67
- def initialize(document)
68
- @operations = document.definitions
69
- .grep(Nodes::OperationDefinition)
70
-
71
- @fragments = document.definitions
72
- .grep(Nodes::FragmentDefinition)
73
- .reduce({}) { |acc, f| acc.merge(f.name => f) }
74
- end
75
-
76
- def parse
77
- queries = Queries.new
78
- printer = Printer.new
79
-
80
- @operations.each do |op|
81
- nodes = [op, *find_fragments(op)]
82
- nodes = nodes.map { |node| printer.print(node) }
83
- queries.add op.name.underscore, nodes.join
84
- end
85
-
86
- queries
87
- end
88
-
89
- private
90
-
91
- def find_fragments(node)
92
- node.selections.flat_map do |field|
93
- if field.is_a? Nodes::FragmentSpread
94
- fragment = @fragments.fetch(field.name)
95
- [fragment, *find_fragments(fragment)]
96
- else
97
- find_fragments(field)
98
- end
99
- end
100
- end
101
- end
102
-
103
- def graphql_fixture(filename)
104
- root = ::RSpec.configuration.graphql_fixture_path
105
- file = File.join(root, filename)
106
- document = ::GraphQL.parse_file(file)
107
- parser = Parser.new(document)
108
- parser.parse
109
- end
110
-
111
- def use_schema(*args, **opts)
112
- Schema.new(*args, **opts)
113
- end
114
- end
115
- end
116
- end
117
-
118
- RSpec::Matchers.define :be_successful_query do
119
- match do |result|
120
- result['errors'].nil?
121
- end
122
-
123
- failure_message do |result|
124
- errors = result['errors'].map(&:deep_stringify_keys)
125
- message = "expected query to be successful, but encountered errors:\n"
126
- message + errors.to_yaml.lines.drop(1).join.indent(2)
127
- end
128
- end
129
-
130
- RSpec.configure do |config|
131
- config.add_setting :graphql_fixture_path, default: "spec/fixtures/graphql"
132
- config.include GraphQL::Extras::RSpec, type: :graphql
133
- end