graphql-batch 0.1.0 → 0.2.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
  SHA1:
3
- metadata.gz: 3cf4d18124c65f414eb8fcdc54c0dab15e569041
4
- data.tar.gz: fddb094273f8333c2cf25300c9a813104d382d6b
3
+ metadata.gz: 4daba918a3d53b660bb662a01fa76be31ab34507
4
+ data.tar.gz: a0bf9e1268a286ddd00c1dc60c74769fceb060ae
5
5
  SHA512:
6
- metadata.gz: 2867edc13e5537b9999d2f1e0752c16524fa8a818ee5d834e2e7ef5273fcbf19db6e3a1f90a22a7874ca38ec7b312c35fd2412d6afae0dda07970bef23cff564
7
- data.tar.gz: bc0e578c9315a211acd23748e765b8b1e993c34edf30e0f4dbd05acf97bc69b5a5671aa5015931491b6017f6940afbf57d2b5f29b0d1faa79f84530161a42488
6
+ metadata.gz: 8d532cace4412ef4a5e2aff115841b03590f9a72feb9f75086d1092849f19de4287fa6fd7e8dd4608868283499cf4cea17c414bd718d1c9bdb46c0817d75aac6
7
+ data.tar.gz: e6de630224733d0e5dcdcbdea70610910e463862164460e03fe8e4b9f91cca634c28ca2be867d60dd6c3355e13610dac0c5f73edb210daae501e883a0adc0e52
data/.travis.yml CHANGED
@@ -1,4 +1,5 @@
1
1
  language: ruby
2
2
  rvm:
3
- - 2.1.5
4
- before_install: gem install bundler -v 1.10.6
3
+ - 2.1
4
+ - 2.2
5
+ before_install: gem install bundler -v 1.11.2
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Graphql::Batch
1
+ # GraphQL::Batch
2
2
 
3
3
  Provides an executor for the [`graphql` gem](https://github.com/rmosolgo/graphql-ruby) which allows queries to be batched.
4
4
 
@@ -28,54 +28,42 @@ Require the library
28
28
  require 'graphql/batch'
29
29
  ```
30
30
 
31
- Define a GraphQL::Batch::Query derived class. Use group_key to specify which queries can be reduced into a batch query, and an execute class method which makes that batch query and passes the result to each individual query.
31
+ Define a custom loader, which is initialized with arguments that are used for grouping and an perform method for performing the batch load.
32
32
 
33
33
  ```ruby
34
- class FindQuery < GraphQL::Batch::Query
35
- attr_reader :model, :id
36
-
37
- def initialize(model, id, &block)
34
+ class RecordLoader < GraphQL::Batch::Loader
35
+ def initialize(model)
38
36
  @model = model
39
- @id = id
40
- super(&block)
41
- end
42
-
43
- # super returns the class name
44
- def group_key
45
- "#{super}:#{model.name}"
46
37
  end
47
38
 
48
- def self.execute(queries)
49
- model = queries.first.model
50
- ids = queries.map(&:id)
51
- records_by_id = model.where(id: ids).index_by(&:id)
52
- queries.each do |query|
53
- query.complete(records_by_id[query.id])
54
- end
39
+ def perform(ids)
40
+ @model.where(id: ids).each { |record| fulfill(record.id, record) }
41
+ ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
55
42
  end
56
43
  end
57
44
  ```
58
45
 
59
- When defining your schema, using the graphql gem, return a your batch query object from the resolve proc.
46
+ Use the batch execution strategy with your schema
60
47
 
61
48
  ```ruby
62
- resolve -> (obj, args, context) { FindQuery.new(Product, args["id"]) }
49
+ MySchema = GraphQL::Schema.new(query: MyQueryType)
50
+ MySchema.query_execution_strategy = GraphQL::Batch::ExecutionStrategy
51
+ MySchema.mutation_execution_strategy = GraphQL::Batch::ExecutionStrategy
63
52
  ```
64
53
 
65
- Use the batch execution strategy with your schema
54
+ The loader class can be used from the resolve proc for a graphql field by calling `.for` with the grouping arguments to get a loader instance, then call `.load` on that instance with the key to load.
66
55
 
67
56
  ```ruby
68
- MySchema = GraphQL::Schema.new(query: MyQueryType)
69
- MySchema.query_execution_strategy = GraphQL::Batch::ExecutionStrategy
57
+ resolve -> (obj, args, context) { RecordLoader.for(Product).load(args["id"]) }
70
58
  ```
71
59
 
72
- ### Query Dependant Computed Fields
60
+ ### Promises
73
61
 
74
- If you don't want to use a query result directly, then you can pass a block which gets called after the query completes.
62
+ GraphQL::Batch::Loader#load returns a Promise using the [promise.rb gem](https://rubygems.org/gems/promise.rb) to provide a promise based API, so you can transform the query results using `.then`
75
63
 
76
64
  ```ruby
77
65
  resolve -> (obj, args, context) do
78
- FindQuery.new(Product, args["id"]) do |product|
66
+ RecordLoader.for(Product).load(args["id"]).then do |product|
79
67
  product.title
80
68
  end
81
69
  end
@@ -85,23 +73,59 @@ You may also need to do another query that depends on the first one to get the r
85
73
 
86
74
  ```ruby
87
75
  resolve -> (obj, args, context) do
88
- FindQuery.new(Product, args["id"]) do |product|
89
- FindQuery.new(Image, product.image_id)
76
+ RecordLoader.for(Product).load(args["id"]).then do |product|
77
+ RecordLoader.for(Image).load(product.image_id)
90
78
  end
91
79
  end
92
80
  ```
93
81
 
94
- If the second query doesn't depend on the other one, then you can use GraphQL::Batch::QueryGroup, which allows each query in the group to be batched with other queries.
82
+ If the second query doesn't depend on the first one, then you can use Promise.all, which allows each query in the group to be batched with other queries.
95
83
 
96
84
  ```ruby
97
85
  resolve -> (obj, args, context) do
98
- smart_collection_query = CountQuery.new(SmartCollection, context.shop_id)
99
- custom_collection_query = CountQuery.new(CustomCollection, context.shop_id)
100
-
101
- QueryGroup.new([smart_collection_query, custom_collection_query]) do
102
- smart_collection_query.result + custom_collection_query.result
86
+ Promise.all([
87
+ CountLoader.for(Shop, :smart_collections).load(context.shop_id),
88
+ CountLoader.for(Shop, :custom_collections).load(context.shop_id),
89
+ ]).then do |results|
90
+ results.reduce(&:+)
103
91
  end
104
92
  end
93
+
94
+ `.then` can optionally take two lambda arguments, the first of which is equivalent to passing a block to `.then`, and the second one handles exceptions. This can be used to provide a fallback
95
+
96
+ ```ruby
97
+ resolve -> (obj, args, context) do
98
+ CacheLoader.for(Product).load(args["id"]).then(nil, lambda do |exc|
99
+ raise exc unless exc.is_a?(Redis::BaseConnectionError)
100
+ logger.warn err.message
101
+ RecordLoader.for(Product).load(args["id"])
102
+ end)
103
+ end
104
+ ```
105
+
106
+ ## Unit Testing
107
+
108
+ GraphQL::Batch::Promise#sync can be used to wait for a promise to be resolved and return its result. This can be useful for debugging and unit testing loaders.
109
+
110
+ ```ruby
111
+ def test_single_query
112
+ product = products(:snowboard)
113
+ query = RecordLoader.for(Product).load(args["id"]).then(&:title)
114
+ assert_equal product.title, query.sync
115
+ end
116
+ ```
117
+
118
+ Use GraphQL::Batch::Promise.all instead of Promise.all to be able to call sync on the returned promise.
119
+
120
+ ```
121
+ def test_batch_query
122
+ products = [products(:snowboard), products(:jacket)]
123
+ query1 = RecordLoader.for(Product).load(products(:snowboard).id).then(&:title)
124
+ query2 = RecordLoader.for(Product).load(products(:jacket).id).then(&:title)
125
+ results = GraphQL::Batch::Promise.all([query1, query2]).sync
126
+ assert_equal products(:snowboard).title, results[0]
127
+ assert_equal products(:jacket).title, results[1]
128
+ end
105
129
  ```
106
130
 
107
131
  ## Development
@@ -110,7 +134,7 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
110
134
 
111
135
  ## Contributing
112
136
 
113
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/graphql-batch.
137
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Shopify/graphql-batch.
114
138
 
115
139
  ## License
116
140
 
@@ -18,8 +18,10 @@ Gem::Specification.new do |spec|
18
18
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
19
  spec.require_paths = ["lib"]
20
20
 
21
- spec.add_runtime_dependency "graphql", "~>0.8"
21
+ spec.add_runtime_dependency "graphql", "~> 0.8"
22
+ spec.add_runtime_dependency "promise.rb", "~> 0.7.0.rc2"
22
23
 
24
+ spec.add_development_dependency "byebug" if RUBY_ENGINE == 'ruby'
23
25
  spec.add_development_dependency "bundler", "~> 1.10"
24
26
  spec.add_development_dependency "rake", "~> 10.0"
25
27
  spec.add_development_dependency "minitest"
data/lib/graphql/batch.rb CHANGED
@@ -1,6 +1,14 @@
1
1
  require "graphql"
2
+ require "promise.rb"
3
+
4
+ module GraphQL
5
+ module Batch
6
+ BrokenPromiseError = Class.new(StandardError)
7
+ end
8
+ end
9
+
2
10
  require_relative "batch/version"
3
- require_relative "batch/query_container"
4
- require_relative "batch/query"
5
- require_relative "batch/query_group"
11
+ require_relative "batch/loader"
12
+ require_relative "batch/executor"
13
+ require_relative "batch/promise"
6
14
  require_relative "batch/execution_strategy"
@@ -1,57 +1,61 @@
1
1
  module GraphQL::Batch
2
2
  class ExecutionStrategy < GraphQL::Query::SerialExecution
3
- attr_reader :batched_queries
3
+ def execute(_, _, _)
4
+ as_promise(super).sync
5
+ ensure
6
+ GraphQL::Batch::Executor.current.clear
7
+ end
8
+
9
+ private
4
10
 
5
- def initialize
6
- @batched_queries = Hash.new{ |hash, key| hash[key] = [] }
11
+ def as_promise(result)
12
+ GraphQL::Batch::Promise.resolve(as_promise_unless_resolved(result))
7
13
  end
8
14
 
9
- class OperationResolution < GraphQL::Query::SerialExecution::OperationResolution
10
- def result
11
- result = super
12
- until execution_strategy.batched_queries.empty?
13
- queries = execution_strategy.batched_queries.shift.last
14
- queries.first.class.execute(queries)
15
+ def as_promise_unless_resolved(result)
16
+ all_promises = []
17
+ each_promise(result) do |obj, key, promise|
18
+ obj[key] = nil
19
+ all_promises << promise.then do |value|
20
+ obj[key] = value
21
+ as_promise_unless_resolved(value)
15
22
  end
16
- result
17
23
  end
24
+ return result if all_promises.empty?
25
+ Promise.all(all_promises).then { result }
18
26
  end
19
27
 
20
- class SelectionResolution < GraphQL::Query::SerialExecution::SelectionResolution
21
- def result
22
- result_hash = super
23
- result_hash.each do |key, value|
24
- if value.is_a?(FieldResolution)
25
- value.result_hash = result_hash
26
- end
28
+ def each_promise(obj, &block)
29
+ case obj
30
+ when Array
31
+ obj.each_with_index do |value, idx|
32
+ each_promise_in_entry(obj, idx, value, &block)
33
+ end
34
+ when Hash
35
+ obj.each do |key, value|
36
+ each_promise_in_entry(obj, key, value, &block)
27
37
  end
28
- result_hash
29
38
  end
30
39
  end
31
40
 
32
- class FieldResolution < GraphQL::Query::SerialExecution::FieldResolution
33
- attr_accessor :result_hash
41
+ def each_promise_in_entry(obj, key, value, &block)
42
+ if value.is_a?(::Promise)
43
+ yield obj, key, value
44
+ else
45
+ each_promise(value, &block)
46
+ end
47
+ end
34
48
 
49
+ class FieldResolution < GraphQL::Query::SerialExecution::FieldResolution
35
50
  def get_finished_value(raw_value)
36
- if raw_value.is_a?(QueryContainer)
37
- raw_value.query_listener = self
38
- register_queries(raw_value)
39
- self
51
+ if raw_value.is_a?(::Promise)
52
+ raw_value.then(->(result) { super(result) }, lambda do |error|
53
+ error.is_a?(GraphQL::ExecutionError) ? super(error) : raise(error)
54
+ end)
40
55
  else
41
56
  super
42
57
  end
43
58
  end
44
-
45
- def register_queries(query_container)
46
- query_container.each_query do |query|
47
- execution_strategy.batched_queries[query.group_key] << query
48
- end
49
- end
50
-
51
- def query_completed(query)
52
- result_key = ast_node.alias || ast_node.name
53
- @result_hash[result_key] = get_finished_value(query.result)
54
- end
55
59
  end
56
60
  end
57
61
  end
@@ -0,0 +1,35 @@
1
+ module GraphQL::Batch
2
+ class Executor
3
+ THREAD_KEY = :"#{name}.batched_queries"
4
+ private_constant :THREAD_KEY
5
+
6
+ def self.current
7
+ Thread.current[THREAD_KEY] ||= new
8
+ end
9
+
10
+ attr_reader :loaders
11
+
12
+ def initialize
13
+ @loaders = {}
14
+ end
15
+
16
+ def shift
17
+ @loaders.shift.last
18
+ end
19
+
20
+ def tick
21
+ shift.resolve
22
+ end
23
+
24
+ def wait(promise)
25
+ tick while promise.pending? && !loaders.empty?
26
+ if promise.pending?
27
+ promise.reject(BrokenPromiseError.new("Promise wasn't fulfilled after all queries were loaded"))
28
+ end
29
+ end
30
+
31
+ def clear
32
+ loaders.clear
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,51 @@
1
+ module GraphQL::Batch
2
+ class Loader
3
+ def self.for(*group_args)
4
+ Executor.current.loaders[group_args] ||= new(*group_args)
5
+ end
6
+
7
+ def promises_by_key
8
+ @promises_by_key ||= {}
9
+ end
10
+
11
+ def keys
12
+ promises_by_key.keys
13
+ end
14
+
15
+ def load(key)
16
+ promises_by_key[key] ||= Promise.new
17
+ end
18
+
19
+ def fulfill(key, value)
20
+ promises_by_key[key].fulfill(value)
21
+ end
22
+
23
+ def fulfilled?(key)
24
+ promises_by_key[key].fulfilled?
25
+ end
26
+
27
+ # batch load keys and fulfill promises
28
+ def perform(keys)
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def resolve
33
+ perform(keys)
34
+ check_for_broken_promises
35
+ rescue => err
36
+ promises_by_key.each do |key, promise|
37
+ promise.reject(err)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def check_for_broken_promises
44
+ promises_by_key.each do |key, promise|
45
+ if promise.pending?
46
+ promise.reject(BrokenPromiseError.new("#{self.class} didn't fulfill promise for key #{key.inspect}"))
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ module GraphQL::Batch
2
+ class Promise < ::Promise
3
+ def wait
4
+ Executor.current.wait(self)
5
+ end
6
+ end
7
+ end
@@ -1,5 +1,5 @@
1
1
  module Graphql
2
2
  module Batch
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-batch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dylan Thacker-Smith
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2015-09-21 00:00:00.000000000 Z
11
+ date: 2016-03-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -24,6 +24,34 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: promise.rb
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.7.0.rc2
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.7.0.rc2
41
+ - !ruby/object:Gem::Dependency
42
+ name: byebug
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: bundler
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -84,9 +112,9 @@ files:
84
112
  - graphql-batch.gemspec
85
113
  - lib/graphql/batch.rb
86
114
  - lib/graphql/batch/execution_strategy.rb
87
- - lib/graphql/batch/query.rb
88
- - lib/graphql/batch/query_container.rb
89
- - lib/graphql/batch/query_group.rb
115
+ - lib/graphql/batch/executor.rb
116
+ - lib/graphql/batch/loader.rb
117
+ - lib/graphql/batch/promise.rb
90
118
  - lib/graphql/batch/version.rb
91
119
  homepage: https://github.com/Shopify/graphql-batch
92
120
  licenses:
@@ -108,7 +136,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
136
  version: '0'
109
137
  requirements: []
110
138
  rubyforge_project:
111
- rubygems_version: 2.2.2
139
+ rubygems_version: 2.2.3
112
140
  signing_key:
113
141
  specification_version: 4
114
142
  summary: A query batching executor for the graphql gem
@@ -1,29 +0,0 @@
1
- module GraphQL::Batch
2
- class Query < QueryContainer
3
- def initialize(&block)
4
- @block = block
5
- end
6
-
7
- # batched queries with the same key are merged together
8
- def group_key
9
- self.class.name
10
- end
11
-
12
- def each_query
13
- yield self
14
- end
15
-
16
- def complete(result)
17
- if @block
18
- result = @block.call(result)
19
- @block = nil
20
- end
21
- super(result)
22
- end
23
-
24
- # execute queries, with the same group_key, as a batch
25
- def self.execute(queries)
26
- raise NotImplementedError
27
- end
28
- end
29
- end
@@ -1,30 +0,0 @@
1
- module GraphQL::Batch
2
- class QueryContainer
3
- attr_accessor :query_listener, :result
4
-
5
- def each_query
6
- raise NotImplementedError
7
- end
8
-
9
- def complete(result)
10
- if result.is_a?(QueryContainer)
11
- result.query_listener = self
12
- register_queries(result)
13
- else
14
- if instance_variable_defined?(:@result)
15
- raise "Query was already completed"
16
- end
17
- @result = result
18
- query_listener.query_completed(self)
19
- end
20
- end
21
-
22
- def query_completed(query)
23
- complete(query.result)
24
- end
25
-
26
- def register_queries(query_container)
27
- query_listener.register_queries(query_container)
28
- end
29
- end
30
- end
@@ -1,35 +0,0 @@
1
- module GraphQL::Batch
2
- class QueryGroup < QueryContainer
3
- def initialize(queries, &block)
4
- @pending_queries = queries.dup
5
- @pending_queries.each do |query|
6
- query.query_listener = self
7
- end
8
- @block = block
9
- raise ArgumentError, "QueryGroup requires a block" unless block
10
- end
11
-
12
- def each_query
13
- @pending_queries.each do |query_container|
14
- query_container.each_query do |query|
15
- yield query
16
- end
17
- end
18
- end
19
-
20
- def query_completed(query)
21
- @pending_queries.delete(query)
22
- if @pending_queries.empty?
23
- result = @block.call
24
- @block = nil
25
- if result.is_a?(QueryContainer)
26
- result.query_listener = self
27
- @pending_queries << result
28
- register_queries(result)
29
- else
30
- complete(result)
31
- end
32
- end
33
- end
34
- end
35
- end