Skip to content

Commit cd9b897

Browse files
authored
Merge pull request #386 from rmosolgo/boxed_values
Lazy resolution API
2 parents df523a7 + f9f21de commit cd9b897

26 files changed

+1237
-206
lines changed

guides/_layouts/default.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ <h3>Guides</h3>
4444
<li><a href="{{ site.baseurl }}/schema/configuration_options">Configuration Options</a></li>
4545
<li><a href="{{ site.baseurl }}/schema/code_reuse">Code Reuse</a></li>
4646
<li><a href="{{ site.baseurl }}/schema/instrumentation">Instrumentation</a></li>
47+
<li><a href="{{ site.baseurl }}/schema/lazy_execution">Lazy Execution</a></li>
4748
<li><a href="{{ site.baseurl }}/schema/limiting_visibility">Limiting Visibility</a></li>
4849
<li><a href="{{ site.baseurl }}/schema/testing">Testing</a></li>
4950
</ul>

guides/schema/lazy_execution.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
title: Schema — Lazy Execution
3+
---
4+
5+
With lazy execution, you can optimize access to external services (such as databases) by making batched calls. Building a lazy loader has three steps:
6+
7+
- Define a lazy-loading class with _one_ method for loading & returning a value
8+
- Connect it to your schema with {{ "GraphQL::Schema#lazy_resolve" | api_doc }}
9+
- In `resolve` functions, return instances of the lazy-loading class
10+
11+
[`graphql-batch`](https://github.com/shopify/graphql-batch) provides a powerful, flexible toolkit for lazy resolution with GraphQL.
12+
13+
## Example: Batched Find
14+
15+
Here's a way to find many objects by ID using one database call, preventing N+1 queries.
16+
17+
1. Lazy-loading class which finds models by ID.
18+
19+
```ruby
20+
class LazyFindPerson
21+
def initialize(query_ctx, person_id)
22+
@person_id = person_id
23+
# Initialize the loading state for this query,
24+
# or get the previously-initiated state
25+
@lazy_state = query_ctx[:lazy_find_person] ||= {
26+
pending_ids: [],
27+
loaded_ids: {},
28+
}
29+
# Register this ID to be loaded later:
30+
@lazy_state[:pending_ids] << person_id
31+
end
32+
33+
# Return the loaded record, hitting the database if needed
34+
def person
35+
# Check if the record was already loaded:
36+
loaded_record = @lazy_state[:loaded_ids][@person_id]
37+
if loaded_record
38+
# The pending IDs were already loaded,
39+
# so return the result of that previous load
40+
loaded_record
41+
else
42+
# The record hasn't been loaded yet, so
43+
# hit the database with all pending IDs
44+
pending_ids = @lazy_state[:pending_ids]
45+
people = Person.where(id: pending_ids)
46+
people.each { |person| @lazy_state[:loaded_ids][person.id] = person }
47+
# Now, get the matching person from the loaded result:
48+
@lazy_state[:loaded_ids][@person_id]
49+
end
50+
end
51+
```
52+
53+
2. Connect the lazy resolve method
54+
55+
```ruby
56+
MySchema = GraphQL::Schema.define do
57+
# ...
58+
lazy_resolve(LazyFindPerson, :person)
59+
end
60+
```
61+
62+
3. Return lazy objects from `resolve`
63+
64+
```ruby
65+
field :author, PersonType do
66+
resolve ->(obj, args, ctx) {
67+
LazyFindPerson(ctx, obj.author_id)
68+
}
69+
end
70+
```
71+
72+
Now, calls to `author` will use batched database access. For example, this query:
73+
74+
```graphql
75+
{
76+
p1: post(id: 1) { author { name } }
77+
p2: post(id: 2) { author { name } }
78+
p3: post(id: 3) { author { name } }
79+
}
80+
```
81+
82+
Will only make one query to load the `author` values.
83+
84+
The example above is simple and has some shortcomings. Consider the `graphql-batch` gem for a robust solution to batched resolution.

lib/graphql.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def self.scan_with_ragel(query_string)
6767
require "graphql/introspection"
6868
require "graphql/language"
6969
require "graphql/analysis"
70+
require "graphql/execution"
7071
require "graphql/schema"
7172
require "graphql/schema/loader"
7273
require "graphql/schema/printer"
@@ -81,5 +82,4 @@ def self.scan_with_ragel(query_string)
8182
require "graphql/static_validation"
8283
require "graphql/version"
8384
require "graphql/relay"
84-
require "graphql/execution"
8585
require "graphql/compatibility"

lib/graphql/compatibility.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
require "graphql/compatibility/execution_specification"
2+
require "graphql/compatibility/lazy_execution_specification"
23
require "graphql/compatibility/query_parser_specification"
34
require "graphql/compatibility/schema_parser_specification"

lib/graphql/compatibility/execution_specification.rb

Lines changed: 12 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
require "graphql/compatibility/execution_specification/counter_schema"
2+
require "graphql/compatibility/execution_specification/specification_schema"
3+
14
module GraphQL
25
module Compatibility
36
# Test an execution strategy. This spec is not meant as a development aid.
@@ -22,164 +25,22 @@ module Compatibility
2225
# - Relay features
2326
#
2427
module ExecutionSpecification
25-
DATA = {
26-
"1001" => OpenStruct.new({
27-
name: "Fannie Lou Hamer",
28-
birthdate: Time.new(1917, 10, 6),
29-
organization_ids: [],
30-
}),
31-
"1002" => OpenStruct.new({
32-
name: "John Lewis",
33-
birthdate: Time.new(1940, 2, 21),
34-
organization_ids: ["2001"],
35-
}),
36-
"1003" => OpenStruct.new({
37-
name: "Diane Nash",
38-
birthdate: Time.new(1938, 5, 15),
39-
organization_ids: ["2001", "2002"],
40-
}),
41-
"1004" => OpenStruct.new({
42-
name: "Ralph Abernathy",
43-
birthdate: Time.new(1926, 3, 11),
44-
organization_ids: ["2002"],
45-
}),
46-
"2001" => OpenStruct.new({
47-
name: "SNCC",
48-
leader_id: nil, # fail on purpose
49-
}),
50-
"2002" => OpenStruct.new({
51-
name: "SCLC",
52-
leader_id: "1004",
53-
}),
54-
}
55-
5628
# Make a minitest suite for this execution strategy, making sure it
5729
# fulfills all the requirements of this library.
5830
# @param execution_strategy [<#new, #execute>] An execution strategy class
5931
# @return [Class<Minitest::Test>] A test suite for this execution strategy
6032
def self.build_suite(execution_strategy)
6133
Class.new(Minitest::Test) do
62-
def self.build_schema(execution_strategy)
63-
organization_type = nil
64-
65-
timestamp_type = GraphQL::ScalarType.define do
66-
name "Timestamp"
67-
coerce_input ->(value) { Time.at(value.to_i) }
68-
coerce_result ->(value) { value.to_i }
69-
end
70-
71-
named_entity_interface_type = GraphQL::InterfaceType.define do
72-
name "NamedEntity"
73-
field :name, !types.String
74-
end
75-
76-
person_type = GraphQL::ObjectType.define do
77-
name "Person"
78-
interfaces [named_entity_interface_type]
79-
field :name, !types.String
80-
field :birthdate, timestamp_type
81-
field :age, types.Int do
82-
argument :on, !timestamp_type
83-
resolve ->(obj, args, ctx) {
84-
if obj.birthdate.nil?
85-
nil
86-
else
87-
age_on = args[:on]
88-
age_years = age_on.year - obj.birthdate.year
89-
this_year_birthday = Time.new(age_on.year, obj.birthdate.month, obj.birthdate.day)
90-
if this_year_birthday > age_on
91-
age_years -= 1
92-
end
93-
end
94-
age_years
95-
}
96-
end
97-
field :organizations, types[organization_type] do
98-
resolve ->(obj, args, ctx) {
99-
obj.organization_ids.map { |id| DATA[id] }
100-
}
101-
end
102-
field :first_organization, !organization_type do
103-
resolve ->(obj, args, ctx) {
104-
DATA[obj.organization_ids.first]
105-
}
106-
end
107-
end
108-
109-
organization_type = GraphQL::ObjectType.define do
110-
name "Organization"
111-
interfaces [named_entity_interface_type]
112-
field :name, !types.String
113-
field :leader, !person_type do
114-
resolve ->(obj, args, ctx) {
115-
DATA[obj.leader_id] || (ctx[:return_error] ? ExecutionError.new("Error on Nullable") : nil)
116-
}
117-
end
118-
field :returnedError, types.String do
119-
resolve ->(o, a, c) {
120-
GraphQL::ExecutionError.new("This error was returned")
121-
}
122-
end
123-
field :raisedError, types.String do
124-
resolve ->(o, a, c) {
125-
raise GraphQL::ExecutionError.new("This error was raised")
126-
}
127-
end
128-
129-
field :nodePresence, !types[!types.Boolean] do
130-
resolve ->(o, a, ctx) {
131-
[
132-
ctx.irep_node.is_a?(GraphQL::InternalRepresentation::Node),
133-
ctx.ast_node.is_a?(GraphQL::Language::Nodes::AbstractNode),
134-
false, # just testing
135-
]
136-
}
137-
end
138-
end
139-
140-
node_union_type = GraphQL::UnionType.define do
141-
name "Node"
142-
possible_types [person_type, organization_type]
143-
end
144-
145-
query_type = GraphQL::ObjectType.define do
146-
name "Query"
147-
field :node, node_union_type do
148-
argument :id, !types.ID
149-
resolve ->(obj, args, ctx) {
150-
obj[args[:id]]
151-
}
152-
end
153-
154-
field :organization, !organization_type do
155-
argument :id, !types.ID
156-
resolve ->(obj, args, ctx) {
157-
args[:id].start_with?("2") && obj[args[:id]]
158-
}
159-
end
160-
161-
field :organizations, types[organization_type] do
162-
resolve ->(obj, args, ctx) {
163-
[obj["2001"], obj["2002"]]
164-
}
165-
end
166-
end
167-
168-
GraphQL::Schema.define do
169-
query_execution_strategy execution_strategy
170-
query query_type
171-
172-
resolve_type ->(obj, ctx) {
173-
obj.respond_to?(:birthdate) ? person_type : organization_type
174-
}
175-
end
34+
class << self
35+
attr_accessor :counter_schema, :specification_schema
17636
end
17737

178-
@@schema = build_schema(execution_strategy)
38+
self.specification_schema = SpecificationSchema.build(execution_strategy)
39+
self.counter_schema = CounterSchema.build(execution_strategy)
17940

18041
def execute_query(query_string, **kwargs)
181-
kwargs[:root_value] = DATA
182-
@@schema.execute(query_string, **kwargs)
42+
kwargs[:root_value] = SpecificationSchema::DATA
43+
self.class.specification_schema.execute(query_string, **kwargs)
18344
end
18445

18546
def test_it_fetches_data
@@ -409,47 +270,7 @@ def test_it_doesnt_add_errors_for_invalid_nulls_from_execution_errors
409270
end
410271

411272
def test_it_only_resolves_fields_once_on_typed_fragments
412-
count = 0
413-
counter_type = nil
414-
415-
has_count_interface = GraphQL::InterfaceType.define do
416-
name "HasCount"
417-
field :count, types.Int
418-
field :counter, ->{ counter_type }
419-
end
420-
421-
counter_type = GraphQL::ObjectType.define do
422-
name "Counter"
423-
interfaces [has_count_interface]
424-
field :count, types.Int, resolve: ->(o,a,c) { count += 1 }
425-
field :counter, has_count_interface, resolve: ->(o,a,c) { :counter }
426-
end
427-
428-
alt_counter_type = GraphQL::ObjectType.define do
429-
name "AltCounter"
430-
interfaces [has_count_interface]
431-
field :count, types.Int, resolve: ->(o,a,c) { count += 1 }
432-
field :counter, has_count_interface, resolve: ->(o,a,c) { :counter }
433-
end
434-
435-
has_counter_interface = GraphQL::InterfaceType.define do
436-
name "HasCounter"
437-
field :counter, counter_type
438-
end
439-
440-
query_type = GraphQL::ObjectType.define do
441-
name "Query"
442-
interfaces [has_counter_interface]
443-
field :counter, has_count_interface, resolve: ->(o,a,c) { :counter }
444-
end
445-
446-
schema = GraphQL::Schema.define(
447-
query: query_type,
448-
resolve_type: ->(o, c) { o == :counter ? counter_type : nil },
449-
orphan_types: [alt_counter_type],
450-
)
451-
452-
res = schema.execute("
273+
res = self.class.counter_schema.execute("
453274
{
454275
counter { count }
455276
... on HasCounter {
@@ -462,10 +283,10 @@ def test_it_only_resolves_fields_once_on_typed_fragments
462283
"counter" => { "count" => 1 }
463284
}
464285
assert_equal expected_data, res["data"]
465-
assert_equal 1, count
286+
assert_equal 1, self.class.counter_schema.metadata[:count]
466287

467288
# Deep typed children are correctly distinguished:
468-
res = schema.execute("
289+
res = self.class.counter_schema.execute("
469290
{
470291
counter {
471292
... on Counter {

0 commit comments

Comments
 (0)