diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb index 46c9de83e6..ec587ab9b7 100644 --- a/lib/graphql/execution/execute.rb +++ b/lib/graphql/execution/execute.rb @@ -132,20 +132,29 @@ def resolve_field(object, field_ctx) end if field_ctx.schema.lazy?(raw_value) - field_ctx.value = Execution::Lazy.new { - inner_value = field_ctx.trace("execute_field_lazy", {context: field_ctx}) { - begin + field_ctx.value = Execution::Lazy.new( + value: -> { + inner_value = field_ctx.trace("execute_field_lazy", {context: field_ctx}) { begin - field_ctx.field.lazy_resolve(raw_value, arguments, field_ctx) - rescue GraphQL::UnauthorizedError => err - field_ctx.schema.unauthorized_object(err) + begin + field_ctx.field.lazy_resolve(raw_value, arguments, field_ctx) + rescue GraphQL::UnauthorizedError => err + field_ctx.schema.unauthorized_object(err) + end + rescue GraphQL::ExecutionError => err + err end - rescue GraphQL::ExecutionError => err - err + } + continue_or_wait(inner_value, field_ctx.type, field_ctx) + }, + exec: -> { + if field_ctx.schema.concurrent?(raw_value) + field_ctx.trace("execute_field_concurrent", {context: field_ctx}) { + field_ctx.field.concurrent_exec(raw_value, arguments, field_ctx) + } end } - continue_or_wait(inner_value, field_ctx.type, field_ctx) - } + ) else continue_or_wait(raw_value, field_ctx.type, field_ctx) end @@ -160,8 +169,10 @@ def resolve_field(object, field_ctx) # and resolve child fields def continue_or_wait(raw_value, field_type, field_ctx) if field_ctx.schema.lazy?(raw_value) - field_ctx.value = Execution::Lazy.new { - inner_value = begin + field_ctx.value = Execution::Lazy.new( + value: -> { + inner_value = + begin begin field_ctx.schema.sync_lazy(raw_value) rescue GraphQL::UnauthorizedError => err @@ -171,8 +182,14 @@ def continue_or_wait(raw_value, field_type, field_ctx) err end - field_ctx.value = continue_or_wait(inner_value, field_type, field_ctx) - } + field_ctx.value = continue_or_wait(inner_value, field_type, field_ctx) + }, + exec: -> { + if field_ctx.schema.concurrent?(raw_value) + field_ctx.schema.exec_concurrent(raw_value) + end + } + ) else field_ctx.value = continue_resolve_field(raw_value, field_type, field_ctx) end diff --git a/lib/graphql/execution/lazy.rb b/lib/graphql/execution/lazy.rb index 2de4d10a4b..34a75d4914 100644 --- a/lib/graphql/execution/lazy.rb +++ b/lib/graphql/execution/lazy.rb @@ -23,22 +23,49 @@ def self.resolve(val) attr_reader :path, :field # Create a {Lazy} which will get its inner value by calling the block + # @param value_proc [Proc] a block to get the inner value (later) # @param path [Array] # @param field [GraphQL::Schema::Field] - # @param get_value_func [Proc] a block to get the inner value (later) - def initialize(path: nil, field: nil, &get_value_func) - @get_value_func = get_value_func + def initialize(original = nil, path: nil, field: nil, value: nil, exec: nil) + @original = original + @value_proc = if value + value + elsif block_given? + Proc.new + else + raise ArgumentError, "A block to call later is required as `value:` ora block" + end + @exec_proc = exec @resolved = false @path = path @field = field end + def execute + return if @resolved + + exec = + begin + e = @exec_proc.call + if e.is_a?(Lazy) + e = e.execute + end + e + rescue GraphQL::ExecutionError => err + err + end + + if exec.is_a?(StandardError) + raise exec + end + end + # @return [Object] The wrapped value, calling the lazy block if necessary def value if !@resolved @resolved = true @value = begin - v = @get_value_func.call + v = @value_proc.call if v.is_a?(Lazy) v = v.value end @@ -57,22 +84,24 @@ def value # @return [Lazy] A {Lazy} whose value depends on another {Lazy}, plus any transformations in `block` def then - self.class.new { - yield(value) - } + self.class.new( + value: -> { yield(value) }, + exec: -> { execute } + ) end # @param lazies [Array] Maybe-lazy objects # @return [Lazy] A lazy which will sync all of `lazies` def self.all(lazies) - self.new { - lazies.map { |l| l.is_a?(Lazy) ? l.value : l } - } + self.new( + value: -> { lazies.map { |l| l.is_a?(Lazy) ? l.value : l } }, + exec: -> { lazies.each { |l| l.is_a?(Lazy) ? l.execute : l } } + ) end # This can be used for fields which _had no_ lazy results # @api private - NullResult = Lazy.new(){} + NullResult = Lazy.new(value: -> {}, exec: -> {}) NullResult.value end end diff --git a/lib/graphql/execution/lazy/lazy_method_map.rb b/lib/graphql/execution/lazy/lazy_method_map.rb index e5809b3a8e..4231ae19ca 100644 --- a/lib/graphql/execution/lazy/lazy_method_map.rb +++ b/lib/graphql/execution/lazy/lazy_method_map.rb @@ -24,10 +24,13 @@ def initialize_copy(other) @storage = other.storage.dup end + LazySpec = Struct.new(:value_method, :exec_method) + private_constant :LazySpec + # @param lazy_class [Class] A class which represents a lazy value (subclasses may also be used) # @param lazy_value_method [Symbol] The method to call on this class to get its value - def set(lazy_class, lazy_value_method) - @storage[lazy_class] = lazy_value_method + def set(lazy_class, lazy_value_method, concurrent_exec_method) + @storage[lazy_class] = LazySpec.new(lazy_value_method, concurrent_exec_method) end # @param value [Object] an object which may have a `lazy_value_method` registered for its class or superclasses diff --git a/lib/graphql/execution/lazy/resolve.rb b/lib/graphql/execution/lazy/resolve.rb index c19415f117..a2876d2bae 100644 --- a/lib/graphql/execution/lazy/resolve.rb +++ b/lib/graphql/execution/lazy/resolve.rb @@ -35,12 +35,17 @@ def self.resolve_in_place(value) if acc.empty? Lazy::NullResult else - Lazy.new { - acc.each_with_index { |ctx, idx| - acc[idx] = ctx.value.value - } - resolve_in_place(acc) - } + Lazy.new( + value: -> { + acc.each { |ctx| ctx.value.execute } + + acc.each_with_index { |ctx, idx| + acc[idx] = ctx.value.value + } + resolve_in_place(acc) + }, + exec: -> {} + ) end end diff --git a/lib/graphql/field.rb b/lib/graphql/field.rb index d39ee1c306..479ddd2d38 100644 --- a/lib/graphql/field.rb +++ b/lib/graphql/field.rb @@ -123,6 +123,7 @@ class Field include GraphQL::Define::InstanceDefinable accepts_definitions :name, :description, :deprecation_reason, :resolve, :lazy_resolve, + :concurrent_exec, :type, :arguments, :property, :hash_key, :complexity, :mutation, :function, @@ -138,6 +139,7 @@ class Field :name, :deprecation_reason, :description, :description=, :property, :hash_key, :mutation, :arguments, :complexity, :function, :resolve, :resolve=, :lazy_resolve, :lazy_resolve=, :lazy_resolve_proc, :resolve_proc, + :concurrent_exec, :concurrent_exec=, :concurrent_exec_proc, :type, :type=, :name=, :property=, :hash_key=, :relay_node_field, :relay_nodes_field, :edges?, :edge_class, :subscription_scope, :introspection? @@ -155,6 +157,9 @@ class Field # @return [<#call(obj, args, ctx)>] A proc-like object which can be called trigger a lazy resolution attr_reader :lazy_resolve_proc + # @return [<#call(obj, args, ctx)>] A proc-like object which can be called trigger a concurrent execution + attr_reader :concurrent_exec_proc + # @return [String] The name of this field on its {GraphQL::ObjectType} (or {GraphQL::InterfaceType}) attr_reader :name alias :graphql_name :name @@ -218,6 +223,7 @@ def initialize @arguments = {} @resolve_proc = build_default_resolver @lazy_resolve_proc = DefaultLazyResolve + @concurrent_exec_proc = DefaultConcurrentExec @relay_node_field = false @connection = false @connection_max_page_size = nil @@ -307,12 +313,21 @@ def lazy_resolve=(new_lazy_resolve_proc) @lazy_resolve_proc = new_lazy_resolve_proc end + def concurrent_exec(obj, args, ctx) + @concurrent_exec_proc.call(obj, args, ctx) + end + + def concurrent_exec=(new_concurrent_exec_proc) + @concurrent_exec_proc = new_concurrent_exec_proc + end + # Prepare a lazy value for this field. It may be `then`-ed and resolved later. # @return [GraphQL::Execution::Lazy] A lazy wrapper around `obj` and its registered method name def prepare_lazy(obj, args, ctx) - GraphQL::Execution::Lazy.new { - lazy_resolve(obj, args, ctx) - } + GraphQL::Execution::Lazy.new( + value: -> { lazy_resolve(obj, args, ctx) }, + exec: -> { concurrent_exec(obj, args, ctx) } + ) end private @@ -326,5 +341,11 @@ def self.call(obj, args, ctx) ctx.schema.sync_lazy(obj) end end + + module DefaultConcurrentExec + def self.call(obj, args, ctx) + ctx.schema.exec_concurrent(obj) + end + end end end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 759cfd4927..a0b50d76d7 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -106,7 +106,9 @@ class Schema }, multiplex_analyzer: ->(schema, analyzer) { schema.multiplex_analyzers << analyzer }, middleware: ->(schema, middleware) { schema.middleware << middleware }, - lazy_resolve: ->(schema, lazy_class, lazy_value_method) { schema.lazy_methods.set(lazy_class, lazy_value_method) }, + lazy_resolve: ->(schema, lazy_class, lazy_value_method, concurrent_exec_method = nil) { + schema.lazy_methods.set(lazy_class, lazy_value_method, concurrent_exec_method) + }, rescue_from: ->(schema, err_class, &block) { schema.rescue_from(err_class, &block) }, tracer: ->(schema, tracer) { schema.tracers.push(tracer) } @@ -172,7 +174,7 @@ def initialize @parse_error_proc = DefaultParseError @instrumenters = Hash.new { |h, k| h[k] = [] } @lazy_methods = GraphQL::Execution::Lazy::LazyMethodMap.new - @lazy_methods.set(GraphQL::Execution::Lazy, :value) + @lazy_methods.set(GraphQL::Execution::Lazy, :value, :execute) @cursor_encoder = Base64Encoder # Default to the built-in execution strategy: @analysis_engine = GraphQL::Analysis @@ -639,7 +641,14 @@ class InvalidDocumentError < Error; end; # @return [Symbol, nil] The method name to lazily resolve `obj`, or nil if `obj`'s class wasn't registered wtih {#lazy_resolve}. def lazy_method_name(obj) - @lazy_methods.get(obj) + spec = @lazy_methods.get(obj) + spec && spec.value_method + end + + # @return [Symbol, nil] The method name to concurrently resolve `obj`, or nil if `obj`'s class wasn't registered wtih {#lazy_resolve} with a concurrent method. + def concurrent_method_name(obj) + spec = @lazy_methods.get(obj) + spec && spec.exec_method end # @return [Boolean] True if this object should be lazily resolved @@ -647,6 +656,11 @@ def lazy?(obj) !!lazy_method_name(obj) end + # @return [Boolean] True if this object should be concurrently executed + def concurrent?(obj) + !!concurrent_method_name(obj) + end + # Return the GraphQL IDL for the schema # @param context [Hash] # @param only [<#call(member, ctx)>] @@ -692,7 +706,7 @@ class << self :static_validator, :introspection_system, :query_analyzers, :tracers, :instrumenters, :execution_strategy_for_operation, - :validate, :multiplex_analyzers, :lazy?, :lazy_method_name, :after_lazy, :sync_lazy, + :validate, :multiplex_analyzers, :lazy?, :lazy_method_name, :concurrent_method_name, :after_lazy, :sync_lazy, # Configuration :analysis_engine, :analysis_engine=, :using_ast_analysis?, :interpreter?, :max_complexity=, :max_depth=, @@ -758,8 +772,9 @@ def to_graphql schema_defn.instrumenters[step] << inst end end - lazy_classes.each do |lazy_class, value_method| - schema_defn.lazy_methods.set(lazy_class, value_method) + + lazy_classes.each do |lazy_class, (value_method, exec_method)| + schema_defn.lazy_methods.set(lazy_class, value_method, exec_method) end if @rescues @rescues.each do |err_class, handler| @@ -990,8 +1005,8 @@ def type_error(type_err, ctx) DefaultTypeError.call(type_err, ctx) end - def lazy_resolve(lazy_class, value_method) - lazy_classes[lazy_class] = value_method + def lazy_resolve(lazy_class, value_method, exec_method = nil) + lazy_classes[lazy_class] = [value_method, exec_method] end def instrument(instrument_step, instrumenter, options = {}) @@ -1118,13 +1133,16 @@ def resolve_type(type, obj, ctx = :__undefined__) # @api private def after_lazy(value) if lazy?(value) - GraphQL::Execution::Lazy.new do - result = sync_lazy(value) - # The returned result might also be lazy, so check it, too - after_lazy(result) do |final_result| - yield(final_result) if block_given? - end - end + GraphQL::Execution::Lazy.new( + value: -> { + result = sync_lazy(value) + # The returned result might also be lazy, so check it, too + after_lazy(result) do |final_result| + yield(final_result) if block_given? + end + }, + exec: -> { exec_concurrent(value) } + ) else yield(value) if block_given? end @@ -1159,6 +1177,24 @@ def sync_lazy(value) } end + # Override this method to handle lazy concurrent objects in a custom way. + # @param value [Object] an instance of a class registered with {.lazy_resolve} + # @param ctx [GraphQL::Query::Context] the context for this query + # @return [Object] A GraphQL-ready (non-lazy) object + def self.exec_concurrent(value) + yield(value) + end + + # @see Schema.exec_concurrent for a hook to override + # @api private + def exec_concurrent(value) + self.class.exec_concurrent(value) do |v| + if concurrent_method = concurrent_method_name(v) + value.public_send(concurrent_method) + end + end + end + protected def rescues? diff --git a/spec/graphql/execution/lazy/lazy_method_map_spec.rb b/spec/graphql/execution/lazy/lazy_method_map_spec.rb index 94c4a73e04..f9c2af75dd 100644 --- a/spec/graphql/execution/lazy/lazy_method_map_spec.rb +++ b/spec/graphql/execution/lazy/lazy_method_map_spec.rb @@ -7,11 +7,12 @@ def self.test_lazy_method_map a = Class.new b = Class.new(a) c = Class.new(b) - lazy_method_map.set(a, :a) + lazy_method_map.set(a, :a, :b) threads = 1000.times.map do |i| Thread.new { d = Class.new(c) - assert_equal :a, lazy_method_map.get(d.new) + assert_equal :a, lazy_method_map.get(d.new).value_method + assert_equal :b, lazy_method_map.get(d.new).exec_method } end threads.map(&:join) @@ -21,15 +22,18 @@ def self.test_lazy_method_map a = Class.new b = Class.new(a) c = Class.new(b) - lazy_method_map.set(a, :a) + lazy_method_map.set(a, :a, :b) lazy_method_map.get(b.new) lazy_method_map.get(c.new) dup_map = lazy_method_map.dup assert_equal 3, dup_map.instance_variable_get(:@storage).size - assert_equal :a, dup_map.get(a.new) - assert_equal :a, dup_map.get(b.new) - assert_equal :a, dup_map.get(c.new) + assert_equal :a, dup_map.get(a.new).value_method + assert_equal :b, dup_map.get(a.new).exec_method + assert_equal :a, dup_map.get(b.new).value_method + assert_equal :b, dup_map.get(b.new).exec_method + assert_equal :a, dup_map.get(c.new).value_method + assert_equal :b, dup_map.get(c.new).exec_method end end diff --git a/spec/graphql/execution/lazy_spec.rb b/spec/graphql/execution/lazy_spec.rb index 1aef6eae29..21c8e26966 100644 --- a/spec/graphql/execution/lazy_spec.rb +++ b/spec/graphql/execution/lazy_spec.rb @@ -10,6 +10,11 @@ assert_equal 3, res["data"]["int"] end + it 'calls concurrent handlers' do + res = run_query('{ concurrentInt(value: 2, plus: 1) }') + assert_equal 3, res['data']['concurrentInt'] + end + it "can do nested lazy values" do res = run_query %| { @@ -43,6 +48,13 @@ value } } + + d: concurrentNestedSum(value: 1) { + value + concurrentNestedSum(value: 2) { + value + } + } } | @@ -65,6 +77,9 @@ {"nestedSum"=>{"value"=>14}}, {"nestedSum"=>{"value"=>14}} ], + "d"=>{"value"=>1, "concurrentNestedSum"=>{ + "value"=>3 + }} } assert_equal expected_data, res["data"] @@ -211,6 +226,7 @@ a: nullableNestedSum(value: 1001) { value } b: nullableNestedSum(value: 1013) { value } c: nullableNestedSum(value: 1002) { value } + d: concurrentNestedSum(value: 2) { value } } GRAPHQL @@ -218,6 +234,7 @@ assert_equal 101, res["data"]["a"]["value"] assert_equal 113, res["data"]["b"]["value"] assert_equal 102, res["data"]["c"]["value"] + assert_equal 2, res["data"]["d"]["value"] end end @@ -227,14 +244,19 @@ class SubWrapper < LazyHelpers::Wrapper; end let(:map) { GraphQL::Execution::Lazy::LazyMethodMap.new } it "finds methods for classes and subclasses" do - map.set(LazyHelpers::Wrapper, :item) - map.set(LazyHelpers::SumAll, :value) + map.set(LazyHelpers::Wrapper, :item, :exec) + map.set(LazyHelpers::SumAll, :value, :exec) b = LazyHelpers::Wrapper.new(1) sub_b = LazyHelpers::Wrapper.new(2) s = LazyHelpers::SumAll.new(3) - assert_equal(:item, map.get(b)) - assert_equal(:item, map.get(sub_b)) - assert_equal(:value, map.get(s)) + assert_equal(:item, map.get(b).value_method) + assert_equal(:exec, map.get(b).exec_method) + + assert_equal(:item, map.get(sub_b).value_method) + assert_equal(:exec, map.get(sub_b).exec_method) + + assert_equal(:value, map.get(s).value_method) + assert_equal(:exec, map.get(s).exec_method) end end end diff --git a/spec/integration/mongoid/star_trek/schema.rb b/spec/integration/mongoid/star_trek/schema.rb index 2fa2faa176..e8d335a6f3 100644 --- a/spec/integration/mongoid/star_trek/schema.rb +++ b/spec/integration/mongoid/star_trek/schema.rb @@ -276,6 +276,10 @@ def value loaded[@id] end + + def execute + # no-op + end end class LazyWrapper @@ -290,6 +294,10 @@ def initialize(value = nil, &block) def value @resolved_value = @value || @lazy_value.call end + + def execute + # no-op + end end LazyNodesWrapper = Struct.new(:relation) @@ -416,8 +424,8 @@ def self.id_from_object(object, type, ctx) GraphQL::Schema::UniqueWithinType.encode(type.name, object.id) end - lazy_resolve(LazyWrapper, :value) - lazy_resolve(LazyLoader, :value) + lazy_resolve(LazyWrapper, :value, :execute) + lazy_resolve(LazyLoader, :value, :execute) instrument(:field, ClassNameRecorder.new(:before_built_ins)) instrument(:field, ClassNameRecorder.new(:after_built_ins), after_built_ins: true) diff --git a/spec/integration/rails/graphql/schema_spec.rb b/spec/integration/rails/graphql/schema_spec.rb index 2026168e73..a428cc92b0 100644 --- a/spec/integration/rails/graphql/schema_spec.rb +++ b/spec/integration/rails/graphql/schema_spec.rb @@ -376,9 +376,10 @@ def instrument(type, field) end end - describe "#lazy? / #lazy_method_name" do + describe "#lazy? / #lazy_method_name / #concurrent? / #concurrent_method_name" do class LazyObj; end class LazyObjChild < LazyObj; end + class ConcurrentObj; end let(:schema) { query_type = GraphQL::ObjectType.define(name: "Query") @@ -386,18 +387,33 @@ class LazyObjChild < LazyObj; end query(query_type) lazy_resolve(Integer, :itself) lazy_resolve(LazyObj, :dup) + lazy_resolve(ConcurrentObj, :dup, :exec) end } it "returns registered lazy method names by class/superclass, or returns nil" do assert_equal :itself, schema.lazy_method_name(68) assert_equal true, schema.lazy?(77) + assert_equal false, schema.concurrent?(77) + assert_equal :dup, schema.lazy_method_name(LazyObj.new) + assert_nil schema.concurrent_method_name(LazyObj.new) assert_equal true, schema.lazy?(LazyObj.new) + assert_equal false, schema.concurrent?(LazyObj.new) + assert_equal :dup, schema.lazy_method_name(LazyObjChild.new) + assert_nil schema.concurrent_method_name(LazyObjChild.new) assert_equal true, schema.lazy?(LazyObjChild.new) + assert_equal false, schema.concurrent?(LazyObjChild.new) + + assert_equal :dup, schema.lazy_method_name(ConcurrentObj.new) + assert_equal :exec, schema.concurrent_method_name(ConcurrentObj.new) + assert_equal true, schema.lazy?(ConcurrentObj.new) + assert_equal true, schema.concurrent?(ConcurrentObj.new) + assert_nil schema.lazy_method_name({}) assert_equal false, schema.lazy?({}) + assert_equal false, schema.concurrent?({}) end end diff --git a/spec/support/lazy_helpers.rb b/spec/support/lazy_helpers.rb index b792bdcc92..324980136a 100644 --- a/spec/support/lazy_helpers.rb +++ b/spec/support/lazy_helpers.rb @@ -21,6 +21,24 @@ def item end end + # This is like the `Wrapper` but it will only evaluate a `value` if the block + # has been executed. This allows for testing that the `execute` block has in + # fact been called before the value has been accessed. While this is not a + # requirement in real applications (the `value` method could also call + # `execute` if it has not yet been called) this simplified class makes testing + # easier. + class ConcurrentWrapper + attr_reader :value + + def initialize(&block) + @block = block + end + + def execute + @value = @block.call + end + end + class SumAll attr_reader :own_value attr_writer :value @@ -49,6 +67,33 @@ def self.all end end + class ConcurrentSumAll + attr_reader :own_value + attr_accessor :value + + def initialize(own_value) + @own_value = own_value + all << self + end + + def execute + @value = begin + total_value = all.map(&:own_value).reduce(&:+) + all.each { |v| v.value = total_value} + all.clear + total_value + end + end + + def all + self.class.all + end + + def self.all + @all ||= [] + end + end + class LazySum < GraphQL::Schema::Object field :value, Integer, null: true def value @@ -85,6 +130,17 @@ def nested_sum(value:) alias :nullable_nested_sum :nested_sum end + class ConcurrentSum < GraphQL::Schema::Object + field :value, Integer, null: true, resolve: ->(o, a, c) { o } + field :concurrentNestedSum, ConcurrentSum, null: false do + argument :value, Integer, required: true + end + + def concurrent_nested_sum(value:) + ConcurrentWrapper.new { @object + value } + end + end + using GraphQL::DeprecatedDSL if RUBY_ENGINE == "jruby" # JRuby doesn't support refinements, so the `using` above won't work @@ -100,6 +156,23 @@ def int(value:, plus:) Wrapper.new(value + plus) end + field :concurrent_int, Integer, null: false do + argument :value, Integer, required: true + argument :plus, Integer, required: false, default_value: 0 + end + + def concurrent_int(value:, plus:) + ConcurrentWrapper.new { value + plus } + end + + field :concurrent_nested_sum, ConcurrentSum, null: false do + argument :value, Integer, required: true + end + + def concurrent_nested_sum(value:) + ConcurrentSumAll.new(value) + end + field :nested_sum, LazySum, null: false do argument :value, Integer, required: true end @@ -166,7 +239,9 @@ class LazySchema < GraphQL::Schema query(LazyQuery) mutation(LazyQuery) lazy_resolve(Wrapper, :item) + lazy_resolve(ConcurrentWrapper, :value, :execute) lazy_resolve(SumAll, :value) + lazy_resolve(ConcurrentSumAll, :value, :execute) instrument(:query, SumAllInstrumentation.new(counter: nil)) instrument(:multiplex, SumAllInstrumentation.new(counter: 1)) instrument(:multiplex, SumAllInstrumentation.new(counter: 2))