Skip to content

Commit a5451ab

Browse files
authored
Merge pull request #5213 from rmosolgo/rails-dataloader
Improve built-in Rails support for Dataloader
2 parents 0afa241 + 8172088 commit a5451ab

17 files changed

+5123
-7930
lines changed

lib/graphql/dataloader.rb

+18
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
require "graphql/dataloader/request"
55
require "graphql/dataloader/request_all"
66
require "graphql/dataloader/source"
7+
require "graphql/dataloader/active_record_association_source"
8+
require "graphql/dataloader/active_record_source"
79

810
module GraphQL
911
# This plugin supports Fiber-based concurrency, along with {GraphQL::Dataloader::Source}.
@@ -255,6 +257,22 @@ def spawn_fiber
255257
}
256258
end
257259

260+
# Pre-warm the Dataloader cache with ActiveRecord objects which were loaded elsewhere.
261+
# These will be used by {Dataloader::ActiveRecordSource}, {Dataloader::ActiveRecordAssociationSource} and their helper
262+
# methods, `dataload_record` and `dataload_association`.
263+
# @param records [Array<ActiveRecord::Base>] Already-loaded records to warm the cache with
264+
# @param index_by [Symbol] The attribute to use as the cache key. (Should match `find_by:` when using {ActiveRecordSource})
265+
# @return [void]
266+
def merge_records(records, index_by: :id)
267+
records_by_class = Hash.new { |h, k| h[k] = {} }
268+
records.each do |r|
269+
records_by_class[r.class][r.public_send(index_by)] = r
270+
end
271+
records_by_class.each do |r_class, records|
272+
with(ActiveRecordSource, r_class).merge(records)
273+
end
274+
end
275+
258276
private
259277

260278
def calculate_fiber_limit
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# frozen_string_literal: true
2+
require "graphql/dataloader/source"
3+
require "graphql/dataloader/active_record_source"
4+
5+
module GraphQL
6+
class Dataloader
7+
class ActiveRecordAssociationSource < GraphQL::Dataloader::Source
8+
RECORD_SOURCE_CLASS = ActiveRecordSource
9+
10+
def initialize(association, scope = nil)
11+
@association = association
12+
@scope = scope
13+
end
14+
15+
def load(record)
16+
if (assoc = record.association(@association)).loaded?
17+
assoc.target
18+
else
19+
super
20+
end
21+
end
22+
23+
def fetch(records)
24+
record_classes = Set.new.compare_by_identity
25+
associated_classes = Set.new.compare_by_identity
26+
records.each do |record|
27+
if record_classes.add?(record.class)
28+
reflection = record.class.reflect_on_association(@association)
29+
if !reflection.polymorphic? && reflection.klass
30+
associated_classes.add(reflection.klass)
31+
end
32+
end
33+
end
34+
35+
available_records = []
36+
associated_classes.each do |assoc_class|
37+
already_loaded_records = dataloader.with(RECORD_SOURCE_CLASS, assoc_class).results.values
38+
available_records.concat(already_loaded_records)
39+
end
40+
41+
::ActiveRecord::Associations::Preloader.new(records: records, associations: @association, available_records: available_records, scope: @scope).call
42+
43+
loaded_associated_records = records.map { |r| r.public_send(@association) }
44+
records_by_model = {}
45+
loaded_associated_records.each do |record|
46+
if record
47+
updates = records_by_model[record.class] ||= {}
48+
updates[record.id] = record
49+
end
50+
end
51+
52+
if @scope.nil?
53+
# Don't cache records loaded via scope because they might have reduced `SELECT`s
54+
# Could check .select_values here?
55+
records_by_model.each do |model_class, updates|
56+
dataloader.with(RECORD_SOURCE_CLASS, model_class).merge(updates)
57+
end
58+
end
59+
60+
loaded_associated_records
61+
end
62+
end
63+
end
64+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
require "graphql/dataloader/source"
3+
4+
module GraphQL
5+
class Dataloader
6+
class ActiveRecordSource < GraphQL::Dataloader::Source
7+
def initialize(model_class, find_by: model_class.primary_key)
8+
@model_class = model_class
9+
@find_by = find_by
10+
@type_for_column = @model_class.type_for_attribute(@find_by)
11+
end
12+
13+
def load(requested_key)
14+
casted_key = @type_for_column.cast(requested_key)
15+
super(casted_key)
16+
end
17+
18+
def fetch(record_ids)
19+
records = @model_class.where(@find_by => record_ids)
20+
record_lookup = {}
21+
records.each { |r| record_lookup[r.public_send(@find_by)] = r }
22+
record_ids.map { |id| record_lookup[id] }
23+
end
24+
end
25+
end
26+
end

lib/graphql/schema/interface.rb

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module DefinitionMethods
1313
include GraphQL::Schema::Member::Scoped
1414
include GraphQL::Schema::Member::HasAstNode
1515
include GraphQL::Schema::Member::HasUnresolvedTypeError
16+
include GraphQL::Schema::Member::HasDataloader
1617
include GraphQL::Schema::Member::HasDirectives
1718
include GraphQL::Schema::Member::HasInterfaces
1819

lib/graphql/schema/member.rb

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require 'graphql/schema/member/base_dsl_methods'
33
require 'graphql/schema/member/graphql_type_names'
44
require 'graphql/schema/member/has_ast_node'
5+
require 'graphql/schema/member/has_dataloader'
56
require 'graphql/schema/member/has_directives'
67
require 'graphql/schema/member/has_deprecation_reason'
78
require 'graphql/schema/member/has_interfaces'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
module GraphQL
4+
class Schema
5+
class Member
6+
module HasDataloader
7+
# @return [GraphQL::Dataloader] The dataloader for the currently-running query
8+
def dataloader
9+
context.dataloader
10+
end
11+
12+
# A shortcut method for loading a key from a source.
13+
# Identical to `dataloader.with(source_class, *source_args).load(load_key)`
14+
# @param source_class [Class<GraphQL::Dataloader::Source>]
15+
# @param source_args [Array<Object>] Any extra parameters defined in `source_class`'s `initialize` method
16+
# @param load_key [Object] The key to look up using `def fetch`
17+
def dataload(source_class, *source_args, load_key)
18+
dataloader.with(source_class, *source_args).load(load_key)
19+
end
20+
21+
# Find an object with ActiveRecord via {Dataloader::ActiveRecordSource}.
22+
# @param model [Class<ActiveRecord::Base>]
23+
# @param find_by_value [Object] Usually an `id`, might be another value if `find_by:` is also provided
24+
# @param find_by [Symbol, String] A column name to look the record up by. (Defaults to the model's primary key.)
25+
# @return [ActiveRecord::Base, nil]
26+
def dataload_record(model, find_by_value, find_by: nil)
27+
source = if find_by
28+
dataloader.with(Dataloader::ActiveRecordSource, model, find_by: find_by)
29+
else
30+
dataloader.with(Dataloader::ActiveRecordSource, model)
31+
end
32+
33+
source.load(find_by_value)
34+
end
35+
36+
# Look up an associated record using a Rails association.
37+
# @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.)
38+
# @param record [ActiveRecord::Base] The object that the association belongs to.
39+
# @param scope [ActiveRecord::Relation] A scope to look up the associated record in
40+
# @return [ActiveRecord::Base, nil] The associated record, if there is one
41+
# @example Looking up a belongs_to on the current object
42+
# dataload_association(:parent) # Equivalent to `object.parent`, but dataloaded
43+
# @example Looking up an associated record on some other object
44+
# dataload_association(:post, comment) # Equivalent to `comment.post`, but dataloaded
45+
def dataload_association(record = object, association_name, scope: nil)
46+
source = if scope
47+
dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name, scope)
48+
else
49+
dataloader.with(Dataloader::ActiveRecordAssociationSource, association_name)
50+
end
51+
source.load(record)
52+
end
53+
end
54+
end
55+
end
56+
end

lib/graphql/schema/object.rb

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Schema
77
class Object < GraphQL::Schema::Member
88
extend GraphQL::Schema::Member::HasFields
99
extend GraphQL::Schema::Member::HasInterfaces
10+
include Member::HasDataloader
1011

1112
# Raised when an Object doesn't have any field defined and hasn't explicitly opted out of this requirement
1213
class FieldsAreRequiredError < GraphQL::Error

lib/graphql/schema/resolver.rb

+1-5
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class Resolver
2828
include Schema::Member::HasPath
2929
extend Schema::Member::HasPath
3030
extend Schema::Member::HasDirectives
31+
include Schema::Member::HasDataloader
3132

3233
# @param object [Object] The application object that this field is being resolved on
3334
# @param context [GraphQL::Query::Context]
@@ -50,11 +51,6 @@ def initialize(object:, context:, field:)
5051
# @return [GraphQL::Query::Context]
5152
attr_reader :context
5253

53-
# @return [GraphQL::Dataloader]
54-
def dataloader
55-
context.dataloader
56-
end
57-
5854
# @return [GraphQL::Schema::Field]
5955
attr_reader :field
6056

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen_string_literal: true
2+
require "spec_helper"
3+
4+
describe GraphQL::Dataloader::ActiveRecordAssociationSource do
5+
if testing_rails?
6+
it_dataloads "queries for associated records when the association isn't already loaded" do |d|
7+
my_first_car = ::Album.find(2)
8+
homey = ::Album.find(4)
9+
log = with_active_record_log(colorize: false) do
10+
vulfpeck, chon = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band).load_all([my_first_car, homey])
11+
assert_equal "Vulfpeck", vulfpeck.name
12+
assert_equal "Chon", chon.name
13+
end
14+
15+
assert_includes log, '[["id", 1], ["id", 3]]'
16+
17+
toms_story = ::Album.find(3)
18+
log = with_active_record_log(colorize: false) do
19+
vulfpeck, chon, toms_story_band = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band).load_all([my_first_car, homey, toms_story])
20+
assert_equal "Vulfpeck", vulfpeck.name
21+
assert_equal "Chon", chon.name
22+
assert_equal "Tom's Story", toms_story_band.name
23+
end
24+
25+
assert_includes log, '[["id", 2]]'
26+
end
27+
28+
it_dataloads "doesn't load records that are already cached by ActiveRecordSource" do |d|
29+
d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load_all([1,2,3])
30+
31+
my_first_car = ::Album.find(2)
32+
homey = ::Album.find(4)
33+
toms_story = ::Album.find(3)
34+
35+
log = with_active_record_log(colorize: false) do
36+
vulfpeck, chon, toms_story_band = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band).load_all([my_first_car, homey, toms_story])
37+
assert_equal "Vulfpeck", vulfpeck.name
38+
assert_equal "Chon", chon.name
39+
assert_equal "Tom's Story", toms_story_band.name
40+
end
41+
42+
assert_equal "", log
43+
end
44+
45+
it_dataloads "warms the cache for ActiveRecordSource" do |d|
46+
my_first_car = ::Album.find(2)
47+
homey = ::Album.find(4)
48+
toms_story = ::Album.find(3)
49+
d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band).load_all([my_first_car, homey, toms_story])
50+
51+
log = with_active_record_log(colorize: false) do
52+
d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load_all([1,2,3])
53+
end
54+
55+
assert_equal "", log
56+
end
57+
58+
it_dataloads "doesn't warm the cache when a scope is given" do |d|
59+
my_first_car = ::Album.find(2)
60+
homey = ::Album.find(4)
61+
summerteeth = ::Album.find(6)
62+
results = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band, ::Band.country).load_all([my_first_car, homey, summerteeth])
63+
assert_equal [nil, nil, ::Band.find(4)], results
64+
65+
log = with_active_record_log(colorize: false) do
66+
d.with(GraphQL::Dataloader::ActiveRecordSource, Band).load_all([1,2,4])
67+
end
68+
69+
assert_includes log, "SELECT \"bands\".* FROM \"bands\" WHERE \"bands\".\"id\" IN (?, ?, ?) [[\"id\", 1], [\"id\", 2], [\"id\", 4]]"
70+
end
71+
72+
it_dataloads "doesn't pause when the association is already loaded" do |d|
73+
source = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :band)
74+
assert_equal 0, source.results.size
75+
assert_equal 0, source.pending.size
76+
77+
my_first_car = ::Album.find(2)
78+
vulfpeck = my_first_car.band
79+
80+
vulfpeck2 = source.load(my_first_car)
81+
82+
assert_equal vulfpeck, vulfpeck2
83+
84+
assert_equal 0, source.results.size
85+
assert_equal 0, source.pending.size
86+
87+
my_first_car.reload
88+
vulfpeck3 = source.load(my_first_car)
89+
assert_equal vulfpeck, vulfpeck3
90+
91+
assert_equal 1, source.results.size
92+
assert_equal 0, source.pending.size
93+
end
94+
95+
it_dataloads "raises an error with a non-existent association" do |d|
96+
my_first_car = ::Album.find(2)
97+
source = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :tour_bus)
98+
assert_raises ActiveRecord::AssociationNotFoundError do
99+
source.load(my_first_car)
100+
end
101+
end
102+
103+
it_dataloads "works with polymorphic associations" do |d|
104+
wilco = ::Band.find(4)
105+
vulfpeck = d.with(GraphQL::Dataloader::ActiveRecordAssociationSource, :thing).load(wilco)
106+
assert_equal ::Band.find(1), vulfpeck
107+
end
108+
end
109+
end

0 commit comments

Comments
 (0)