Skip to content

Commit dc2479f

Browse files
authored
Merge pull request #5575 from rmosolgo/dataloader-shorthands
exec-next: Add dataload: shorthands
2 parents 6758b11 + 1003df4 commit dc2479f

11 files changed

Lines changed: 241 additions & 67 deletions

File tree

guides/execution/next.md

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -111,32 +111,75 @@ This is a high-performance option for when you need to do I/O to generate result
111111

112112
These fields use a _class method_ to map parent objects to field results, configured with `resolve_batch:`:
113113

114-
```ruby
115-
field :title, String, resolve_batch: :titles do
116-
argument :language, Types::Language, required: false, default_value: "EN"
117-
end
114+
```ruby
115+
field :title, String, resolve_batch: :titles do
116+
argument :language, Types::Language, required: false, default_value: "EN"
117+
end
118118

119-
def self.titles(objects, context, language:)
120-
# This is equivalent to plain `field :title, ...`, but for example:
121-
objects.map { |obj| obj.title(language:) }
122-
end
123-
```
119+
def self.titles(objects, context, language:)
120+
# This is equivalent to plain `field :title, ...`, but for example:
121+
objects.map { |obj| obj.title(language:) }
122+
end
123+
```
124124

125-
This is especially useful when batching Dataloader calls:
125+
This is especially useful when batching Dataloader calls:
126126

127-
```ruby
128-
class Types::Comment < BaseObject
129-
field :post, Types::Post, resolve_batch: :posts
127+
```ruby
128+
class Types::Comment < BaseObject
129+
field :author_rating, Integer, resolve_batch: true
130130

131-
# Use `.load_all(ids)` to fetch all in a single round-trip
132-
def self.posts(objects, context)
133-
# TODO: add a shorthand for this in GraphQL-Ruby
134-
context.dataloader
135-
.with(GraphQL::Dataloader::ActiveRecordSource)
136-
.load_all(objects.map(&:post_id))
137-
end
131+
def self.author_rating(objects, context)
132+
authors = context.dataload_all_records(objects, :author)
133+
context.dataload_all(Sources::AuthorRating, authors)
138134
end
139-
```
135+
end
136+
```
137+
138+
### Dataloader
139+
140+
`Execution::Next` supports field configuration shorthands for common dataloader usage. Under the hood, these make sure data fetching is batched and cached.
141+
142+
#### Sources
143+
144+
Use a custom dataloader source from your application:
145+
146+
```ruby
147+
class Types::CommentType
148+
# Equivalent to `dataload(Sources::CommentRating, object)`
149+
field :rating, Integer, dataload: Sources::CommentRating
150+
151+
# `using:`: A method to call to get a value to pass to dataloader
152+
# `by: [...]`: An array of arguments to pass on to dataloader
153+
#
154+
# Equivalent to `dataload(Sources::ReadingDuration, :comment, object.body)
155+
field :reading_duration, Integer, dataload: { with: Sources::ReadingDuration, using: :body, by: [:comment] }
156+
```
157+
158+
#### Rails Associations
159+
160+
Load ActiveRecord associations using {{ "GraphQL::Dataloader::ActiveRecordAssociationSource" | api_doc }}:
161+
162+
```ruby
163+
class Types::CommentType < Types::BaseObject
164+
# Equivalent to `dataload_association(:post)`
165+
field :post, Types::Post, dataload: { association: true }
166+
# Equivalent to `dataload_association(:user)
167+
field :author, Types::Post, dataload: { association: :user }
168+
end
169+
```
170+
171+
#### Rails Records
172+
173+
Load ActiveRecord associations using {{ "GraphQL::Dataloader::ActiveRecordSource" | api_doc }}.
174+
175+
```ruby
176+
class Types::SearchResult < Types::BaseObject
177+
# Equivalent to `dataload_record(Post, object.post_id)`
178+
field :post, Types::Post, dataload: { model: Post, using: :post_id }
179+
# Equivalent to `dataload_record(User, object.created_by_handle, find_by: :handle)`
180+
field :author, Types::User, dataload: { model: User, using: :created_by_handle, find_by: :handle }
181+
end
182+
```
140183

141184
### Legacy instance methods
142185

lib/graphql/execution/next/field_resolve_step.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,27 @@ def resolve_batch(objects, context, args_hash)
644644
end
645645
when :dig
646646
objects.map { |o| o.dig(*@field_definition.execution_next_mode_key) }
647+
when :dataload
648+
if (k = @field_definition.execution_next_mode_key).is_a?(Class)
649+
context.dataload_all(k, objects)
650+
elsif (source_class = k[:with])
651+
if (batch_args = k[:by])
652+
context.dataload_all(source_class, *batch_args, objects)
653+
else
654+
context.dataload_all(source_class, objects)
655+
end
656+
elsif (model = k[:model])
657+
value_method = k[:using]
658+
values = objects.map(&value_method)
659+
context.dataload_all_records(model, values, find_by: k[:find_by])
660+
elsif (assoc = k[:association])
661+
if assoc == true
662+
assoc = @field_definition.original_name
663+
end
664+
context.dataload_all_associations(objects, assoc, scope: k[:scope])
665+
else
666+
raise ArgumentError, "Unexpected `dataload: ...` configuration: #{k.inspect}"
667+
end
647668
when :resolver_class
648669
results = Array.new(objects.size, nil)
649670
ps = @pending_steps ||= []

lib/graphql/schema/field.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ def method_conflict_warning?
197197
# @param resolve_batch [Symbol, true, nil] Used by {Schema.execute_next} map `objects` to a same-sized Array of results. Called on the owner type class with `objects, context, **arguments`.
198198
# @param resolve_each [Symbol, true, nil] Used by {Schema.execute_next} to get a value value for each item. Called on the owner type class with `object, context, **arguments`.
199199
# @param resolve_legacy_instance_method [Symbol, true, nil] Used by {Schema.execute_next} to get a value value for each item. Calls an instance method on the object type class.
200+
# @param dataload [Class, Hash] Shorthand for making dataloader calls
200201
# @param max_page_size [Integer, nil] For connections, the maximum number of items to return from this field, or `nil` to allow unlimited results.
201202
# @param default_page_size [Integer, nil] For connections, the default number of items to return from this field, or `nil` to return unlimited results.
202203
# @param introspection [Boolean] If true, this field will be marked as `#introspection?` and the name may begin with `__`
@@ -219,7 +220,7 @@ def method_conflict_warning?
219220
# @param relay_nodes_field [Boolean] (Private, used by GraphQL-Ruby)
220221
# @param extras [Array<:ast_node, :parent, :lookahead, :owner, :execution_errors, :graphql_name, :argument_details, Symbol>] Extra arguments to be injected into the resolver for this field
221222
# @param definition_block [Proc] an additional block for configuring the field. Receive the field as a block param, or, if no block params are defined, then the block is `instance_eval`'d on the new {Field}.
222-
def initialize(type: nil, name: nil, owner: nil, null: nil, description: NOT_CONFIGURED, comment: NOT_CONFIGURED, deprecation_reason: nil, method: nil, resolve_legacy_instance_method: nil, resolve_static: nil, resolve_each: nil, resolve_batch: nil, hash_key: nil, dig: nil, resolver_method: nil, connection: nil, max_page_size: NOT_CONFIGURED, default_page_size: NOT_CONFIGURED, scope: nil, introspection: false, camelize: true, trace: nil, complexity: nil, ast_node: nil, extras: EMPTY_ARRAY, extensions: EMPTY_ARRAY, connection_extension: self.class.connection_extension, resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, method_conflict_warning: true, broadcastable: NOT_CONFIGURED, arguments: EMPTY_HASH, directives: EMPTY_HASH, validates: EMPTY_ARRAY, fallback_value: NOT_CONFIGURED, dynamic_introspection: false, &definition_block)
223+
def initialize(type: nil, name: nil, owner: nil, null: nil, description: NOT_CONFIGURED, comment: NOT_CONFIGURED, deprecation_reason: nil, method: nil, resolve_legacy_instance_method: nil, resolve_static: nil, resolve_each: nil, resolve_batch: nil, hash_key: nil, dig: nil, resolver_method: nil, connection: nil, max_page_size: NOT_CONFIGURED, default_page_size: NOT_CONFIGURED, scope: nil, introspection: false, camelize: true, trace: nil, complexity: nil, dataload: nil, ast_node: nil, extras: EMPTY_ARRAY, extensions: EMPTY_ARRAY, connection_extension: self.class.connection_extension, resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, method_conflict_warning: true, broadcastable: NOT_CONFIGURED, arguments: EMPTY_HASH, directives: EMPTY_HASH, validates: EMPTY_ARRAY, fallback_value: NOT_CONFIGURED, dynamic_introspection: false, &definition_block)
223224
if name.nil?
224225
raise ArgumentError, "missing first `name` argument or keyword `name:`"
225226
end
@@ -289,6 +290,9 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, description: NOT_CON
289290
elsif resolve_legacy_instance_method
290291
@execution_next_mode = :resolve_legacy_instance_method
291292
@execution_next_mode_key = resolve_legacy_instance_method == true ? @method_sym : resolve_legacy_instance_method
293+
elsif dataload
294+
@execution_next_mode = :dataload
295+
@execution_next_mode_key = dataload
292296
else
293297
@execution_next_mode = :direct_send
294298
@execution_next_mode_key = @method_sym

lib/graphql/schema/member/has_dataloader.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ def dataload_record(model, find_by_value, find_by: nil)
5656
source.load(find_by_value)
5757
end
5858

59+
# @see dataload_record Like `dataload_record`, but accepts an Array of `find_by_values`
60+
def dataload_all_records(model, find_by_values, find_by: nil)
61+
source = if find_by
62+
dataloader.with(Dataloader::ActiveRecordSource, model, find_by: find_by)
63+
else
64+
dataloader.with(Dataloader::ActiveRecordSource, model)
65+
end
66+
source.load_all(find_by_values)
67+
end
68+
5969
# Look up an associated record using a Rails association (via {Dataloader::ActiveRecordAssociationSource})
6070
# @param association_name [Symbol] A `belongs_to` or `has_one` association. (If a `has_many` association is named here, it will be selected without pagination.)
6171
# @param record [ActiveRecord::Base] The object that the association belongs to.
@@ -73,6 +83,16 @@ def dataload_association(record = object, association_name, scope: nil)
7383
end
7484
source.load(record)
7585
end
86+
87+
# @see dataload_association Like `dataload_assocation` but accepts an Array of records (required param)
88+
def dataload_all_associations(records, association_name, scope: nil)
89+
source = if scope
90+
dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name, scope)
91+
else
92+
dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name)
93+
end
94+
source.load_all(records)
95+
end
7696
end
7797
end
7898
end

lib/graphql/schema/member/has_fields.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ module HasFields
4848
# @option kwargs [Boolean] :dynamic_introspection (Private, used by GraphQL-Ruby)
4949
# @option kwargs [Boolean] :relay_node_field (Private, used by GraphQL-Ruby)
5050
# @option kwargs [Boolean] :relay_nodes_field (Private, used by GraphQL-Ruby)
51+
# @option kwargs [Class, Hash] :dataload Shorthand for dataloader lookups
5152
# @option kwargs [Array<:ast_node, :parent, :lookahead, :owner, :execution_errors, :graphql_name, :argument_details, Symbol>] :extras Extra arguments to be injected into the resolver for this field
5253
# @param kwargs [Hash] Keywords for defining the field. Any not documented here will be passed to your base field class where they must be handled.
5354
# @param definition_block [Proc] an additional block for configuring the field. Receive the field as a block param, or, if no block params are defined, then the block is `instance_eval`'d on the new {Field}.

spec/graphql/dataloader/active_record_association_source_spec.rb

Lines changed: 19 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,50 +3,7 @@
33

44
describe GraphQL::Dataloader::ActiveRecordAssociationSource do
55
if testing_rails?
6-
class VulfpeckSchema < GraphQL::Schema
7-
class Album < GraphQL::Schema::Object
8-
field :name, String
9-
end
10-
class Band < GraphQL::Schema::Object
11-
field :albums, [Album] do
12-
argument :genre, String, required: false
13-
argument :reverse, Boolean, required: false, default_value: false
14-
argument :unscoped, Boolean, required: false, default_value: false
15-
end
16-
17-
def albums(genre: nil, reverse:, unscoped:)
18-
if unscoped
19-
scope = nil
20-
else
21-
scope = ::Album
22-
if genre
23-
scope = scope.where(band_genre: genre)
24-
end
25-
26-
scope = if reverse
27-
scope.order(name: :desc)
28-
else
29-
scope.order(:name)
30-
end
31-
end
32-
dataload_association(:albums, scope: scope)
33-
end
34-
end
35-
36-
class Query < GraphQL::Schema::Object
37-
field :band, Band do
38-
argument :name, String
39-
end
40-
41-
def band(name:)
42-
::Band.find_by(name: name)
43-
end
44-
end
45-
46-
query(Query)
47-
use GraphQL::Dataloader
48-
end
49-
6+
include VulfpeckSchemaHelpers
507
it "works with different scopes on the same object at runtime" do
518
query_str = <<~GRAPHQL
529
{
@@ -67,13 +24,30 @@ def band(name:)
6724
}
6825
GRAPHQL
6926

70-
result = VulfpeckSchema.execute(query_str)
27+
result = exec_query(query_str)
7128
assert_equal ["Mit Peck", "My First Car"], result["data"]["band"]["allAlbums"].map { |a| a["name"] }
7229
assert_equal ["Mit Peck", "My First Car"], result["data"]["band"]["unscopedAlbums"].map { |a| a["name"] }
7330
assert_equal ["My First Car", "Mit Peck"], result["data"]["band"]["reverseAlbums"].map { |a| a["name"] }
7431
assert_equal [], result["data"]["band"]["countryAlbums"]
7532
end
7633

34+
it "works with field shorthands" do
35+
skip("NOT IMPLEMENTED") unless TESTING_EXEC_NEXT
36+
result = exec_query <<-GRAPHQL
37+
{
38+
band(name: "Vulfpeck") {
39+
allAlbums {
40+
name
41+
band { name }
42+
}
43+
}
44+
}
45+
GRAPHQL
46+
47+
assert_equal ["Mit Peck", "My First Car"], result["data"]["band"]["allAlbums"].map { |a| a["name"] }
48+
assert_equal ["Vulfpeck", "Vulfpeck"], result["data"]["band"]["allAlbums"].map { |a| a["band"]["name"] }
49+
end
50+
7751
it_dataloads "queries for associated records when the association isn't already loaded" do |d|
7852
my_first_car = ::Album.find(2)
7953
homey = ::Album.find(4)

spec/graphql/dataloader/active_record_source_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33

44
describe GraphQL::Dataloader::ActiveRecordSource do
55
if testing_rails?
6+
include VulfpeckSchemaHelpers
7+
it "works with field config shorthands" do
8+
skip("Not implemented") unless TESTING_EXEC_NEXT
9+
query_str = "{ rootBand { name } }"
10+
assert_equal "Wilco", exec_query(query_str, root_value: OpenStruct.new(band_name: "Wilco"))["data"]["rootBand"]["name"]
11+
assert_equal "Chon", exec_query(query_str, root_value: OpenStruct.new(band_name: "Chon"))["data"]["rootBand"]["name"]
12+
end
13+
614
describe "finding by ID" do
715
it_dataloads "loads once, then returns from a cache when available" do |d|
816
log = with_active_record_log(colorize: false) do

spec/graphql/dataloader/source_spec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ def fetch(keys)
88
end
99
end
1010

11+
if testing_rails?
12+
describe "with field configuration shorthands" do
13+
include VulfpeckSchemaHelpers
14+
it "calls the configured source" do
15+
skip("Not implemented") unless TESTING_EXEC_NEXT
16+
result = exec_query("{ bandsCount albumsCount }")
17+
assert_equal 4, result["data"]["bandsCount"]
18+
assert_equal 6, result["data"]["albumsCount"]
19+
end
20+
end
21+
end
22+
1123
it "raises an error when it tries too many times to sync" do
1224
dl = GraphQL::Dataloader.new
1325
dl.append_job { dl.with(FailsToLoadSource).load(1) }

spec/graphql/schema/member/has_dataloader_spec.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ def fetch(keys)
2626
assert_equal 4, example.dataload_record(Album, "Homey", find_by: :name).id
2727
end
2828

29+
it_dataloads "loads many records with dataload_all_records" do |d|
30+
example = DataloaderExample.new(d)
31+
assert_equal ["Homey", "Mit Peck"], example.dataload_all_records(Album, [4, 1]).map(&:name)
32+
assert_equal [4, 1], example.dataload_all_records(Album, ["Homey", "Mit Peck"], find_by: :name).map(&:id)
33+
end
34+
2935
it_dataloads "loads association with dataload_association" do |d|
3036
album1 = Album.find(1)
3137
example = DataloaderExample.new(d, album1)
@@ -37,6 +43,17 @@ def fetch(keys)
3743
assert_nil example.dataload_association(album, :band, scope: Band.country)
3844
end
3945

46+
it_dataloads "loads association on many objects with dataload_all_associations" do |d|
47+
album1 = Album.find(1)
48+
album4 = Album.find(4)
49+
example = DataloaderExample.new(d, album1)
50+
51+
assert_equal ["Vulfpeck", "Chon"], example.dataload_all_associations([album1, album4], :band).map(&:name)
52+
album1.reload
53+
album4.reload
54+
assert_equal [nil, nil], example.dataload_all_associations([album1, album4], :band, scope: Band.country)
55+
end
56+
4057
it_dataloads "calls any source with dataload..." do |d|
4158
example = DataloaderExample.new(d)
4259
d.with(PlusSource).request(2)

0 commit comments

Comments
 (0)