Skip to content

Commit

Permalink
Merge pull request #1 from onrunning/features/1.10-ready
Browse files Browse the repository at this point in the history
Features/1.10 ready
  • Loading branch information
rkorzeniec authored Aug 26, 2020
2 parents 01b55b7 + 6678d7f commit d1a6eb8
Show file tree
Hide file tree
Showing 12 changed files with 452 additions and 111 deletions.
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

0 comments on commit d1a6eb8

Please sign in to comment.