diff --git a/Rakefile b/Rakefile index b2bd9f7..ee83901 100644 --- a/Rakefile +++ b/Rakefile @@ -32,6 +32,12 @@ namespace :benchmark do GraphQLBenchmark.benchmark_lazy_execution end + desc "Benchmark introspection" + task :introspection do + prepare_benchmark + GraphQLBenchmark.benchmark_introspection + end + desc "Memory profile" task :memory do prepare_benchmark diff --git a/benchmark/run.rb b/benchmark/run.rb index 059f8cf..3dffa51 100644 --- a/benchmark/run.rb +++ b/benchmark/run.rb @@ -93,6 +93,28 @@ def benchmark_lazy_execution end end + def benchmark_introspection + document = GraphQL.parse(GraphQL::Introspection.query) + + Benchmark.ips do |x| + x.report("graphql-ruby: introspection") do + GRAPHQL_GEM_SCHEMA.execute(document: document) + end + + x.report("graphql-cardinal introspection") do + GraphQL::Cardinal::Executor.new( + SCHEMA, + BREADTH_RESOLVERS, + document, + {}, + tracers: [CARDINAL_TRACER], + ).perform + end + + x.compare! + end + end + def memory_profile default_data_sizes = "10, 1000" sizes = ENV.fetch("SIZES", default_data_sizes).split(",").map(&:to_i) diff --git a/lib/graphql/cardinal.rb b/lib/graphql/cardinal.rb index 895eace..b2e933c 100644 --- a/lib/graphql/cardinal.rb +++ b/lib/graphql/cardinal.rb @@ -15,5 +15,6 @@ module Cardinal require_relative "cardinal/loader" require_relative "cardinal/tracer" require_relative "cardinal/field_resolvers" +require_relative "cardinal/introspection" require_relative "cardinal/executor" require_relative "cardinal/version" diff --git a/lib/graphql/cardinal/executor.rb b/lib/graphql/cardinal/executor.rb index bab2eb2..93e8fb1 100644 --- a/lib/graphql/cardinal/executor.rb +++ b/lib/graphql/cardinal/executor.rb @@ -24,7 +24,7 @@ def initialize(schema, resolvers, document, root_object, variables: {}, context: @root_object = root_object @tracers = tracers @variables = variables - @context = context + @context = @query.context @data = {} @errors = [] @exec_queue = [] @@ -211,10 +211,8 @@ def resolve_execution_field(exec_field, resolved_sources) # DANGER: HOT PATH! 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? - coerce_enum_value(return_type, val) + elsif return_type.kind.leaf? + coerce_leaf_value(exec_field, field_type, val) else val end diff --git a/lib/graphql/cardinal/executor/hot_paths.rb b/lib/graphql/cardinal/executor/hot_paths.rb index 3e23d88..34ece0e 100644 --- a/lib/graphql/cardinal/executor/hot_paths.rb +++ b/lib/graphql/cardinal/executor/hot_paths.rb @@ -3,6 +3,8 @@ module GraphQL::Cardinal class Executor module HotPaths + INCORRECT_LIST_VALUE = "Incorrect result for list field. Expected Array, got ".freeze + # DANGER: HOT PATH! # Overhead added here scales dramatically... def build_composite_response(exec_field, current_type, source, next_sources, next_responses) @@ -13,7 +15,7 @@ def build_composite_response(exec_field, current_type, source, next_sources, nex 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}", field: exec_field) + report_exception("#{INCORRECT_LIST_VALUE}#{source.class}", field: exec_field) return build_missing_value(exec_field, current_type, nil) end @@ -47,28 +49,25 @@ def build_missing_value(exec_field, current_type, val) # DANGER: HOT PATH! # Overhead added here scales dramatically... - def coerce_scalar_value(type, value) - case type.graphql_name - when "String" - value.is_a?(String) ? value : value.to_s - when "ID" - value.is_a?(String) || value.is_a?(Numeric) ? value : value.to_s - when "Int" - value.is_a?(Integer) ? value : Integer(value) - when "Float" - value.is_a?(Float) ? value : Float(value) - when "Boolean" - value == TrueClass || value == FalseClass ? value : !!value + def coerce_leaf_value(exec_field, current_type, val) + if current_type.list? + unless val.is_a?(Array) + report_exception("#{INCORRECT_LIST_VALUE}#{val.class}", field: exec_field) + return build_missing_value(exec_field, current_type, nil) + end + + current_type = current_type.of_type while current_type.non_null? + + val.each { coerce_leaf_value(exec_field, current_type.of_type, _1) } else - value + begin + current_type.unwrap.coerce_result(val, @context) + rescue StandardError => e + report_exception("Coercion error", field: exec_field) + build_missing_value(exec_field, current_type, val) + end end end - - # DANGER: HOT PATH! - # Overhead added here scales dramatically... - def coerce_enum_value(type, value) - value - end end end end diff --git a/lib/graphql/cardinal/field_resolvers.rb b/lib/graphql/cardinal/field_resolvers.rb index 4a6040d..5c49831 100644 --- a/lib/graphql/cardinal/field_resolvers.rb +++ b/lib/graphql/cardinal/field_resolvers.rb @@ -36,6 +36,18 @@ def resolve(objects, _args, _ctx, _scope) end end + class MethodResolver < FieldResolver + def initialize(name) + @name = name + end + + def resolve(objects, _args, _ctx, _scope) + map_sources(objects) do |obj| + obj.public_send(@name) + end + end + end + class TypenameResolver < FieldResolver def resolve(objects, _args, _ctx, scope) typename = scope.parent_type.graphql_name.freeze diff --git a/lib/graphql/cardinal/introspection.rb b/lib/graphql/cardinal/introspection.rb new file mode 100644 index 0000000..19309cf --- /dev/null +++ b/lib/graphql/cardinal/introspection.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +module GraphQL + module Cardinal + module Introspection + module Schema + class EndpointResolver < FieldResolver + def resolve(objects, _args, ctx, _exec_field) + Array.new(objects.length, ctx.query.schema) + end + end + + class TypesResolver < FieldResolver + def resolve(objects, _args, ctx, _exec_field) + types = ctx.query.types.all_types + Array.new(objects.length, types) + end + end + + class DirectivesResolver < FieldResolver + def resolve(objects, _args, ctx, _exec_field) + directives = ctx.query.types.directives + Array.new(objects.length, directives) + end + end + end + + module Type + class EndpointResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + type = ctx.query.get_type(args["name"]) + Array.new(objects.length, type) + end + end + + class TypeKindResolver < FieldResolver + def resolve(objects, _args, ctx, _exec_field) + map_sources(objects) do |type| + type.kind.name + end + end + end + + class EnumValuesResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) do |type| + if type.kind.enum? + enum_values = ctx.query.types.enum_values(type) + enum_values = enum_values.reject(&:deprecation_reason) unless args["includeDeprecated"] + enum_values + end + end + end + end + + class FieldsResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) do |type| + if type.kind.fields? + fields = ctx.query.types.fields(type) + fields = fields.reject(&:deprecation_reason) unless args["includeDeprecated"] + fields + end + end + end + end + + class InputFieldsResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) do |type| + if type.kind.input_object? + fields = ctx.query.types.arguments(type) + fields = fields.reject(&:deprecation_reason) unless args["includeDeprecated"] + fields + end + end + end + end + + class InterfacesResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) do |type| + ctx.query.types.interfaces(type) if type.kind.fields? + end + end + end + + class PossibleTypesResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) do |type| + ctx.query.possible_types(type) if type.kind.abstract? + end + end + end + + class OfTypeResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) do |type| + type.of_type if type.kind.wraps? + end + end + end + + class SpecifiedByUrlResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) do |type| + type.specified_by_url if type.kind.scalar? + end + end + end + end + + class ArgumentsResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) do |owner| + owner_args = ctx.query.types.arguments(owner) + owner_args = owner_args.reject(&:deprecation_reason) unless args["includeDeprecated"] + owner_args + end + end + end + + class ArgumentDefaultValueResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + builder = nil + printer = nil + map_sources(objects) do |arg| + next nil unless arg.default_value? + + builder ||= GraphQL::Language::DocumentFromSchemaDefinition.new(ctx.query.schema, context: ctx) + printer ||= GraphQL::Language::Printer.new + printer.print(builder.build_default_value(arg.default_value, arg.type)) + end + end + end + + class IsDeprecatedResolver < FieldResolver + def resolve(objects, args, ctx, _exec_field) + map_sources(objects) { !!_1.deprecation_reason } + end + end + + ENTRYPOINT_RESOLVERS = { + "__schema" => Schema::EndpointResolver.new, + "__type" => Type::EndpointResolver.new, + }.freeze + + TYPE_RESOLVERS = { + "__Schema" => { + "description" => MethodResolver.new(:description), + "directives" => Schema::DirectivesResolver.new, + "mutationType" => MethodResolver.new(:mutation), + "queryType" => MethodResolver.new(:query), + "subscriptionType" => MethodResolver.new(:subscription), + "types" => Schema::TypesResolver.new, + }, + "__Type" => { + "description" => MethodResolver.new(:description), + "enumValues" => Type::EnumValuesResolver.new, + "fields" => Type::FieldsResolver.new, + "inputFields" => Type::InputFieldsResolver.new, + "interfaces" => Type::InterfacesResolver.new, + "kind" => Type::TypeKindResolver.new, + "name" => MethodResolver.new(:graphql_name), + "ofType" => Type::OfTypeResolver.new, + "possibleTypes" => Type::PossibleTypesResolver.new, + "specifiedByURL" => Type::SpecifiedByUrlResolver.new, + }, + "__Field" => { + "args" => ArgumentsResolver.new, + "deprecationReason" => MethodResolver.new(:deprecation_reason), + "description" => MethodResolver.new(:description), + "isDeprecated" => IsDeprecatedResolver.new, + "name" => MethodResolver.new(:graphql_name), + "type" => MethodResolver.new(:type), + }, + "__InputValue" => { + "defaultValue" => ArgumentDefaultValueResolver.new, + "deprecationReason" => MethodResolver.new(:deprecation_reason), + "description" => MethodResolver.new(:description), + "isDeprecated" => IsDeprecatedResolver.new, + "name" => MethodResolver.new(:graphql_name), + "type" => MethodResolver.new(:type), + }, + "__EnumValue" => { + "deprecationReason" => MethodResolver.new(:deprecation_reason), + "description" => MethodResolver.new(:description), + "isDeprecated" => IsDeprecatedResolver.new, + "name" => MethodResolver.new(:graphql_name), + }, + "__Directive" => { + "args" => ArgumentsResolver.new, + "description" => MethodResolver.new(:description), + "isRepeatable" => MethodResolver.new(:repeatable?), + "locations" => MethodResolver.new(:locations), + "name" => MethodResolver.new(:graphql_name), + } + }.freeze + end + end +end diff --git a/test/fixtures.rb b/test/fixtures.rb index d310509..9bccff1 100644 --- a/test/fixtures.rb +++ b/test/fixtures.rb @@ -96,6 +96,7 @@ def perform(keys) end BREADTH_RESOLVERS = { + **GraphQL::Cardinal::Introspection::TYPE_RESOLVERS, "Node" => { "id" => GraphQL::Cardinal::HashKeyResolver.new("id"), "__type__" => ->(obj, ctx) { ctx[:query].get_type(obj["__typename__"]) }, @@ -130,6 +131,7 @@ def perform(keys) "value" => GraphQL::Cardinal::HashKeyResolver.new("value"), }, "Query" => { + **GraphQL::Cardinal::Introspection::ENTRYPOINT_RESOLVERS, "products" => GraphQL::Cardinal::HashKeyResolver.new("products"), "nodes" => GraphQL::Cardinal::HashKeyResolver.new("nodes"), "node" => GraphQL::Cardinal::HashKeyResolver.new("node"), diff --git a/test/graphql/cardinal/executor/introspection_test.rb b/test/graphql/cardinal/executor/introspection_test.rb new file mode 100644 index 0000000..9da7125 --- /dev/null +++ b/test/graphql/cardinal/executor/introspection_test.rb @@ -0,0 +1,542 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::Executor::IntrospectionTest < Minitest::Test + TEST_SCHEMA = GraphQL::Schema.from_definition(%| + """@stitch description""" + directive @stitch(key: ID!) repeatable on FIELD_DEFINITION + + """Status description""" + enum Status { + YES + NO + """maybe description""" + MAYBE @deprecated(reason: "use no") + } + + """Node description""" + interface Node { + id: ID! + } + + """Widget description""" + type Widget implements Node { + id: ID! + status: Status! + } + + """Sprocket description""" + type Sprocket implements Node { + id: ID! + status: Status! + } + + """Trinket description""" + union Trinket = Widget \| Sprocket + + type Query { + node: Node! @stitch(key: "id") + """widget description""" + widget(id: ID!): Widget! @deprecated(reason: "use widgets") + widgets(ids: [ID!]!): [Widget]! + sprockets(ids: [ID!]!): [Sprocket]! + } + + """TrinketInput description""" + input TrinketInput { + status: Status! + state: String @deprecated(reason: "use status") + } + type Mutation { + makeTrinkets( + trinket: TrinketInput! + """input description""" + input: TrinketInput @deprecated(reason: "use trinket") + ): [Trinket!]! + } + |) + + TEST_SCHEMA.get_type("String").specified_by_url("https://string.test") + + TEST_RESOLVERS = { + **GraphQL::Cardinal::Introspection::TYPE_RESOLVERS, + "Query" => { + **GraphQL::Cardinal::Introspection::ENTRYPOINT_RESOLVERS, + } + }.freeze + + def test_introspect_schema_root_types + result = execute_query(%|{ + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + } + }|) + + expected = { + "data" => { + "__schema" => { + "queryType" => { "name" => "Query" }, + "mutationType" => { "name" => "Mutation" }, + "subscriptionType" => nil, + } + } + } + + assert_equal expected, result + end + + def test_introspect_all_schema_types + result = execute_query(%|{ + __schema { + types { + description + fields { name } + interfaces { name } + kind { name } + name + ofType { name } + possibleTypes { name } + } + } + }|) + + expected = [ + "Boolean", + "ID", + "Mutation", + "Node", + "Query", + "Sprocket", + "Status", + "String", + "Trinket", + "TrinketInput", + "Widget", + "__Directive", + "__DirectiveLocation", + "__EnumValue", + "__Field", + "__InputValue", + "__Schema", + "__Type", + "__TypeKind", + ] + + assert_equal expected, result.dig("data", "__schema", "types").map { _1["name"] }.sort + end + + def test_introspect_all_schema_directives + result = execute_query(%|{ + __schema { + directives { + args { name } + description + isRepeatable + locations + name + } + } + }|) + + expected = ["deprecated", "include", "oneOf", "skip", "specifiedBy", "stitch"] + assert_equal expected, result.dig("data", "__schema", "directives").map { _1["name"] }.sort + end + + def test_introspect_directive_type + result = execute_query(%|{ + __schema { + directives { + args { name } + description + isRepeatable + locations + name + } + } + }|) + + expected = { + "args" => [{ "name" => "key" }], + "description" => "@stitch description", + "isRepeatable" => true, + "locations" => [:FIELD_DEFINITION], + "name" => "stitch" + } + + assert_equal expected, result.dig("data", "__schema", "directives").find { _1["name"] == "stitch" } + end + + def test_introspect_type_access + result = execute_query(%|{ + __type(name: "Widget") { + description + kind + name + } + }|) + + expected = { + "data" => { + "__type" => { + "description" => "Widget description", + "kind" => "OBJECT", + "name" => "Widget", + }, + }, + } + + assert_equal expected, result + end + + def test_introspect_type_fields + result = execute_query(%|{ + has_fields: __type(name: "Query") { + some: fields { name } + all: fields(includeDeprecated: true) { name } + } + no_fields: __type(name: "Trinket") { + fields { name } + } + }|) + + expected_some = ["node", "sprockets", "widgets"] + expected_all = ["node", "sprockets", "widget", "widgets"] + + assert_equal expected_some, result.dig("data", "has_fields", "some").map { _1["name"] }.sort + assert_equal expected_all, result.dig("data", "has_fields", "all").map { _1["name"] }.sort + assert_nil result.dig("data", "no_fields", "fields") + end + + def test_introspect_type_enum_values + result = execute_query(%|{ + is_enum: __type(name: "Status") { + some: enumValues { name } + all: enumValues(includeDeprecated: true) { name } + } + non_enum: __type(name: "Widget") { + enumValues { name } + } + }|) + + expected_some = ["NO", "YES"] + expected_all = ["MAYBE", "NO", "YES"] + + assert_equal expected_some, result.dig("data", "is_enum", "some").map { _1["name"] }.sort + assert_equal expected_all, result.dig("data", "is_enum", "all").map { _1["name"] }.sort + assert_nil result.dig("data", "non_enum", "enumValues") + end + + def test_introspect_type_input_fields + result = execute_query(%|{ + is_input: __type(name: "TrinketInput") { + some: inputFields { name } + all: inputFields(includeDeprecated: true) { name } + } + non_input: __type(name: "Widget") { + inputFields { name } + } + }|) + + expected_some = ["status"] + expected_all = ["state", "status"] + + assert_equal expected_some, result.dig("data", "is_input", "some").map { _1["name"] }.sort + assert_equal expected_all, result.dig("data", "is_input", "all").map { _1["name"] }.sort + assert_nil result.dig("data", "non_input", "inputFields") + end + + def test_introspect_type_interfaces + result = execute_query(%|{ + has_interfaces: __type(name: "Widget") { + interfaces { name } + } + no_interfaces: __type(name: "Trinket") { + interfaces { name } + } + }|) + + assert_equal [{ "name" => "Node" }], result.dig("data", "has_interfaces", "interfaces") + assert_nil result.dig("data", "no_interfaces", "interfaces") + end + + def test_introspect_type_possible_types + result = execute_query(%|{ + is_abstract: __type(name: "Trinket") { + possibleTypes { name } + } + not_abstract: __type(name: "Widget") { + possibleTypes { name } + } + }|) + + expected = ["Sprocket", "Widget"] + assert_equal expected, result.dig("data", "is_abstract", "possibleTypes").map { _1["name"] }.sort + assert_nil result.dig("data", "not_abstract", "possibleTypes") + end + + def test_introspect_type_specified_by_url + result = execute_query(%|{ + scalar: __type(name: "String") { + specifiedByURL + } + non_scalar: __type(name: "Widget") { + specifiedByURL + } + }|) + + expected = { + "data" => { + "scalar" => { + "specifiedByURL" => "https://string.test", + }, + "non_scalar" => { + "specifiedByURL" => nil, + }, + }, + } + + assert_equal expected, result + end + + def test_introspect_type_kind_of_type + result = execute_query(%|{ + __type(name: "Mutation") { + name + kind + ofType { name } + fields { + type { + name + kind + ofType { + name + kind + ofType { + name + kind + ofType { + name + kind + } + } + } + } + } + } + }|) + + expected = { + "data" => { + "__type" => { + "name" => "Mutation", + "kind" => "OBJECT", + "ofType" => nil, + "fields" => [{ + "type" => { + "name" => nil, + "kind" => "NON_NULL", + "ofType" => { + "name" => nil, + "kind" => "LIST", + "ofType" => { + "name" => nil, + "kind" => "NON_NULL", + "ofType" => { + "name" => "Trinket", + "kind" => "UNION", + }, + }, + }, + }, + }] + }, + }, + } + + assert_equal expected, result + end + + def test_introspect_enum_value + result = execute_query(%|{ + __type(name: "Status") { + enumValues(includeDeprecated: true) { + deprecationReason + description + isDeprecated + name + } + } + }|) + + expected = { + "data" => { + "__type" => { + "enumValues" => [{ + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "YES", + }, { + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "NO", + }, { + "deprecationReason" => "use no", + "description" => "maybe description", + "isDeprecated" => true, + "name" => "MAYBE", + }], + }, + }, + } + + assert_equal expected, result + end + + def test_introspect_field + result = execute_query(%|{ + __type(name: "Query") { + fields(includeDeprecated: true) { + args { name } + deprecationReason + description + isDeprecated + name + type { kind } + } + } + }|) + + expected = { + "data" => { + "__type" => { + "fields" => [{ + "args" => [], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "node", + "type" => { "kind" => "NON_NULL" }, + }, { + "args" => [{ "name" => "id" }], + "deprecationReason" => "use widgets", + "description" => "widget description", + "isDeprecated" => true, + "name" => "widget", + "type" => { "kind" => "NON_NULL" }, + }, { + "args" => [{ "name" => "ids" }], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "widgets", + "type" => { "kind" => "NON_NULL" }, + }, { + "args" => [{ "name" => "ids" }], + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "sprockets", + "type" => { "kind" => "NON_NULL" }, + }] + } + } + } + + assert_equal expected, result + end + + def test_introspect_arguments + result = execute_query(%|{ + __type(name: "Mutation") { + fields { + some: args { name } + all: args(includeDeprecated: true) { + deprecationReason + description + isDeprecated + name + type { kind } + } + } + } + }|) + + expected = { + "some" => [{ "name" => "trinket" }], + "all" => [{ + "deprecationReason" => nil, + "description" => nil, + "isDeprecated" => false, + "name" => "trinket", + "type" => { "kind" => "NON_NULL" }, + }, { + "deprecationReason" => "use trinket", + "description" => "input description", + "isDeprecated" => true, + "name" => "input", + "type" => { "kind" => "INPUT_OBJECT" }, + }], + } + assert_equal expected, result.dig("data", "__type", "fields", 0) + end + + def test_introspect_argument_default_values + schema = GraphQL::Schema.from_definition(%| + enum TestEnum { + A + B + } + input TestInput { + a: TestEnum! + b: String + c: [String] + } + type Query { + test1(input: TestEnum = A): Boolean + test2(input: [TestEnum] = [A, B]): Boolean + test3(input: TestInput = { a: A, b: "sfoo", c: ["sfoo"] }): Boolean + test4(input: [TestInput] = [{ a: A }]): Boolean + test5(input: String = "sfoo"): Boolean + test6(input: Int = 23): Boolean + test7(input: Float = 23.77): Boolean + test8(input: Boolean = true): Boolean + test9(input: Boolean = null): Boolean + } + |) + + result = execute_query(%|{ + __type(name: "Query") { + fields { + args { defaultValue } + } + } + }|, schema: schema) + + expected_values = [ + "A", + "[A, B]", + %|{a: A, b: "sfoo", c: ["sfoo"]}|, + "[{a: A}]", + %|"sfoo"|, + "23", + "23.77", + "true", + "null", + ] + + expected_values.each_with_index do |value, i| + assert_equal value, result.dig("data", "__type", "fields", i, "args", 0, "defaultValue"), "Mismatch with ##{i}" + end + end + + private + + def execute_query(document, schema: TEST_SCHEMA) + GraphQL::Cardinal::Executor.new(schema, TEST_RESOLVERS, GraphQL.parse(document), {}).perform + end +end diff --git a/test/graphql/cardinal/executor/lists_test.rb b/test/graphql/cardinal/executor/lists_test.rb new file mode 100644 index 0000000..19dca63 --- /dev/null +++ b/test/graphql/cardinal/executor/lists_test.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Cardinal::Executor::ListsTest < Minitest::Test + TEST_SCHEMA = GraphQL::Schema.from_definition(%| + enum WidgetStatus { + YES + NO + } + type Widget { + parent: Widget + title: String! + status: WidgetStatus! + titles: [String!]! + statuses: [WidgetStatus!]! + children: [Widget]! + titleGroups: [[String!]!]! + statusGroups: [[WidgetStatus!]!]! + childGroups: [[Widget!]!]! + } + type Query { + widget: Widget + } + |) + + TEST_RESOLVERS = { + "Widget" => { + "parent" => GraphQL::Cardinal::HashKeyResolver.new("parent"), + "title" => GraphQL::Cardinal::HashKeyResolver.new("title"), + "status" => GraphQL::Cardinal::HashKeyResolver.new("status"), + "titles" => GraphQL::Cardinal::HashKeyResolver.new("titles"), + "statuses" => GraphQL::Cardinal::HashKeyResolver.new("statuses"), + "children" => GraphQL::Cardinal::HashKeyResolver.new("children"), + "titleGroups" => GraphQL::Cardinal::HashKeyResolver.new("titleGroups"), + "statusGroups" => GraphQL::Cardinal::HashKeyResolver.new("statusGroups"), + "childGroups" => GraphQL::Cardinal::HashKeyResolver.new("childGroups"), + }, + "Query" => { + "widget" => GraphQL::Cardinal::HashKeyResolver.new("widget"), + }, + }.freeze + + def test_resolves_flat_object_lists + query = %|{ + widget { + children { + parent { title } + title + status + } + } + }| + + source = { + "widget" => { + "children" => [{ + "parent" => { "title" => "Z" }, + "title" => "A", + "status" => "YES", + }, { + "parent" => { "title" => "Z" }, + "title" => "B", + "status" => "NO", + }], + }, + } + + result = execute_query(query, source) + expected = { "data" => source } + assert_equal expected, result + end + + def test_resolves_nested_object_lists + query = %|{ + widget { + childGroups { + parent { title } + title + status + } + } + }| + + source = { + "widget" => { + "childGroups" => [ + [{ + "parent" => { "title" => "Z" }, + "title" => "A", + "status" => "YES", + }, { + "parent" => { "title" => "Z" }, + "title" => "B", + "status" => "NO", + }], [{ + "parent" => { "title" => "Z" }, + "title" => "C", + "status" => "YES", + }], + ], + }, + } + + result = execute_query(query, source) + expected = { "data" => source } + assert_equal expected, result + end + + def test_resolves_flat_leaf_lists + query = %|{ + widget { + titles + statuses + } + }| + + source = { + "widget" => { + "titles" => ["A", "B"], + "statuses" => ["YES", "NO"], + }, + } + + result = execute_query(query, source) + expected = { "data" => source } + assert_equal expected, result + end + + def test_resolves_nested_leaf_lists + query = %|{ + widget { + titleGroups + statusGroups + } + }| + + source = { + "widget" => { + "titleGroups" => [["A", "B"], ["C", "D"]], + "statusGroups" => [["YES", "NO"], ["YES", "YES"]], + }, + } + + result = execute_query(query, source) + expected = { "data" => source } + assert_equal expected, result + end + + private + + def execute_query(document, source, schema: TEST_SCHEMA) + GraphQL::Cardinal::Executor.new(schema, TEST_RESOLVERS, GraphQL.parse(document), source).perform + end +end