Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Features/1.10 ready #1

Merged
merged 2 commits into from
Aug 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2.5.8
9 changes: 5 additions & 4 deletions graphql-preload.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ Gem::Specification.new do |spec|
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 'graphql', '>= 1.9', '< 2'
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
18 changes: 11 additions & 7 deletions lib/graphql/preload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
require 'promise.rb'

GraphQL::Field.accepts_definitions(
preload: ->(type, *args) do
preload: lambda do |type, *args|
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
enable_preloading: lambda do |schema|
schema.instrument(:field, GraphQL::Preload::Instrument.new)
end
)
Expand All @@ -20,6 +20,7 @@ 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 +31,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 @preload
field_defn.metadata[:preload_scope] = @preload_scope if @preload_scope
field_defn
end
end
Expand Down
19 changes: 19 additions & 0 deletions lib/graphql/preload/field_extension.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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 = field.preload_scope.call(arguments, context) if field.preload_scope

preload(object.object, options, scope).then do
yield(object, arguments)
end
end
end
end
end
62 changes: 62 additions & 0 deletions lib/graphql/preload/field_preloader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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
101 changes: 18 additions & 83 deletions lib/graphql/preload/instrument.rb
Original file line number Diff line number Diff line change
@@ -1,98 +1,33 @@
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?
include FieldPreloader

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

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
23 changes: 12 additions & 11 deletions lib/graphql/preload/loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def load(record)
end

return Promise.resolve(record) if association_loaded?(record)

super
end

Expand All @@ -30,31 +31,31 @@ def perform(records)
records.each { |record| fulfill(record, record) }
end

private def association_loaded?(record)
private

def association_loaded?(record)
record.association(association).loaded?
end

private def preload_association(records)
def preload_association(records)
ActiveRecord::Associations::Preloader.new.preload(records, association, preload_scope)
end

private def preload_scope
def preload_scope
return nil unless scope

reflection = model.reflect_on_association(association)
raise ArgumentError, 'Cannot specify preload_scope for polymorphic associations' if reflection.polymorphic?

scope if scope.try(:klass) == reflection.klass
end

private def validate_association
unless association.is_a?(Symbol)
raise ArgumentError, 'Association must be a Symbol object'
end

unless model < ActiveRecord::Base
raise ArgumentError, 'Model must be an ActiveRecord::Base descendant'
end
def validate_association
raise ArgumentError, 'Association must be a Symbol object' unless association.is_a?(Symbol)
raise ArgumentError, "Model #{model} must be an ActiveRecord::Base descendant" unless model < ActiveRecord::Base

return if model.reflect_on_association(association)

raise TypeError, "Association :#{association} does not exist on #{model}"
end
end
Expand Down
Loading