Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bb4c5ea
Adds support to preload decorated objects
tulak Oct 21, 2019
5fe5d45
Allow rails 6
manuelpuyol Mar 27, 2020
13c02ed
Change preload_associations
manuelpuyol Mar 27, 2020
e4d74e0
update ruby and gems
rkorzeniec Aug 18, 2020
6678d7f
implement field extensions
rkorzeniec Aug 18, 2020
d1a6eb8
Merge pull request #1 from onrunning/features/1.10-ready
rkorzeniec Aug 26, 2020
568b991
add some frozen string literal magic comments
rkorzeniec Aug 27, 2020
b25df91
fix passing preloading in an option to field extension
rkorzeniec Aug 27, 2020
6a3f07a
update readme regarding field extensions
rkorzeniec Aug 27, 2020
dd03d91
Merge pull request #2 from onrunning/fixes/handle-scope
rkorzeniec Aug 28, 2020
2f202c4
Add support for rails ~> 7.0.0
JoeyLeadJig Jan 20, 2022
a8ff117
Merge pull request #1 from onrunning/master
joeyparis Apr 4, 2022
d119d7c
Add support for Rails 7 and Ruby 3
JoeyLeadJig Apr 5, 2022
6cbec95
Remove unnecessary batch block
JoeyLeadJig Apr 5, 2022
bd1ecef
Update README to encourage using field extensions instead of instruments
JoeyLeadJig Apr 5, 2022
0e1259a
Merge branch 'field-extension' into pr/3
JoeyLeadJig Apr 5, 2022
14af1cf
Merge branch 'field-extension' into pr/2
JoeyLeadJig Apr 5, 2022
4a35095
Merge branch 'master' of https://github.com/joeyparis/graphql-preload…
JoeyLeadJig Apr 5, 2022
c03bdd1
Merge branch 'master' of https://github.com/joeyparis/graphql-preload…
JoeyLeadJig Apr 5, 2022
093419f
Update loader preloader syntax
JoeyLeadJig Apr 5, 2022
d16e337
Add support for Ruby GraphQL ~> 2.0
JoeyLeadJig Apr 5, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
/pkg/
/spec/reports/
/tmp/
*.db
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.5
3.0.3
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,61 @@ First, enable preloading in your `GraphQL::Schema`:
Schema = GraphQL::Schema.define do
use GraphQL::Batch

enable_preloading
enable_preloading # Only use this line if using the deprecated `Field Instrument` interface.
end
```

There are two approaches available to config the caching for the field.
Using currently supported [GraphQL gem](https://graphql-ruby.org/) `Field Extension` interface or `Field Instrument` interface
which is no longer supported as of `graphql 1.10.0`.


#### Field Extension (Preferred):

```ruby
PostType = GraphQL::ObjectType.define do
name 'Post'

field :comments, [CommentType], null: false do
# Post.includes(:comments)
extension GraphQL::Preload::FieldExtension, preload: :comments

# Post.includes(:comments, :authors)
extension GraphQL::Preload::FieldExtension, preload: [:comments, :authors]

# Post.includes(:comments, authors: [:followers, :posts])
extension GraphQL::Preload::FieldExtension, preload: [:comments, { authors: [:followers, :posts] }]

resolve ->(obj, args, ctx) { obj.comments }
end
end
```

### `preload_scope`
Starting with Rails 4.1, you can scope your preloaded records by passing a valid scope to [`ActiveRecord::Associations::Preloader`](https://apidock.com/rails/v4.1.8/ActiveRecord/Associations/Preloader/preload). Scoping can improve performance by reducing the number of models to be instantiated and can help with certain business goals (e.g., only returning records that have not been soft deleted).

This functionality is surfaced through the `preload_scope` option:

```ruby
PostType = GraphQL::ObjectType.define do
name 'Post'

field :comments, [CommentType], null: false do
extension GraphQL::Preload::FieldExtension, { preload: :comments, preload_scope: ->(args, ctx) { Comment.where(deleted_at: nil) } }

# Resolves with records returned from the following query:
# SELECT "comments".*
# FROM "comments"
# WHERE "comments"."deleted_at" IS NULL
# AND "comments"."post_id" IN (1, 2, 3)
resolve ->(obj, args, ctx) { obj.comments }
end
end
```


#### Field Instrument (Deprecated):

Call `preload` when defining your field:

```ruby
Expand Down
11 changes: 6 additions & 5 deletions graphql-preload.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']

spec.add_runtime_dependency 'activerecord', '>= 4.1', '< 6'
spec.add_runtime_dependency 'graphql', '>= 1.8', '< 2'
spec.add_runtime_dependency 'graphql-batch', '~> 0.3'
spec.add_runtime_dependency 'activerecord', '>= 4.1', '< 7.1'
spec.add_runtime_dependency 'graphql', '>= 1.9', '~> 2.0'
spec.add_runtime_dependency 'graphql-batch', '~> 0.4'
spec.add_runtime_dependency 'promise.rb', '~> 0.7'

spec.add_development_dependency 'bundler', '~> 1.16'
spec.add_development_dependency 'bundler', '~> 2.1'
spec.add_development_dependency 'minitest', '~> 5.0'
spec.add_development_dependency 'pry', '~> 0.10'
spec.add_development_dependency 'pry-byebug', '~> 3.7'
spec.add_development_dependency 'rake', '~> 10.0'
spec.add_development_dependency 'sqlite3', '~> 1.4'
spec.add_development_dependency 'yard', '~> 0.9'
end
30 changes: 11 additions & 19 deletions lib/graphql/preload.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
# frozen_string_literal: true

require 'graphql'
require 'graphql/batch'
require 'promise.rb'

GraphQL::Field.accepts_definitions(
preload: ->(type, *args) do
type.metadata[:preload] ||= []
type.metadata[:preload].concat(args)
end,
preload_scope: ->(type, arg) { type.metadata[:preload_scope] = arg }
)

GraphQL::Schema.accepts_definitions(
enable_preloading: ->(schema) do
schema.instrument(:field, GraphQL::Preload::Instrument.new)
end
)

module GraphQL
# Provides a GraphQL::Field definition to preload ActiveRecord::Associations
module Preload
autoload :Instrument, 'graphql/preload/instrument'
autoload :FieldExtension, 'graphql/preload/field_extension'
autoload :Loader, 'graphql/preload/loader'
autoload :VERSION, 'graphql/preload/version'

Expand All @@ -30,21 +19,24 @@ def enable_preloading
end

module FieldMetadata
attr_reader :preload
attr_reader :preload_scope

def initialize(*args, preload: nil, preload_scope: nil, **kwargs, &block)
if preload
@preload ||= []
@preload.concat Array.wrap preload
end
if preload_scope
@preload_scope = preload_scope
end

@preload_scope = preload_scope if preload_scope

super(*args, **kwargs, &block)
end

def to_graphql
field_defn = super
field_defn.metadata[:preload] = @preload
field_defn.metadata[:preload_scope] = @preload_scope
field_defn.metadata[:preload] = @preload if defined?(@preload) && @preload
field_defn.metadata[:preload_scope] = @preload_scope if defined?(@preload_scope) && @preload_scope
field_defn
end
end
Expand Down
23 changes: 23 additions & 0 deletions lib/graphql/preload/field_extension.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

require 'graphql/preload/field_preloader'

module GraphQL
module Preload
class FieldExtension < GraphQL::Schema::FieldExtension
include FieldPreloader

def resolve(object:, arguments:, context:)
yield(object, arguments) unless object

scope = options[:preload_scope].call(arguments, context) if options[:preload_scope]

record = object.is_a?(GraphQL::Schema::Object) && object.respond_to?(:object) ? object.object : object

preload(record, options[:preload], scope).then do
yield(object, arguments)
end
end
end
end
end
64 changes: 64 additions & 0 deletions lib/graphql/preload/field_preloader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module GraphQL
module Preload
module FieldPreloader
private

def preload(record, associations, scope)
if associations.is_a?(String)
raise TypeError, "Expected #{associations} to be a Symbol, not a String"
elsif associations.is_a?(Symbol)
return preload_single_association(record, associations, scope)
end

promises = []

Array.wrap(associations).each do |association|
case association
when Symbol
promises << preload_single_association(record, association, scope)
when Array
association.each do |sub_association|
promises << preload(record, sub_association, scope)
end
when Hash
association.each do |sub_association, nested_association|
promises << preload_single_association(record, sub_association, scope).then do
associated_records = record.public_send(sub_association)

case associated_records
when ActiveRecord::Base
preload(associated_records, nested_association, scope)
else
Promise.all(
Array.wrap(associated_records).map do |associated_record|
preload(associated_record, nested_association, scope)
end
)
end
end
end
end
end

Promise.all(promises)
end

def preload_single_association(record, association, scope)
# We would like to pass the `scope` (which is an `ActiveRecord::Relation`),
# directly into `Loader.for`. However, because the scope is
# created for each parent record, they are different objects and
# return different loaders, breaking batching.
# Therefore, we pass in `scope.to_sql`, which is the same for all the
# scopes and set the `scope` using an accessor. The actual scope
# object used will be the last one, which shouldn't make any difference,
# because even though they are different objects, they are all
# functionally equivalent.
loader = GraphQL::Preload::Loader.for(record.class, association, scope.try(:to_sql))
loader.scope = scope
loader.load(record)
end
end
end
end
103 changes: 20 additions & 83 deletions lib/graphql/preload/instrument.rb
Original file line number Diff line number Diff line change
@@ -1,98 +1,35 @@
# frozen_string_literal: true

require 'graphql/preload/field_preloader'

module GraphQL
module Preload
# Provides an instrument for the GraphQL::Field :preload definition
class Instrument
def instrument(_type, field)
metadata = merged_metadata(field)
return field if metadata.fetch(:preload, nil).nil?

old_resolver = field.resolve_proc
new_resolver = ->(obj, args, ctx) do
return old_resolver.call(obj, args, ctx) unless obj

if metadata[:preload_scope]
scope = metadata[:preload_scope].call(args, ctx)
end

is_graphql_object = obj.is_a?(GraphQL::Schema::Object)
respond_to_object = obj.respond_to?(:object)
record = is_graphql_object && respond_to_object ? obj.object : obj

preload(record, metadata[:preload], scope).then do
old_resolver.call(obj, args, ctx)
end
end

field.redefine do
resolve(new_resolver)
end
end
include FieldPreloader

private def preload(record, associations, scope)
if associations.is_a?(String)
raise TypeError, "Expected #{associations} to be a Symbol, not a String"
elsif associations.is_a?(Symbol)
return preload_single_association(record, associations, scope)
end
def instrument(_type, field)
return field unless field.metadata.include?(:preload)

promises = []
if defined?(FieldExtension) && (type_class = field.metadata[:type_class])
type_class.extension(FieldExtension)
field
else
old_resolver = field.resolve_proc
new_resolver = lambda do |obj, args, ctx|
return old_resolver.call(obj, args, ctx) unless obj

Array.wrap(associations).each do |association|
case association
when Symbol
promises << preload_single_association(record, association, scope)
when Array
association.each do |sub_association|
promises << preload(record, sub_association, scope)
end
when Hash
association.each do |sub_association, nested_association|
promises << preload_single_association(record, sub_association, scope).then do
associated_records = record.public_send(sub_association)
scope = field.metadata[:preload_scope].call(args, ctx) if field.metadata[:preload_scope]

case associated_records
when ActiveRecord::Base
preload(associated_records, nested_association, scope)
else
Promise.all(
Array.wrap(associated_records).map do |associated_record|
preload(associated_record, nested_association, scope)
end
)
end
end
preload(obj.object, field.metadata[:preload], scope).then do
old_resolver.call(obj, args, ctx)
end
end
end

Promise.all(promises)
end

private def preload_single_association(record, association, scope)
# We would like to pass the `scope` (which is an `ActiveRecord::Relation`),
# directly into `Loader.for`. However, because the scope is
# created for each parent record, they are different objects and
# return different loaders, breaking batching.
# Therefore, we pass in `scope.to_sql`, which is the same for all the
# scopes and set the `scope` using an accessor. The actual scope
# object used will be the last one, which shouldn't make any difference,
# because even though they are different objects, they are all
# functionally equivalent.
loader = GraphQL::Preload::Loader.for(record.class, association, scope.try(:to_sql))
loader.scope = scope
loader.load(record)
end

private def merged_metadata(field)
type_class = field.metadata.fetch(:type_class, nil)

if type_class.nil? || !type_class.respond_to?(:to_graphql)
field.metadata
else
field.metadata.merge(type_class.to_graphql.metadata)
field.redefine do
resolve(new_resolver)
end
end
end

end
end
end
Loading