diff --git a/lib/graphql/cardinal/executor.rb b/lib/graphql/cardinal/executor.rb index 88fc069..bab2eb2 100644 --- a/lib/graphql/cardinal/executor.rb +++ b/lib/graphql/cardinal/executor.rb @@ -5,13 +5,12 @@ require_relative "./executor/authorization" require_relative "./executor/hot_paths" require_relative "./executor/response_hash" -require_relative "./executor/error_formatting" +require_relative "./executor/error_formatter" module GraphQL module Cardinal class Executor include HotPaths - include ErrorFormatting TYPENAME_FIELD = "__typename" TYPENAME_FIELD_RESOLVER = TypenameResolver.new @@ -28,7 +27,6 @@ def initialize(schema, resolvers, document, root_object, variables: {}, context: @context = context @data = {} @errors = [] - @path = [] @exec_queue = [] @exec_count = 0 @context[:query] = @query @@ -69,7 +67,7 @@ def perform execute_scope(@exec_queue.shift) until @exec_queue.empty? end - response = { "data" => @errors.empty? ? @data : format_inline_errors(@data, @errors) } + response = { "data" => @errors.empty? ? @data : ErrorFormatter.new(@query, @data, @errors).perform } response["errors"] = @errors.map(&:to_h) unless @errors.empty? response end @@ -78,14 +76,13 @@ def perform def execute_scope(exec_scope) unless exec_scope.fields - lazy_field_keys = [] exec_scope.fields = execution_fields_by_key(exec_scope.parent_type, exec_scope.selections) exec_scope.fields.each_value do |exec_field| - @path.push(exec_field.key) parent_type = exec_scope.parent_type parent_sources = exec_scope.sources field_name = exec_field.name + exec_field.scope = exec_scope exec_field.type = @query.get_field(parent_type, field_name).type value_type = exec_field.type.unwrap @@ -94,71 +91,61 @@ def execute_scope(exec_scope) if field_name == TYPENAME_FIELD field_resolver = TYPENAME_FIELD_RESOLVER else - raise NotImplementedError, "No field resolver for `#{parent_type.graphql_name}.#{field_name}`" + raise NotImplementedError, "No field resolver for '#{parent_type.graphql_name}.#{field_name}'" end end - resolved_sources = if !field_resolver.authorized?(@context) - @errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: @path.dup, base: true) + exec_field.result = if !field_resolver.authorized?(@context) + @errors << AuthorizationError.new(type_name: parent_type.graphql_name, field_name: field_name, path: exec_field.path, base: true) Array.new(parent_sources.length, @errors.last) elsif !Authorization.can_access_type?(value_type, @context) - @errors << AuthorizationError.new(type_name: value_type.graphql_name, path: @path.dup, base: true) + @errors << AuthorizationError.new(type_name: value_type.graphql_name, path: exec_field.path, base: true) Array.new(parent_sources.length, @errors.last) else begin @tracers.each { _1.before_resolve_field(parent_type, field_name, parent_sources.length, @context) } field_resolver.resolve(parent_sources, exec_field.arguments(@variables), @context, exec_scope) rescue StandardError => e - report_exception(error: e) - @errors << InternalError.new(path: @path.dup, base: true) + report_exception(error: e, field: exec_field) + @errors << InternalError.new(path: exec_field.path, base: true) Array.new(parent_sources.length, @errors.last) ensure @tracers.each { _1.after_resolve_field(parent_type, field_name, parent_sources.length, @context) } @exec_count += 1 end end - - if resolved_sources.is_a?(Promise) - exec_field.promise = resolved_sources - lazy_field_keys << exec_field.key - else - resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field_keys) - lazy_field_keys.clear - end - - @path.pop end end - if exec_scope.lazy_fields_pending? + if exec_scope.lazy_fields? if exec_scope.lazy_fields_ready? - exec_scope.method(:lazy_exec!).call # << noop for loaders that have already run + exec_scope.send(:lazy_exec!) # << noop for loaders that have already run exec_scope.fields.each_value do |exec_field| - next unless exec_field.promise - - @path.push(exec_field.key) - resolve_execution_field(exec_scope, exec_field, exec_field.promise.value) - @path.pop + sources = exec_field.result.is_a?(Promise) ? exec_field.result.value : exec_field.result + resolve_execution_field(exec_field, sources) end else # requeue the scope to wait on others that haven't built fields yet @exec_queue << exec_scope end + else + exec_scope.fields.each_value do |exec_field| + resolve_execution_field(exec_field, exec_field.result) + end end nil end - def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field_keys = nil) - parent_sources = exec_scope.sources - parent_responses = exec_scope.responses + def resolve_execution_field(exec_field, resolved_sources) + parent_sources = exec_field.scope.sources + parent_responses = exec_field.scope.responses field_key = exec_field.key - field_name = exec_field.name field_type = exec_field.type return_type = field_type.unwrap if resolved_sources.length != parent_sources.length - report_exception("Incorrect number of results resolved. Expected #{parent_sources.length}, got #{resolved_sources.length}") + report_exception("Incorrect number of results resolved. Expected #{parent_sources.length}, got #{resolved_sources.length}", field: exec_field) resolved_sources = Array.new(parent_sources.length, nil) end @@ -168,9 +155,7 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field next_responses = [] resolved_sources.each_with_index do |source, i| # DANGER: HOT PATH! - response = parent_responses[i] - lazy_field_keys.each { |k| response[k] = nil } if lazy_field_keys && !lazy_field_keys.empty? - response[field_key] = build_composite_response(field_type, source, next_sources, next_responses) + parent_responses[i][field_key] = build_composite_response(exec_field, field_type, source, next_sources, next_responses) end if return_type.kind.abstract? @@ -184,7 +169,7 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field next_sources.each_with_index do |source, i| # DANGER: HOT PATH! impl_type = type_resolver.call(source, @context) - next_sources_by_type[impl_type] << (field_name == TYPENAME_FIELD ? impl_type.graphql_name : source) + next_sources_by_type[impl_type] << (exec_field.name == TYPENAME_FIELD ? impl_type.graphql_name : source) next_responses_by_type[impl_type] << next_responses[i].tap { |r| r.typename = impl_type.graphql_name } end @@ -193,7 +178,7 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field next_sources_by_type.each do |impl_type, impl_type_sources| # check concrete type access only once per resolved type... unless Authorization.can_access_type?(impl_type, @context) - @errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: @path.dup, base: true) + @errors << AuthorizationError.new(type_name: impl_type.graphql_name, path: exec_field.path, base: true) impl_type_sources = Array.new(impl_type_sources.length, @errors.last) end @@ -204,7 +189,8 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field responses: next_responses_by_type[impl_type], loader_cache: loader_cache, loader_group: loader_group, - parent: exec_scope, + path: exec_field.path, + parent: exec_field.scope, ) end @@ -215,17 +201,16 @@ def resolve_execution_field(exec_scope, exec_field, resolved_sources, lazy_field selections: exec_field.selections, sources: next_sources, responses: next_responses, - parent: exec_scope, + path: exec_field.path, + parent: exec_field.scope, ) end else # build leaf results resolved_sources.each_with_index do |val, i| # DANGER: HOT PATH! - response = parent_responses[i] - lazy_field_keys.each { |k| response[k] = nil } if lazy_field_keys && !lazy_field_keys.empty? - response[field_key] = if val.nil? || val.is_a?(StandardError) - build_missing_value(field_type, val) + parent_responses[i][field_key] = if val.nil? || val.is_a?(StandardError) + build_missing_value(exec_field, field_type, val) elsif return_type.kind.scalar? coerce_scalar_value(return_type, val) elsif return_type.kind.enum? @@ -254,7 +239,7 @@ def execution_fields_by_key(parent_type, selections, map: Hash.new { |h, k| h[k] fragment = @query.fragments[node.name] fragment_type = @query.get_type(fragment.type.name) if @query.possible_types(fragment_type).include?(parent_type) - execution_fields_by_key(parent_type, node.selections, map: map) + execution_fields_by_key(parent_type, fragment.selections, map: map) end else @@ -286,9 +271,9 @@ def if_argument?(bool_arg) end end - def report_exception(message = nil, error: nil, path: @path.dup) + def report_exception(message = nil, error: nil, field: nil) # todo: add real error reporting... - puts "Error at #{path.join(".")}: #{message || error&.message}" + puts "Error at #{field.path.join(".")}: #{message || error&.message}" if field puts error.backtrace.join("\n") if error end end diff --git a/lib/graphql/cardinal/executor/error_formatting.rb b/lib/graphql/cardinal/executor/error_formatter.rb similarity index 75% rename from lib/graphql/cardinal/executor/error_formatting.rb rename to lib/graphql/cardinal/executor/error_formatter.rb index aaf1ca1..a9ddba1 100644 --- a/lib/graphql/cardinal/executor/error_formatting.rb +++ b/lib/graphql/cardinal/executor/error_formatter.rb @@ -3,35 +3,51 @@ module GraphQL::Cardinal class Executor - module ErrorFormatting - private + class ErrorFormatter + def initialize(query, data, errors) + @query = query + @data = data + @target_paths = errors.map(&:path).tap(&:compact!).tap(&:uniq!) + @selection_path = [] + @actual_path = [] + end + + def perform + return @data if @target_paths.empty? - def format_inline_errors(data, _errors) - # todo: make this smarter to only traverse down actual error paths - @path = [] propagate_object_scope_errors( - data, + @data, @query.root_type_for_operation(@query.selected_operation.operation_type), @query.selected_operation.selections, ) end + private + def propagate_object_scope_errors(raw_object, parent_type, selections) return nil if raw_object.nil? selections.each do |node| case node when GraphQL::Language::Nodes::Field - field_name = node.alias || node.name - @path << field_name + field_key = node.alias || node.name + + return raw_object unless @target_paths.any? do |target_path| + target_path[@selection_path.length] == field_key && @selection_path.each_with_index.all? do |part, i| + part == target_path[i] + end + end + + @selection_path << field_key + @actual_path << field_key begin node_type = @query.get_field(parent_type, node.name).type named_type = node_type.unwrap - raw_value = raw_object[field_name] + raw_value = raw_object[field_key] - raw_object[field_name] = if raw_value.is_a?(ExecutionError) - raw_value.replace_path(@path.dup) unless raw_value.base_error? + raw_object[field_key] = if raw_value.is_a?(ExecutionError) + raw_value.replace_path(@actual_path.dup) unless raw_value.base_error? nil elsif node_type.list? node_type = node_type.of_type while node_type.non_null? @@ -42,9 +58,10 @@ def propagate_object_scope_errors(raw_object, parent_type, selections) propagate_object_scope_errors(raw_value, named_type, node.selections) end - return nil if node_type.non_null? && raw_object[field_name].nil? + return nil if node_type.non_null? && raw_object[field_key].nil? ensure - @path.pop + @selection_path.pop + @actual_path.pop end when GraphQL::Language::Nodes::InlineFragment @@ -79,7 +96,7 @@ def propagate_list_scope_errors(raw_list, current_node_type, selections) contains_null = false resolved_list = raw_list.map!.with_index do |raw_list_element, index| - @path << index + @actual_path << index begin result = if next_node_type.list? @@ -97,7 +114,7 @@ def propagate_list_scope_errors(raw_list, current_node_type, selections) result ensure - @path.pop + @actual_path.pop end end diff --git a/lib/graphql/cardinal/executor/execution_field.rb b/lib/graphql/cardinal/executor/execution_field.rb index fbeecc5..0a00205 100644 --- a/lib/graphql/cardinal/executor/execution_field.rb +++ b/lib/graphql/cardinal/executor/execution_field.rb @@ -4,20 +4,28 @@ module GraphQL::Cardinal class Executor class ExecutionField attr_reader :key, :node - attr_accessor :type, :promise + attr_accessor :scope, :type, :result - def initialize(key) + def initialize(key, scope = nil) @key = key.freeze + @scope = scope + @name = nil @node = nil @nodes = nil + @type = nil + @result = nil @arguments = nil - @promise = nil + @path = nil end def name @name ||= @node.name.freeze end + def path + @path ||= (@scope ? [*@scope.path, @key] : []).freeze + end + def add_node(n) if !@node @node = n diff --git a/lib/graphql/cardinal/executor/execution_scope.rb b/lib/graphql/cardinal/executor/execution_scope.rb index a6909c7..1fb170b 100644 --- a/lib/graphql/cardinal/executor/execution_scope.rb +++ b/lib/graphql/cardinal/executor/execution_scope.rb @@ -3,7 +3,7 @@ module GraphQL::Cardinal class Executor class ExecutionScope - attr_reader :parent_type, :selections, :sources, :responses, :parent + attr_reader :parent_type, :selections, :sources, :responses, :path, :parent attr_accessor :fields def initialize( @@ -13,6 +13,7 @@ def initialize( responses:, loader_cache: nil, loader_group: nil, + path: [], parent: nil ) @parent_type = parent_type @@ -21,6 +22,7 @@ def initialize( @responses = responses @loader_cache = loader_cache @loader_group = loader_group + @path = path.freeze @parent = parent @fields = nil end @@ -30,9 +32,8 @@ def defer(loader_class, keys:, group: nil) loader.load(keys) end - # does any field in this scope have a pending promise? - def lazy_fields_pending? - @fields&.each_value&.any? { _1.promise&.pending? } || false + def lazy_fields? + @fields&.each_value&.any? { _1.result.is_a?(Promise) } || false end # is this scope ungrouped, or have all scopes in the group built their fields? @@ -47,7 +48,7 @@ def loader_cache end def lazy_exec! - loader_cache.each_value { |loader| loader.method(:lazy_exec!).call } + loader_cache.each_value { |loader| loader.send(:lazy_exec!) } end end end diff --git a/lib/graphql/cardinal/executor/hot_paths.rb b/lib/graphql/cardinal/executor/hot_paths.rb index e26c81f..3e23d88 100644 --- a/lib/graphql/cardinal/executor/hot_paths.rb +++ b/lib/graphql/cardinal/executor/hot_paths.rb @@ -5,22 +5,22 @@ class Executor module HotPaths # DANGER: HOT PATH! # Overhead added here scales dramatically... - def build_composite_response(field_type, source, next_sources, next_responses) + def build_composite_response(exec_field, current_type, source, next_sources, next_responses) # if object authorization check implemented, then... # unless Authorization.can_access_object?(return_type, source, @context) if source.nil? || source.is_a?(ExecutionError) - build_missing_value(field_type, source) - elsif field_type.list? + build_missing_value(exec_field, current_type, source) + elsif current_type.list? unless source.is_a?(Array) - report_exception("Incorrect result for list field. Expected Array, got #{source.class}") - return build_missing_value(field_type, nil) + report_exception("Incorrect result for list field. Expected Array, got #{source.class}", field: exec_field) + return build_missing_value(exec_field, current_type, nil) end - field_type = field_type.of_type while field_type.non_null? + current_type = current_type.of_type while current_type.non_null? source.map do |src| - build_composite_response(field_type.of_type, src, next_sources, next_responses) + build_composite_response(exec_field, current_type.of_type, src, next_sources, next_responses) end else next_sources << source @@ -31,15 +31,14 @@ def build_composite_response(field_type, source, next_sources, next_responses) # DANGER: HOT PATH! # Overhead added here scales dramatically... - def build_missing_value(field_type, val) + def build_missing_value(exec_field, current_type, val) # the provided value should always be nil or an error object - - if field_type.non_null? - val ||= InvalidNullError.new(path: @path.dup) + if current_type.non_null? + val ||= InvalidNullError.new(path: exec_field.path) end if val - val.replace_path(@path.dup) unless val.path + val.replace_path(exec_field.path) unless val.path @errors << val unless val.base_error? end diff --git a/lib/graphql/cardinal/promise.rb b/lib/graphql/cardinal/promise.rb index a16df1d..9a94c79 100644 --- a/lib/graphql/cardinal/promise.rb +++ b/lib/graphql/cardinal/promise.rb @@ -23,6 +23,12 @@ def initialize end end + def self.resolve(value) + Promise.new do |resolve, reject| + resolve.call(value) + end + end + def self.all(promises) return Promise.resolve([]) if promises.empty? @@ -57,8 +63,8 @@ def then(on_fulfilled = nil, on_rejected = nil, &block) end end - def catch(on_rejected = nil) - self.then(nil, on_rejected) + def catch(on_rejected = nil, &block) + self.then(->(value) { value }, on_rejected || block) end def resolved? @@ -107,7 +113,7 @@ def handle_then(on_fulfilled, on_rejected, resolve, reject) if resolved? begin result = on_fulfilled.call(@value) - resolve.call(result) + result.is_a?(Promise) ? result.then(resolve, reject) : resolve.call(result) rescue => error reject.call(error) end diff --git a/test/fixtures.rb b/test/fixtures.rb index eb8f338..d310509 100644 --- a/test/fixtures.rb +++ b/test/fixtures.rb @@ -38,6 +38,7 @@ products(first: Int): ProductConnection nodes(ids: [ID!]!): [Node]! node(id: ID!): Node + noResolver: String } type WriteValuePayload { @@ -325,4 +326,3 @@ def perform(keys) }], }, } - diff --git a/test/graphql/cardinal/executor/abstracts_test.rb b/test/graphql/cardinal/executor/abstracts_test.rb new file mode 100644 index 0000000..3e5f20d --- /dev/null +++ b/test/graphql/cardinal/executor/abstracts_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::Executor::AbstractsTest < Minitest::Test + def test_abstract_type_object_access + document = %|{ + node(id: "Product/1") { + ... on Product { id } + __typename + } + }| + + source = { + "node" => { "id" => "Product/1", "__typename__" => "Product" }, + } + + expected = { + "node" => { "id" => "Product/1", "__typename" => "Product" }, + } + + assert_equal expected, breadth_exec(document, source).dig("data") + end + + def test_abstract_type_list_access + document = %|{ + nodes(ids: ["Product/1", "Variant/1"]) { + __typename + ... on Product { + id + } + ... on Variant { + title + } + } + }| + + source = { + "nodes" => [ + { "id" => "Product/1", "title" => "Product 1", "__typename__" => "Product" }, + { "id" => "Variant/1", "title" => "Variant 1", "__typename__" => "Variant" }, + ], + } + + expected = { + "nodes" => [ + { "id" => "Product/1", "__typename" => "Product" }, + { "title" => "Variant 1", "__typename" => "Variant" }, + ], + } + + assert_equal expected, breadth_exec(document, source).dig("data") + end +end diff --git a/test/graphql/cardinal/executor/aggregate_selections_test.rb b/test/graphql/cardinal/executor/aggregate_selections_test.rb new file mode 100644 index 0000000..a05ecdf --- /dev/null +++ b/test/graphql/cardinal/executor/aggregate_selections_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::Executor::AggregateSelectionsTest < Minitest::Test + NODE_SOURCE = { + "node" => { + "title" => "Banana", + "metafield" => { "key" => "test", "value" => "okay" }, + "__typename__" => "Product", + }, + }.freeze + + NODE_EXPECTED = { + "node" => { + "title" => "Banana", + "metafield" => { "key" => "test", "value" => "okay" }, + }, + }.freeze + + def test_aggregate_field_selections + document = %|{ + products(first: 1) { + nodes { + title + metafield(key: "test") { + key + } + metafield(key: "test") { + value + } + } + } + }| + + source = { + "products" => { + "nodes" => [{ + "title" => "Banana", + "metafield" => { "key" => "test", "value" => "okay" }, + }], + }, + } + + assert_equal source, breadth_exec(document, source).dig("data") + end + + def test_aggregate_field_access_across_inline_fragments + document = %|{ + node(id: "Product/1") { + ... on Product { + title + metafield(key: "test") { + key + } + } + ...on HasMetafields { + metafield(key: "test") { + value + } + } + } + }| + + assert_equal NODE_EXPECTED, breadth_exec(document, NODE_SOURCE).dig("data") + end + + def test_aggregate_field_access_across_fragment_spreads + document = %|{ + node(id: "Product/1") { + ... ProductAttrs + ... HasMetafieldsAttrs + } + } + fragment ProductAttrs on Product { + title + metafield(key: "test") { + key + } + } + fragment HasMetafieldsAttrs on HasMetafields { + metafield(key: "test") { + value + } + }| + + assert_equal NODE_EXPECTED, breadth_exec(document, NODE_SOURCE).dig("data") + end +end diff --git a/test/graphql/cardinal/executor/arguments_test.rb b/test/graphql/cardinal/executor/arguments_test.rb new file mode 100644 index 0000000..d38ce00 --- /dev/null +++ b/test/graphql/cardinal/executor/arguments_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::Executor::ArgumentsTest < Minitest::Test + def test_arguments_receive_string_variables + document = %|mutation($value: String!) { + writeValue(value: $value) { + value + } + }| + + source = { "writeValue" => { "value" => nil } } + expected = { "writeValue" => { "value" => "success!" } } + assert_equal expected, breadth_exec(document, source, variables: { "value" => "success!" }).dig("data") + end + + def test_arguments_receive_symbol_variables + document = %|mutation($value: String!) { + writeValue(value: $value) { + value + } + }| + + source = { "writeValue" => { "value" => nil } } + expected = { "writeValue" => { "value" => "success!" } } + assert_equal expected, breadth_exec(document, source, variables: { value: "success!" }).dig("data") + end +end diff --git a/test/graphql/cardinal/executor/conditionals_test.rb b/test/graphql/cardinal/executor/conditionals_test.rb new file mode 100644 index 0000000..189775a --- /dev/null +++ b/test/graphql/cardinal/executor/conditionals_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::Executor::ConditionalsTest < Minitest::Test + SOURCE = { + "products" => { + "nodes" => [ + { "id" => "Product/1", "title" => "Product 1" }, + { "id" => "Product/2", "title" => "Product 2" }, + ], + }, + }.freeze + + SKIPPED_SOURCE = { + "products" => { + "nodes" => [ + { "id" => "Product/1" }, + { "id" => "Product/2" }, + ], + }, + }.freeze + + def test_follows_skip_directive_omissions + document = %|{ + products(first: 3) { + nodes { + id + title @skip(if: true) + } + } + }| + + assert_equal SKIPPED_SOURCE, breadth_exec(document, SOURCE).dig("data") + end + + def test_follows_skip_directive_inclusions + document = %|{ + products(first: 3) { + nodes { + id + title @skip(if: false) + } + } + }| + + assert_equal SOURCE, breadth_exec(document, SOURCE).dig("data") + end + + def test_follows_skip_directives_with_string_variable + document = %|query($skip: Boolean!) { + products(first: 3) { + nodes { + id + title @skip(if: $skip) + } + } + }| + + assert_equal SKIPPED_SOURCE, breadth_exec(document, SOURCE, variables: { "skip" => true }).dig("data") + end + + def test_follows_skip_directives_with_symbol_variable + document = %|query($skip: Boolean!) { + products(first: 3) { + nodes { + id + title @skip(if: $skip) + } + } + }| + + assert_equal SKIPPED_SOURCE, breadth_exec(document, SOURCE, variables: { skip: true }).dig("data") + end + + def test_follows_include_directive_omissions + document = %|{ + products(first: 3) { + nodes { + id + title @include(if: false) + } + } + }| + + assert_equal SKIPPED_SOURCE, breadth_exec(document, SOURCE).dig("data") + end + + def test_follows_include_directive_inclusions + document = %|{ + products(first: 3) { + nodes { + id + title @include(if: true) + } + } + }| + + assert_equal SOURCE, breadth_exec(document, SOURCE).dig("data") + end + + def test_follows_include_directives_with_string_variable + document = %|query($include: Boolean!) { + products(first: 3) { + nodes { + id + title @include(if: $include) + } + } + }| + + assert_equal SKIPPED_SOURCE, breadth_exec(document, SOURCE, variables: { "include" => false }).dig("data") + end + + def test_follows_include_directives_with_symbol_variable + document = %|query($include: Boolean!) { + products(first: 3) { + nodes { + id + title @include(if: $include) + } + } + }| + + assert_equal SKIPPED_SOURCE, breadth_exec(document, SOURCE, variables: { include: false }).dig("data") + end +end diff --git a/test/graphql/cardinal/executor/errors_test.rb b/test/graphql/cardinal/executor/errors_test.rb index e4dbfd9..39a76d4 100644 --- a/test/graphql/cardinal/executor/errors_test.rb +++ b/test/graphql/cardinal/executor/errors_test.rb @@ -3,7 +3,7 @@ require "test_helper" class GraphQL::Cardinal::Executor::ErrorsTest < Minitest::Test - def test_nullable_positional_errors + def test_nullable_positional_error_adds_path document = %|{ products(first: 3) { nodes { @@ -17,7 +17,7 @@ def test_nullable_positional_errors "nodes" => [ { "maybe" => "okay!" }, { "maybe" => nil }, - { "maybe" => GraphQL::Cardinal::ExecutionError.new }, + { "maybe" => GraphQL::Cardinal::ExecutionError.new("Not okay!") }, ], }, } @@ -33,7 +33,7 @@ def test_nullable_positional_errors }, }, "errors" => [{ - "message" => "An unknown error occurred", + "message" => "Not okay!", "path" => ["products", "nodes", 2, "maybe"], }], } @@ -41,7 +41,38 @@ def test_nullable_positional_errors assert_equal expected, breadth_exec(document, source) end - def test_non_null_positional_errors + def test_non_null_positional_error_adds_path_and_propagates + document = %|{ + products(first: 3) { + nodes { + must + } + } + }| + + source = { + "products" => { + "nodes" => [ + { "must" => "okay!" }, + { "must" => GraphQL::Cardinal::ExecutionError.new("Not okay!") }, + ], + }, + } + + expected = { + "data" => { + "products" => { "nodes" => nil }, + }, + "errors" => [{ + "message" => "Not okay!", + "path" => ["products", "nodes", 1, "must"], + }], + } + + assert_equal expected, breadth_exec(document, source) + end + + def test_null_in_non_null_position_propagates document = %|{ products(first: 3) { nodes { @@ -55,23 +86,17 @@ def test_non_null_positional_errors "nodes" => [ { "must" => "okay!" }, { "must" => nil }, - { "must" => GraphQL::Cardinal::ExecutionError.new }, ], }, } expected = { "data" => { - "products" => { - "nodes" => nil, - }, + "products" => { "nodes" => nil }, }, "errors" => [{ "message" => "Failed to resolve expected value", "path" => ["products", "nodes", 1, "must"], - }, { - "message" => "An unknown error occurred", - "path" => ["products", "nodes", 2, "must"], }], } diff --git a/test/graphql/cardinal/executor/fragments_test.rb b/test/graphql/cardinal/executor/fragments_test.rb new file mode 100644 index 0000000..afc33e2 --- /dev/null +++ b/test/graphql/cardinal/executor/fragments_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::Executor::FragmentsTest < Minitest::Test + SOURCE = { + "products" => { + "nodes" => [{ + "title" => "Banana", + "metafield" => { "key" => "test", "value" => "okay" }, + }], + }, + }.freeze + + def test_selects_via_inline_fragments + document = %|{ + products(first: 1) { + nodes { + ... on Product { title } + } + } + }| + + expected = { + "products" => { + "nodes" => [{ "title" => "Banana" }], + }, + } + + assert_equal expected, breadth_exec(document, SOURCE).dig("data") + end + + def test_selects_via_fragment_spreads + document = %|{ + products(first: 1) { + nodes { + ... ProductAttrs + } + } + } + fragment ProductAttrs on Product { + title + }| + + expected = { + "products" => { + "nodes" => [{ "title" => "Banana" }], + }, + } + + assert_equal expected, breadth_exec(document, SOURCE).dig("data") + end + + def test_selects_via_nested_fragments + document = %|{ + products(first: 1) { + nodes { + ... on Product { + ... ProductAttrs + } + } + } + } + fragment ProductAttrs on Product { + ... on Product { title } + }| + + expected = { + "products" => { + "nodes" => [{ "title" => "Banana" }], + }, + } + + assert_equal expected, breadth_exec(document, SOURCE).dig("data") + end + + def test_selects_via_abstract_fragments + document = %|{ + products(first: 1) { + nodes { + ... on HasMetafields { + metafield(key: "test") { key value } + } + } + } + }| + + expected = { + "products" => { + "nodes" => [{ + "metafield" => { "key" => "test", "value" => "okay" }, + }], + }, + } + + assert_equal expected, breadth_exec(document, SOURCE).dig("data") + end +end diff --git a/test/graphql/cardinal/executor_test.rb b/test/graphql/cardinal/executor_test.rb index 80d4808..db18379 100644 --- a/test/graphql/cardinal/executor_test.rb +++ b/test/graphql/cardinal/executor_test.rb @@ -3,7 +3,7 @@ require "test_helper" class GraphQL::Cardinal::ExecutorTest < Minitest::Test - def test_runs + def test_resolves_basic_query assert_equal BASIC_SOURCE, breadth_exec(BASIC_DOCUMENT, BASIC_SOURCE).dig("data") end @@ -20,9 +20,7 @@ def test_resolves_typename_field }| source = { - "products" => { - "nodes" => [{}], - }, + "products" => { "nodes" => [{}] }, "node" => { "__typename__" => "Product" }, } @@ -36,166 +34,7 @@ def test_resolves_typename_field assert_equal expected, breadth_exec(document, source).dig("data") end - def test_follows_skip_directives - document = %|{ - products(first: 3) { - nodes { - id - title @skip(if: true) - } - } - }| - - source = { - "products" => { - "nodes" => [ - { "id" => "Product/1" }, - { "id" => "Product/2" }, - ], - }, - } - - assert_equal source, breadth_exec(document, source).dig("data") - end - - def test_follows_include_directives - document = %|{ - products(first: 3) { - nodes { - id - title @include(if: false) - } - } - }| - - source = { - "products" => { - "nodes" => [ - { "id" => "Product/1" }, - { "id" => "Product/2" }, - ], - }, - } - - assert_equal source, breadth_exec(document, source).dig("data") - end - - def test_aggregate_field_access - document = %|{ - node(id: "Product/1") { - ... on Product { - title - metafield(key: "test") { - key - } - metafield(key: "test") { - value - } - } - } - }| - - source = { - "node" => { - "title" => "Banana", - "metafield" => { "key" => "test", "value" => "okay" }, - "__typename__" => "Product", - }, - } - - expected = { - "node" => { - "title" => "Banana", - "metafield" => { "key" => "test", "value" => "okay" }, - }, - } - - assert_equal expected, breadth_exec(document, source).dig("data") - end - - def test_aggregate_field_access_across_fragments - document = %|{ - node(id: "Product/1") { - ... on Product { - title - metafield(key: "test") { - key - } - } - ...on HasMetafields { - metafield(key: "test") { - value - } - } - } - }| - - source = { - "node" => { - "title" => "Banana", - "metafield" => { "key" => "test", "value" => "okay" }, - "__typename__" => "Product", - }, - } - - expected = { - "node" => { - "title" => "Banana", - "metafield" => { "key" => "test", "value" => "okay" }, - }, - } - - assert_equal expected, breadth_exec(document, source).dig("data") - end - - def test_abstract_type_object_access - document = %|{ - node(id: "Product/1") { - ... on Product { id } - } - }| - - source = { - "node" => { "id" => "Product/1", "__typename__" => "Product" }, - } - - expected = { - "node" => { "id" => "Product/1" }, - } - - assert_equal expected, breadth_exec(document, source).dig("data") - end - - def test_abstract_type_list_access - document = %|{ - nodes(ids: ["Product/1", "Variant/1"]) { - ... on Product { - id - } - ... on Variant { - title - } - } - }| - - source = { - "nodes" => [ - { "id" => "Product/1", "title" => "Product 1", "__typename__" => "Product" }, - { "id" => "Variant/1", "title" => "Variant 1", "__typename__" => "Variant" }, - ], - } - - expected = { - "nodes" => [ - { "id" => "Product/1" }, - { "title" => "Variant 1" }, - ], - } - - assert_equal expected, breadth_exec(document, source).dig("data") - end - - def test_serial_mutations + def test_mutations_run_serially document = %|mutation { a: writeValue(value: "test1") { value @@ -220,4 +59,26 @@ def test_serial_mutations assert_equal expected, breadth_exec(document, source).dig("data") end + + def test_subscriptions_not_supported + document = %|subscription { + onWriteValue { value } + }| + + error = assert_raises(GraphQL::Cardinal::DocumentError) do + breadth_exec(document, {}) + end + + assert_equal "Unsupported operation type: subscription", error.message + end + + def test_raises_not_implemented_for_missing_resolvers + document = %|{ noResolver }| + + error = assert_raises(NotImplementedError) do + breadth_exec(document, {}) + end + + assert_equal "No field resolver for 'Query.noResolver'", error.message + end end diff --git a/test/graphql/cardinal/promise_test.rb b/test/graphql/cardinal/promise_test.rb new file mode 100644 index 0000000..fb01d72 --- /dev/null +++ b/test/graphql/cardinal/promise_test.rb @@ -0,0 +1,364 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::PromiseTest < Minitest::Test + def setup + @promise = GraphQL::Cardinal::Promise.new + end + + def test_initial_state_is_pending + assert_predicate @promise, :pending? + refute_predicate @promise, :resolved? + refute_predicate @promise, :rejected? + assert_nil @promise.value + assert_nil @promise.reason + end + + def test_resolve_changes_state_to_fulfilled + @promise.send(:resolve, "test value") + + assert_predicate @promise, :resolved? + refute_predicate @promise, :pending? + refute_predicate @promise, :rejected? + assert_equal "test value", @promise.value + assert_nil @promise.reason + end + + def test_reject_changes_state_to_rejected + error = StandardError.new("test error") + @promise.send(:reject, error) + + assert_predicate @promise, :rejected? + refute_predicate @promise, :pending? + refute_predicate @promise, :resolved? + assert_nil @promise.value + assert_equal error, @promise.reason + end + + def test_cannot_resolve_already_resolved_promise + @promise.send(:resolve, "first value") + @promise.send(:resolve, "second value") + + assert_equal "first value", @promise.value + end + + def test_cannot_reject_already_resolved_promise + @promise.send(:resolve, "value") + error = StandardError.new("error") + @promise.send(:reject, error) + + assert_predicate @promise, :resolved? + assert_equal "value", @promise.value + assert_nil @promise.reason + end + + def test_cannot_resolve_already_rejected_promise + error = StandardError.new("error") + @promise.send(:reject, error) + @promise.send(:resolve, "value") + + assert_predicate @promise, :rejected? + assert_equal error, @promise.reason + assert_nil @promise.value + end + + def test_cannot_reject_already_rejected_promise + error1 = StandardError.new("first error") + error2 = StandardError.new("second error") + @promise.send(:reject, error1) + @promise.send(:reject, error2) + + assert_equal error1, @promise.reason + end + + def test_constructor_with_block_resolves + promise = GraphQL::Cardinal::Promise.new do |resolve, reject| + resolve.call("test value") + end + + assert_predicate promise, :resolved? + assert_equal "test value", promise.value + end + + def test_constructor_with_block_rejects + error = StandardError.new("test error") + promise = GraphQL::Cardinal::Promise.new do |resolve, reject| + reject.call(error) + end + + assert_predicate promise, :rejected? + assert_equal error, promise.reason + end + + def test_constructor_with_block_catches_exceptions + promise = GraphQL::Cardinal::Promise.new do |resolve, reject| + raise StandardError.new("test error") + end + + assert_predicate promise, :rejected? + assert_instance_of StandardError, promise.reason + assert_equal "test error", promise.reason.message + end + + def test_then_with_fulfilled_promise + @promise.send(:resolve, "original value") + + result_promise = @promise.then { |value| "transformed: #{value}" } + + assert_predicate result_promise, :resolved? + assert_equal "transformed: original value", result_promise.value + end + + def test_then_with_rejected_promise + error = StandardError.new("original error") + @promise.send(:reject, error) + + result_promise = @promise.then( + ->(value) { "should not be called" }, + ->(reason) { "handled: #{reason.message}" } + ) + + assert_predicate result_promise, :resolved? + assert_equal "handled: original error", result_promise.value + end + + def test_then_with_pending_promise + fulfilled_called = false + rejected_called = false + + result_promise = @promise.then( + ->(value) { fulfilled_called = true; "fulfilled: #{value}" }, + ->(reason) { rejected_called = true; "rejected: #{reason.message}" } + ) + + assert_predicate result_promise, :pending? + refute fulfilled_called + refute rejected_called + + @promise.send(:resolve, "test value") + + assert_predicate result_promise, :resolved? + assert_equal "fulfilled: test value", result_promise.value + assert fulfilled_called + refute rejected_called + end + + def test_then_with_pending_promise_rejected + fulfilled_called = false + rejected_called = false + + result_promise = @promise.then( + ->(value) { fulfilled_called = true; "fulfilled: #{value}" }, + ->(reason) { rejected_called = true; "rejected: #{reason.message}" } + ) + + error = StandardError.new("test error") + @promise.send(:reject, error) + + assert_predicate result_promise, :resolved? + assert_equal "rejected: test error", result_promise.value + refute fulfilled_called + assert rejected_called + end + + def test_then_catches_exceptions_in_fulfilled_handler + @promise.send(:resolve, "test value") + + result_promise = @promise.then { |value| raise StandardError.new("handler error") } + + assert_predicate result_promise, :rejected? + assert_instance_of StandardError, result_promise.reason + assert_equal "handler error", result_promise.reason.message + end + + def test_then_catches_exceptions_in_rejected_handler + @promise.send(:reject, StandardError.new("original error")) + + result_promise = @promise.then( + ->(value) { "should not be called" }, + ->(reason) { raise StandardError.new("handler error") } + ) + + assert_predicate result_promise, :rejected? + assert_instance_of StandardError, result_promise.reason + assert_equal "handler error", result_promise.reason.message + end + + def test_then_returns_promise_from_fulfilled_handler + inner_promise = GraphQL::Cardinal::Promise.new + @promise.send(:resolve, "test value") + + result_promise = @promise.then { |value| inner_promise } + + assert_predicate result_promise, :pending? + + inner_promise.send(:resolve, "inner value") + + assert_predicate result_promise, :resolved? + assert_equal "inner value", result_promise.value + end + + def test_then_returns_promise_from_rejected_handler + inner_promise = GraphQL::Cardinal::Promise.new + @promise.send(:reject, StandardError.new("original error")) + + result_promise = @promise.then( + ->(value) { "should not be called" }, + ->(reason) { inner_promise } + ) + + assert_predicate result_promise, :pending? + + inner_promise.send(:resolve, "recovered value") + + assert_predicate result_promise, :resolved? + assert_equal "recovered value", result_promise.value + end + + def test_then_requires_fulfilled_handler_or_block + assert_raises ArgumentError do + @promise.then + end + end + + def test_then_forbids_both_fulfilled_handler_and_block + assert_raises ArgumentError do + @promise.then(->(v) { v }) { |v| v } + end + end + + def test_catch_handles_rejected_promise + error = StandardError.new("test error") + @promise.send(:reject, error) + + result_promise = @promise.catch { |reason| "caught: #{reason.message}" } + + assert_predicate result_promise, :resolved? + assert_equal "caught: test error", result_promise.value + end + + def test_catch_passes_through_resolved_promise + @promise.send(:resolve, "test value") + + result_promise = @promise.catch { |reason| "should not be called" } + + assert_predicate result_promise, :resolved? + assert_equal "test value", result_promise.value + end + + def test_promise_resolve_creates_resolved_promise + result_promise = GraphQL::Cardinal::Promise.resolve("test value") + + assert_predicate result_promise, :resolved? + assert_equal "test value", result_promise.value + end + + def test_all_with_empty_array + result_promise = GraphQL::Cardinal::Promise.all([]) + + assert_predicate result_promise, :resolved? + assert_equal [], result_promise.value + end + + def test_all_with_single_resolved_promise + promise1 = GraphQL::Cardinal::Promise.new + promise1.send(:resolve, "value1") + + result_promise = GraphQL::Cardinal::Promise.all([promise1]) + + assert_predicate result_promise, :resolved? + assert_equal ["value1"], result_promise.value + end + + def test_all_with_multiple_resolved_promises + promise1 = GraphQL::Cardinal::Promise.new + promise2 = GraphQL::Cardinal::Promise.new + promise3 = GraphQL::Cardinal::Promise.new + + promise1.send(:resolve, "value1") + promise2.send(:resolve, "value2") + promise3.send(:resolve, "value3") + + result_promise = GraphQL::Cardinal::Promise.all([promise1, promise2, promise3]) + + assert_predicate result_promise, :resolved? + assert_equal ["value1", "value2", "value3"], result_promise.value + end + + def test_all_with_pending_promises + promise1 = GraphQL::Cardinal::Promise.new + promise2 = GraphQL::Cardinal::Promise.new + promise3 = GraphQL::Cardinal::Promise.new + + result_promise = GraphQL::Cardinal::Promise.all([promise1, promise2, promise3]) + + assert_predicate result_promise, :pending? + + promise1.send(:resolve, "value1") + assert_predicate result_promise, :pending? + + promise2.send(:resolve, "value2") + assert_predicate result_promise, :pending? + + promise3.send(:resolve, "value3") + assert_predicate result_promise, :resolved? + assert_equal ["value1", "value2", "value3"], result_promise.value + end + + def test_all_rejects_if_any_promise_rejects + promise1 = GraphQL::Cardinal::Promise.new + promise2 = GraphQL::Cardinal::Promise.new + promise3 = GraphQL::Cardinal::Promise.new + + result_promise = GraphQL::Cardinal::Promise.all([promise1, promise2, promise3]) + + promise1.send(:resolve, "value1") + error = StandardError.new("test error") + promise2.send(:reject, error) + promise3.send(:resolve, "value3") + + assert_predicate result_promise, :rejected? + assert_equal error, result_promise.reason + end + + def test_all_maintains_order + promise1 = GraphQL::Cardinal::Promise.new + promise2 = GraphQL::Cardinal::Promise.new + promise3 = GraphQL::Cardinal::Promise.new + + result_promise = GraphQL::Cardinal::Promise.all([promise1, promise2, promise3]) + + # Resolve in different order + promise3.send(:resolve, "value3") + promise1.send(:resolve, "value1") + promise2.send(:resolve, "value2") + + assert_predicate result_promise, :resolved? + assert_equal ["value1", "value2", "value3"], result_promise.value + end + + def test_chaining_then_calls + @promise.send(:resolve, 1) + + result_promise = @promise + .then { |value| value + 1 } + .then { |value| value * 2 } + .then { |value| "result: #{value}" } + + assert_predicate result_promise, :resolved? + assert_equal "result: 4", result_promise.value + end + + def test_error_propagation_through_chain + @promise.send(:resolve, "test") + + result_promise = @promise + .then { |value| raise StandardError.new("chain error") } + .then { |value| "should not be called" } + .catch { |error| "caught: #{error.message}" } + + assert_predicate result_promise, :resolved? + assert_equal "caught: chain error", result_promise.value + end +end