graphql-extras 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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