diff --git a/.travis.yml b/.travis.yml index 77973e3fe2..40c0114172 100644 --- a/.travis.yml +++ b/.travis.yml @@ -40,6 +40,29 @@ matrix: gemfile: spec/dummy/Gemfile script: - cd spec/dummy && bundle exec rails test:system + - env: + - DISPLAY=':99.0' + - TESTING_INTERPRETER=yes + rvm: 2.4.3 + addons: + apt: + sources: + - google-chrome + packages: + - google-chrome-stable + before_install: + - export CHROMEDRIVER_VERSION=`curl -s http://chromedriver.storage.googleapis.com/LATEST_RELEASE` + - curl -L -O "http://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip" + - unzip chromedriver_linux64.zip && chmod +x chromedriver && sudo mv chromedriver /usr/local/bin + before_script: + - sh -e /etc/init.d/xvfb start + gemfile: spec/dummy/Gemfile + script: + - cd spec/dummy && bundle exec rails test:system + - env: + - TESTING_INTERPRETER=yes + rvm: 2.4.3 + gemfile: gemfiles/rails_5.2.gemfile - rvm: 2.2.8 gemfile: Gemfile - rvm: 2.2.8 diff --git a/benchmark/run.rb b/benchmark/run.rb index 33d473e86b..24968a6a85 100644 --- a/benchmark/run.rb +++ b/benchmark/run.rb @@ -1,26 +1,26 @@ # frozen_string_literal: true -require "dummy/schema" +TESTING_INTERPRETER = true +require "graphql" +require "jazz" require "benchmark/ips" -require 'ruby-prof' -require 'memory_profiler' +require "ruby-prof" +require "memory_profiler" module GraphQLBenchmark QUERY_STRING = GraphQL::Introspection::INTROSPECTION_QUERY DOCUMENT = GraphQL.parse(QUERY_STRING) - SCHEMA = Dummy::Schema + SCHEMA = Jazz::Schema BENCHMARK_PATH = File.expand_path("../", __FILE__) CARD_SCHEMA = GraphQL::Schema.from_definition(File.read(File.join(BENCHMARK_PATH, "schema.graphql"))) ABSTRACT_FRAGMENTS = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "abstract_fragments.graphql"))) ABSTRACT_FRAGMENTS_2 = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "abstract_fragments_2.graphql"))) - BIG_SCHEMA = GraphQL::Schema.from_definition(File.join(BENCHMARK_PATH, "big_schema.graphql")) BIG_QUERY = GraphQL.parse(File.read(File.join(BENCHMARK_PATH, "big_query.graphql"))) module_function def self.run(task) - Benchmark.ips do |x| case task when "query" @@ -40,15 +40,14 @@ def self.profile # Warm up any caches: SCHEMA.execute(document: DOCUMENT) # CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS) - + res = nil result = RubyProf.profile do # CARD_SCHEMA.validate(ABSTRACT_FRAGMENTS) - SCHEMA.execute(document: DOCUMENT) + res = SCHEMA.execute(document: DOCUMENT) end - - printer = RubyProf::FlatPrinter.new(result) + # printer = RubyProf::FlatPrinter.new(result) # printer = RubyProf::GraphHtmlPrinter.new(result) - # printer = RubyProf::FlatPrinterWithLineNumbers.new(result) + printer = RubyProf::FlatPrinterWithLineNumbers.new(result) printer.print(STDOUT, {}) end diff --git a/guides/queries/interpreter.md b/guides/queries/interpreter.md new file mode 100644 index 0000000000..895c06eb8a --- /dev/null +++ b/guides/queries/interpreter.md @@ -0,0 +1,100 @@ +--- +title: Interpreter +layout: guide +doc_stub: false +search: true +section: Queries +desc: A New Runtime for GraphQL-Ruby +experimental: true +index: 11 +--- + +GraphQL-Ruby 1.9.0 includes a new runtime module which you may use for your schema. Eventually, it will become the default. + +It's called `GraphQL::Execute::Interpreter` and you can hook it up with `use ...` in your schema class: + +```ruby +class MySchema < GraphQL::Schema + use GraphQL::Execute::Interpreter +end +``` + +Read on to learn more! + +## Rationale + +The new runtime was added to address a few specific concerns: + +- __Validation Performance__: The previous runtime depended on a preparation step (`GraphQL::InternalRepresentation::Rewrite`) which could be very slow in some cases. In many cases, the overhead of that step provided no value. +- __Runtime Performance__: For very large results, the previous runtime was slow because it allocated a new `ctx` object for every field, even very simple fields that didn't need any special tracking. +- __Extensibility__: Although the GraphQL specification supports custom directives, GraphQL-Ruby didn't have a good way to build them. + +## Installation + +You can opt in to the interpreter in your schema class: + +```ruby +class MySchema < GraphQL::Schema + use GraphQL::Execution::Interpreter +end +``` + +If you have a subscription root type, it will also need an update. Extend this new module: + +```ruby +class Types::Subscription < Types::BaseObject + # Extend this module to support subscription root fields with Interpreter + extend GraphQL::Subscriptions::SubscriptionRoot +end +``` + +Some Relay configurations must be updated too. For example: + +```diff +- field :node, field: GraphQL::Relay::Node.field ++ add_field(GraphQL::Types::Relay::NodeField) +``` + +(Alternatively, consider implementing `Query.node` in your own app, using `NodeField` as inspiration.) + +## Compatibility + +The new runtime works with class-based schemas only. Several features are no longer supported: + +- Proc-dependent field features: + + - Field Instrumentation + - Middleware + - Resolve procs + + All these depend on the memory- and time-hungry per-field `ctx` object. To improve performance, only method-based resolves are supported. If need something from `ctx`, you can get it with the `extras: [...]` configuration option. To wrap resolve behaviors, try {% internal_link "Field Extensions", "/type_definitions/field_extensions" %}. + +- Query analyzers and `irep_node`s + + These depend on the now-removed `Rewrite` step, which wasted a lot of time making often-unneeded preparation. Most of the attributes you might need from an `irep_node` are available with `extras: [...]`. Query analyzers can be refactored to be static checks (custom validation rules) or dynamic checks, made at runtime. The built-in analyzers have been refactored to run as validators. + + `irep_node`-based lookahead is not supported. Stay tuned for a replacement. + +- `rescue_from` + + This was built on middleware, which is not supported anymore. Stay tuned for a replacement. + +- `.graphql_definition` and `def to_graphql` + + The interpreter uses class-based schema definitions only, and never converts them to legacy GraphQL definition objects. Any custom definitions to GraphQL objects should be re-implemented on custom base classes. + +Maybe this section should have been called _incompatibility_ 🤔. + +## Extending the Runtime + +🚧 👷🚧 + +The internals aren't clean enough to build on yet. Stay tuned. + +## Implementation Notes + +Instead of a tree of `irep_nodes`, the interpreter consumes the AST directly. This removes a complicated concept from GraphQL-Ruby (`irep_node`s) and simplifies the query lifecycle. The main difference relates to how fragment spreads are resolved. In the previous runtime, the possible combinations of fields for a given object were calculated ahead of time, then some of those combinations were used during runtime, but many of them may not have been. In the new runtime, no precalculation is made; instead each object is checked against each fragment at runtime. + +Instead of creating a `GraphQL::Query::Context::FieldResolutionContext` for _every_ field in the response, the interpreter uses long-lived, mutable objects for execution bookkeeping. This is more complicated to manage, since the changes to those objects can be hard to predict, but it's worth it for the performance gain. When needed, those bookkeeping objects can be "forked", so that two parts of an operation can be resolved independently. + +Instead of calling `.to_graphql` internally to convert class-based definitions to `.define`-based definitions, the interpreter operates on class-based definitions directly. This simplifies the workflow for creating custom configurations and using them at runtime. diff --git a/lib/graphql.rb b/lib/graphql.rb index b81a40d64b..d78e292d14 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -66,8 +66,8 @@ def self.scan_with_ragel(graphql_string) require "graphql/language" require "graphql/analysis" require "graphql/tracing" -require "graphql/execution" require "graphql/schema" +require "graphql/execution" require "graphql/types" require "graphql/relay" require "graphql/boolean_type" diff --git a/lib/graphql/argument.rb b/lib/graphql/argument.rb index 670a4770ab..aaf193c9ee 100644 --- a/lib/graphql/argument.rb +++ b/lib/graphql/argument.rb @@ -89,6 +89,11 @@ def expose_as @expose_as ||= (@as || @name).to_s end + # Backport this to support legacy-style directives + def keyword + @keyword ||= GraphQL::Schema::Member::BuildType.underscore(expose_as).to_sym + end + # @param value [Object] The incoming value from variables or query string literal # @param ctx [GraphQL::Query::Context] # @return [Object] The prepared `value` for this argument or `value` itself if no `prepare` function exists. diff --git a/lib/graphql/execution.rb b/lib/graphql/execution.rb index 951cc6ae55..91714b7c7e 100644 --- a/lib/graphql/execution.rb +++ b/lib/graphql/execution.rb @@ -3,6 +3,7 @@ require "graphql/execution/execute" require "graphql/execution/flatten" require "graphql/execution/instrumentation" +require "graphql/execution/interpreter" require "graphql/execution/lazy" require "graphql/execution/multiplex" require "graphql/execution/typecast" diff --git a/lib/graphql/execution/execute.rb b/lib/graphql/execution/execute.rb index 834c95eda2..33cd369467 100644 --- a/lib/graphql/execution/execute.rb +++ b/lib/graphql/execution/execute.rb @@ -24,6 +24,23 @@ def execute(ast_operation, root_type, query) GraphQL::Execution::Flatten.call(query.context) end + def self.begin_multiplex(_multiplex) + end + + def self.begin_query(query, _multiplex) + ExecutionFunctions.resolve_root_selection(query) + end + + def self.finish_multiplex(results, multiplex) + ExecutionFunctions.lazy_resolve_root_selection(results, multiplex: multiplex) + end + + def self.finish_query(query, _multiplex) + { + "data" => Execution::Flatten.call(query.context) + } + end + # @api private module ExecutionFunctions module_function @@ -179,7 +196,7 @@ def continue_resolve_field(raw_value, field_type, field_ctx) if list_errors.any? list_errors.each do |error, index| error.ast_node = field_ctx.ast_node - error.path = field_ctx.path + [index] + error.path = field_ctx.path + (field_ctx.type.list? ? [index] : []) query.context.errors.push(error) end end diff --git a/lib/graphql/execution/interpreter.rb b/lib/graphql/execution/interpreter.rb new file mode 100644 index 0000000000..7c9318c5f3 --- /dev/null +++ b/lib/graphql/execution/interpreter.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true +require "graphql/execution/interpreter/execution_errors" +require "graphql/execution/interpreter/hash_response" +require "graphql/execution/interpreter/runtime" + +module GraphQL + module Execution + class Interpreter + def initialize + # A buffer shared by all queries running in this interpreter + @lazies = [] + end + + # Support `Executor` :S + def execute(_operation, _root_type, query) + runtime = evaluate(query) + sync_lazies(query: query) + runtime.final_value + end + + def self.use(schema_defn) + schema_defn.query_execution_strategy(GraphQL::Execution::Interpreter) + schema_defn.mutation_execution_strategy(GraphQL::Execution::Interpreter) + schema_defn.subscription_execution_strategy(GraphQL::Execution::Interpreter) + end + + def self.begin_multiplex(multiplex) + # Since this is basically the batching context, + # share it for a whole multiplex + multiplex.context[:interpreter_instance] ||= self.new + end + + def self.begin_query(query, multiplex) + # The batching context is shared by the multiplex, + # so fetch it out and use that instance. + interpreter = + query.context.namespace(:interpreter)[:interpreter_instance] = + multiplex.context[:interpreter_instance] + interpreter.evaluate(query) + query + end + + def self.finish_multiplex(_results, multiplex) + interpreter = multiplex.context[:interpreter_instance] + interpreter.sync_lazies(multiplex: multiplex) + end + + def self.finish_query(query, _multiplex) + { + "data" => query.context.namespace(:interpreter)[:runtime].final_value + } + end + + def evaluate(query) + query.context.interpreter = true + # Although queries in a multiplex _share_ an Interpreter instance, + # they also have another item of state, which is private to that query + # in particular, assign it here: + runtime = Runtime.new( + query: query, + lazies: @lazies, + response: HashResponse.new, + ) + query.context.namespace(:interpreter)[:runtime] = runtime + + query.trace("execute_query", {query: query}) do + runtime.run_eager + end + + runtime + end + + def sync_lazies(query: nil, multiplex: nil) + tracer = query || multiplex + if query.nil? && multiplex.queries.length == 1 + query = multiplex.queries[0] + end + tracer.trace("execute_query_lazy", {multiplex: multiplex, query: query}) do + while @lazies.any? + next_wave = @lazies.dup + @lazies.clear + # This will cause a side-effect with `.write(...)` + next_wave.each(&:value) + end + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/execution_errors.rb b/lib/graphql/execution/interpreter/execution_errors.rb new file mode 100644 index 0000000000..e85041f8bc --- /dev/null +++ b/lib/graphql/execution/interpreter/execution_errors.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + class ExecutionErrors + def initialize(ctx, ast_node, path) + @context = ctx + @ast_node = ast_node + @path = path + end + + def add(err_or_msg) + err = case err_or_msg + when String + GraphQL::ExecutionError.new(err_or_msg) + when GraphQL::ExecutionError + err_or_msg + else + raise ArgumentError, "expected String or GraphQL::ExecutionError, not #{err_or_msg.class} (#{err_or_msg.inspect})" + end + err.ast_node ||= @ast_node + err.path ||= @path + @context.add_error(err) + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/hash_response.rb b/lib/graphql/execution/interpreter/hash_response.rb new file mode 100644 index 0000000000..68214a468b --- /dev/null +++ b/lib/graphql/execution/interpreter/hash_response.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + # This response class handles `#write` by accumulating + # values into a Hash. + class HashResponse + def initialize + @result = {} + end + + def final_value + @result + end + + def inspect + "#<#{self.class.name} result=#{@result.inspect}>" + end + + # Add `value` at `path`. + # @return [void] + def write(path, value, propagating_nil: false) + write_target = @result + if write_target + if path.none? + @result = value + else + path.each_with_index do |path_part, idx| + next_part = path[idx + 1] + if next_part.nil? + if write_target[path_part].nil? || (propagating_nil) + write_target[path_part] = value + else + raise "Invariant: Duplicate write to #{path} (previous: #{write_target[path_part].inspect}, new: #{value.inspect})" + end + else + # Don't have to worry about dead paths here + # because it's tracked by the runtime, + # and values for dead paths are not sent to this method. + write_target = write_target.fetch(path_part, :__unset) + end + end + end + end + nil + end + end + end + end +end diff --git a/lib/graphql/execution/interpreter/runtime.rb b/lib/graphql/execution/interpreter/runtime.rb new file mode 100644 index 0000000000..9689474b9b --- /dev/null +++ b/lib/graphql/execution/interpreter/runtime.rb @@ -0,0 +1,464 @@ +# frozen_string_literal: true + +module GraphQL + module Execution + class Interpreter + # I think it would be even better if we could somehow make + # `continue_field` not recursive. "Trampolining" it somehow. + class Runtime + # @return [GraphQL::Query] + attr_reader :query + + # @return [Class] + attr_reader :schema + + # @return [GraphQL::Query::Context] + attr_reader :context + + def initialize(query:, lazies:, response:) + @query = query + @schema = query.schema + @context = query.context + @lazies = lazies + @response = response + @dead_paths = {} + @types_at_paths = {} + end + + def final_value + @response.final_value + end + + def inspect + "#<#{self.class.name} response=#{@response.inspect}>" + end + + # This _begins_ the execution. Some deferred work + # might be stored up in {@lazies}. + # @return [void] + def run_eager + root_operation = query.selected_operation + root_op_type = root_operation.operation_type || "query" + legacy_root_type = schema.root_type_for_operation(root_op_type) + root_type = legacy_root_type.metadata[:type_class] || raise("Invariant: type must be class-based: #{legacy_root_type}") + object_proxy = root_type.authorized_new(query.root_value, context) + + path = [] + evaluate_selections(path, object_proxy, root_type, root_operation.selections, root_operation_type: root_op_type) + end + + private + + def gather_selections(owner_type, selections, selections_by_name) + selections.each do |node| + case node + when GraphQL::Language::Nodes::Field + if passes_skip_and_include?(node) + response_key = node.alias || node.name + s = selections_by_name[response_key] ||= [] + s << node + end + when GraphQL::Language::Nodes::InlineFragment + if passes_skip_and_include?(node) + include_fragmment = if node.type + type_defn = schema.types[node.type.name] + type_defn = type_defn.metadata[:type_class] + possible_types = query.warden.possible_types(type_defn).map { |t| t.metadata[:type_class] } + possible_types.include?(owner_type) + else + true + end + if include_fragmment + gather_selections(owner_type, node.selections, selections_by_name) + end + end + when GraphQL::Language::Nodes::FragmentSpread + if passes_skip_and_include?(node) + fragment_def = query.fragments[node.name] + type_defn = schema.types[fragment_def.type.name] + type_defn = type_defn.metadata[:type_class] + possible_types = schema.possible_types(type_defn).map { |t| t.metadata[:type_class] } + if possible_types.include?(owner_type) + gather_selections(owner_type, fragment_def.selections, selections_by_name) + end + end + else + raise "Invariant: unexpected selection class: #{node.class}" + end + end + end + + def evaluate_selections(path, owner_object, owner_type, selections, root_operation_type: nil) + selections_by_name = {} + owner_type = resolve_if_late_bound_type(owner_type) + gather_selections(owner_type, selections, selections_by_name) + selections_by_name.each do |result_name, fields| + ast_node = fields.first + field_name = ast_node.name + field_defn = owner_type.fields[field_name] + is_introspection = false + if field_defn.nil? + field_defn = if owner_type == schema.query.metadata[:type_class] && (entry_point_field = schema.introspection_system.entry_point(name: field_name)) + is_introspection = true + entry_point_field.metadata[:type_class] + elsif (dynamic_field = schema.introspection_system.dynamic_field(name: field_name)) + is_introspection = true + dynamic_field.metadata[:type_class] + else + raise "Invariant: no field for #{owner_type}.#{field_name}" + end + end + + return_type = resolve_if_late_bound_type(field_defn.type) + + next_path = [*path, result_name].freeze + # This seems janky, but we need to know + # the field's return type at this path in order + # to propagate `null` + set_type_at_path(next_path, return_type) + + object = owner_object + + if is_introspection + object = field_defn.owner.authorized_new(object, context) + end + + kwarg_arguments = arguments(object, field_defn, ast_node) + # It might turn out that making arguments for every field is slow. + # If we have to cache them, we'll need a more subtle approach here. + if field_defn.extras.include?(:ast_node) + kwarg_arguments[:ast_node] = ast_node + end + if field_defn.extras.include?(:execution_errors) + kwarg_arguments[:execution_errors] = ExecutionErrors.new(context, ast_node, next_path) + end + + next_selections = fields.inject([]) { |memo, f| memo.concat(f.selections) } + + app_result = query.trace("execute_field", {field: field_defn, path: next_path}) do + field_defn.resolve(object, kwarg_arguments, context) + end + + after_lazy(app_result, field: field_defn, path: next_path, eager: root_operation_type == "mutation") do |inner_result| + should_continue, continue_value = continue_value(next_path, inner_result, field_defn, return_type, ast_node) + if should_continue + continue_field(next_path, continue_value, field_defn, return_type, ast_node, next_selections) + end + end + end + end + + def continue_value(path, value, field, as_type, ast_node) + if value.nil? || value.is_a?(GraphQL::ExecutionError) + if value.nil? + if as_type.non_null? + err = GraphQL::InvalidNullError.new(field.owner, field, value) + write_in_response(path, err, propagating_nil: true) + else + write_in_response(path, nil) + end + else + value.path ||= path + value.ast_node ||= ast_node + write_in_response(path, value, propagating_nil: as_type.non_null?) + end + false + elsif value.is_a?(Array) && value.all? { |v| v.is_a?(GraphQL::ExecutionError) } + value.each do |v| + v.path ||= path + v.ast_node ||= ast_node + end + write_in_response(path, value, propagating_nil: as_type.non_null?) + false + elsif value.is_a?(GraphQL::UnauthorizedError) + # this hook might raise & crash, or it might return + # a replacement value + next_value = begin + schema.unauthorized_object(value) + rescue GraphQL::ExecutionError => err + err + end + + continue_value(path, next_value, field, as_type, ast_node) + elsif GraphQL::Execution::Execute::SKIP == value + false + else + return true, value + end + end + + def continue_field(path, value, field, type, ast_node, next_selections) + type = resolve_if_late_bound_type(type) + + case type.kind + when TypeKinds::SCALAR, TypeKinds::ENUM + r = type.coerce_result(value, context) + write_in_response(path, r) + when TypeKinds::UNION, TypeKinds::INTERFACE + resolved_type = query.resolve_type(type, value) + possible_types = query.possible_types(type) + + if !possible_types.include?(resolved_type) + parent_type = field.owner + type_error = GraphQL::UnresolvedTypeError.new(value, field, parent_type, resolved_type, possible_types) + schema.type_error(type_error, context) + write_in_response(path, nil, propagating_nil: field.type.non_null?) + else + resolved_type = resolved_type.metadata[:type_class] + continue_field(path, value, field, resolved_type, ast_node, next_selections) + end + when TypeKinds::OBJECT + object_proxy = begin + type.authorized_new(value, context) + rescue GraphQL::ExecutionError => err + err + end + after_lazy(object_proxy, path: path, field: field) do |inner_object| + should_continue, continue_value = continue_value(path, inner_object, field, type, ast_node) + if should_continue + write_in_response(path, {}) + evaluate_selections(path, continue_value, type, next_selections) + end + end + when TypeKinds::LIST + write_in_response(path, []) + inner_type = type.of_type + value.each_with_index.each do |inner_value, idx| + next_path = [*path, idx].freeze + set_type_at_path(next_path, inner_type) + after_lazy(inner_value, path: next_path, field: field) do |inner_inner_value| + should_continue, continue_value = continue_value(next_path, inner_inner_value, field, inner_type, ast_node) + if should_continue + continue_field(next_path, continue_value, field, inner_type, ast_node, next_selections) + end + end + end + when TypeKinds::NON_NULL + inner_type = type.of_type + # Don't `set_type_at_path` because we want the static type, + # we're going to use that to determine whether a `nil` should be propagated or not. + continue_field(path, value, field, inner_type, ast_node, next_selections) + else + raise "Invariant: Unhandled type kind #{type.kind} (#{type})" + end + end + + def passes_skip_and_include?(node) + # Eventually this should actually call out to the directives + # instead of having magical hard-coded behavior. + node.directives.each do |dir| + dir_defn = schema.directives.fetch(dir.name) + if dir.name == "skip" && arguments(nil, dir_defn, dir)[:if] == true + return false + elsif dir.name == "include" && arguments(nil, dir_defn, dir)[:if] == false + return false + end + end + true + end + + def resolve_if_late_bound_type(type) + if type.is_a?(GraphQL::Schema::LateBoundType) + query.warden.get_type(type.name).metadata[:type_class] + else + type + end + end + + # @param obj [Object] Some user-returned value that may want to be batched + # @param path [Array] + # @param field [GraphQL::Schema::Field] + # @param eager [Boolean] Set to `true` for mutation root fields only + # @return [GraphQL::Execution::Lazy, Object] If loading `object` will be deferred, it's a wrapper over it. + def after_lazy(obj, field:, path:, eager: false) + if schema.lazy?(obj) + lazy = GraphQL::Execution::Lazy.new do + # Wrap the execution of _this_ method with tracing, + # but don't wrap the continuation below + inner_obj = query.trace("execute_field_lazy", {field: field, path: path}) do + method_name = schema.lazy_method_name(obj) + begin + obj.public_send(method_name) + rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err + yield(err) + end + end + after_lazy(inner_obj, field: field, path: path, eager: eager) do |really_inner_obj| + yield(really_inner_obj) + end + end + + if eager + lazy.value + else + @lazies << lazy + end + else + yield(obj) + end + end + + def arguments(graphql_object, arg_owner, ast_node) + kwarg_arguments = {} + ast_node.arguments.each do |arg| + arg_defn = arg_owner.arguments[arg.name] + # Need to distinguish between client-provided `nil` + # and nothing-at-all + is_present, value = arg_to_value(graphql_object, arg_defn.type, arg.value) + if is_present + # This doesn't apply to directives, which are legacy + # Can remove this when Skip and Include use classes or something. + if graphql_object + value = arg_defn.prepare_value(graphql_object, value) + end + kwarg_arguments[arg_defn.keyword] = value + end + end + arg_owner.arguments.each do |name, arg_defn| + if arg_defn.default_value? && !kwarg_arguments.key?(arg_defn.keyword) + kwarg_arguments[arg_defn.keyword] = arg_defn.default_value + end + end + kwarg_arguments + end + + # Get a Ruby-ready value from a client query. + # @param graphql_object [Object] The owner of the field whose argument this is + # @param arg_type [Class, GraphQL::Schema::NonNull, GraphQL::Schema::List] + # @param ast_value [GraphQL::Language::Nodes::VariableIdentifier, String, Integer, Float, Boolean] + # @return [Array(is_present, value)] + def arg_to_value(graphql_object, arg_type, ast_value) + if ast_value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) + # If it's not here, it will get added later + if query.variables.key?(ast_value.name) + return true, query.variables[ast_value.name] + else + return false, nil + end + elsif arg_type.is_a?(GraphQL::Schema::NonNull) + arg_to_value(graphql_object, arg_type.of_type, ast_value) + elsif arg_type.is_a?(GraphQL::Schema::List) + # Treat a single value like a list + arg_value = Array(ast_value) + list = [] + arg_value.map do |inner_v| + _present, value = arg_to_value(graphql_object, arg_type.of_type, inner_v) + list << value + end + return true, list + elsif arg_type.is_a?(Class) && arg_type < GraphQL::Schema::InputObject + # For these, `prepare` is applied during `#initialize`. + # Pass `nil` so it will be skipped in `#arguments`. + # What a mess. + args = arguments(nil, arg_type, ast_value) + # We're not tracking defaults_used, but for our purposes + # we compare the value to the default value. + return true, arg_type.new(ruby_kwargs: args, context: context, defaults_used: nil) + else + flat_value = flatten_ast_value(ast_value) + return true, arg_type.coerce_input(flat_value, context) + end + end + + def flatten_ast_value(v) + case v + when GraphQL::Language::Nodes::Enum + v.name + when GraphQL::Language::Nodes::InputObject + h = {} + v.arguments.each do |arg| + h[arg.name] = flatten_ast_value(arg.value) + end + h + when Array + v.map { |v2| flatten_ast_value(v2) } + when GraphQL::Language::Nodes::VariableIdentifier + flatten_ast_value(query.variables[v.name]) + else + v + end + end + + def write_in_response(path, value, propagating_nil: false) + if dead_path?(path) + return + else + if value.is_a?(GraphQL::ExecutionError) || (value.is_a?(Array) && value.any? && value.all? { |v| v.is_a?(GraphQL::ExecutionError)}) + Array(value).each do |v| + context.errors << v + end + write_in_response(path, nil, propagating_nil: propagating_nil) + add_dead_path(path) + elsif value.is_a?(GraphQL::InvalidNullError) + schema.type_error(value, context) + write_in_response(path, nil, propagating_nil: true) + add_dead_path(path) + elsif value.nil? && path.any? && type_at(path).non_null? + # This nil is invalid, try writing it at the previous spot + propagate_path = path[0..-2] + write_in_response(propagate_path, value, propagating_nil: true) + add_dead_path(propagate_path) + else + @response.write(path, value, propagating_nil: propagating_nil) + end + end + end + + # To propagate nulls, we have to know what the field type was + # at previous parts of the response. + # This hash matches the response + def type_at(path) + t = @types_at_paths + path.each do |part| + if part.is_a?(Integer) + part = 0 + end + t = t[part] || (raise("Invariant: #{part.inspect} not found in #{t}")) + end + t = t[:__type] + t + end + + def set_type_at_path(path, type) + types = @types_at_paths + path.each do |part| + if part.is_a?(Integer) + part = 0 + end + + types = types[part] ||= {} + end + # Use this magic key so that the hash contains: + # - string keys for nested fields + # - :__type for the object type of a selection + types[:__type] ||= type + nil + end + + # Mark `path` as having been permanently nulled out. + # No values will be added beyond that path. + def add_dead_path(path) + dead = @dead_paths + path.each do |part| + dead = dead[part] ||= {} + end + dead[:__dead] = true + end + + def dead_path?(path) + res = @dead_paths + path.each do |part| + if res + if res[:__dead] + break + else + res = res[part] + end + end + end + res && res[:__dead] + end + end + end + end +end diff --git a/lib/graphql/execution/lazy.rb b/lib/graphql/execution/lazy.rb index 4bb3cd08c7..de8d84d973 100644 --- a/lib/graphql/execution/lazy.rb +++ b/lib/graphql/execution/lazy.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "graphql/execution/lazy/lazy_method_map" require "graphql/execution/lazy/resolve" + module GraphQL module Execution # This wraps a value which is available, but not yet calculated, like a promise or future. diff --git a/lib/graphql/execution/multiplex.rb b/lib/graphql/execution/multiplex.rb index 3e845035f4..89d920be52 100644 --- a/lib/graphql/execution/multiplex.rb +++ b/lib/graphql/execution/multiplex.rb @@ -56,7 +56,11 @@ def run_all(schema, query_options, *args) def run_queries(schema, queries, context: {}, max_complexity: schema.max_complexity) multiplex = self.new(schema: schema, queries: queries, context: context) multiplex.trace("execute_multiplex", { multiplex: multiplex }) do - if has_custom_strategy?(schema) + if supports_multiplexing?(schema) + instrument_and_analyze(multiplex, max_complexity: max_complexity) do + run_as_multiplex(multiplex) + end + else if queries.length != 1 raise ArgumentError, "Multiplexing doesn't support custom execution strategies, run one query at a time instead" else @@ -64,10 +68,6 @@ def run_queries(schema, queries, context: {}, max_complexity: schema.max_complex [run_one_legacy(schema, queries.first)] end end - else - instrument_and_analyze(multiplex, max_complexity: max_complexity) do - run_as_multiplex(multiplex) - end end end end @@ -75,19 +75,21 @@ def run_queries(schema, queries, context: {}, max_complexity: schema.max_complex private def run_as_multiplex(multiplex) + + multiplex.schema.query_execution_strategy.begin_multiplex(multiplex) queries = multiplex.queries # Do as much eager evaluation of the query as possible results = queries.map do |query| - begin_query(query) + begin_query(query, multiplex) end # Then, work through lazy results in a breadth-first way - GraphQL::Execution::Execute::ExecutionFunctions.lazy_resolve_root_selection(results, { multiplex: multiplex }) + multiplex.schema.query_execution_strategy.finish_multiplex(results, multiplex) # Then, find all errors and assign the result to the query object results.each_with_index.map do |data_result, idx| query = queries[idx] - finish_query(data_result, query) + finish_query(data_result, query, multiplex) # Get the Query::Result, not the Hash query.result end @@ -99,13 +101,14 @@ def run_as_multiplex(multiplex) # @param query [GraphQL::Query] # @return [Hash] The initial result (may not be finished if there are lazy values) - def begin_query(query) + def begin_query(query, multiplex) operation = query.selected_operation if operation.nil? || !query.valid? NO_OPERATION else begin - GraphQL::Execution::Execute::ExecutionFunctions.resolve_root_selection(query) + # These were checked to be the same in `#supports_multiplexing?` + query.schema.query_execution_strategy.begin_query(query, multiplex) rescue GraphQL::ExecutionError => err query.context.errors << err NO_OPERATION @@ -116,7 +119,7 @@ def begin_query(query) # @param data_result [Hash] The result for the "data" key, if any # @param query [GraphQL::Query] The query which was run # @return [Hash] final result of this query, including all values and errors - def finish_query(data_result, query) + def finish_query(data_result, query, multiplex) # Assign the result so that it can be accessed in instrumentation query.result_values = if data_result.equal?(NO_OPERATION) if !query.valid? @@ -126,9 +129,7 @@ def finish_query(data_result, query) end else # Use `context.value` which was assigned during execution - result = { - "data" => Execution::Flatten.call(query.context) - } + result = query.schema.query_execution_strategy.finish_query(query, multiplex) if query.context.errors.any? error_result = query.context.errors.map(&:to_h) @@ -153,10 +154,15 @@ def run_one_legacy(schema, query) end end - def has_custom_strategy?(schema) - schema.query_execution_strategy != GraphQL::Execution::Execute || - schema.mutation_execution_strategy != GraphQL::Execution::Execute || - schema.subscription_execution_strategy != GraphQL::Execution::Execute + DEFAULT_STRATEGIES = [ + GraphQL::Execution::Execute, + GraphQL::Execution::Interpreter + ] + # @return [Boolean] True if the schema is only using one strategy, and it's one that supports multiplexing. + def supports_multiplexing?(schema) + schema_strategies = [schema.query_execution_strategy, schema.mutation_execution_strategy, schema.subscription_execution_strategy] + schema_strategies.uniq! + schema_strategies.size == 1 && DEFAULT_STRATEGIES.include?(schema_strategies.first) end # Apply multiplex & query instrumentation to `queries`. diff --git a/lib/graphql/introspection/dynamic_fields.rb b/lib/graphql/introspection/dynamic_fields.rb index 8040cb6fda..288018a89b 100644 --- a/lib/graphql/introspection/dynamic_fields.rb +++ b/lib/graphql/introspection/dynamic_fields.rb @@ -3,8 +3,14 @@ module GraphQL module Introspection class DynamicFields < Introspection::BaseObject field :__typename, String, "The name of this type", null: false, extras: [:irep_node] - def __typename(irep_node:) - irep_node.owner_type.name + + # `irep_node:` will be nil for the interpreter, since there is no such thing + def __typename(irep_node: nil) + if context.interpreter? + object.class.graphql_name + else + irep_node.owner_type.name + end end end end diff --git a/lib/graphql/introspection/entry_points.rb b/lib/graphql/introspection/entry_points.rb index 687ee0bbca..52d89eed86 100644 --- a/lib/graphql/introspection/entry_points.rb +++ b/lib/graphql/introspection/entry_points.rb @@ -15,14 +15,15 @@ def __schema end def __type(name:) - type = @context.warden.get_type(name) - if type + # This will probably break with non-Interpreter runtime + type = context.warden.get_type(name) + # The interpreter provides this wrapping, other execution doesnt, so support both. + if type && !context.interpreter? # Apply wrapping manually since this field isn't wrapped by instrumentation - type_type = @context.schema.introspection_system.type_type - type_type.metadata[:type_class].authorized_new(type, @context) - else - nil + type_type = context.schema.introspection_system.type_type + type = type_type.metadata[:type_class].authorized_new(type, context) end + type end end end diff --git a/lib/graphql/introspection/enum_value_type.rb b/lib/graphql/introspection/enum_value_type.rb index 2f9a581d40..da19d3bc22 100644 --- a/lib/graphql/introspection/enum_value_type.rb +++ b/lib/graphql/introspection/enum_value_type.rb @@ -11,6 +11,10 @@ class EnumValueType < Introspection::BaseObject field :is_deprecated, Boolean, null: false field :deprecation_reason, String, null: true + def name + object.graphql_name + end + def is_deprecated !!@object.deprecation_reason end diff --git a/lib/graphql/introspection/schema_type.rb b/lib/graphql/introspection/schema_type.rb index dddd81bb05..ea55bfa3a1 100644 --- a/lib/graphql/introspection/schema_type.rb +++ b/lib/graphql/introspection/schema_type.rb @@ -14,7 +14,12 @@ class SchemaType < Introspection::BaseObject field :directives, [GraphQL::Schema::LateBoundType.new("__Directive")], "A list of all directives supported by this server.", null: false def types - @context.warden.types + types = @context.warden.types + if context.interpreter? + types.map { |t| t.metadata[:type_class] || raise("Invariant: can't introspect non-class-based type: #{t}") } + else + types + end end def query_type @@ -30,7 +35,7 @@ def subscription_type end def directives - @object.directives.values + context.schema.directives.values end private diff --git a/lib/graphql/introspection/type_type.rb b/lib/graphql/introspection/type_type.rb index 7450d47e8d..583b87be0d 100644 --- a/lib/graphql/introspection/type_type.rb +++ b/lib/graphql/introspection/type_type.rb @@ -25,6 +25,10 @@ class TypeType < Introspection::BaseObject field :input_fields, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: true field :of_type, GraphQL::Schema::LateBoundType.new("__Type"), null: true + def name + object.graphql_name + end + def kind @object.kind.name end @@ -33,7 +37,7 @@ def enum_values(include_deprecated:) if !@object.kind.enum? nil else - enum_values = @context.warden.enum_values(@object) + enum_values = @context.warden.enum_values(@object.graphql_definition) if !include_deprecated enum_values = enum_values.select {|f| !f.deprecation_reason } diff --git a/lib/graphql/invalid_null_error.rb b/lib/graphql/invalid_null_error.rb index 77aa14b26d..f949e3351c 100644 --- a/lib/graphql/invalid_null_error.rb +++ b/lib/graphql/invalid_null_error.rb @@ -16,7 +16,7 @@ def initialize(parent_type, field, value) @parent_type = parent_type @field = field @value = value - super("Cannot return null for non-nullable field #{@parent_type.name}.#{@field.name}") + super("Cannot return null for non-nullable field #{@parent_type.graphql_name}.#{@field.graphql_name}") end # @return [Hash] An entry for the response's "errors" key diff --git a/lib/graphql/language/nodes.rb b/lib/graphql/language/nodes.rb index d6e62b2038..66084bfc84 100644 --- a/lib/graphql/language/nodes.rb +++ b/lib/graphql/language/nodes.rb @@ -375,6 +375,11 @@ def children_method_name class FragmentSpread < AbstractNode scalar_methods :name children_methods(directives: GraphQL::Language::Nodes::Directive) + + def children_method_name + :selections + end + # @!attribute name # @return [String] The identifier of the fragment to apply, corresponds with {FragmentDefinition#name} end @@ -387,6 +392,10 @@ class InlineFragment < AbstractNode directives: GraphQL::Language::Nodes::Directive, }) + def children_method_name + :selections + end + # @!attribute type # @return [String, nil] Name of the type this fragment applies to, or `nil` if this fragment applies to any type end diff --git a/lib/graphql/language/visitor.rb b/lib/graphql/language/visitor.rb index 2d58bd23e5..d0a87b2fef 100644 --- a/lib/graphql/language/visitor.rb +++ b/lib/graphql/language/visitor.rb @@ -37,6 +37,7 @@ class Visitor SKIP = :_skip class DeleteNode; end + # When this is returned from a visitor method, # Then the `node` passed into the method is removed from `parent`'s children. DELETE_NODE = DeleteNode.new @@ -131,13 +132,17 @@ def on_abstract_node(node, parent) alias :on_variable_definition :on_abstract_node alias :on_variable_identifier :on_abstract_node + def visit_node(node, parent) + public_send(node.visit_method, node, parent) + end + private # Run the hooks for `node`, and if the hooks return a copy of `node`, # copy `parent` so that it contains the copy of that node as a child, # then return the copies def on_node_with_modifications(node, parent) - new_node, new_parent = public_send(node.visit_method, node, parent) + new_node, new_parent = visit_node(node, parent) if new_node.is_a?(Nodes::AbstractNode) && !node.equal?(new_node) # The user-provided hook returned a new node. new_parent = new_parent && new_parent.replace_child(node, new_node) diff --git a/lib/graphql/query/context.rb b/lib/graphql/query/context.rb index 5862dad46b..9fd0678c3b 100644 --- a/lib/graphql/query/context.rb +++ b/lib/graphql/query/context.rb @@ -149,8 +149,18 @@ def initialize(query:, values: , object:) @path = [] @value = nil @context = self # for SharedMethods + # The interpreter will set this + @interpreter = nil end + # @return [Boolean] True if using the new {GraphQL::Execution::Interpreter} + def interpreter? + @interpreter + end + + # @api private + attr_writer :interpreter + # @api private attr_writer :value @@ -222,7 +232,7 @@ def path def_delegators :@context, :[], :[]=, :key?, :fetch, :to_h, :namespace, :spawn, :warden, :errors, - :execution_strategy, :strategy + :execution_strategy, :strategy, :interpreter? # @return [GraphQL::Language::Nodes::Field] The AST node for the currently-executing field def ast_node diff --git a/lib/graphql/relay/node.rb b/lib/graphql/relay/node.rb index da81c12c53..8b7f222ae9 100644 --- a/lib/graphql/relay/node.rb +++ b/lib/graphql/relay/node.rb @@ -8,13 +8,7 @@ def self.field(**kwargs, &block) # We have to define it fresh each time because # its name will be modified and its description # _may_ be modified. - field = GraphQL::Field.define do - type(GraphQL::Relay::Node.interface) - description("Fetches an object given its ID.") - argument(:id, types.ID.to_non_null_type, "ID of the object.") - resolve(GraphQL::Relay::Node::FindNode) - relay_node_field(true) - end + field = GraphQL::Types::Relay::NodeField.graphql_definition if kwargs.any? || block field = field.redefine(kwargs, &block) @@ -24,13 +18,7 @@ def self.field(**kwargs, &block) end def self.plural_field(**kwargs, &block) - field = GraphQL::Field.define do - type(!types[GraphQL::Relay::Node.interface]) - description("Fetches a list of objects given a list of IDs.") - argument(:ids, types.ID.to_non_null_type.to_list_type.to_non_null_type, "IDs of the objects.") - resolve(GraphQL::Relay::Node::FindNodes) - relay_nodes_field(true) - end + field = GraphQL::Types::Relay::NodesField.graphql_definition if kwargs.any? || block field = field.redefine(kwargs, &block) @@ -43,20 +31,6 @@ def self.plural_field(**kwargs, &block) def self.interface @interface ||= GraphQL::Types::Relay::Node.graphql_definition end - - # A field resolve for finding objects by IDs - module FindNodes - def self.call(obj, args, ctx) - args[:ids].map { |id| ctx.query.schema.object_from_id(id, ctx) } - end - end - - # A field resolve for finding an object by ID - module FindNode - def self.call(obj, args, ctx) - ctx.query.schema.object_from_id(args[:id], ctx ) - end - end end end end diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 81f29fb626..ee55efde6b 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -83,7 +83,7 @@ class Schema :object_from_id, :id_from_object, :default_mask, :cursor_encoder, - directives: ->(schema, directives) { schema.directives = directives.reduce({}) { |m, d| m[d.name] = d; m }}, + directives: ->(schema, directives) { schema.directives = directives.reduce({}) { |m, d| m[d.name] = d; m } }, instrument: ->(schema, type, instrumenter, after_built_ins: false) { if type == :field && after_built_ins type = :field_after_built_ins @@ -94,7 +94,7 @@ 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) }, - rescue_from: ->(schema, err_class, &block) { schema.rescue_from(err_class, &block)}, + rescue_from: ->(schema, err_class, &block) { schema.rescue_from(err_class, &block) }, tracer: ->(schema, tracer) { schema.tracers.push(tracer) } attr_accessor \ @@ -135,8 +135,6 @@ def default_filter # @see {Query#tracers} for query-specific tracers attr_reader :tracers - self.default_execution_strategy = GraphQL::Execution::Execute - DIRECTIVES = [GraphQL::Directive::IncludeDirective, GraphQL::Directive::SkipDirective, GraphQL::Directive::DeprecatedDirective] DYNAMIC_FIELDS = ["__type", "__typename", "__schema"] @@ -161,9 +159,9 @@ def initialize @lazy_methods.set(GraphQL::Execution::Lazy, :value) @cursor_encoder = Base64Encoder # Default to the built-in execution strategy: - @query_execution_strategy = self.class.default_execution_strategy - @mutation_execution_strategy = self.class.default_execution_strategy - @subscription_execution_strategy = self.class.default_execution_strategy + @query_execution_strategy = self.class.default_execution_strategy || GraphQL::Execution::Execute + @mutation_execution_strategy = self.class.default_execution_strategy || GraphQL::Execution::Execute + @subscription_execution_strategy = self.class.default_execution_strategy || GraphQL::Execution::Execute @default_mask = GraphQL::Schema::NullMask @rebuilding_artifacts = false @context_class = GraphQL::Query::Context @@ -171,6 +169,10 @@ def initialize @introspection_system = nil end + def inspect + "#<#{self.class.name} ...>" + end + def initialize_copy(other) super @orphan_types = other.orphan_types.dup @@ -390,7 +392,7 @@ def get_field(parent_type, field_name) # Fields for this type, after instrumentation is applied # @return [Hash] def get_fields(type) - @instrumented_field_map[type.name] + @instrumented_field_map[type.graphql_name] end def type_from_ast(ast_node) @@ -658,6 +660,7 @@ class << self :static_validator, :introspection_system, :query_analyzers, :tracers, :instrumenters, :query_execution_strategy, :mutation_execution_strategy, :subscription_execution_strategy, + :execution_strategy_for_operation, :validate, :multiplex_analyzers, :lazy?, :lazy_method_name, :after_lazy, # Configuration :max_complexity=, :max_depth=, @@ -715,7 +718,6 @@ def to_graphql schema_defn.instrumenters[step] << inst end end - schema_defn.instrumenters[:query] << GraphQL::Schema::Member::Instrumentation lazy_classes.each do |lazy_class, value_method| schema_defn.lazy_methods.set(lazy_class, value_method) end @@ -738,6 +740,10 @@ def to_graphql end end end + # Do this after `plugins` since Interpreter is a plugin + if schema_defn.query_execution_strategy != GraphQL::Execution::Interpreter + schema_defn.instrumenters[:query] << GraphQL::Schema::Member::Instrumentation + end schema_defn.send(:rebuild_artifacts) schema_defn @@ -963,7 +969,7 @@ def defined_multiplex_analyzers # @see {.accessible?} # @see {.authorized?} def call_on_type_class(member, method_name, *args, default:) - member = if member.respond_to?(:metadata) + member = if member.respond_to?(:metadata) && member.metadata member.metadata[:type_class] || member else member diff --git a/lib/graphql/schema/argument.rb b/lib/graphql/schema/argument.rb index 80151751ac..45a11442d8 100644 --- a/lib/graphql/schema/argument.rb +++ b/lib/graphql/schema/argument.rb @@ -51,6 +51,14 @@ def initialize(arg_name = nil, type_expr = nil, desc = nil, required:, type: nil end end + # @return [Object] the value used when the client doesn't provide a value for this argument + attr_reader :default_value + + # @return [Boolean] True if this argument has a default value + def default_value? + @default_value != NO_DEFAULT + end + attr_writer :description # @return [String] Documentation for this argument diff --git a/lib/graphql/schema/field.rb b/lib/graphql/schema/field.rb index 5f4080b243..d0de71c4bb 100644 --- a/lib/graphql/schema/field.rb +++ b/lib/graphql/schema/field.rb @@ -29,7 +29,6 @@ class Field # @return [Class] The type that this field belongs to attr_reader :owner - # @return [Class, nil] The {Schema::Resolver} this field was derived from, if there is one def resolver @resolver_class @@ -37,6 +36,15 @@ def resolver alias :mutation :resolver + # @return [Array] + attr_reader :extras + + # @return [Boolean] Apply tracing to this field? (Default: skip scalars, this is the override value) + attr_reader :trace + + # @return [String, nil] + attr_reader :subscription_scope + # Create a field instance from a list of arguments, keyword arguments, and a block. # # This method implements prioritization between the `resolver` or `mutation` defaults @@ -130,7 +138,8 @@ def scoped? # @param scope [Boolean] If true, the return type's `.scope_items` method will be called on the return value # @param subscription_scope [Symbol, String] A key in `context` which will be used to scope subscription payloads # @param extensions [Array] Named extensions to apply to this field (see also {#extension}) - def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function: nil, description: nil, deprecation_reason: nil, method: nil, connection: nil, max_page_size: nil, scope: nil, resolve: nil, introspection: false, hash_key: nil, camelize: true, trace: nil, complexity: 1, extras: [], extensions: [], resolver_class: nil, subscription_scope: nil, arguments: {}, &definition_block) + # @param trace [Boolean] If true, a {GraphQL::Tracing} tracer will measure this scalar field + def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function: nil, description: nil, deprecation_reason: nil, method: nil, connection: nil, max_page_size: nil, scope: nil, resolve: nil, introspection: false, hash_key: nil, camelize: true, trace: nil, complexity: 1, extras: [], extensions: [], resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, arguments: {}, &definition_block) if name.nil? raise ArgumentError, "missing first `name` argument or keyword `name:`" end @@ -174,6 +183,8 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, field: nil, function @resolver_class = resolver_class @scope = scope @trace = trace + @relay_node_field = relay_node_field + @relay_nodes_field = relay_nodes_field # Override the default from HasArguments @own_arguments = {} @@ -282,7 +293,6 @@ def complexity(new_complexity) else raise("Invalid complexity: #{new_complexity.inspect} on #{@name}") end - end # @return [Integer, nil] Applied to connections if present @@ -322,6 +332,14 @@ def to_graphql field_defn.trace = @trace end + if @relay_node_field + field_defn.relay_node_field = @relay_node_field + end + + if @relay_nodes_field + field_defn.relay_nodes_field = @relay_nodes_field + end + field_defn.resolve = self.method(:resolve_field) field_defn.connection = connection? field_defn.connection_max_page_size = max_page_size @@ -372,22 +390,25 @@ def accessible?(context) end def authorized?(object, context) - if @resolver_class + self_auth = if @resolver_class @resolver_class.authorized?(object, context) else true end + + self_auth && arguments.each_value.all? { |a| a.authorized?(object, context) } end # Implement {GraphQL::Field}'s resolve API. # # Eventually, we might hook up field instances to execution in another way. TBD. + # @see #resolve for how the interpreter hooks up to it def resolve_field(obj, args, ctx) ctx.schema.after_lazy(obj) do |after_obj| # First, apply auth ... query_ctx = ctx.query.context inner_obj = after_obj && after_obj.object - if authorized?(inner_obj, query_ctx) && arguments.each_value.all? { |a| a.authorized?(inner_obj, query_ctx) } + if authorized?(inner_obj, query_ctx) # Then if it passed, resolve the field if @resolve_proc # Might be nil, still want to call the func in that case @@ -401,6 +422,47 @@ def resolve_field(obj, args, ctx) end end + # This method is called by the interpreter for each field. + # You can extend it in your base field classes. + # @param object [GraphQL::Schema::Object] An instance of some type class, wrapping an application object + # @param args [Hash] A symbol-keyed hash of Ruby keyword arguments. (Empty if no args) + # @param ctx [GraphQL::Query::Context] + def resolve(object, args, ctx) + if @resolve_proc + raise "Can't run resolve proc for #{path} when using GraphQL::Execution::Interpreter" + end + begin + # Unwrap the GraphQL object to get the application object. + application_object = object.object + if self.authorized?(application_object, ctx) + # Apply field extensions + with_extensions(object, args, ctx) do |extended_obj, extended_args| + field_receiver = if @resolver_class + resolver_obj = if extended_obj.is_a?(GraphQL::Schema::Object) + extended_obj.object + else + extended_obj + end + @resolver_class.new(object: resolver_obj, context: ctx) + else + extended_obj + end + + # Call the method with kwargs, if there are any + if extended_args.any? + field_receiver.public_send(method_sym, extended_args) + else + field_receiver.public_send(method_sym) + end + end + end + rescue GraphQL::UnauthorizedError => err + ctx.schema.unauthorized_object(err) + end + rescue GraphQL::ExecutionError => err + err + end + # Find a way to resolve this field, checking: # # - Hash keys, if the wrapped object is a hash; diff --git a/lib/graphql/schema/input_object.rb b/lib/graphql/schema/input_object.rb index a7aeaca8f7..081b6f54de 100644 --- a/lib/graphql/schema/input_object.rb +++ b/lib/graphql/schema/input_object.rb @@ -6,11 +6,15 @@ class InputObject < GraphQL::Schema::Member extend Forwardable extend GraphQL::Schema::Member::HasArguments - def initialize(values, context:, defaults_used:) + def initialize(values = nil, ruby_kwargs: nil, context:, defaults_used:) @context = context - @arguments = self.class.arguments_class.new(values, context: context, defaults_used: defaults_used) - # Symbolized, underscored hash: - @ruby_style_hash = @arguments.to_kwargs + if ruby_kwargs + @ruby_style_hash = ruby_kwargs + else + @arguments = self.class.arguments_class.new(values, context: context, defaults_used: defaults_used) + # Symbolized, underscored hash: + @ruby_style_hash = @arguments.to_kwargs + end # Apply prepares, not great to have it duplicated here. self.class.arguments.each do |name, arg_defn| ruby_kwargs_key = arg_defn.keyword @@ -56,13 +60,15 @@ def unwrap_value(value) def [](key) if @ruby_style_hash.key?(key) @ruby_style_hash[key] - else + elsif @arguments @arguments[key] + else + nil end end def key?(key) - @ruby_style_hash.key?(key) || @arguments.key?(key) + @ruby_style_hash.key?(key) || (@arguments && @arguments.key?(key)) end # A copy of the Ruby-style hash @@ -78,8 +84,9 @@ def argument(*args) argument_defn = super # Add a method access arg_name = argument_defn.graphql_definition.name - define_method(Member::BuildType.underscore(arg_name)) do - @arguments.public_send(arg_name) + method_name = Member::BuildType.underscore(arg_name).to_sym + define_method(method_name) do + @ruby_style_hash[method_name] end end diff --git a/lib/graphql/schema/member/base_dsl_methods.rb b/lib/graphql/schema/member/base_dsl_methods.rb index 267535a38e..29e91f781d 100644 --- a/lib/graphql/schema/member/base_dsl_methods.rb +++ b/lib/graphql/schema/member/base_dsl_methods.rb @@ -55,6 +55,10 @@ def introspection(new_introspection = nil) end end + def introspection? + introspection + end + # The mutation this type was derived from, if it was derived from a mutation # @return [Class] def mutation(mutation_class = nil) diff --git a/lib/graphql/schema/member/has_fields.rb b/lib/graphql/schema/member/has_fields.rb index 96a0b08f8a..2ebdd67e0f 100644 --- a/lib/graphql/schema/member/has_fields.rb +++ b/lib/graphql/schema/member/has_fields.rb @@ -94,7 +94,11 @@ def field_class(new_field_class = nil) end def global_id_field(field_name) - field field_name, "ID", null: false, resolve: GraphQL::Relay::GlobalIdResolve.new(type: self) + id_resolver = GraphQL::Relay::GlobalIdResolve.new(type: self) + field field_name, "ID", null: false + define_method(field_name) do + id_resolver.call(object, {}, context) + end end # @return [Array] Fields defined on this class _specifically_, not parent classes diff --git a/lib/graphql/schema/non_null.rb b/lib/graphql/schema/non_null.rb index d7a6d64a29..84c4318743 100644 --- a/lib/graphql/schema/non_null.rb +++ b/lib/graphql/schema/non_null.rb @@ -24,10 +24,14 @@ def non_null? def list? @of_type.list? end - + def to_type_signature "#{@of_type.to_type_signature}!" end + + def inspect + "#<#{self.class.name} @of_type=#{@of_type.inspect}>" + end end end end diff --git a/lib/graphql/schema/object.rb b/lib/graphql/schema/object.rb index 081448f5e4..6dcafd5bc1 100644 --- a/lib/graphql/schema/object.rb +++ b/lib/graphql/schema/object.rb @@ -35,19 +35,33 @@ class << self # @return [GraphQL::Schema::Object, GraphQL::Execution::Lazy] # @raise [GraphQL::UnauthorizedError] if the user-provided hook returns `false` def authorized_new(object, context) - context.schema.after_lazy(authorized?(object, context)) do |is_authorized| + auth_val = begin + authorized?(object, context) + # rescue GraphQL::UnauthorizedError => err + # context.schema.unauthorized_object(err) + end + + context.schema.after_lazy(auth_val) do |is_authorized| if is_authorized self.new(object, context) else # It failed the authorization check, so go to the schema's authorized object hook err = GraphQL::UnauthorizedError.new(object: object, type: self, context: context) # If a new value was returned, wrap that instead of the original value - new_obj = context.schema.unauthorized_object(err) - if new_obj - self.new(new_obj, context) + begin + new_obj = context.schema.unauthorized_object(err) + if new_obj + self.new(new_obj, context) + else + nil + end + # rescue GraphQL::ExecutionError => err + # err end end end + # rescue GraphQL::ExecutionError => err + # err end end diff --git a/lib/graphql/schema/relay_classic_mutation.rb b/lib/graphql/schema/relay_classic_mutation.rb index ede874244c..1cf09ce310 100644 --- a/lib/graphql/schema/relay_classic_mutation.rb +++ b/lib/graphql/schema/relay_classic_mutation.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require "graphql/types/string" + module GraphQL class Schema # Mutations that extend this base class get some conventions added for free: @@ -27,10 +28,43 @@ class RelayClassicMutation < GraphQL::Schema::Mutation # Override {GraphQL::Schema::Resolver#resolve_with_support} to # delete `client_mutation_id` from the kwargs. - def resolve_with_support(**kwargs) - # This is handled by Relay::Mutation::Resolve, a bit hacky, but here we are. - kwargs.delete(:client_mutation_id) - super + def resolve_with_support(**inputs) + # Without the interpreter, the inputs are unwrapped by an instrumenter. + # But when using the interpreter, no instrumenters are applied. + if context.interpreter? + input = inputs[:input] + else + input = inputs + end + + if input + # This is handled by Relay::Mutation::Resolve, a bit hacky, but here we are. + input_kwargs = input.to_h + client_mutation_id = input_kwargs.delete(:client_mutation_id) + else + # Relay Classic Mutations with no `argument`s + # don't require `input:` + input_kwargs = {} + end + + return_value = if input_kwargs.any? + super(input_kwargs) + else + super() + end + + # Again, this is done by an instrumenter when using non-interpreter execution. + if context.interpreter? + context.schema.after_lazy(return_value) do |return_hash| + # It might be an error + if return_hash.is_a?(Hash) + return_hash[:client_mutation_id] = client_mutation_id + end + return_hash + end + else + return_value + end end class << self diff --git a/lib/graphql/schema/traversal.rb b/lib/graphql/schema/traversal.rb index 4beedaca4d..86167d9b77 100644 --- a/lib/graphql/schema/traversal.rb +++ b/lib/graphql/schema/traversal.rb @@ -21,15 +21,19 @@ class Traversal def initialize(schema, introspection: true) @schema = schema @introspection = introspection + built_in_insts = [ + GraphQL::Relay::ConnectionInstrumentation, + GraphQL::Relay::EdgesInstrumentation, + GraphQL::Relay::Mutation::Instrumentation, + ] + + if schema.query_execution_strategy != GraphQL::Execution::Interpreter + built_in_insts << GraphQL::Schema::Member::Instrumentation + end + @field_instrumenters = schema.instrumenters[:field] + - # Wrap Relay-related objects in wrappers - [ - GraphQL::Relay::ConnectionInstrumentation, - GraphQL::Relay::EdgesInstrumentation, - GraphQL::Relay::Mutation::Instrumentation, - GraphQL::Schema::Member::Instrumentation, - ] + + built_in_insts + schema.instrumenters[:field_after_built_ins] # These fields have types specified by _name_, diff --git a/lib/graphql/static_validation/rules/fields_will_merge.rb b/lib/graphql/static_validation/rules/fields_will_merge.rb index a2d72f57ca..15730d18e8 100644 --- a/lib/graphql/static_validation/rules/fields_will_merge.rb +++ b/lib/graphql/static_validation/rules/fields_will_merge.rb @@ -1,4 +1,6 @@ # frozen_string_literal: true + +# frozen_string_literal: true module GraphQL module StaticValidation module FieldsWillMerge diff --git a/lib/graphql/subscriptions.rb b/lib/graphql/subscriptions.rb index a482345651..26df123817 100644 --- a/lib/graphql/subscriptions.rb +++ b/lib/graphql/subscriptions.rb @@ -6,6 +6,7 @@ if defined?(ActionCable) require "graphql/subscriptions/action_cable_subscriptions" end +require "graphql/subscriptions/subscription_root" module GraphQL class Subscriptions diff --git a/lib/graphql/subscriptions/event.rb b/lib/graphql/subscriptions/event.rb index 52970007bb..2e7b4c38a1 100644 --- a/lib/graphql/subscriptions/event.rb +++ b/lib/graphql/subscriptions/event.rb @@ -36,11 +36,15 @@ def self.serialize(name, arguments, field, scope:) when GraphQL::Query::Arguments arguments when Hash - GraphQL::Query::LiteralInput.from_arguments( - arguments, - field, - nil, - ) + if field.is_a?(GraphQL::Schema::Field) + stringify_args(arguments) + else + GraphQL::Query::LiteralInput.from_arguments( + arguments, + field, + nil, + ) + end else raise ArgumentError, "Unexpected arguments: #{arguments}, must be Hash or GraphQL::Arguments" end @@ -48,6 +52,25 @@ def self.serialize(name, arguments, field, scope:) sorted_h = normalized_args.to_h.sort.to_h Serialize.dump_recursive([scope, name, sorted_h]) end + + class << self + private + def stringify_args(args) + case args + when Hash + next_args = {} + args.each do |k, v| + str_k = GraphQL::Schema::Member::BuildType.camelize(k.to_s) + next_args[str_k] = stringify_args(v) + end + next_args + when Array + args.map { |a| stringify_args(a) } + else + args + end + end + end end end end diff --git a/lib/graphql/subscriptions/subscription_root.rb b/lib/graphql/subscriptions/subscription_root.rb new file mode 100644 index 0000000000..1c9d5df2d1 --- /dev/null +++ b/lib/graphql/subscriptions/subscription_root.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module GraphQL + class Subscriptions + # Extend this module in your subscription root when using {GraphQL::Execution::Interpreter}. + module SubscriptionRoot + def field(*args, extensions: [], **rest, &block) + extensions += [Extension] + super(*args, extensions: extensions, **rest, &block) + end + + class Extension < GraphQL::Schema::FieldExtension + def after_resolve(value:, context:, object:, arguments:, **rest) + if value.is_a?(GraphQL::ExecutionError) + value + elsif (events = context.namespace(:subscriptions)[:events]) + # This is the first execution, so gather an Event + # for the backend to register: + events << Subscriptions::Event.new( + name: field.name, + arguments: arguments, + context: context, + field: field, + ) + context.skip + elsif context.query.subscription_topic == Subscriptions::Event.serialize( + field.name, + arguments, + field, + scope: (field.subscription_scope ? context[field.subscription_scope] : nil), + ) + # The root object is _already_ the subscription update, + # it was passed to `.trigger` + object.object + else + # This is a subscription update, but this event wasn't triggered. + context.skip + end + end + end + end + end +end diff --git a/lib/graphql/tracing.rb b/lib/graphql/tracing.rb index 10770bfef7..6c29031fb7 100644 --- a/lib/graphql/tracing.rb +++ b/lib/graphql/tracing.rb @@ -47,8 +47,8 @@ module GraphQL # execute_multiplex | `{ multiplex: GraphQL::Execution::Multiplex }` # execute_query | `{ query: GraphQL::Query }` # execute_query_lazy | `{ query: GraphQL::Query?, multiplex: GraphQL::Execution::Multiplex? }` - # execute_field | `{ context: GraphQL::Query::Context::FieldResolutionContext }` - # execute_field_lazy | `{ context: GraphQL::Query::Context::FieldResolutionContext }` + # execute_field | `{ context: GraphQL::Query::Context::FieldResolutionContext?, field: GraphQL::Schema::Field?, path: Array?}` + # execute_field_lazy | `{ context: GraphQL::Query::Context::FieldResolutionContext?, field: GraphQL::Schema::Field?, path: Array?}` # module Tracing # Objects may include traceable to gain a `.trace(...)` method. diff --git a/lib/graphql/tracing/appsignal_tracing.rb b/lib/graphql/tracing/appsignal_tracing.rb index 45ad5cff52..a2c8faa9ed 100644 --- a/lib/graphql/tracing/appsignal_tracing.rb +++ b/lib/graphql/tracing/appsignal_tracing.rb @@ -21,7 +21,7 @@ def platform_trace(platform_key, key, data) end def platform_field_key(type, field) - "#{type.name}.#{field.name}.graphql" + "#{type.graphql_name}.#{field.graphql_name}.graphql" end end end diff --git a/lib/graphql/tracing/data_dog_tracing.rb b/lib/graphql/tracing/data_dog_tracing.rb index f6319c6b61..5584d80eea 100644 --- a/lib/graphql/tracing/data_dog_tracing.rb +++ b/lib/graphql/tracing/data_dog_tracing.rb @@ -42,7 +42,7 @@ def tracer end def platform_field_key(type, field) - "#{type.name}.#{field.name}" + "#{type.graphql_name}.#{field.graphql_name}" end end end diff --git a/lib/graphql/tracing/new_relic_tracing.rb b/lib/graphql/tracing/new_relic_tracing.rb index dc3b1f4d1c..06d7ccb9b2 100644 --- a/lib/graphql/tracing/new_relic_tracing.rb +++ b/lib/graphql/tracing/new_relic_tracing.rb @@ -47,7 +47,7 @@ def platform_trace(platform_key, key, data) end def platform_field_key(type, field) - "GraphQL/#{type.name}/#{field.name}" + "GraphQL/#{type.graphql_name}/#{field.graphql_name}" end end end diff --git a/lib/graphql/tracing/platform_tracing.rb b/lib/graphql/tracing/platform_tracing.rb index be8427b938..33843102c7 100644 --- a/lib/graphql/tracing/platform_tracing.rb +++ b/lib/graphql/tracing/platform_tracing.rb @@ -26,7 +26,23 @@ def trace(key, data) yield end when "execute_field", "execute_field_lazy" - if (platform_key = data[:context].field.metadata[:platform_key]) + if data[:context] + field = data[:context].field + platform_key = field.metadata[:platform_key] + trace_field = true # implemented with instrumenter + else + field = data[:field] + # Lots of duplicated work here, can this be done ahead of time? + platform_key = platform_field_key(field.owner, field) + return_type = field.type.unwrap + trace_field = if return_type.kind.scalar? || return_type.kind.enum? + (field.trace.nil? && @trace_scalars) || field.trace + else + true + end + end + + if platform_key && trace_field platform_trace(platform_key, key, data) do yield end diff --git a/lib/graphql/tracing/prometheus_tracing.rb b/lib/graphql/tracing/prometheus_tracing.rb index c3de0c5f2d..ca066917c5 100644 --- a/lib/graphql/tracing/prometheus_tracing.rb +++ b/lib/graphql/tracing/prometheus_tracing.rb @@ -33,7 +33,7 @@ def platform_trace(platform_key, key, data, &block) end def platform_field_key(type, field) - "#{type.name}.#{field.name}" + "#{type.graphql_name}.#{field.graphql_name}" end private diff --git a/lib/graphql/tracing/scout_tracing.rb b/lib/graphql/tracing/scout_tracing.rb index fbad688505..93e5372359 100644 --- a/lib/graphql/tracing/scout_tracing.rb +++ b/lib/graphql/tracing/scout_tracing.rb @@ -28,7 +28,7 @@ def platform_trace(platform_key, key, data) end def platform_field_key(type, field) - "#{type.name}.#{field.name}" + "#{type.graphql_name}.#{field.graphql_name}" end end end diff --git a/lib/graphql/tracing/skylight_tracing.rb b/lib/graphql/tracing/skylight_tracing.rb index 6336372a39..cc67e2d50f 100644 --- a/lib/graphql/tracing/skylight_tracing.rb +++ b/lib/graphql/tracing/skylight_tracing.rb @@ -54,7 +54,7 @@ def platform_trace(platform_key, key, data) end def platform_field_key(type, field) - "graphql.#{type.name}.#{field.name}" + "graphql.#{type.graphql_name}.#{field.graphql_name}" end end end diff --git a/lib/graphql/types/relay.rb b/lib/graphql/types/relay.rb index bdca586e42..2f962d780c 100644 --- a/lib/graphql/types/relay.rb +++ b/lib/graphql/types/relay.rb @@ -6,6 +6,8 @@ require "graphql/types/relay/base_connection" require "graphql/types/relay/base_edge" require "graphql/types/relay/node" +require "graphql/types/relay/node_field" +require "graphql/types/relay/nodes_field" module GraphQL module Types diff --git a/lib/graphql/types/relay/base_connection.rb b/lib/graphql/types/relay/base_connection.rb index 3f259dd68f..3a264af7f3 100644 --- a/lib/graphql/types/relay/base_connection.rb +++ b/lib/graphql/types/relay/base_connection.rb @@ -35,6 +35,9 @@ class << self # @return [Class] attr_reader :node_type + # @return [Class] + attr_reader :edge_class + # Configure this connection to return `edges` and `nodes` based on `edge_type_class`. # # This method will use the inputs to create: @@ -51,11 +54,11 @@ def edge_type(edge_type_class, edge_class: GraphQL::Relay::Edge, node_type: edge @node_type = node_type @edge_type = edge_type_class + @edge_class = edge_class field :edges, [edge_type_class, null: true], null: true, description: "A list of edges.", - method: :edge_nodes, edge_class: edge_class define_nodes_field if nodes_field @@ -101,6 +104,17 @@ def define_nodes_field def nodes @object.edge_nodes end + + def edges + if context.interpreter? + context.schema.after_lazy(object.edge_nodes) do |nodes| + nodes.map { |n| self.class.edge_class.new(n, object) } + end + else + # This is done by edges_instrumentation + @object.edge_nodes + end + end end end end diff --git a/lib/graphql/types/relay/node.rb b/lib/graphql/types/relay/node.rb index ad7b0e2b86..b0a638e976 100644 --- a/lib/graphql/types/relay/node.rb +++ b/lib/graphql/types/relay/node.rb @@ -11,7 +11,6 @@ module Node default_relay(true) description "An object with an ID." field(:id, ID, null: false, description: "ID of the object.") - # TODO Should I implement `id` here to call the schema's hook? end end end diff --git a/lib/graphql/types/relay/node_field.rb b/lib/graphql/types/relay/node_field.rb new file mode 100644 index 0000000000..a0a109a00c --- /dev/null +++ b/lib/graphql/types/relay/node_field.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module GraphQL + module Types + module Relay + # This can be used for implementing `Query.node(id: ...)`, + # or use it for inspiration for your own field definition. + NodeField = GraphQL::Schema::Field.new( + name: "node", + owner: nil, + type: GraphQL::Types::Relay::Node, + null: true, + description: "Fetches an object given its ID.", + relay_node_field: true, + ) do + argument :id, "ID!", required: true, + description: "ID of the object." + + def resolve(obj, args, ctx) + ctx.schema.object_from_id(args[:id], ctx) + end + + def resolve_field(obj, args, ctx) + resolve(obj, args, ctx) + end + end + end + end +end diff --git a/lib/graphql/types/relay/nodes_field.rb b/lib/graphql/types/relay/nodes_field.rb new file mode 100644 index 0000000000..899adadbee --- /dev/null +++ b/lib/graphql/types/relay/nodes_field.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module GraphQL + module Types + module Relay + # This can be used for implementing `Query.nodes(ids: ...)`, + # or use it for inspiration for your own field definition. + # @see GraphQL::Types::Relay::NodeField + NodesField = GraphQL::Schema::Field.new( + name: "nodes", + owner: nil, + type: [GraphQL::Types::Relay::Node, null: true], + null: false, + description: "Fetches a list of objects given a list of IDs.", + relay_nodes_field: true, + ) do + argument :ids, "[ID!]!", required: true, + description: "IDs of the objects." + + def resolve(obj, args, ctx) + args[:ids].map { |id| ctx.schema.object_from_id(id, ctx) } + end + + def resolve_field(obj, args, ctx) + resolve(obj, args, ctx) + end + end + end + end +end diff --git a/lib/graphql/unauthorized_error.rb b/lib/graphql/unauthorized_error.rb index 5d158c33a1..e48bbe34ef 100644 --- a/lib/graphql/unauthorized_error.rb +++ b/lib/graphql/unauthorized_error.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true module GraphQL + # When an `authorized?` hook returns false, this error is used to communicate the failure. + # It's passed to {Schema.unauthorized_object}. + # + # Alternatively, custom code in `authorized?` may raise this error. It will be routed the same way. class UnauthorizedError < GraphQL::Error # @return [Object] the application object that failed the authorization check attr_reader :object diff --git a/spec/dummy/app/channels/graphql_channel.rb b/spec/dummy/app/channels/graphql_channel.rb index 17f0572412..0463d2def0 100644 --- a/spec/dummy/app/channels/graphql_channel.rb +++ b/spec/dummy/app/channels/graphql_channel.rb @@ -1,20 +1,28 @@ # frozen_string_literal: true class GraphqlChannel < ActionCable::Channel::Base - QueryType = GraphQL::ObjectType.define do - name "Query" - field :value, types.Int, resolve: Proc.new { 3 } + class QueryType < GraphQL::Schema::Object + field :value, Integer, null: false + def value + 3 + end end - SubscriptionType = GraphQL::ObjectType.define do - name "Subscription" - field :payload, PayloadType do - argument :id, !types.ID - end + class PayloadType < GraphQL::Schema::Object + field :value, Integer, null: false end - PayloadType = GraphQL::ObjectType.define do - name "Payload" - field :value, types.Int + class SubscriptionType < GraphQL::Schema::Object + if TESTING_INTERPRETER + extend GraphQL::Subscriptions::SubscriptionRoot + end + + field :payload, PayloadType, null: false do + argument :id, ID, required: true + end + + def payload(id:) + id + end end # Wacky behavior around the number 4 @@ -42,6 +50,9 @@ class GraphQLSchema < GraphQL::Schema subscription(SubscriptionType) use GraphQL::Subscriptions::ActionCableSubscriptions, serializer: CustomSerializer + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end def subscribed diff --git a/spec/dummy/test/test_helper.rb b/spec/dummy/test/test_helper.rb index 4ccb83c0f4..29193f7185 100644 --- a/spec/dummy/test/test_helper.rb +++ b/spec/dummy/test/test_helper.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true +TESTING_INTERPRETER = !!ENV["TESTING_INTERPRETER"] require File.expand_path('../../config/environment', __FILE__) require 'rails/test_help' diff --git a/spec/graphql/authorization_spec.rb b/spec/graphql/authorization_spec.rb index dfc983eed8..5e708dfcde 100644 --- a/spec/graphql/authorization_spec.rb +++ b/spec/graphql/authorization_spec.rb @@ -369,6 +369,9 @@ class Mutation < BaseObject end class Schema < GraphQL::Schema + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end query(Query) mutation(Mutation) @@ -598,7 +601,7 @@ def auth_execute(*args) end describe "applying the authorized? method" do - it "halts on unauthorized objects" do + it "halts on unauthorized objects, replacing the object with nil" do query = "{ unauthorizedObject { __typename } }" hidden_response = auth_execute(query, context: { hide: true }) assert_nil hidden_response["data"].fetch("unauthorizedObject") @@ -658,7 +661,25 @@ def auth_execute(*args) unauthorized_res = auth_execute(query, context: { unauthorized_relay: true }) conn = unauthorized_res["data"].fetch("unauthorizedConnection") assert_equal "RelayObjectConnection", conn.fetch("__typename") - assert_equal nil, conn.fetch("nodes") + # This is tricky: the previous behavior was to replace the _whole_ + # list with `nil`. This was due to an implementation detail: + # The list field's return value (an array of integers) was wrapped + # _before_ returning, and during this wrapping, a cascading error + # caused the entire field to be nilled out. + # + # In the interpreter, each list item is contained and the error doesn't propagate + # up to the whole list. + # + # Originally, I thought that this was a _feature_ that obscured list entries. + # But really, look at the test below: you don't get this "feature" if + # you use `edges { node }`, so it can't be relied on in any way. + # + # All that to say, in the interpreter, `nodes` and `edges { node }` behave + # the same. + # + # TODO revisit the docs for this. + failed_nodes_value = TESTING_INTERPRETER ? [nil] : nil + assert_equal failed_nodes_value, conn.fetch("nodes") assert_equal [{"node" => nil, "__typename" => "RelayObjectEdge"}], conn.fetch("edges") edge = unauthorized_res["data"].fetch("unauthorizedEdge") @@ -667,7 +688,7 @@ def auth_execute(*args) unauthorized_object_paths = [ ["unauthorizedConnection", "edges", 0, "node"], - ["unauthorizedConnection", "nodes"], + TESTING_INTERPRETER ? ["unauthorizedConnection", "nodes", 0] : ["unauthorizedConnection", "nodes"], ["unauthorizedEdge", "node"] ] diff --git a/spec/graphql/execution/execute_spec.rb b/spec/graphql/execution/execute_spec.rb index 558a3dabdb..ae504697d2 100644 --- a/spec/graphql/execution/execute_spec.rb +++ b/spec/graphql/execution/execute_spec.rb @@ -280,20 +280,20 @@ def ints field_3_lazy, field_4_lazy, query_lazy, multiplex = exec_traces - assert_equal ["b1"], field_1_eager[:context].path - assert_equal ["b2"], field_2_eager[:context].path + assert_equal ["b1"], field_1_eager[:path] + assert_equal ["b2"], field_2_eager[:path] assert_instance_of GraphQL::Query, query_eager[:query] assert_equal [first_id.to_s, last_id.to_s], lazy_loader[:ids] assert_equal StarWars::Base, lazy_loader[:model] - assert_equal ["b1", "name"], field_3_eager[:context].path - assert_equal ["b1"], field_1_lazy[:context].path - assert_equal ["b2", "name"], field_4_eager[:context].path - assert_equal ["b2"], field_2_lazy[:context].path + assert_equal ["b1", "name"], field_3_eager[:path] + assert_equal ["b1"], field_1_lazy[:path] + assert_equal ["b2", "name"], field_4_eager[:path] + assert_equal ["b2"], field_2_lazy[:path] - assert_equal ["b1", "name"], field_3_lazy[:context].path - assert_equal ["b2", "name"], field_4_lazy[:context].path + assert_equal ["b1", "name"], field_3_lazy[:path] + assert_equal ["b2", "name"], field_4_lazy[:path] assert_instance_of GraphQL::Query, query_lazy[:query] assert_instance_of GraphQL::Execution::Multiplex, multiplex[:multiplex] diff --git a/spec/graphql/execution/interpreter_spec.rb b/spec/graphql/execution/interpreter_spec.rb new file mode 100644 index 0000000000..d09ba58146 --- /dev/null +++ b/spec/graphql/execution/interpreter_spec.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true +require "spec_helper" + +describe GraphQL::Execution::Interpreter do + module InterpreterTest + class Box + attr_reader :value + + def initialize(value:) + @value = value + end + end + + class Expansion < GraphQL::Schema::Object + field :sym, String, null: false + field :lazy_sym, String, null: false + field :name, String, null: false + field :cards, ["InterpreterTest::Card"], null: false + + def cards + Query::CARDS.select { |c| c.expansion_sym == @object.sym } + end + + def lazy_sym + Box.new(value: sym) + end + end + + class Card < GraphQL::Schema::Object + field :name, String, null: false + field :colors, "[InterpreterTest::Color]", null: false + field :expansion, Expansion, null: false + + def expansion + Query::EXPANSIONS.find { |e| e.sym == @object.expansion_sym } + end + end + + class Color < GraphQL::Schema::Enum + value "WHITE" + value "BLUE" + value "BLACK" + value "RED" + value "GREEN" + end + + class Entity < GraphQL::Schema::Union + possible_types Card, Expansion + + def self.resolve_type(obj, ctx) + obj.sym ? Expansion : Card + end + end + + class FieldCounter < GraphQL::Schema::Object + field :field_counter, FieldCounter, null: false + def field_counter; :field_counter; end + + field :calls, Integer, null: false do + argument :expected, Integer, required: true + end + def calls(expected:) + c = context[:calls] += 1 + if c != expected + raise "Expected #{expected} calls but had #{c} so far" + else + c + end + end + end + + class Query < GraphQL::Schema::Object + field :card, Card, null: true do + argument :name, String, required: true + end + + def card(name:) + Box.new(value: CARDS.find { |c| c.name == name }) + end + + field :expansion, Expansion, null: true do + argument :sym, String, required: true + end + + def expansion(sym:) + EXPANSIONS.find { |e| e.sym == sym } + end + + field :expansions, [Expansion], null: false + def expansions + EXPANSIONS + end + + CARDS = [ + OpenStruct.new(name: "Dark Confidant", colors: ["BLACK"], expansion_sym: "RAV"), + ] + + EXPANSIONS = [ + OpenStruct.new(name: "Ravnica, City of Guilds", sym: "RAV"), + # This data has an error, for testing null propagation + OpenStruct.new(name: nil, sym: "XYZ"), + ] + + field :find, [Entity], null: false do + argument :id, [ID], required: true + end + + def find(id:) + id.map do |ent_id| + Query::EXPANSIONS.find { |e| e.sym == ent_id } || + Query::CARDS.find { |c| c.name == ent_id } + end + end + + field :field_counter, FieldCounter, null: false + def field_counter; :field_counter; end + end + + class Schema < GraphQL::Schema + use GraphQL::Execution::Interpreter + query(Query) + lazy_resolve(Box, :value) + end + end + + it "runs a query" do + query_string = <<-GRAPHQL + query($expansion: String!, $id1: ID!, $id2: ID!){ + card(name: "Dark Confidant") { + colors + expansion { + ... { + name + } + cards { + name + } + } + } + expansion(sym: $expansion) { + ... ExpansionFields + } + find(id: [$id1, $id2]) { + __typename + ... on Card { + name + } + ... on Expansion { + sym + } + } + } + + fragment ExpansionFields on Expansion { + cards { + name + } + } + GRAPHQL + + vars = {expansion: "RAV", id1: "Dark Confidant", id2: "RAV"} + result = InterpreterTest::Schema.execute(query_string, variables: vars) + assert_equal ["BLACK"], result["data"]["card"]["colors"] + assert_equal "Ravnica, City of Guilds", result["data"]["card"]["expansion"]["name"] + assert_equal [{"name" => "Dark Confidant"}], result["data"]["card"]["expansion"]["cards"] + assert_equal [{"name" => "Dark Confidant"}], result["data"]["expansion"]["cards"] + expected_abstract_list = [ + {"__typename" => "Card", "name" => "Dark Confidant"}, + {"__typename" => "Expansion", "sym" => "RAV"}, + ] + assert_equal expected_abstract_list, result["data"]["find"] + end + + it "runs skip and include" do + query_str = <<-GRAPHQL + query($truthy: Boolean!, $falsey: Boolean!){ + exp1: expansion(sym: "RAV") @skip(if: true) { name } + exp2: expansion(sym: "RAV") @skip(if: false) { name } + exp3: expansion(sym: "RAV") @include(if: true) { name } + exp4: expansion(sym: "RAV") @include(if: false) { name } + exp5: expansion(sym: "RAV") @include(if: $truthy) { name } + exp6: expansion(sym: "RAV") @include(if: $falsey) { name } + } + GRAPHQL + + vars = {truthy: true, falsey: false} + result = InterpreterTest::Schema.execute(query_str, variables: vars) + expected_data = { + "exp2" => {"name" => "Ravnica, City of Guilds"}, + "exp3" => {"name" => "Ravnica, City of Guilds"}, + "exp5" => {"name" => "Ravnica, City of Guilds"}, + } + assert_equal expected_data, result["data"] + end + + describe "temporary interpreter flag" do + it "is set" do + # This can be removed later, just a sanity check during migration + res = InterpreterTest::Schema.execute("{ __typename }") + assert_equal true, res.context.interpreter? + end + end + + describe "CI setup" do + it "sets interpreter based on a constant" do + if TESTING_INTERPRETER + assert_equal GraphQL::Execution::Interpreter, Jazz::Schema.query_execution_strategy + assert_equal GraphQL::Execution::Interpreter, Dummy::Schema.query_execution_strategy + else + refute_equal GraphQL::Execution::Interpreter, Jazz::Schema.query_execution_strategy + refute_equal GraphQL::Execution::Interpreter, Dummy::Schema.query_execution_strategy + end + end + end + describe "null propagation" do + it "propagates nulls" do + query_str = <<-GRAPHQL + { + expansion(sym: "XYZ") { + name + sym + lazySym + } + } + GRAPHQL + + res = InterpreterTest::Schema.execute(query_str) + # Although the expansion was found, its name of `nil` + # propagated to here + assert_nil res["data"].fetch("expansion") + end + + it "propagates nulls in lists" do + query_str = <<-GRAPHQL + { + expansions { + name + sym + lazySym + } + } + GRAPHQL + + res = InterpreterTest::Schema.execute(query_str) + # A null in one of the list items removed the whole list + assert_nil(res["data"]) + end + end + + describe "duplicated fields" do + it "doesn't run them multiple times" do + query_str = <<-GRAPHQL + { + fieldCounter { + calls(expected: 1) + # This should not be called since it matches the above + calls(expected: 1) + fieldCounter { + calls(expected: 2) + } + ...ExtraFields + } + } + fragment ExtraFields on FieldCounter { + fieldCounter { + # This should not be called since it matches the inline field: + calls(expected: 2) + # This _should_ be called + c3: calls(expected: 3) + } + } + GRAPHQL + + # It will raise an error if it doesn't match the expectation + res = InterpreterTest::Schema.execute(query_str, context: { calls: 0 }) + assert_equal 3, res["data"]["fieldCounter"]["fieldCounter"]["c3"] + end + end +end diff --git a/spec/graphql/execution/multiplex_spec.rb b/spec/graphql/execution/multiplex_spec.rb index dd42f7e57b..65db926ede 100644 --- a/spec/graphql/execution/multiplex_spec.rb +++ b/spec/graphql/execution/multiplex_spec.rb @@ -105,7 +105,7 @@ def multiplex(*a) {query: q3}, {query: q4}, ]) - assert_equal expected_res, res + assert_equal expected_res, res.map(&:to_h) end end diff --git a/spec/graphql/execution_error_spec.rb b/spec/graphql/execution_error_spec.rb index afd7ff6469..7c7e5135ef 100644 --- a/spec/graphql/execution_error_spec.rb +++ b/spec/graphql/execution_error_spec.rb @@ -3,8 +3,9 @@ describe GraphQL::ExecutionError do let(:result) { Dummy::Schema.execute(query_string) } - describe "when returned from a field" do - let(:query_string) {%| + if TESTING_RESCUE_FROM + describe "when returned from a field" do + let(:query_string) {%| { cheese(id: 1) { id @@ -51,93 +52,94 @@ fragment similarCheeseFields on Cheese { id, flavor } - |} - it "the error is inserted into the errors key and the rest of the query is fulfilled" do - expected_result = { - "data"=>{ - "cheese"=>{ - "id" => 1, - "error1"=> nil, - "error2"=> nil, - "nonError"=> { - "id" => 3, - "flavor" => "Manchego", - }, - "flavor" => "Brie", + |} + it "the error is inserted into the errors key and the rest of the query is fulfilled" do + expected_result = { + "data"=>{ + "cheese"=>{ + "id" => 1, + "error1"=> nil, + "error2"=> nil, + "nonError"=> { + "id" => 3, + "flavor" => "Manchego", + }, + "flavor" => "Brie", + }, + "allDairy" => [ + { "flavor" => "Brie" }, + { "flavor" => "Gouda" }, + { "flavor" => "Manchego" }, + { "source" => "COW", "executionError" => nil } + ], + "dairyErrors" => [ + { "__typename" => "Cheese" }, + nil, + { "__typename" => "Cheese" }, + { "__typename" => "Milk" } + ], + "dairy" => { + "milks" => [ + { + "source" => "COW", + "executionError" => nil, + "allDairy" => [ + { "__typename" => "Cheese" }, + { "__typename" => "Cheese" }, + { "__typename" => "Cheese" }, + { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil } + ] + } + ] + }, + "executionError" => nil, + "valueWithExecutionError" => 0 }, - "allDairy" => [ - { "flavor" => "Brie" }, - { "flavor" => "Gouda" }, - { "flavor" => "Manchego" }, - { "source" => "COW", "executionError" => nil } - ], - "dairyErrors" => [ - { "__typename" => "Cheese" }, - nil, - { "__typename" => "Cheese" }, - { "__typename" => "Milk" } - ], - "dairy" => { - "milks" => [ - { - "source" => "COW", - "executionError" => nil, - "allDairy" => [ - { "__typename" => "Cheese" }, - { "__typename" => "Cheese" }, - { "__typename" => "Cheese" }, - { "__typename" => "Milk", "origin" => "Antiquity", "executionError" => nil } - ] - } - ] - }, - "executionError" => nil, - "valueWithExecutionError" => 0 - }, - "errors"=>[ - { - "message"=>"No cheeses are made from Yak milk!", - "locations"=>[{"line"=>5, "column"=>9}], - "path"=>["cheese", "error1"] - }, - { - "message"=>"No cheeses are made from Yak milk!", - "locations"=>[{"line"=>8, "column"=>9}], - "path"=>["cheese", "error2"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>22, "column"=>11}], - "path"=>["allDairy", 3, "executionError"] - }, - { - "message"=>"missing dairy", - "locations"=>[{"line"=>25, "column"=>7}], - "path"=>["dairyErrors", 1] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>31, "column"=>11}], - "path"=>["dairy", "milks", 0, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>36, "column"=>15}], - "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] - }, - { - "message"=>"There was an execution error", - "locations"=>[{"line"=>41, "column"=>7}], - "path"=>["executionError"] - }, - { - "message"=>"Could not fetch latest value", - "locations"=>[{"line"=>42, "column"=>7}], - "path"=>["valueWithExecutionError"] - }, - ] - } - assert_equal(expected_result, result.to_h) + "errors"=>[ + { + "message"=>"No cheeses are made from Yak milk!", + "locations"=>[{"line"=>5, "column"=>9}], + "path"=>["cheese", "error1"] + }, + { + "message"=>"No cheeses are made from Yak milk!", + "locations"=>[{"line"=>8, "column"=>9}], + "path"=>["cheese", "error2"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>22, "column"=>11}], + "path"=>["allDairy", 3, "executionError"] + }, + { + "message"=>"missing dairy", + "locations"=>[{"line"=>25, "column"=>7}], + "path"=>["dairyErrors", 1] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>31, "column"=>11}], + "path"=>["dairy", "milks", 0, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>36, "column"=>15}], + "path"=>["dairy", "milks", 0, "allDairy", 3, "executionError"] + }, + { + "message"=>"There was an execution error", + "locations"=>[{"line"=>41, "column"=>7}], + "path"=>["executionError"] + }, + { + "message"=>"Could not fetch latest value", + "locations"=>[{"line"=>42, "column"=>7}], + "path"=>["valueWithExecutionError"] + }, + ] + } + assert_equal(expected_result, result.to_h) + end end end @@ -294,15 +296,27 @@ describe "more than one ExecutionError" do let(:query_string) { %|{ multipleErrorsOnNonNullableField} |} it "the errors are inserted into the errors key and the data is nil even for a NonNullable field " do + + # I Think the path here is _wrong_, since this is not an array field: + # expected_result = { + # "data"=>nil, + # "errors"=> + # [{"message"=>"This is an error message for some error.", + # "locations"=>[{"line"=>1, "column"=>3}], + # "path"=>["multipleErrorsOnNonNullableField", 0]}, + # {"message"=>"This is another error message for a different error.", + # "locations"=>[{"line"=>1, "column"=>3}], + # "path"=>["multipleErrorsOnNonNullableField", 1]}] + # } expected_result = { - "data"=>nil, - "errors"=> - [{"message"=>"This is an error message for some error.", - "locations"=>[{"line"=>1, "column"=>3}], - "path"=>["multipleErrorsOnNonNullableField", 0]}, - {"message"=>"This is another error message for a different error.", - "locations"=>[{"line"=>1, "column"=>3}], - "path"=>["multipleErrorsOnNonNullableField", 1]}] + "data"=>nil, + "errors"=> + [{"message"=>"This is an error message for some error.", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["multipleErrorsOnNonNullableField"]}, + {"message"=>"This is another error message for a different error.", + "locations"=>[{"line"=>1, "column"=>3}], + "path"=>["multipleErrorsOnNonNullableField"]}], } assert_equal(expected_result, result) end diff --git a/spec/graphql/introspection/type_type_spec.rb b/spec/graphql/introspection/type_type_spec.rb index d85dfc16aa..b50357ab61 100644 --- a/spec/graphql/introspection/type_type_spec.rb +++ b/spec/graphql/introspection/type_type_spec.rb @@ -144,7 +144,6 @@ } } GRAPHQL - type_result = res["data"]["__schema"]["types"].find { |t| t["name"] == "Faction" } field_result = type_result["fields"].find { |f| f["name"] == "bases" } all_arg_names = ["after", "before", "first", "last", "nameIncludes"] diff --git a/spec/graphql/language/visitor_spec.rb b/spec/graphql/language/visitor_spec.rb index e5bf69a36c..7ea4a680b4 100644 --- a/spec/graphql/language/visitor_spec.rb +++ b/spec/graphql/language/visitor_spec.rb @@ -184,6 +184,25 @@ def on_directive(node, parent) super end end + + def on_inline_fragment(node, parent) + if node.selections.map(&:name) == ["renameFragmentField", "spread"] + _field, spread = node.selections + new_node = node.merge(selections: [GraphQL::Language::Nodes::Field.new(name: "renamed"), spread]) + super(new_node, parent) + else + super(node, parent) + end + end + + def on_fragment_spread(node, parent) + if node.name == "spread" + new_node = node.merge(name: "renamedSpread") + super(new_node, parent) + else + super(node, parent) + end + end end def get_result(query_str) @@ -298,6 +317,30 @@ def get_result(query_str) } anotherAddition(addedArgument: 1) @doStuff(addedArgument2: 2) } +GRAPHQL + + document, new_document = get_result(before_query) + assert_equal before_query, document.to_query_string + assert_equal after_query, new_document.to_query_string + end + + it "can modify inline fragments" do + before_query = <<-GRAPHQL.chop +query { + ... on Query { + renameFragmentField + ...spread + } +} +GRAPHQL + + after_query = <<-GRAPHQL.chop +query { + ... on Query { + renamed + ...renamedSpread + } +} GRAPHQL document, new_document = get_result(before_query) diff --git a/spec/graphql/non_null_type_spec.rb b/spec/graphql/non_null_type_spec.rb index e80ec6b5d1..ba823cad49 100644 --- a/spec/graphql/non_null_type_spec.rb +++ b/spec/graphql/non_null_type_spec.rb @@ -39,7 +39,7 @@ query_string = %|{ cow { name cantBeNullButIs } }| err = assert_raises(GraphQL::InvalidNullError) { raise_schema.execute(query_string) } assert_equal("Cannot return null for non-nullable field Cow.cantBeNullButIs", err.message) - assert_equal("Cow", err.parent_type.name) + assert_equal("Cow", err.parent_type.graphql_name) assert_equal("cantBeNullButIs", err.field.name) assert_equal(nil, err.value) end diff --git a/spec/graphql/query/executor_spec.rb b/spec/graphql/query/executor_spec.rb index 7a0202e5f0..5d73b4cc60 100644 --- a/spec/graphql/query/executor_spec.rb +++ b/spec/graphql/query/executor_spec.rb @@ -186,29 +186,31 @@ end end - describe "if the schema has a rescue handler" do - before do - # HACK: reach to the underlying instance to perform a side-effect - schema.graphql_definition.rescue_from(RuntimeError) { "Error was handled!" } - end + if TESTING_RESCUE_FROM + describe "if the schema has a rescue handler" do + before do + # HACK: reach to the underlying instance to perform a side-effect + schema.graphql_definition.rescue_from(RuntimeError) { "Error was handled!" } + end - after do - # remove the handler from the middleware: - schema.remove_handler(RuntimeError) - end + after do + # remove the handler from the middleware: + schema.remove_handler(RuntimeError) + end - it "adds to the errors key" do - expected = { - "data" => {"error" => nil}, - "errors"=>[ - { - "message"=>"Error was handled!", - "locations" => [{"line"=>1, "column"=>17}], - "path"=>["error"] - } - ] - } - assert_equal(expected, result) + it "adds to the errors key" do + expected = { + "data" => {"error" => nil}, + "errors"=>[ + { + "message"=>"Error was handled!", + "locations" => [{"line"=>1, "column"=>17}], + "path"=>["error"] + } + ] + } + assert_equal(expected, result) + end end end end diff --git a/spec/graphql/schema/argument_spec.rb b/spec/graphql/schema/argument_spec.rb index 7c0947cb38..43a7305cc9 100644 --- a/spec/graphql/schema/argument_spec.rb +++ b/spec/graphql/schema/argument_spec.rb @@ -35,6 +35,9 @@ def multiply(val) class Schema < GraphQL::Schema query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/schema/catchall_middleware_spec.rb b/spec/graphql/schema/catchall_middleware_spec.rb index 9b5c508967..8449cc62ed 100644 --- a/spec/graphql/schema/catchall_middleware_spec.rb +++ b/spec/graphql/schema/catchall_middleware_spec.rb @@ -13,22 +13,23 @@ Dummy::Schema.middleware.delete(GraphQL::Schema::CatchallMiddleware) end - describe "rescuing errors" do - let(:errors) { query.context.errors } + if TESTING_RESCUE_FROM + describe "rescuing errors" do + let(:errors) { query.context.errors } - it "turns into error messages" do - expected = { - "data" => { "error" => nil }, - "errors"=> [ - { - "message"=>"Internal error", - "locations"=>[{"line"=>1, "column"=>17}], - "path"=>["error"] - }, - ] - } - assert_equal(expected, result) + it "turns into error messages" do + expected = { + "data" => { "error" => nil }, + "errors"=> [ + { + "message"=>"Internal error", + "locations"=>[{"line"=>1, "column"=>17}], + "path"=>["error"] + }, + ] + } + assert_equal(expected, result) + end end end - end diff --git a/spec/graphql/schema/field_extension_spec.rb b/spec/graphql/schema/field_extension_spec.rb index d96c571078..c70db03e25 100644 --- a/spec/graphql/schema/field_extension_spec.rb +++ b/spec/graphql/schema/field_extension_spec.rb @@ -55,6 +55,9 @@ def pass_thru(input:) class Schema < GraphQL::Schema query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/schema/input_object_spec.rb b/spec/graphql/schema/input_object_spec.rb index 2bba23c91a..da3c56c0d4 100644 --- a/spec/graphql/schema/input_object_spec.rb +++ b/spec/graphql/schema/input_object_spec.rb @@ -81,6 +81,9 @@ def inputs(input:) class Schema < GraphQL::Schema query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/schema/instrumentation_spec.rb b/spec/graphql/schema/instrumentation_spec.rb index 9c435fdb77..0e719cb859 100644 --- a/spec/graphql/schema/instrumentation_spec.rb +++ b/spec/graphql/schema/instrumentation_spec.rb @@ -26,6 +26,9 @@ def some_field class Schema < GraphQL::Schema query Query orphan_types [SomeType] + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/schema/introspection_system_spec.rb b/spec/graphql/schema/introspection_system_spec.rb index d2c3fbb9c6..d5f7e03b36 100644 --- a/spec/graphql/schema/introspection_system_spec.rb +++ b/spec/graphql/schema/introspection_system_spec.rb @@ -13,7 +13,7 @@ assert_equal "ENSEMBLE", res["data"]["__type"]["name"] end - it "serves custom entry points" do + it "serves custom entry points" do res = Jazz::Schema.execute("{ __classname }", root_value: Set.new) assert_equal "Set", res["data"]["__classname"] end @@ -35,5 +35,13 @@ res = Dummy::Schema.execute("{ ensembles { __typenameLength } }") assert_equal 1, res["errors"].length end + + it "runs the introspection query" do + res = Jazz::Schema.execute(GraphQL::Introspection::INTROSPECTION_QUERY) + assert res + query_type = res["data"]["__schema"]["types"].find { |t| t["name"] == "QUERY" } + ensembles_field = query_type["fields"].find { |f| f["name"] == "ensembles" } + assert_equal [], ensembles_field["args"] + end end end diff --git a/spec/graphql/schema/member/accepts_definition_spec.rb b/spec/graphql/schema/member/accepts_definition_spec.rb index 917e43bf18..a80ddc4c57 100644 --- a/spec/graphql/schema/member/accepts_definition_spec.rb +++ b/spec/graphql/schema/member/accepts_definition_spec.rb @@ -6,6 +6,10 @@ class AcceptsDefinitionSchema < GraphQL::Schema accepts_definition :set_metadata set_metadata :a, 999 + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end + class BaseField < GraphQL::Schema::Field class BaseField < GraphQL::Schema::Argument accepts_definition :metadata diff --git a/spec/graphql/schema/member/has_fields_spec.rb b/spec/graphql/schema/member/has_fields_spec.rb index a67906efc7..5bf5d70007 100644 --- a/spec/graphql/schema/member/has_fields_spec.rb +++ b/spec/graphql/schema/member/has_fields_spec.rb @@ -79,6 +79,9 @@ def int class Schema < GraphQL::Schema query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/schema/member/scoped_spec.rb b/spec/graphql/schema/member/scoped_spec.rb index cd339934e1..efa5f5babe 100644 --- a/spec/graphql/schema/member/scoped_spec.rb +++ b/spec/graphql/schema/member/scoped_spec.rb @@ -74,6 +74,9 @@ def things end query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end describe ".scope_items(items, ctx)" do @@ -118,6 +121,19 @@ def get_item_names_with_context(ctx, field_name: "items") res = ScopeSchema.execute(query_str, context: {english: true}) names = res["data"]["itemsConnection"]["edges"].map { |e| e["node"]["name"] } assert_equal ["Paperclip"], names + + query_str = " + { + itemsConnection { + nodes { + name + } + } + } + " + res = ScopeSchema.execute(query_str, context: {english: true}) + names = res["data"]["itemsConnection"]["nodes"].map { |e| e["name"] } + assert_equal ["Paperclip"], names end it "is called for abstract types" do diff --git a/spec/graphql/schema/mutation_spec.rb b/spec/graphql/schema/mutation_spec.rb index 49d928338a..7684c170fb 100644 --- a/spec/graphql/schema/mutation_spec.rb +++ b/spec/graphql/schema/mutation_spec.rb @@ -23,7 +23,7 @@ describe "argument prepare" do it "calls methods on the mutation, uses `as:`" do - query_str = 'mutation { prepareInput(input: 4) }' + query_str = "mutation { prepareInput(input: 4) }" res = Jazz::Schema.execute(query_str) assert_equal 16, res["data"]["prepareInput"], "It's squared by the prepare method" end @@ -63,7 +63,8 @@ assert_equal(GraphQL::Schema::Object, GraphQL::Schema::Mutation.object_class) assert_equal(obj_class, mutation_class.object_class) - assert_equal(obj_class, mutation_subclass.object_class) end + assert_equal(obj_class, mutation_subclass.object_class) + end end describe ".argument_class" do @@ -101,7 +102,8 @@ response = Jazz::Schema.execute(query_str) assert_equal "Trombone", response["data"]["addInstrument"]["instrument"]["name"] assert_equal "BRASS", response["data"]["addInstrument"]["instrument"]["family"] - assert_equal "GraphQL::Query::Context::ExecutionErrors", response["data"]["addInstrument"]["ee"] + errors_class = TESTING_INTERPRETER ? "GraphQL::Execution::Interpreter::ExecutionErrors" : "GraphQL::Query::Context::ExecutionErrors" + assert_equal errors_class, response["data"]["addInstrument"]["ee"] assert_equal 7, response["data"]["addInstrument"]["entries"].size end end diff --git a/spec/graphql/schema/object_spec.rb b/spec/graphql/schema/object_spec.rb index aaac46cd1e..b7fd28546f 100644 --- a/spec/graphql/schema/object_spec.rb +++ b/spec/graphql/schema/object_spec.rb @@ -236,7 +236,9 @@ module InterfaceType it "skips fields properly" do query_str = "{ find(id: \"MagicalSkipId\") { __typename } }" res = Jazz::Schema.execute(query_str) - assert_equal({"data" => nil }, res.to_h) + # TBH I think `{}` is probably righter than `nil`, I guess we'll see. + skip_value = TESTING_INTERPRETER ? {} : nil + assert_equal({"data" => skip_value }, res.to_h) end end end diff --git a/spec/graphql/schema/resolver_spec.rb b/spec/graphql/schema/resolver_spec.rb index bdee18731a..8128c361ef 100644 --- a/spec/graphql/schema/resolver_spec.rb +++ b/spec/graphql/schema/resolver_spec.rb @@ -280,6 +280,14 @@ def resolve_field(*args) end value end + + def resolve(*) + value = super + if @name == "resolver3" + value << -1 + end + value + end end field_class(CustomField) @@ -314,6 +322,9 @@ class Schema < GraphQL::Schema query(Query) lazy_resolve LazyBlock, :value orphan_types IntegerWrapper + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/schema/warden_spec.rb b/spec/graphql/schema/warden_spec.rb index 8f6f5e3cc5..778b2c3e7d 100644 --- a/spec/graphql/schema/warden_spec.rb +++ b/spec/graphql/schema/warden_spec.rb @@ -2,20 +2,45 @@ require "spec_helper" module MaskHelpers - PhonemeType = GraphQL::ObjectType.define do - name "Phoneme" - description "A building block of sound in a given language" - metadata :hidden_type, true - interfaces [LanguageMemberInterface] + class BaseArgument < GraphQL::Schema::Argument + accepts_definition :metadata + end + + class BaseField < GraphQL::Schema::Field + accepts_definition :metadata + argument_class BaseArgument + end + + class BaseObject < GraphQL::Schema::Object + accepts_definition :metadata + field_class BaseField + end + + class BaseEnumValue < GraphQL::Schema::EnumValue + accepts_definition :metadata + end + + class BaseEnum < GraphQL::Schema::Enum + accepts_definition :metadata + enum_value_class BaseEnumValue + end + + class BaseInputObject < GraphQL::Schema::InputObject + accepts_definition :metadata + argument_class BaseArgument + end + + class BaseUnion < GraphQL::Schema::Union + accepts_definition :metadata + end - field :name, types.String.to_non_null_type - field :symbol, types.String.to_non_null_type - field :languages, LanguageType.to_list_type - field :manner, MannerEnum + module BaseInterface + include GraphQL::Schema::Interface + accepts_definition :metadata + field_class BaseField end - MannerEnum = GraphQL::EnumType.define do - name "Manner" + class MannerType < BaseEnum description "Manner of articulation for this sound" metadata :hidden_input_type, true value "STOP" @@ -28,105 +53,108 @@ module MaskHelpers end end - LanguageType = GraphQL::ObjectType.define do - name "Language" - field :name, types.String.to_non_null_type - field :families, types.String.to_list_type - field :phonemes, PhonemeType.to_list_type - field :graphemes, GraphemeType.to_list_type + class LanguageType < BaseObject + field :name, String, null: false + field :families, [String], null: false + field :phonemes, "[MaskHelpers::PhonemeType]", null: false + field :graphemes, "[MaskHelpers::GraphemeType]", null: false end - GraphemeType = GraphQL::ObjectType.define do - name "Grapheme" + module LanguageMemberType + include BaseInterface + metadata :hidden_abstract_type, true + description "Something that belongs to one or more languages" + field :languages, [LanguageType], null: false + end + + class GraphemeType < BaseObject description "A building block of spelling in a given language" - interfaces [LanguageMemberInterface] + implements LanguageMemberType - field :name, types.String.to_non_null_type - field :glyph, types.String.to_non_null_type - field :languages, LanguageType.to_list_type + field :name, String, null: false + field :glyph, String, null: false + field :languages, [LanguageType], null: false end - LanguageMemberInterface = GraphQL::InterfaceType.define do - name "LanguageMember" - metadata :hidden_abstract_type, true - description "Something that belongs to one or more languages" - field :languages, LanguageType.to_list_type + class PhonemeType < BaseObject + description "A building block of sound in a given language" + metadata :hidden_type, true + implements LanguageMemberType + + field :name, String, null: false + field :symbol, String, null: false + field :languages, [LanguageType], null: false + field :manner, MannerType, null: false end - EmicUnitUnion = GraphQL::UnionType.define do - name "EmicUnit" + class EmicUnitType < BaseUnion description "A building block of a word in a given language" - possible_types [GraphemeType, PhonemeType] + possible_types GraphemeType, PhonemeType end - WithinInputType = GraphQL::InputObjectType.define do - name "WithinInput" + class WithinInputType < BaseInputObject metadata :hidden_input_object_type, true - argument :latitude, !types.Float - argument :longitude, !types.Float - argument :miles, !types.Float do + argument :latitude, Float, required: true + argument :longitude, Float, required: true + argument :miles, Float, required: true do metadata :hidden_input_field, true end end - CheremeInput = GraphQL::InputObjectType.define do - name "CheremeInput" - input_field :name, types.String + class CheremeInput < BaseInputObject + argument :name, String, required: false end - Chereme = GraphQL::ObjectType.define do - name "Chereme" + class Chereme < BaseObject description "A basic unit of signed communication" - field :name, types.String.to_non_null_type + field :name, String, null: false end - Character = GraphQL::ObjectType.define do - name "Character" - interfaces [LanguageMemberInterface] - field :code, types.Int + class Character < BaseObject + implements LanguageMemberType + field :code, Int, null: false end - QueryType = GraphQL::ObjectType.define do - name "Query" - field :languages, LanguageType.to_list_type do - argument :within, WithinInputType, "Find languages nearby a point" do + class QueryType < BaseObject + field :languages, [LanguageType], null: false do + argument :within, WithinInputType, required: false, description: "Find languages nearby a point" do metadata :hidden_argument_with_input_object, true end end - field :language, LanguageType do + + field :language, LanguageType, null: true do metadata :hidden_field, true - argument :name, !types.String do + argument :name, String, required: true do metadata :hidden_argument, true end end - field :chereme, Chereme do + field :chereme, Chereme, null: false do metadata :hidden_field, true end - field :phonemes, PhonemeType.to_list_type do - argument :manners, MannerEnum.to_list_type, "Filter phonemes by manner of articulation" + field :phonemes, [PhonemeType], null: false do + argument :manners, [MannerType], required: false, description: "Filter phonemes by manner of articulation" end - field :phoneme, PhonemeType do + field :phoneme, PhonemeType, null: true do description "Lookup a phoneme by symbol" - argument :symbol, !types.String + argument :symbol, String, required: true end - field :unit, EmicUnitUnion do + field :unit, EmicUnitType, null: true do description "Find an emic unit by its name" - argument :name, types.String.to_non_null_type + argument :name, String, required: true end end - MutationType = GraphQL::ObjectType.define do - name "Mutation" - field :add_phoneme, PhonemeType do - argument :symbol, types.String + class MutationType < BaseObject + field :add_phoneme, PhonemeType, null: true do + argument :symbol, String, required: false end - field :add_chereme, types.String do - argument :chereme, CheremeInput do + field :add_chereme, String, null: true do + argument :chereme, CheremeInput, required: false do metadata :hidden_argument, true end end @@ -145,18 +173,24 @@ def self.before_query(query) def self.after_query(q); end end - Schema = GraphQL::Schema.define do + class Schema < GraphQL::Schema query QueryType mutation MutationType subscription MutationType orphan_types [Character] - resolve_type ->(type, obj, ctx) { PhonemeType } + def self.resolve_type(type, obj, ctx) + PhonemeType + end + instrument :query, FilterInstrumentation + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end module Data UVULAR_TRILL = OpenStruct.new({name: "Uvular Trill", symbol: "ʀ", manner: "TRILL"}) - def self.unit + def self.unit(name:) UVULAR_TRILL end end @@ -205,16 +239,16 @@ def error_messages(query_result) end describe "hiding root types" do - let(:mask) { ->(m, ctx) { m == MaskHelpers::MutationType } } + let(:mask) { ->(m, ctx) { m == MaskHelpers::MutationType.graphql_definition } } it "acts as if the root doesn't exist" do - query_string = %|mutation { add_phoneme(symbol: "ϕ") { name } }| + query_string = %|mutation { addPhoneme(symbol: "ϕ") { name } }| res = MaskHelpers.query_with_mask(query_string, mask) assert MaskHelpers::Schema.mutation # it _does_ exist assert_equal 1, res["errors"].length assert_equal "Schema is not configured for mutations", res["errors"][0]["message"] - query_string = %|subscription { add_phoneme(symbol: "ϕ") { name } }| + query_string = %|subscription { addPhoneme(symbol: "ϕ") { name } }| res = MaskHelpers.query_with_mask(query_string, mask) assert MaskHelpers::Schema.subscription # it _does_ exist assert_equal 1, res["errors"].length @@ -557,7 +591,6 @@ def error_messages(query_result) end end - describe "hiding arguments" do let(:mask) { ->(member, ctx) { member.metadata[:hidden_argument] || member.metadata[:hidden_input_type] } @@ -737,30 +770,30 @@ def error_messages(query_result) res = MaskHelpers.query_with_mask(query_string, mask) # It's not a good error message ... but it's something! expected_errors = [ - "Argument 'manners' on Field 'phonemes' has an invalid value. Expected type '[Manner]'.", + "Argument 'manners' on Field 'phonemes' has an invalid value. Expected type '[Manner!]'.", ] assert_equal expected_errors, error_messages(res) end it "isn't a valid default value" do query_string = %| - query getPhonemes($manners: [Manner] = [STOP, TRILL]){ phonemes(manners: $manners) { symbol } } + query getPhonemes($manners: [Manner!] = [STOP, TRILL]){ phonemes(manners: $manners) { symbol } } | res = MaskHelpers.query_with_mask(query_string, mask) - expected_errors = ["Default value for $manners doesn't match type [Manner]"] + expected_errors = ["Default value for $manners doesn't match type [Manner!]"] assert_equal expected_errors, error_messages(res) end it "isn't a valid variable input" do query_string = %| - query getPhonemes($manners: [Manner]!) { + query getPhonemes($manners: [Manner!]!) { phonemes(manners: $manners) { symbol } } | res = MaskHelpers.query_with_mask(query_string, mask, variables: { "manners" => ["STOP", "TRILL"] }) # It's not a good error message ... but it's something! expected_errors = [ - "Variable manners of type [Manner]! was provided invalid value", + "Variable manners of type [Manner!]! was provided invalid value", ] assert_equal expected_errors, error_messages(res) end diff --git a/spec/graphql/subscriptions_spec.rb b/spec/graphql/subscriptions_spec.rb index 50525ca75b..92a2f40bff 100644 --- a/spec/graphql/subscriptions_spec.rb +++ b/spec/graphql/subscriptions_spec.rb @@ -109,6 +109,9 @@ class StreamInput < GraphQL::Schema::InputObject end class Subscription < GraphQL::Schema::Object + if TESTING_INTERPRETER + extend GraphQL::Subscriptions::SubscriptionRoot + end field :payload, Payload, null: false do argument :id, ID, required: true end @@ -133,9 +136,13 @@ def my_event(type: nil) object end - field :failed_event, Payload, null: false, resolve: ->(o, a, c) { raise GraphQL::ExecutionError.new("unauthorized") } do + field :failed_event, Payload, null: false do argument :id, ID, required: true end + + def failed_event(id:) + raise GraphQL::ExecutionError.new("unauthorized") + end end class Query < GraphQL::Schema::Object @@ -146,6 +153,9 @@ class Schema < GraphQL::Schema query(Query) subscription(Subscription) use InMemoryBackend::Subscriptions, extra: 123 + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end @@ -236,9 +246,17 @@ def to_param res_1 = schema.execute(query_str, context: { socket: "1" }, variables: { "id" => "100" }, root_value: root_object) res_2 = schema.execute(query_str, context: { socket: "2" }, variables: { "id" => "200" }, root_value: root_object) + # This difference is because of how `SKIP` is handled. + # Honestly the new way is probably better, since it puts a value there. + empty_response = if TESTING_INTERPRETER && schema == ClassBasedInMemoryBackend::Schema + {} + else + nil + end + # Initial response is nil, no broadcasts yet - assert_equal(nil, res_1["data"]) - assert_equal(nil, res_2["data"]) + assert_equal(empty_response, res_1["data"]) + assert_equal(empty_response, res_2["data"]) assert_equal [], deliveries["1"] assert_equal [], deliveries["2"] @@ -410,12 +428,12 @@ def str failedEvent(id: $id) { str, int } } GRAPHQL - assert_equal nil, res["data"] assert_equal "unauthorized", res["errors"][0]["message"] # this is to make sure nothing actually got subscribed.. but I don't have any idea better than checking its instance variable - assert_equal 0, schema.subscriptions.instance_variable_get(:@subscriptions).size + subscriptions = schema.subscriptions.instance_variable_get(:@subscriptions) + assert_equal 0, subscriptions.size end it "lets unhandled errors crash" do diff --git a/spec/graphql/tracing/new_relic_tracing_spec.rb b/spec/graphql/tracing/new_relic_tracing_spec.rb index b599522365..81fb3081c4 100644 --- a/spec/graphql/tracing/new_relic_tracing_spec.rb +++ b/spec/graphql/tracing/new_relic_tracing_spec.rb @@ -14,11 +14,17 @@ def int class SchemaWithoutTransactionName < GraphQL::Schema query(Query) use(GraphQL::Tracing::NewRelicTracing) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end class SchemaWithTransactionName < GraphQL::Schema query(Query) use(GraphQL::Tracing::NewRelicTracing, set_transaction_name: true) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/tracing/platform_tracing_spec.rb b/spec/graphql/tracing/platform_tracing_spec.rb index b28992cab6..8a4b628f2c 100644 --- a/spec/graphql/tracing/platform_tracing_spec.rb +++ b/spec/graphql/tracing/platform_tracing_spec.rb @@ -17,7 +17,7 @@ class CustomPlatformTracer < GraphQL::Tracing::PlatformTracing } def platform_field_key(type, field) - "#{type.name[0]}.#{field.name[0]}" + "#{type.graphql_name[0]}.#{field.graphql_name[0]}" end def platform_trace(platform_key, key, data) @@ -39,17 +39,25 @@ def platform_trace(platform_key, key, data) it "calls the platform's own method with its own keys" do schema.execute(" { cheese(id: 1) { flavor } }") - expected_trace = [ - "em", - "l", - "p", - "v", - "am", - "aq", - "eq", - "Q.c", # notice that the flavor is skipped - "eql", - ] + # This is different because schema/member/instrumentation + # calls `irep_selection` which causes the query to be parsed. + # But interpreter doesn't require parsing until later. + expected_trace = if TESTING_INTERPRETER + [ + "em", + "am", + "l", + "p", + "v", + "aq", + "eq", + "Q.c", # notice that the flavor is skipped + "eql", + ] + else + ["em", "l", "p", "v", "am", "aq", "eq", "Q.c", "eql"] + end + assert_equal expected_trace, CustomPlatformTracer::TRACE end end @@ -67,18 +75,25 @@ def platform_trace(platform_key, key, data) it "only traces traceTrue, not traceFalse or traceNil" do schema.execute(" { tracingScalar { traceNil traceFalse traceTrue } }") - expected_trace = [ - "em", - "l", - "p", - "v", - "am", - "aq", - "eq", - "Q.t", - "T.t", - "eql", - ] + # This is different because schema/member/instrumentation + # calls `irep_selection` which causes the query to be parsed. + # But interpreter doesn't require parsing until later. + expected_trace = if TESTING_INTERPRETER + [ + "em", + "am", + "l", + "p", + "v", + "aq", + "eq", + "Q.t", + "T.t", + "eql", + ] + else + ["em", "l", "p", "v", "am", "aq", "eq", "Q.t", "T.t", "eql"] + end assert_equal expected_trace, CustomPlatformTracer::TRACE end end @@ -96,19 +111,26 @@ def platform_trace(platform_key, key, data) it "traces traceTrue and traceNil but not traceFalse" do schema.execute(" { tracingScalar { traceNil traceFalse traceTrue } }") - expected_trace = [ - "em", - "l", - "p", - "v", - "am", - "aq", - "eq", - "Q.t", - "T.t", - "T.t", - "eql", - ] + # This is different because schema/member/instrumentation + # calls `irep_selection` which causes the query to be parsed. + # But interpreter doesn't require parsing until later. + expected_trace = if TESTING_INTERPRETER + [ + "em", + "am", + "l", + "p", + "v", + "aq", + "eq", + "Q.t", + "T.t", + "T.t", + "eql", + ] + else + ["em", "l", "p", "v", "am", "aq", "eq", "Q.t", "T.t", "T.t", "eql"] + end assert_equal expected_trace, CustomPlatformTracer::TRACE end end diff --git a/spec/graphql/tracing/prometheus_tracing_spec.rb b/spec/graphql/tracing/prometheus_tracing_spec.rb index 180500efc5..ad4ec9ff1e 100644 --- a/spec/graphql/tracing/prometheus_tracing_spec.rb +++ b/spec/graphql/tracing/prometheus_tracing_spec.rb @@ -14,6 +14,9 @@ def int class Schema < GraphQL::Schema query Query + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/tracing/skylight_tracing_spec.rb b/spec/graphql/tracing/skylight_tracing_spec.rb index a2d21b76c3..873be26b88 100644 --- a/spec/graphql/tracing/skylight_tracing_spec.rb +++ b/spec/graphql/tracing/skylight_tracing_spec.rb @@ -14,11 +14,17 @@ def int class SchemaWithoutTransactionName < GraphQL::Schema query(Query) use(GraphQL::Tracing::SkylightTracing) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end class SchemaWithTransactionName < GraphQL::Schema query(Query) use(GraphQL::Tracing::SkylightTracing, set_endpoint_name: true) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/graphql/types/iso_8601_date_time_spec.rb b/spec/graphql/types/iso_8601_date_time_spec.rb index 40d8157fec..6085803831 100644 --- a/spec/graphql/types/iso_8601_date_time_spec.rb +++ b/spec/graphql/types/iso_8601_date_time_spec.rb @@ -29,6 +29,9 @@ def parse_date(date:) class Schema < GraphQL::Schema query(Query) + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/integration/mongoid/star_trek/schema.rb b/spec/integration/mongoid/star_trek/schema.rb index b7afb87271..dc7f168ca5 100644 --- a/spec/integration/mongoid/star_trek/schema.rb +++ b/spec/integration/mongoid/star_trek/schema.rb @@ -389,6 +389,10 @@ class Schema < GraphQL::Schema mutation(MutationType) default_max_page_size 3 + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end + def self.resolve_type(type, object, ctx) if object == :test_error :not_a_type diff --git a/spec/integration/rails/graphql/relay/connection_instrumentation_spec.rb b/spec/integration/rails/graphql/relay/connection_instrumentation_spec.rb index caea27153e..c96871c725 100644 --- a/spec/integration/rails/graphql/relay/connection_instrumentation_spec.rb +++ b/spec/integration/rails/graphql/relay/connection_instrumentation_spec.rb @@ -53,25 +53,28 @@ assert_instance_of GraphQL::Relay::ConnectionResolve, redefined_connection_field.resolve_proc end - describe "after_built_ins instrumentation" do - it "has access to connection objects" do - query_str = <<-GRAPHQL - { - rebels { - ships { - pageInfo { - __typename + # field instrumentation doesn't exist here + if !TESTING_INTERPRETER + describe "after_built_ins instrumentation" do + it "has access to connection objects" do + query_str = <<-GRAPHQL + { + rebels { + ships { + pageInfo { + __typename + } } } } - } - GRAPHQL - ctx = { before_built_ins: [], after_built_ins: [] } - star_wars_query(query_str, {}, context: ctx) - # These are data classes, later they're wrapped with type proxies - assert_equal ["StarWars::FactionRecord", "GraphQL::Relay::ArrayConnection", "GraphQL::Relay::ArrayConnection"], ctx[:before_built_ins] - # After the object is wrapped in a connection, it sees the connection object - assert_equal ["StarWars::Faction", "StarWars::ShipConnectionWithParentType", "GraphQL::Types::Relay::PageInfo"], ctx[:after_built_ins] + GRAPHQL + ctx = { before_built_ins: [], after_built_ins: [] } + star_wars_query(query_str, {}, context: ctx) + # These are data classes, later they're wrapped with type proxies + assert_equal ["StarWars::FactionRecord", "GraphQL::Relay::ArrayConnection", "GraphQL::Relay::ArrayConnection"], ctx[:before_built_ins] + # After the object is wrapped in a connection, it sees the connection object + assert_equal ["StarWars::Faction", "StarWars::ShipConnectionWithParentType", "GraphQL::Types::Relay::PageInfo"], ctx[:after_built_ins] + end end end end diff --git a/spec/integration/rails/graphql/schema_spec.rb b/spec/integration/rails/graphql/schema_spec.rb index f179afbf87..f3222baaf1 100644 --- a/spec/integration/rails/graphql/schema_spec.rb +++ b/spec/integration/rails/graphql/schema_spec.rb @@ -73,10 +73,13 @@ end end - describe "#subscription" do - it "calls fields on the subscription type" do - res = schema.execute("subscription { test }") - assert_equal("Test", res["data"]["test"]) + # Interpreter has subscription support hardcoded, it doesn't just call through. + if !TESTING_INTERPRETER + describe "#subscription" do + it "calls fields on the subscription type" do + res = schema.execute("subscription { test }") + assert_equal("Test", res["data"]["test"]) + end end end diff --git a/spec/integration/rails/graphql/tracing/active_support_notifications_tracing_spec.rb b/spec/integration/rails/graphql/tracing/active_support_notifications_tracing_spec.rb index 451ba135cf..2e036635d6 100644 --- a/spec/integration/rails/graphql/tracing/active_support_notifications_tracing_spec.rb +++ b/spec/integration/rails/graphql/tracing/active_support_notifications_tracing_spec.rb @@ -12,7 +12,14 @@ traces = [] callback = ->(name, started, finished, id, data) { - traces << name + path_str = if data.key?(:field) + " (#{data[:field].path})" + elsif data.key?(:context) + " (#{data[:context].irep_node.owner_type}.#{data[:context].field.name})" + else + "" + end + traces << "#{name}#{path_str}" } query_string = <<-GRAPHQL @@ -37,16 +44,16 @@ "graphql.validate", "graphql.analyze_query", "graphql.analyze_multiplex", - "graphql.execute_field", - "graphql.execute_field", + "graphql.execute_field (Query.batchedBase)", + "graphql.execute_field (Query.batchedBase)", "graphql.execute_query", "graphql.lazy_loader", - "graphql.execute_field_lazy", - "graphql.execute_field", - "graphql.execute_field_lazy", - "graphql.execute_field", - "graphql.execute_field_lazy", - "graphql.execute_field_lazy", + "graphql.execute_field_lazy (Query.batchedBase)", + "graphql.execute_field (Base.name)", + "graphql.execute_field_lazy (Query.batchedBase)", + "graphql.execute_field (Base.name)", + "graphql.execute_field_lazy (Base.name)", + "graphql.execute_field_lazy (Base.name)", "graphql.execute_query_lazy", "graphql.execute_multiplex", ] diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f7a66abfec..d5afe4d99c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,10 @@ # Print full backtrace for failiures: ENV["BACKTRACE"] = "1" +# Set this env var to use Interpreter for fixture schemas. +# Eventually, interpreter will be the default. +TESTING_INTERPRETER = ENV["TESTING_INTERPRETER"] +TESTING_RESCUE_FROM = !TESTING_INTERPRETER require "codeclimate-test-reporter" CodeClimate::TestReporter.start @@ -100,6 +104,7 @@ def traces def trace(key, data) data[:key] = key + data[:path] ||= data.key?(:context) ? data[:context].path : nil result = yield data[:result] = result traces << data diff --git a/spec/support/dummy/schema.rb b/spec/support/dummy/schema.rb index 6dbed7ccba..e7e6c77390 100644 --- a/spec/support/dummy/schema.rb +++ b/spec/support/dummy/schema.rb @@ -270,42 +270,43 @@ def self.coerce_result(value, ctx) end end - class FetchItem < GraphQL::Function - attr_reader :type, :description, :arguments + class FetchItem < GraphQL::Schema::Resolver + class << self + attr_accessor :data + end - def initialize(type:, data:, id_type: !GraphQL::INT_TYPE) - @type = type - @data = data - @description = "Find a #{type.name} by id" - @arguments = self.class.arguments.merge({"id" => GraphQL::Argument.define(name: "id", type: id_type)}) + def self.build(type:, data:, id_type: "Int") + Class.new(self) do + self.data = data + type(type, null: true) + description("Find a #{type.name} by id") + argument :id, id_type, required: true + end end - def call(obj, args, ctx) - id_string = args["id"].to_s # Cheese has Int type, Milk has ID type :( - _id, item = @data.find { |id, _item| id.to_s == id_string } + def resolve(id:) + id_string = id.to_s # Cheese has Int type, Milk has ID type :( + _id, item = self.class.data.find { |item_id, _item| item_id.to_s == id_string } item end end - class GetSingleton < GraphQL::Function - attr_reader :description, :type - - def initialize(type:, data:) - @description = "Find the only #{type.name}" - @type = type - @data = data + class GetSingleton < GraphQL::Schema::Resolver + class << self + attr_accessor :data end - def call(obj, args, ctx) - @data + def self.build(type:, data:) + Class.new(self) do + description("Find the only #{type.name}") + type(type, null: true) + self.data = data + end end - end - FavoriteFieldDefn = GraphQL::Field.define do - name "favoriteEdible" - description "My favorite food" - type Edible - resolve ->(t, a, c) { MILKS[1] } + def resolve + self.class.data + end end class DairyAppQuery < BaseObject @@ -316,9 +317,9 @@ class DairyAppQuery < BaseObject def root object end - field :cheese, function: FetchItem.new(type: Cheese, data: CHEESES) - field :milk, function: FetchItem.new(type: Milk, data: MILKS, id_type: GraphQL::Types::ID.to_non_null_type) - field :dairy, function: GetSingleton.new(type: Dairy, data: DAIRY) + field :cheese, resolver: FetchItem.build(type: Cheese, data: CHEESES) + field :milk, resolver: FetchItem.build(type: Milk, data: MILKS, id_type: "ID") + field :dairy, resolver: GetSingleton.build(type: Dairy, data: DAIRY) field :from_source, [Cheese, null: true], null: true, description: "Cheese from source" do argument :source, DairyAnimal, required: false, default_value: 1 end @@ -326,12 +327,16 @@ def from_source(source:) CHEESES.values.select { |c| c.source == source } end - field :favorite_edible, field: FavoriteFieldDefn - field :cow, function: GetSingleton.new(type: Cow, data: COWS[1]) + field :favorite_edible, Edible, null: true, description: "My favorite food" + def favorite_edible + MILKS[1] + end + + field :cow, resolver: GetSingleton.build(type: Cow, data: COWS[1]) field :search_dairy, DairyProduct, null: false do description "Find dairy products matching a description" # This is a list just for testing 😬 - argument :product, [DairyProductInput, null: true], required: false, default_value: [{"source" => "SHEEP"}] + argument :product, [DairyProductInput, null: true], required: false, default_value: [{source: "SHEEP"}] argument :expires_after, Time, required: false end @@ -445,7 +450,7 @@ def push_value(val:) def replace_values(input:) GLOBAL_VALUES.clear - GLOBAL_VALUES.concat(input["values"]) + GLOBAL_VALUES.concat(input[:values]) GLOBAL_VALUES end end @@ -468,5 +473,8 @@ class Schema < GraphQL::Schema def self.resolve_type(type, obj, ctx) Schema.types[obj.class.name.split("::").last] end + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/support/jazz.rb b/spec/support/jazz.rb index 1a4e998179..5efad84efb 100644 --- a/spec/support/jazz.rb +++ b/spec/support/jazz.rb @@ -9,7 +9,7 @@ module Models Key = Struct.new(:root, :sharp, :flat) do def self.from_notation(key_str) key, sharp_or_flat = key_str.split("") - sharp = sharp_or_flat == "♯" + sharp = sharp_or_flat == "♯" flat = sharp_or_flat == "♭" Models::Key.new(key, sharp, flat) end @@ -36,7 +36,7 @@ def self.reset ], "Musician" => [ Models::Musician.new("Herbie Hancock", Models::Key.from_notation("B♭")), - ] + ], } end @@ -61,6 +61,7 @@ def to_graphql # A custom field class that supports the `upcase:` option class BaseField < GraphQL::Schema::Field argument_class BaseArgument + def initialize(*args, **options, &block) @upcase = options.delete(:upcase) super(*args, **options, &block) @@ -74,6 +75,15 @@ def resolve_field(*) result end end + + def resolve(*) + result = super + if @upcase && result + result.upcase + else + result + end + end end class BaseObject < GraphQL::Schema::Object @@ -91,7 +101,7 @@ def configs def to_graphql type_defn = super - configs.each do |k,v| + configs.each do |k, v| type_defn.metadata[k] = v end type_defn @@ -156,10 +166,9 @@ def self.find(id) end end - # A legacy-style interface used by new-style types - NamedEntity = GraphQL::InterfaceType.define do - name "NamedEntity" - field :name, !types.String + module NamedEntity + include BaseInterface + field :name, String, null: false end # test field inheritance @@ -177,7 +186,6 @@ module HasMusicians field :musicians, "[Jazz::Musician]", null: false end - # Here's a new-style GraphQL type definition class Ensemble < ObjectWithUpcasedName # Test string type names @@ -214,19 +222,18 @@ class Family < BaseEnum # Lives side-by-side with an old-style definition using GraphQL::DeprecatedDSL # for ! and types[] - InstrumentType = GraphQL::ObjectType.define do - name "Instrument" - interfaces [NamedEntity] + + class InstrumentType < BaseObject + implements NamedEntity implements GloballyIdentifiableType - field :id, !types.ID, "A unique identifier for this object", resolve: ->(obj, args, ctx) { GloballyIdentifiableType.to_id(obj) } - field :upcasedId, !types.ID, resolve: ->(obj, args, ctx) { GloballyIdentifiableType.to_id(obj).upcase } - if RUBY_ENGINE == "jruby" - # JRuby doesn't support refinements, so the `using` above won't work - field :family, Family.to_non_null_type - else - field :family, !Family + field :upcased_id, ID, null: false + + def upcased_id + GloballyIdentifiableType.to_id(object).upcase end + + field :family, Family, null: false end class Key < GraphQL::Schema::Scalar @@ -261,6 +268,7 @@ class Musician < BaseObject # Test lists with nullable members: field :inspect_context, [String, null: true], null: false field :add_error, String, null: false, extras: [:execution_errors] + def inspect_context [ @context.custom_method, @@ -276,21 +284,22 @@ def add_error(execution_errors:) end end - LegacyInputType = GraphQL::InputObjectType.define do - name "LegacyInput" - argument :intValue, !types.Int + # Since this is not a legacy input type, this test can be removed + class LegacyInputType < GraphQL::Schema::InputObject + argument :int_value, Int, required: true end class InspectableInput < GraphQL::Schema::InputObject argument :string_value, String, required: true, description: "Test description kwarg" argument :nested_input, InspectableInput, required: false argument :legacy_input, LegacyInputType, required: false + def helper_method [ # Context is available in the InputObject context[:message], - # A GraphQL::Query::Arguments instance is available - arguments[:stringValue], + # ~~A GraphQL::Query::Arguments instance is available~~ not anymore + self[:string_value], # Legacy inputs have underscored method access too legacy_input ? legacy_input.int_value : "-", # Access by method call is available @@ -336,7 +345,10 @@ class Query < BaseObject field :inspect_key, InspectableKey, null: false do argument :key, Key, required: true end - field :nowPlaying, PerformingAct, null: false, resolve: ->(o, a, c) { Models.data["Ensemble"].first } + field :now_playing, PerformingAct, null: false + + def now_playing; Models.data["Ensemble"].first; end + # For asserting that the object is initialized once: field :object_id, Integer, null: false field :inspect_context, [String], null: false @@ -381,8 +393,8 @@ def inspect_input(input:) # Access by key: input[:string_value], input.key?(:string_value).to_s, - # Access by legacy key - input[:stringValue], + # ~~Access by legacy key~~ # not anymore + input[:string_value], ] end @@ -394,7 +406,7 @@ def inspect_context [ context.custom_method, context[:magic_key], - context[:normal_key] + context[:normal_key], ] end @@ -402,11 +414,11 @@ def hashy_ensemble # Both string and symbol keys are supported: { - name: "The Grateful Dead", - "musicians" => [ - OpenStruct.new(name: "Jerry Garcia"), - ], - "formedAtDate" => "May 5, 1965", + name: "The Grateful Dead", + "musicians" => [ + OpenStruct.new(name: "Jerry Garcia"), + ], + "formedAtDate" => "May 5, 1965", } end @@ -420,12 +432,13 @@ def echo_first_json(input:) field :hash_by_string, HashKeyTest, null: false field :hash_by_sym, HashKeyTest, null: false + def hash_by_string - { "falsey" => false } + {"falsey" => false} end def hash_by_sym - { falsey: false } + {falsey: false} end field :named_entities, [NamedEntity, null: true], null: false @@ -453,10 +466,11 @@ class AddInstrument < GraphQL::Schema::Mutation field :ee, String, null: false extras [:execution_errors] + def resolve(name:, family:, execution_errors:) instrument = Jazz::Models::Instrument.new(name, family) Jazz::Models.data["Instrument"] << instrument - { instrument: instrument, entries: Jazz::Models.data["Instrument"], ee: execution_errors.class.name} + {instrument: instrument, entries: Jazz::Models.data["Instrument"], ee: execution_errors.class.name} end end @@ -468,7 +482,7 @@ class AddSitar < GraphQL::Schema::RelayClassicMutation def resolve instrument = Models::Instrument.new("Sitar", :str) - { instrument: instrument } + {instrument: instrument} end end @@ -517,7 +531,7 @@ def resolve(ensemble:, new_name:) dup_ensemble = ensemble.dup dup_ensemble.name = new_name { - ensemble: dup_ensemble + ensemble: dup_ensemble, } end end @@ -624,7 +638,8 @@ def custom_method module Introspection class TypeType < GraphQL::Introspection::TypeType def name - object.name.upcase + n = object.graphql_name + n && n.upcase end end @@ -632,6 +647,7 @@ class SchemaType < GraphQL::Introspection::SchemaType graphql_name "__Schema" field :is_jazzy, Boolean, null: false + def is_jazzy true end @@ -640,7 +656,8 @@ def is_jazzy class DynamicFields < GraphQL::Introspection::DynamicFields field :__typename_length, Int, null: false, extras: [:irep_node] field :__ast_node_class, String, null: false, extras: [:ast_node] - def __typename_length(irep_node:) + + def __typename_length(irep_node: nil) __typename(irep_node: irep_node).length end @@ -651,8 +668,13 @@ def __ast_node_class(ast_node:) class EntryPoints < GraphQL::Introspection::EntryPoints field :__classname, String, "The Ruby class name of the root object", null: false + def __classname - object.class.name + if context.interpreter? + object.object.class.name + else + object.class.name + end end end end @@ -672,5 +694,8 @@ def self.resolve_type(type, obj, ctx) def self.object_from_id(id, ctx) GloballyIdentifiableType.find(id) end + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end end diff --git a/spec/support/lazy_helpers.rb b/spec/support/lazy_helpers.rb index 0ba536078b..008c9cb51c 100644 --- a/spec/support/lazy_helpers.rb +++ b/spec/support/lazy_helpers.rb @@ -47,7 +47,11 @@ def self.all end class LazySum < GraphQL::Schema::Object - field :value, Integer, null: true, resolve: ->(o, a, c) { o == 13 ? nil : o } + field :value, Integer, null: true + def value + object == 13 ? nil : object + end + field :nestedSum, LazySum, null: false do argument :value, Integer, required: true end @@ -72,33 +76,40 @@ def nested_sum(value:) GraphQL::DeprecatedDSL.activate end - LazyQuery = GraphQL::ObjectType.define do - name "Query" - field :int, !types.Int do - argument :value, !types.Int - argument :plus, types.Int, default_value: 0 - resolve ->(o, a, c) { Wrapper.new(a[:value] + a[:plus])} + class LazyQuery < GraphQL::Schema::Object + field :int, Integer, null: false do + argument :value, Integer, required: true + argument :plus, Integer, required: false, default_value: 0 + end + def int(value:, plus:) + Wrapper.new(value + plus) + end + + field :nested_sum, LazySum, null: false do + argument :value, Integer, required: true + end + + def nested_sum(value:) + SumAll.new(context, value) end - field :nestedSum, !LazySum do - argument :value, !types.Int - resolve ->(o, args, c) { SumAll.new(c, args[:value]) } + field :nullable_nested_sum, LazySum, null: true do + argument :value, Integer, required: true end - field :nullableNestedSum, LazySum do - argument :value, types.Int - resolve ->(o, args, c) { - if args[:value] == 13 - Wrapper.new { raise GraphQL::ExecutionError.new("13 is unlucky") } - else - SumAll.new(c, args[:value]) - end - } + def nullable_nested_sum(value:) + if value == 13 + Wrapper.new { raise GraphQL::ExecutionError.new("13 is unlucky") } + else + SumAll.new(context, value) + end end - field :listSum, types[LazySum] do - argument :values, types[types.Int] - resolve ->(o, args, c) { args[:values] } + field :list_sum, [LazySum], null: true do + argument :values, [Integer], required: true + end + def list_sum(values:) + values end end @@ -142,6 +153,10 @@ class LazySchema < GraphQL::Schema instrument(:query, SumAllInstrumentation.new(counter: nil)) instrument(:multiplex, SumAllInstrumentation.new(counter: 1)) instrument(:multiplex, SumAllInstrumentation.new(counter: 2)) + + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end end def run_query(query_str) diff --git a/spec/support/star_wars/schema.rb b/spec/support/star_wars/schema.rb index 4c9de1ad12..17950c727d 100644 --- a/spec/support/star_wars/schema.rb +++ b/spec/support/star_wars/schema.rb @@ -15,15 +15,16 @@ class BaseType < GraphQL::Schema::Object graphql_name "Base" implements GraphQL::Relay::Node.interface global_id_field :id - field :name, String, null: false, resolve: ->(obj, args, ctx) { + field :name, String, null: false + def name LazyWrapper.new { - if obj.id.nil? + if object.id.nil? raise GraphQL::ExecutionError, "Boom!" else - obj.name + object.name end } - } + end field :planet, String, null: true end @@ -119,7 +120,11 @@ def resolve class Faction < GraphQL::Schema::Object implements GraphQL::Relay::Node.interface - field :id, ID, null: false, resolve: GraphQL::Relay::GlobalIdResolve.new(type: Faction) + field :id, ID, null: false + def id + GraphQL::Relay::GlobalIdResolve.new(type: Faction).call(object, {}, context) + end + field :name, String, null: true field :ships, ShipConnectionWithParentType, connection: true, max_page_size: 1000, null: true do argument :name_includes, String, required: false @@ -224,26 +229,6 @@ class IntroduceShipMutation < GraphQL::Schema::RelayClassicMutation field :aliased_faction, Faction, hash_key: :aliased_faction, null: true def resolve(ship_name: nil, faction_id:) - IntroduceShipFunction.new.call(object, {ship_name: ship_name, faction_id: faction_id}, context) - end - end - - class IntroduceShipFunction < GraphQL::Function - description "Add a ship to this faction" - - argument :shipName, GraphQL::STRING_TYPE - argument :factionId, !GraphQL::ID_TYPE - - type(GraphQL::ObjectType.define do - name "IntroduceShipFunctionPayload" - field :shipEdge, Ship.edge_type, hash_key: :shipEdge - field :faction, Faction, hash_key: :shipEdge - end) - - def call(obj, args, ctx) - # support old and new args - ship_name = args["shipName"] || args[:ship_name] - faction_id = args["factionId"] || args[:faction_id] if ship_name == 'Millennium Falcon' GraphQL::ExecutionError.new("Sorry, Millennium Falcon ship is reserved") elsif ship_name == 'Leviathan' @@ -254,15 +239,14 @@ def call(obj, args, ctx) ship = DATA.create_ship(ship_name, faction_id) faction = DATA["Faction"][faction_id] connection_class = GraphQL::Relay::BaseConnection.connection_for_nodes(faction.ships) - ships_connection = connection_class.new(faction.ships, args) + ships_connection = connection_class.new(faction.ships, {ship_name: ship_name, faction: faction}) ship_edge = GraphQL::Relay::Edge.new(ship, ships_connection) result = { - shipEdge: ship_edge, ship_edge: ship_edge, # support new-style, too faction: faction, aliased_faction: faction, } - if args["shipName"] == "Slave II" + if ship_name == "Slave II" LazyWrapper.new(result) else result @@ -271,12 +255,6 @@ def call(obj, args, ctx) end end - IntroduceShipFunctionMutation = GraphQL::Relay::Mutation.define do - # Used as the root for derived types: - name "IntroduceShipFunction" - function IntroduceShipFunction.new - end - # GraphQL-Batch knockoff class LazyLoader def self.defer(ctx, model, id) @@ -338,11 +316,21 @@ def edge_nodes class QueryType < GraphQL::Schema::Object graphql_name "Query" - field :rebels, Faction, null: true, resolve: ->(obj, args, ctx) { StarWars::DATA["Faction"]["1"]} + field :rebels, Faction, null: true + def rebels + StarWars::DATA["Faction"]["1"] + end - field :empire, Faction, null: true, resolve: ->(obj, args, ctx) { StarWars::DATA["Faction"]["2"]} + field :empire, Faction, null: true + def empire + StarWars::DATA["Faction"]["2"] + end - field :largestBase, BaseType, null: true, resolve: ->(obj, args, ctx) { Base.find(3) } + field :largestBase, BaseType, null: true + + def largest_base + Base.find(3) + end field :newestBasesGroupedByFaction, BaseConnection, null: true @@ -359,17 +347,44 @@ def bases_with_null_name [OpenStruct.new(id: nil)] end - field :node, field: GraphQL::Relay::Node.field + if TESTING_INTERPRETER + add_field(GraphQL::Types::Relay::NodeField) + else + field :node, field: GraphQL::Relay::Node.field + end - custom_node_field = GraphQL::Relay::Node.field do - resolve ->(_, _, _) { StarWars::DATA["Faction"]["1"] } + if TESTING_INTERPRETER + field :node_with_custom_resolver, GraphQL::Types::Relay::Node, null: true do + argument :id, ID, required: true + end + def node_with_custom_resolver(id:) + StarWars::DATA["Faction"]["1"] + end + else + custom_node_field = GraphQL::Relay::Node.field do + resolve ->(_, _, _) { StarWars::DATA["Faction"]["1"] } + end + field :nodeWithCustomResolver, field: custom_node_field end - field :nodeWithCustomResolver, field: custom_node_field - field :nodes, field: GraphQL::Relay::Node.plural_field - field :nodesWithCustomResolver, field: GraphQL::Relay::Node.plural_field( - resolve: ->(_, _, _) { [StarWars::DATA["Faction"]["1"], StarWars::DATA["Faction"]["2"]] } - ) + if TESTING_INTERPRETER + add_field(GraphQL::Types::Relay::NodesField) + else + field :nodes, field: GraphQL::Relay::Node.plural_field + end + + if TESTING_INTERPRETER + field :nodes_with_custom_resolver, [GraphQL::Types::Relay::Node, null: true], null: true do + argument :ids, [ID], required: true + end + def nodes_with_custom_resolver(ids:) + [StarWars::DATA["Faction"]["1"], StarWars::DATA["Faction"]["2"]] + end + else + field :nodesWithCustomResolver, field: GraphQL::Relay::Node.plural_field( + resolve: ->(_, _, _) { [StarWars::DATA["Faction"]["1"], StarWars::DATA["Faction"]["2"]] } + ) + end field :batchedBase, BaseType, null: true do argument :id, ID, required: true @@ -383,7 +398,6 @@ def batched_base(id:) class MutationType < GraphQL::Schema::Object graphql_name "Mutation" field :introduceShip, mutation: IntroduceShipMutation - field :introduceShipFunction, field: IntroduceShipFunctionMutation.field end class ClassNameRecorder @@ -411,6 +425,10 @@ class Schema < GraphQL::Schema mutation(MutationType) default_max_page_size 3 + if TESTING_INTERPRETER + use GraphQL::Execution::Interpreter + end + def self.resolve_type(type, object, ctx) if object == :test_error :not_a_type