From 2d785ec3c08858eb4c2861fa3d24a6f407c0af97 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Mon, 1 Jul 2024 22:58:58 -0400 Subject: [PATCH] v1.4.0 (#141) --- README.md | 64 ++- docs/resolver.md | 101 ++++ lib/graphql/stitching.rb | 3 +- lib/graphql/stitching/client.rb | 6 +- lib/graphql/stitching/composer.rb | 160 +++---- .../stitching/composer/resolver_config.rb | 29 +- .../stitching/composer/validate_interfaces.rb | 8 +- .../stitching/composer/validate_resolvers.rb | 45 +- .../stitching/executor/resolver_source.rb | 51 +- lib/graphql/stitching/export_selection.rb | 42 -- lib/graphql/stitching/plan.rb | 5 +- lib/graphql/stitching/planner.rb | 31 +- lib/graphql/stitching/planner_step.rb | 2 +- lib/graphql/stitching/resolver.rb | 67 ++- lib/graphql/stitching/resolver/arguments.rb | 284 ++++++++++++ lib/graphql/stitching/resolver/keys.rb | 206 +++++++++ lib/graphql/stitching/shaper.rb | 6 +- lib/graphql/stitching/skip_include.rb | 2 +- lib/graphql/stitching/supergraph.rb | 141 +----- .../stitching/supergraph/key_directive.rb | 13 + .../supergraph/resolver_directive.rb | 9 +- .../stitching/supergraph/to_definition.rb | 165 +++++++ lib/graphql/stitching/util.rb | 28 ++ lib/graphql/stitching/version.rb | 2 +- .../stitching/composer/configuration_test.rb | 38 +- .../composer/merge_arguments_test.rb | 12 +- .../stitching/composer/merge_fields_test.rb | 4 +- .../composer/merge_resolvers_test.rb | 74 ++- .../composer/merge_root_objects_test.rb | 6 +- .../composer/validate_composition_test.rb | 2 +- .../composer/validate_resolvers_test.rb | 81 ++-- .../stitching/executor/executor_test.rb | 18 +- .../executor/resolver_source_test.rb | 69 +-- .../stitching/export_selection_test.rb | 30 -- .../stitching/integration/arguments_test.rb | 69 +++ .../integration/composite_keys_test.rb | 118 +++++ test/graphql/stitching/plan_test.rb | 21 +- .../stitching/planner/plan_abstracts_test.rb | 26 +- .../stitching/planner/plan_resolvers_test.rb | 53 ++- .../stitching/resolver/arguments_test.rb | 437 ++++++++++++++++++ test/graphql/stitching/resolver/keys_test.rb | 264 +++++++++++ test/graphql/stitching/supergraph_test.rb | 52 ++- test/schemas/arguments.rb | 185 ++++++++ test/schemas/composite_keys.rb | 223 +++++++++ test/test_helper.rb | 24 +- 45 files changed, 2665 insertions(+), 611 deletions(-) create mode 100644 docs/resolver.md delete mode 100644 lib/graphql/stitching/export_selection.rb create mode 100644 lib/graphql/stitching/resolver/arguments.rb create mode 100644 lib/graphql/stitching/resolver/keys.rb create mode 100644 lib/graphql/stitching/supergraph/key_directive.rb create mode 100644 lib/graphql/stitching/supergraph/to_definition.rb delete mode 100644 test/graphql/stitching/export_selection_test.rb create mode 100644 test/graphql/stitching/integration/arguments_test.rb create mode 100644 test/graphql/stitching/integration/composite_keys_test.rb create mode 100644 test/graphql/stitching/resolver/arguments_test.rb create mode 100644 test/graphql/stitching/resolver/keys_test.rb create mode 100644 test/schemas/arguments.rb create mode 100644 test/schemas/composite_keys.rb diff --git a/README.md b/README.md index 169d1ef5..cbb9fad0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso **Supports:** - Merged object and abstract types. -- Multiple keys per merged type. +- Multiple and composite keys per merged type. - Shared objects, fields, enums, and inputs across locations. - Combining local and remote schemas. - File uploads via [multipart form spec](https://github.com/jaydenseric/graphql-multipart-request-spec). @@ -94,7 +94,7 @@ To facilitate this merging of types, stitching must know how to cross-reference Types merge through resolver queries identified by a `@stitch` directive: ```graphql -directive @stitch(key: String!) repeatable on FIELD_DEFINITION +directive @stitch(key: String!, arguments: String) repeatable on FIELD_DEFINITION ``` This directive (or [static configuration](#sdl-based-schemas)) is applied to root queries where a merged type may be accessed in each location, and a `key` argument specifies a field needed from other locations to be used as a query argument. @@ -151,7 +151,7 @@ type Query { ``` * The `@stitch` directive is applied to a root query where the merged type may be accessed. The merged type identity is inferred from the field return. -* The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible ([more on arguments](#multiple-query-arguments) later). +* The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible ([more on arguments](#argument-shapes) later). Each location that provides a unique variant of a type must provide at least one resolver query for the type. The exception to this requirement are [outbound-only types](./docs/mechanics.md#outbound-only-merged-types) and/or [foreign key types](./docs/mechanics.md##modeling-foreign-keys-for-stitching) that contain no exclusive data: @@ -198,7 +198,7 @@ type Query { To customize which types an abstract query provides and their respective keys, you may extend the `@stitch` directive with a `typeName` constraint. This can be repeated to select multiple types. ```graphql -directive @stitch(key: String!, typeName: String) repeatable on FIELD_DEFINITION +directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITION type Product { sku: ID! } type Order { id: ID! } @@ -212,19 +212,69 @@ type Query { } ``` -#### Multiple query arguments +#### Argument shapes -Stitching infers which argument to use for queries with a single argument, or when the key name matches its intended argument. For queries that accept multiple arguments with unmatched names, the key should provide an argument alias specified as `":"`. +Stitching infers which argument to use for queries with a single argument, or when the key name matches its intended argument. For custom mappings, the `arguments` option may specify a template of GraphQL arguments that insert key selections: ```graphql type Product { id: ID! } type Query { - product(byId: ID, bySku: ID): Product @stitch(key: "byId:id") + product(byId: ID, bySku: ID): Product + @stitch(key: "id", arguments: "byId: $.id") } ``` +Key insertions are prefixed by `$` and specify a dot-notation path to any selections made by the resolver `key`, or `__typename`. This syntax allows sending multiple arguments that intermix stitching keys with complex input shapes and other static values: + +```graphql +type Product { + id: ID! +} +union Entity = Product +input EntityKey { + id: ID! + type: String! +} + +type Query { + entities(keys: [EntityKey!]!, source: String="database"): [Entity]! + @stitch(key: "id", arguments: "keys: { id: $.id, type: $.__typename }, source: 'cache'") +} +``` + +See [resolver arguments](./docs/resolver.md#arguments) for full documentation on shaping input. + +#### Composite type keys + +Resolver keys may make composite selections for multiple key fields and/or nested scopes, for example: + +```graphql +interface FieldOwner { + id: ID! + type: String! +} +type CustomField { + owner: FieldOwner! + key: String! + value: String +} +input CustomFieldLookup { + ownerId: ID! + ownerType: String! + key: String! +} +type Query { + customFields(lookups: [CustomFieldLookup!]!): [CustomField]! @stitch( + key: "owner { id type } key", + arguments: "lookups: { ownerId: $.owner.id, ownerType: $.owner.type, key: $.key }" + ) +} +``` + +Note that composite key selections may _not_ be distributed across locations. The complete selection criteria must be available in each location that provides the key. + #### Multiple type keys A type may exist in multiple locations across the graph using different keys, for example: diff --git a/docs/resolver.md b/docs/resolver.md new file mode 100644 index 00000000..a9625966 --- /dev/null +++ b/docs/resolver.md @@ -0,0 +1,101 @@ +## GraphQL::Stitching::Resolver + +A `Resolver` contains all information about a root query used by stitching to fetch location-specific variants of a merged type. Specifically, resolvers manage parsed keys and argument structures. + +### Arguments + +Resolvers configure arguments through a template string of [GraphQL argument literal syntax](https://spec.graphql.org/October2021/#sec-Language.Arguments). This allows sending multiple arguments that intermix stitching keys with complex object shapes and other static values. + +#### Key insertions + +Key values fetched from previous locations may be inserted into arguments. Key insertions are prefixed by `$` and specify a dot-notation path to any selections made by the resolver `key`, or `__typename`. + +```graphql +type Query { + entity(id: ID!, type: String!): [Entity]! + @stitch(key: "owner { id }", arguments: "id: $.owner.id, type: $.__typename") +} +``` + +Key insertions are _not_ quoted to differentiate them from other literal values. + +#### Lists + +List arguments may specify input just like non-list arguments, and [GraphQL list input coercion](https://spec.graphql.org/October2021/#sec-List.Input-Coercion) will assume the shape represents a list item: + +```graphql +type Query { + product(ids: [ID!]!, source: DataSource!): [Product]! + @stitch(key: "id", arguments: "ids: $.id, source: CACHE") +} +``` + +List resolvers (that return list types) may _only_ insert keys into repeatable list arguments, while non-list arguments may only contain static values. Nested list inputs are neither common nor practical, so are not supported. + +#### Built-in scalars + +Built-in scalars are written as normal literal values. For convenience, string literals may be enclosed in single quotes rather than escaped double-quotes: + +```graphql +type Query { + product(id: ID!, source: String!): Product + @stitch(key: "id", arguments: "id: $.id, source: 'cache'") + + variant(id: ID!, limit: Int!): Variant + @stitch(key: "id", arguments: "id: $.id, limit: 100") +} +``` + +All scalar usage must be legal to the resolver field's arguments schema. + +#### Enums + +Enum literals may be provided anywhere in the input structure. They are _not_ quoted: + +```graphql +enum DataSource { + CACHE +} +type Query { + product(id: ID!, source: DataSource!): [Product]! + @stitch(key: "id", arguments: "id: $.id, source: CACHE") +} +``` + +All enum usage must be legal to the resolver field's arguments schema. + +#### Input Objects + +Input objects may be provided anywhere in the input, even as nested structures. The stitching resolver will build the specified object shape: + +```graphql +input ComplexKey { + id: ID + nested: ComplexKey +} +type Query { + product(key: ComplexKey!): [Product]! + @stitch(key: "id", arguments: "key: { nested: { id: $.id } }") +} +``` + +Input object shapes must conform to their respective schema definitions based on their placement within resolver arguments. + +#### Custom scalars + +Custom scalar keys allow any input shape to be submitted, from primitive scalars to complex object structures. These values will be sent and recieved as untyped JSON input: + +```graphql +type Product { + id: ID! +} +union Entity = Product +scalar Key + +type Query { + entities(representations: [Key!]!): [Entity]! + @stitch(key: "id", arguments: "representations: { id: $.id, __typename: $.__typename }") +} +``` + +Custom scalar arguments have no structured schema definition to validate against. This makes them flexible but quite lax, for better or worse. diff --git a/lib/graphql/stitching.rb b/lib/graphql/stitching.rb index e805b488..1d4eab30 100644 --- a/lib/graphql/stitching.rb +++ b/lib/graphql/stitching.rb @@ -8,6 +8,8 @@ module Stitching EMPTY_ARRAY = [].freeze class StitchingError < StandardError; end + class CompositionError < StitchingError; end + class ValidationError < CompositionError; end class << self def stitch_directive @@ -28,7 +30,6 @@ def stitching_directive_names require_relative "stitching/client" require_relative "stitching/composer" require_relative "stitching/executor" -require_relative "stitching/export_selection" require_relative "stitching/http_executable" require_relative "stitching/plan" require_relative "stitching/planner_step" diff --git a/lib/graphql/stitching/client.rb b/lib/graphql/stitching/client.rb index b83f89e5..1e4b2df7 100644 --- a/lib/graphql/stitching/client.rb +++ b/lib/graphql/stitching/client.rb @@ -70,7 +70,11 @@ def on_error(&block) def load_plan(request) if @on_cache_read && plan_json = @on_cache_read.call(request) plan = GraphQL::Stitching::Plan.from_json(JSON.parse(plan_json)) - return request.plan(plan) + + # only use plans referencing current resolver versions + if plan.ops.all? { |op| !op.resolver || @supergraph.resolvers_by_version[op.resolver] } + return request.plan(plan) + end end plan = request.plan diff --git a/lib/graphql/stitching/composer.rb b/lib/graphql/stitching/composer.rb index e4a13ffb..69be9bac 100644 --- a/lib/graphql/stitching/composer.rb +++ b/lib/graphql/stitching/composer.rb @@ -8,9 +8,6 @@ module GraphQL module Stitching class Composer - class ComposerError < StitchingError; end - class ValidationError < ComposerError; end - # @api private NO_DEFAULT_VALUE = begin class T < GraphQL::Schema::Object @@ -41,7 +38,7 @@ class T < GraphQL::Schema::Object attr_reader :mutation_name # @api private - attr_reader :candidate_types_by_name_and_location + attr_reader :subgraph_types_by_name_and_location # @api private attr_reader :schema_directives @@ -67,8 +64,8 @@ def initialize( @field_map = nil @resolver_map = nil @mapped_type_names = nil - @candidate_directives_by_name_and_location = nil - @candidate_types_by_name_and_location = nil + @subgraph_directives_by_name_and_location = nil + @subgraph_types_by_name_and_location = nil @schema_directives = nil end @@ -76,8 +73,8 @@ def perform(locations_input) reset! schemas, executables = prepare_locations_input(locations_input) - # "directive_name" => "location" => candidate_directive - @candidate_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo| + # "directive_name" => "location" => subgraph_directive + @subgraph_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo| (schema.directives.keys - schema.default_directives.keys - GraphQL::Stitching.stitching_directive_names).each do |directive_name| memo[directive_name] ||= {} memo[directive_name][location] = schema.directives[directive_name] @@ -85,44 +82,44 @@ def perform(locations_input) end # "directive_name" => merged_directive - @schema_directives = @candidate_directives_by_name_and_location.each_with_object({}) do |(directive_name, directives_by_location), memo| + @schema_directives = @subgraph_directives_by_name_and_location.each_with_object({}) do |(directive_name, directives_by_location), memo| memo[directive_name] = build_directive(directive_name, directives_by_location) end @schema_directives.merge!(GraphQL::Schema.default_directives) - # "Typename" => "location" => candidate_type - @candidate_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo| - raise ComposerError, "Location keys must be strings" unless location.is_a?(String) - raise ComposerError, "The subscription operation is not supported." if schema.subscription + # "Typename" => "location" => subgraph_type + @subgraph_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo| + raise CompositionError, "Location keys must be strings" unless location.is_a?(String) + raise CompositionError, "The subscription operation is not supported." if schema.subscription introspection_types = schema.introspection_system.types.keys - schema.types.each do |type_name, type_candidate| + schema.types.each do |type_name, subgraph_type| next if introspection_types.include?(type_name) - if type_name == @query_name && type_candidate != schema.query - raise ComposerError, "Query name \"#{@query_name}\" is used by non-query type in #{location} schema." - elsif type_name == @mutation_name && type_candidate != schema.mutation - raise ComposerError, "Mutation name \"#{@mutation_name}\" is used by non-mutation type in #{location} schema." + if type_name == @query_name && subgraph_type != schema.query + raise CompositionError, "Query name \"#{@query_name}\" is used by non-query type in #{location} schema." + elsif type_name == @mutation_name && subgraph_type != schema.mutation + raise CompositionError, "Mutation name \"#{@mutation_name}\" is used by non-mutation type in #{location} schema." end - type_name = @query_name if type_candidate == schema.query - type_name = @mutation_name if type_candidate == schema.mutation - @mapped_type_names[type_candidate.graphql_name] = type_name if type_candidate.graphql_name != type_name + type_name = @query_name if subgraph_type == schema.query + type_name = @mutation_name if subgraph_type == schema.mutation + @mapped_type_names[subgraph_type.graphql_name] = type_name if subgraph_type.graphql_name != type_name memo[type_name] ||= {} - memo[type_name][location] = type_candidate + memo[type_name][location] = subgraph_type end end enum_usage = build_enum_usage_map(schemas.values) # "Typename" => merged_type - schema_types = @candidate_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo| + schema_types = @subgraph_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo| kinds = types_by_location.values.map { _1.kind.name }.tap(&:uniq!) if kinds.length > 1 - raise ComposerError, "Cannot merge different kinds for `#{type_name}`. Found: #{kinds.join(", ")}." + raise CompositionError, "Cannot merge different kinds for `#{type_name}`. Found: #{kinds.join(", ")}." end extract_resolvers(type_name, types_by_location) if type_name == @query_name @@ -141,7 +138,7 @@ def perform(locations_input) when "INPUT_OBJECT" build_input_object_type(type_name, types_by_location) else - raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{kinds.first}." + raise CompositionError, "Unexpected kind encountered for `#{type_name}`. Found: #{kinds.first}." end end @@ -184,9 +181,9 @@ def prepare_locations_input(locations_input) schema = input[:schema] if schema.nil? - raise ComposerError, "A schema is required for `#{location}` location." + raise CompositionError, "A schema is required for `#{location}` location." elsif !(schema.is_a?(Class) && schema <= GraphQL::Schema) - raise ComposerError, "The schema for `#{location}` location must be a GraphQL::Schema class." + raise CompositionError, "The schema for `#{location}` location must be a GraphQL::Schema class." end @resolver_configs.merge!(ResolverConfig.extract_directive_assignments(schema, location, input[:stitch])) @@ -234,10 +231,10 @@ def build_enum_type(type_name, types_by_location, enum_usage) builder = self # "value" => "location" => enum_value - enum_values_by_name_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo| - type_candidate.enum_values.each do |enum_value_candidate| - memo[enum_value_candidate.graphql_name] ||= {} - memo[enum_value_candidate.graphql_name][location] = enum_value_candidate + enum_values_by_name_location = types_by_location.each_with_object({}) do |(location, subgraph_type), memo| + subgraph_type.enum_values.each do |subgraph_enum_value| + memo[subgraph_enum_value.graphql_name] ||= {} + memo[subgraph_enum_value.graphql_name][location] = subgraph_enum_value end end @@ -342,14 +339,14 @@ def build_type_binding(type_name) # @!visibility private def build_merged_fields(type_name, types_by_location, owner) # "field_name" => "location" => field - fields_by_name_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo| + fields_by_name_location = types_by_location.each_with_object({}) do |(location, subgraph_type), memo| @field_map[type_name] ||= {} - type_candidate.fields.each do |field_name, field_candidate| - @field_map[type_name][field_candidate.name] ||= [] - @field_map[type_name][field_candidate.name] << location + subgraph_type.fields.each do |field_name, subgraph_field| + @field_map[type_name][subgraph_field.name] ||= [] + @field_map[type_name][subgraph_field.name] << location memo[field_name] ||= {} - memo[field_name][location] = field_candidate + memo[field_name][location] = subgraph_field end end @@ -375,8 +372,8 @@ def build_merged_fields(type_name, types_by_location, owner) # @!visibility private def build_merged_arguments(type_name, members_by_location, owner, field_name: nil, directive_name: nil) # "argument_name" => "location" => argument - args_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo| - member_candidate.arguments.each do |argument_name, argument| + args_by_name_location = members_by_location.each_with_object({}) do |(location, subgraph_member), memo| + subgraph_member.arguments.each do |argument_name, argument| memo[argument_name] ||= {} memo[argument_name][location] = argument end @@ -388,7 +385,7 @@ def build_merged_arguments(type_name, members_by_location, owner, field_name: ni if arguments_by_location.length != members_by_location.length if value_types.any?(&:non_null?) path = [type_name, field_name, argument_name].compact.join(".") - raise ComposerError, "Required argument `#{path}` must be defined in all locations." # ...or hidden? + raise CompositionError, "Required argument `#{path}` must be defined in all locations." # ...or hidden? end next end @@ -429,8 +426,8 @@ def build_merged_arguments(type_name, members_by_location, owner, field_name: ni # @!scope class # @!visibility private def build_merged_directives(type_name, members_by_location, owner, field_name: nil, argument_name: nil, enum_value: nil) - directives_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo| - member_candidate.directives.each do |directive| + directives_by_name_location = members_by_location.each_with_object({}) do |(location, subgraph_member), memo| + subgraph_member.directives.each do |directive| memo[directive.graphql_name] ||= {} memo[directive.graphql_name][location] = directive end @@ -470,18 +467,18 @@ def build_merged_directives(type_name, members_by_location, owner, field_name: n # @!scope class # @!visibility private - def merge_value_types(type_name, type_candidates, field_name: nil, argument_name: nil) + def merge_value_types(type_name, subgraph_types, field_name: nil, argument_name: nil) path = [type_name, field_name, argument_name].tap(&:compact!).join(".") - alt_structures = type_candidates.map { Util.flatten_type_structure(_1) } + alt_structures = subgraph_types.map { Util.flatten_type_structure(_1) } basis_structure = alt_structures.shift alt_structures.each do |alt_structure| if alt_structure.length != basis_structure.length - raise ComposerError, "Cannot compose mixed list structures at `#{path}`." + raise CompositionError, "Cannot compose mixed list structures at `#{path}`." end if alt_structure.last.name != basis_structure.last.name - raise ComposerError, "Cannot compose mixed types at `#{path}`." + raise CompositionError, "Cannot compose mixed types at `#{path}`." end end @@ -528,63 +525,60 @@ def merge_deprecations(type_name, members_by_location, field_name: nil, argument # @!scope class # @!visibility private def extract_resolvers(type_name, types_by_location) - types_by_location.each do |location, type_candidate| - type_candidate.fields.each do |field_name, field_candidate| - resolver_type = field_candidate.type.unwrap - resolver_structure = Util.flatten_type_structure(field_candidate.type) + types_by_location.each do |location, subgraph_type| + subgraph_type.fields.each do |field_name, subgraph_field| + resolver_type = subgraph_field.type.unwrap + resolver_structure = Util.flatten_type_structure(subgraph_field.type) resolver_configs = @resolver_configs.fetch("#{location}.#{field_name}", []) - field_candidate.directives.each do |directive| + subgraph_field.directives.each do |directive| next unless directive.graphql_name == GraphQL::Stitching.stitch_directive resolver_configs << ResolverConfig.from_kwargs(directive.arguments.keyword_arguments) end resolver_configs.each do |config| - key_selections = GraphQL.parse("{ #{config.key} }").definitions[0].selections - - if key_selections.length != 1 - raise ComposerError, "Resolver key at #{type_name}.#{field_name} must specify exactly one key." - end - - argument = field_candidate.arguments[key_selections[0].alias] - argument ||= if field_candidate.arguments.size == 1 - field_candidate.arguments.values.first - else - field_candidate.arguments[config.key] - end - - unless argument - raise ComposerError, "No resolver argument matched for #{type_name}.#{field_name}. " \ - "Add an alias to the key that specifies its intended argument, ex: `arg:key`" - end - - argument_structure = Util.flatten_type_structure(argument.type) - if argument_structure.length != resolver_structure.length - raise ComposerError, "Mismatched input/output for #{type_name}.#{field_name}.#{argument.graphql_name} resolver. " \ - "Arguments must map directly to results." - end - resolver_type_name = if config.type_name if !resolver_type.kind.abstract? - raise ComposerError, "Resolver config may only specify a type name for abstract resolvers." + raise CompositionError, "Resolver config may only specify a type name for abstract resolvers." elsif !resolver_type.possible_types.find { _1.graphql_name == config.type_name } - raise ComposerError, "Type `#{config.type_name}` is not a possible return type for query `#{field_name}`." + raise CompositionError, "Type `#{config.type_name}` is not a possible return type for query `#{field_name}`." end config.type_name else resolver_type.graphql_name end + key = Resolver.parse_key_with_types( + config.key, + @subgraph_types_by_name_and_location[resolver_type_name], + ) + + arguments_format = config.arguments || begin + argument = if subgraph_field.arguments.size == 1 + subgraph_field.arguments.values.first + else + subgraph_field.arguments[key.default_argument_name] + end + + unless argument + raise CompositionError, "No resolver argument matched for `#{type_name}.#{field_name}`." \ + "An argument mapping is required for unmatched names and composite keys." + end + + "#{argument.graphql_name}: $.#{key.default_argument_name}" + end + + arguments = Resolver.parse_arguments_with_field(arguments_format, subgraph_field) + arguments.each { _1.verify_key(key) } + @resolver_map[resolver_type_name] ||= [] @resolver_map[resolver_type_name] << Resolver.new( location: location, type_name: resolver_type_name, - key: key_selections[0].name, - field: field_candidate.name, - arg: argument.graphql_name, - arg_type_name: argument.type.unwrap.graphql_name, + field: subgraph_field.name, list: resolver_structure.first.list?, - representations: config.representations, + key: key, + arguments: arguments, ) end end @@ -619,7 +613,7 @@ def expand_abstract_resolvers(schema) next unless resolver_type.kind.abstract? expanded_types = Util.expand_abstract_type(schema, resolver_type) - expanded_types.select { @candidate_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type| + expanded_types.select { @subgraph_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type| @resolver_map[expanded_type.graphql_name] ||= [] @resolver_map[expanded_type.graphql_name].push(*@resolver_map[type_name]) end @@ -673,7 +667,7 @@ def reset! @field_map = {} @resolver_map = {} @mapped_type_names = {} - @candidate_directives_by_name_and_location = nil + @subgraph_directives_by_name_and_location = nil @schema_directives = nil end end diff --git a/lib/graphql/stitching/composer/resolver_config.rb b/lib/graphql/stitching/composer/resolver_config.rb index 9a5a57f4..89535d04 100644 --- a/lib/graphql/stitching/composer/resolver_config.rb +++ b/lib/graphql/stitching/composer/resolver_config.rb @@ -12,10 +12,10 @@ def extract_directive_assignments(schema, location, assignments) assignments.each_with_object({}) do |kwargs, memo| type = kwargs[:parent_type_name] ? schema.get_type(kwargs[:parent_type_name]) : schema.query - raise ComposerError, "Invalid stitch directive type `#{kwargs[:parent_type_name]}`" unless type + raise CompositionError, "Invalid stitch directive type `#{kwargs[:parent_type_name]}`" unless type field = type.get_field(kwargs[:field_name]) - raise ComposerError, "Invalid stitch directive field `#{kwargs[:field_name]}`" unless field + raise CompositionError, "Invalid stitch directive field `#{kwargs[:field_name]}`" unless field field_path = "#{location}.#{field.name}" memo[field_path] ||= [] @@ -30,15 +30,15 @@ def extract_federation_entities(schema, location) entity_type.directives.each do |directive| next unless directive.graphql_name == "key" - key = directive.arguments.keyword_arguments.fetch(:fields).strip - raise ComposerError, "Composite federation keys are not supported." unless /^\w+$/.match?(key) - + key = Resolver.parse_key(directive.arguments.keyword_arguments.fetch(:fields)) + key_fields = key.map { "#{_1.name}: $.#{_1.name}" } field_path = "#{location}._entities" + memo[field_path] ||= [] memo[field_path] << new( - key: key, + key: key.to_definition, type_name: entity_type.graphql_name, - representations: true, + arguments: "representations: { #{key_fields.join(", ")}, __typename: $.__typename }", ) end end @@ -48,7 +48,7 @@ def from_kwargs(kwargs) new( key: kwargs[:key], type_name: kwargs[:type_name] || kwargs[:typeName], - representations: kwargs[:representations] || false, + arguments: kwargs[:arguments], ) end @@ -57,16 +57,21 @@ def from_kwargs(kwargs) def federation_entities_schema?(schema) entity_type = schema.get_type(ENTITY_TYPENAME) entities_query = schema.query.get_field(ENTITIES_QUERY) - entity_type && entity_type.kind.union? && entities_query && entities_query.type.unwrap == entity_type + entity_type && + entity_type.kind.union? && + entities_query && + entities_query.arguments["representations"] && + entities_query.type.list? && + entities_query.type.unwrap == entity_type end end - attr_reader :key, :type_name, :representations + attr_reader :key, :type_name, :arguments - def initialize(key:, type_name:, representations: false) + def initialize(key:, type_name:, arguments: nil) @key = key @type_name = type_name - @representations = representations + @arguments = arguments end end end diff --git a/lib/graphql/stitching/composer/validate_interfaces.rb b/lib/graphql/stitching/composer/validate_interfaces.rb index 8a8cb752..1ff28df4 100644 --- a/lib/graphql/stitching/composer/validate_interfaces.rb +++ b/lib/graphql/stitching/composer/validate_interfaces.rb @@ -15,7 +15,7 @@ def perform(supergraph, composer) # graphql-ruby will dynamically apply interface fields on a type implementation, # so check the delegation map to assure that all materialized fields have resolver locations. unless supergraph.locations_by_type_and_field[possible_type.graphql_name][field_name]&.any? - raise Composer::ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\ + raise ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\ "which is required by interface #{interface_type.graphql_name}." end @@ -24,7 +24,7 @@ def perform(supergraph, composer) possible_type_structure = Util.flatten_type_structure(intersecting_field.type) if possible_type_structure.length != interface_type_structure.length - raise Composer::ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\ + raise ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}." end @@ -32,12 +32,12 @@ def perform(supergraph, composer) possible_struct = possible_type_structure[index] if possible_struct.name != interface_struct.name - raise Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\ + raise ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}." end if possible_struct.null? && interface_struct.non_null? - raise Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\ + raise ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}." end end diff --git a/lib/graphql/stitching/composer/validate_resolvers.rb b/lib/graphql/stitching/composer/validate_resolvers.rb index 8ba7e9f0..c79685ff 100644 --- a/lib/graphql/stitching/composer/validate_resolvers.rb +++ b/lib/graphql/stitching/composer/validate_resolvers.rb @@ -12,81 +12,82 @@ def perform(supergraph, composer) next if type.graphql_name.start_with?("__") # multiple subschemas implement the type - candidate_types_by_location = composer.candidate_types_by_name_and_location[type_name] - next unless candidate_types_by_location.length > 1 + subgraph_types_by_location = composer.subgraph_types_by_name_and_location[type_name] + next unless subgraph_types_by_location.length > 1 resolvers = supergraph.resolvers[type_name] if resolvers&.any? - validate_as_resolver(supergraph, type, candidate_types_by_location, resolvers) + validate_as_resolver(supergraph, type, subgraph_types_by_location, resolvers) elsif type.kind.object? - validate_as_shared(supergraph, type, candidate_types_by_location) + validate_as_shared(supergraph, type, subgraph_types_by_location) end end end private - def validate_as_resolver(supergraph, type, candidate_types_by_location, resolvers) + def validate_as_resolver(supergraph, type, subgraph_types_by_location, resolvers) # abstract resolvers are expanded with their concrete implementations, which each get validated. Ignore the abstract itself. return if type.kind.abstract? # only one resolver allowed per type/location/key resolvers_by_location_and_key = resolvers.each_with_object({}) do |resolver, memo| - if memo.dig(resolver.location, resolver.key) - raise Composer::ValidationError, "Multiple resolver queries for `#{type.graphql_name}.#{resolver.key}` "\ + if memo.dig(resolver.location, resolver.key.to_definition) + raise ValidationError, "Multiple resolver queries for `#{type.graphql_name}.#{resolver.key}` "\ "found in #{resolver.location}. Limit one resolver query per type and key in each location. "\ "Abstract resolvers provide all possible types." end memo[resolver.location] ||= {} - memo[resolver.location][resolver.key] = resolver + memo[resolver.location][resolver.key.to_definition] = resolver end - resolver_keys = resolvers.map(&:key).to_set + resolver_keys = resolvers.map(&:key) + resolver_key_strs = resolver_keys.map(&:to_definition).to_set # All non-key fields must be resolvable in at least one resolver location supergraph.locations_by_type_and_field[type.graphql_name].each do |field_name, locations| - next if resolver_keys.include?(field_name) + next if resolver_key_strs.include?(field_name) if locations.none? { resolvers_by_location_and_key[_1] } where = locations.length > 1 ? "one of #{locations.join(", ")} locations" : locations.first - raise Composer::ValidationError, "A resolver query is required for `#{type.graphql_name}` in #{where} to resolve field `#{field_name}`." + raise ValidationError, "A resolver query is required for `#{type.graphql_name}` in #{where} to resolve field `#{field_name}`." end end - # All locations of a resolver type must include at least one key field - supergraph.fields_by_type_and_location[type.graphql_name].each do |location, field_names| - if field_names.none? { resolver_keys.include?(_1) } - raise Composer::ValidationError, "A resolver key is required for `#{type.graphql_name}` in #{location} to join with other locations." + # All locations of a merged type must include at least one resolver key + supergraph.fields_by_type_and_location[type.graphql_name].each_key do |location| + if resolver_keys.none? { _1.locations.include?(location) } + raise ValidationError, "A resolver key is required for `#{type.graphql_name}` in #{location} to join with other locations." end end # verify that all outbound locations can access all inbound locations resolver_locations = resolvers_by_location_and_key.keys - candidate_types_by_location.each_key do |location| + subgraph_types_by_location.each_key do |location| remote_locations = resolver_locations.reject { _1 == location } paths = supergraph.route_type_to_locations(type.graphql_name, location, remote_locations) if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? } - raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` resolvers in #{location} to all other locations. "\ + raise ValidationError, "Cannot route `#{type.graphql_name}` resolvers in #{location} to all other locations. "\ "All locations must provide a resolver query with a joining key." end end end - def validate_as_shared(supergraph, type, candidate_types_by_location) + def validate_as_shared(supergraph, type, subgraph_types_by_location) expected_fields = begin type.fields.keys.sort rescue StandardError => e # bug with inherited interfaces in older versions of GraphQL if type.interfaces.any? { _1.is_a?(GraphQL::Schema::LateBoundType) } - raise Composer::ComposerError, "Merged interface inheritance requires GraphQL >= v2.0.3" + raise CompositionError, "Merged interface inheritance requires GraphQL >= v2.0.3" else raise e end end - candidate_types_by_location.each do |location, candidate_type| - if candidate_type.fields.keys.sort != expected_fields - raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\ + subgraph_types_by_location.each do |location, subgraph_type| + if subgraph_type.fields.keys.sort != expected_fields + raise ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\ "or else define resolver queries so that its unique fields may be accessed remotely." end end diff --git a/lib/graphql/stitching/executor/resolver_source.rb b/lib/graphql/stitching/executor/resolver_source.rb index b84f9650..ebddb914 100644 --- a/lib/graphql/stitching/executor/resolver_source.rb +++ b/lib/graphql/stitching/executor/resolver_source.rb @@ -17,7 +17,7 @@ def fetch(ops) if op.if_type # operations planned around unused fragment conditions should not trigger requests - origin_set.select! { _1[ExportSelection.typename_node.alias] == op.if_type } + origin_set.select! { _1[Resolver::TYPENAME_EXPORT_NODE.alias] == op.if_type } end memo[op] = origin_set if origin_set.any? @@ -53,24 +53,35 @@ def build_document(origin_sets_by_operation, operation_name = nil, operation_dir variable_defs = {} query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index| variable_defs.merge!(op.variables) - resolver = op.resolver + resolver = @executor.request.supergraph.resolvers_by_version[op.resolver] if resolver.list? - variable_name = "_#{batch_index}_key" - - @variables[variable_name] = origin_set.map do |origin_obj| - build_key(resolver.key, origin_obj, as_representation: resolver.representations?) + arguments = resolver.arguments.map.with_index do |arg, i| + if arg.key? + variable_name = "_#{batch_index}_key_#{i}".freeze + @variables[variable_name] = origin_set.map { arg.build(_1) } + variable_defs[variable_name] = arg.to_type_signature + "#{arg.name}:$#{variable_name}" + else + "#{arg.name}:#{arg.value.print}" + end end - variable_defs[variable_name] = "[#{resolver.arg_type_name}!]!" - "_#{batch_index}_result: #{resolver.field}(#{resolver.arg}:$#{variable_name}) #{op.selections}" + "_#{batch_index}_result: #{resolver.field}(#{arguments.join(",")}) #{op.selections}" else origin_set.map.with_index do |origin_obj, index| - variable_name = "_#{batch_index}_#{index}_key" - @variables[variable_name] = build_key(resolver.key, origin_obj, as_representation: resolver.representations?) + arguments = resolver.arguments.map.with_index do |arg, i| + if arg.key? + variable_name = "_#{batch_index}_#{index}_key_#{i}".freeze + @variables[variable_name] = arg.build(origin_obj) + variable_defs[variable_name] = arg.to_type_signature + "#{arg.name}:$#{variable_name}" + else + "#{arg.name}:#{arg.value.print}" + end + end - variable_defs[variable_name] = "#{resolver.arg_type_name}!" - "_#{batch_index}_#{index}_result: #{resolver.field}(#{resolver.arg}:$#{variable_name}) #{op.selections}" + "_#{batch_index}_#{index}_result: #{resolver.field}(#{arguments.join(",")}) #{op.selections}" end end end @@ -85,8 +96,7 @@ def build_document(origin_sets_by_operation, operation_name = nil, operation_dir end if variable_defs.any? - variable_str = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",") - doc << "(#{variable_str})" + doc << "(#{variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")})" end if operation_directives @@ -100,22 +110,11 @@ def build_document(origin_sets_by_operation, operation_name = nil, operation_dir end end - def build_key(key, origin_obj, as_representation: false) - if as_representation - { - "__typename" => origin_obj[ExportSelection.typename_node.alias], - key => origin_obj[ExportSelection.key(key)], - } - else - origin_obj[ExportSelection.key(key)] - end - end - def merge_results!(origin_sets_by_operation, raw_result) return unless raw_result origin_sets_by_operation.each_with_index do |(op, origin_set), batch_index| - results = if op.resolver.list? + results = if @executor.request.supergraph.resolvers_by_version[op.resolver].list? raw_result["_#{batch_index}_result"] else origin_set.map.with_index { |_, index| raw_result["_#{batch_index}_#{index}_result"] } diff --git a/lib/graphql/stitching/export_selection.rb b/lib/graphql/stitching/export_selection.rb deleted file mode 100644 index b56922e5..00000000 --- a/lib/graphql/stitching/export_selection.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module GraphQL - module Stitching - # Builds hidden selection fields added by stitiching code, - # used to request operational data about resolved objects. - class ExportSelection - EXPORT_PREFIX = "_export_" - - class << self - @typename_node = nil - - def key?(name) - return false unless name - - name.start_with?(EXPORT_PREFIX) - end - - def key(name) - "#{EXPORT_PREFIX}#{name}" - end - - # The argument assigning Field.alias changed from - # a generic `alias` hash key to a structured `field_alias` kwarg. - # See https://github.com/rmosolgo/graphql-ruby/pull/4718 - FIELD_ALIAS_KWARG = !GraphQL::Language::Nodes::Field.new(field_alias: "a").alias.nil? - - def key_node(field_name) - if FIELD_ALIAS_KWARG - GraphQL::Language::Nodes::Field.new(field_alias: key(field_name), name: field_name) - else - GraphQL::Language::Nodes::Field.new(alias: key(field_name), name: field_name) - end - end - - def typename_node - @typename_node ||= key_node("__typename") - end - end - end - end -end diff --git a/lib/graphql/stitching/plan.rb b/lib/graphql/stitching/plan.rb index a01467a9..18338481 100644 --- a/lib/graphql/stitching/plan.rb +++ b/lib/graphql/stitching/plan.rb @@ -27,7 +27,7 @@ def as_json variables: variables, path: path, if_type: if_type, - resolver: resolver&.as_json + resolver: resolver }.tap(&:compact!) end end @@ -36,7 +36,6 @@ class << self def from_json(json) ops = json["ops"] ops = ops.map do |op| - resolver = op["resolver"] Op.new( step: op["step"], after: op["after"], @@ -46,7 +45,7 @@ def from_json(json) variables: op["variables"], path: op["path"], if_type: op["if_type"], - resolver: resolver ? GraphQL::Stitching::Resolver.new(**resolver) : nil, + resolver: op["resolver"], ) end new(ops: ops) diff --git a/lib/graphql/stitching/planner.rb b/lib/graphql/stitching/planner.rb index c058d984..c4f92274 100644 --- a/lib/graphql/stitching/planner.rb +++ b/lib/graphql/stitching/planner.rb @@ -74,7 +74,7 @@ def add_step( resolver: nil ) # coalesce repeat parameters into a single entrypoint - entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{resolver&.key}") + entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{resolver&.key&.to_definition}") path.each { entrypoint << "/#{_1}" } step = @steps_by_entrypoint[entrypoint] @@ -153,7 +153,7 @@ def build_root_entrypoints end else - raise "Invalid operation type." + raise StitchingError, "Invalid operation type." end end @@ -173,7 +173,7 @@ def each_field_in_scope(parent_type, input_selections, &block) each_field_in_scope(parent_type, fragment.selections, &block) else - raise "Unexpected node of type #{node.class.name} in selection set." + raise StitchingError, "Unexpected node of type #{node.class.name} in selection set." end end end @@ -199,8 +199,8 @@ def extract_locale_selections( input_selections.each do |node| case node when GraphQL::Language::Nodes::Field - if node.alias&.start_with?(ExportSelection::EXPORT_PREFIX) - raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{ExportSelection::EXPORT_PREFIX}" is a reserved prefix.) + if node.alias&.start_with?(Resolver::EXPORT_PREFIX) + raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{Resolver::EXPORT_PREFIX}" is a reserved prefix.) elsif node.name == TYPENAME locale_selections << node next @@ -255,14 +255,14 @@ def extract_locale_selections( end else - raise "Unexpected node of type #{node.class.name} in selection set." + raise StitchingError, "Unexpected node of type #{node.class.name} in selection set." end end # B.4) Add a `__typename` export to abstracts and types that implement # fragments so that resolved type information is available during execution. if requires_typename - locale_selections << ExportSelection.typename_node + locale_selections << Resolver::TYPENAME_EXPORT_NODE end if remote_selections @@ -276,20 +276,7 @@ def extract_locale_selections( routes.each_value do |route| route.reduce(locale_selections) do |parent_selections, resolver| # E.1) Add the key of each resolver query into the prior location's selection set. - if resolver.key - foreign_key = ExportSelection.key(resolver.key) - has_key = false - has_typename = false - - parent_selections.each do |node| - next unless node.is_a?(GraphQL::Language::Nodes::Field) - has_key ||= node.alias == foreign_key - has_typename ||= node.alias == ExportSelection.typename_node.alias - end - - parent_selections << ExportSelection.key_node(resolver.key) unless has_key - parent_selections << ExportSelection.typename_node unless has_typename - end + parent_selections.push(*resolver.key.export_nodes) if resolver.key # E.2) Add a planner step for each new entrypoint location. add_step( @@ -302,6 +289,8 @@ def extract_locale_selections( ).selections end end + + locale_selections.uniq! { _1.alias || _1.name } end locale_selections diff --git a/lib/graphql/stitching/planner_step.rb b/lib/graphql/stitching/planner_step.rb index 5219acb4..3f097614 100644 --- a/lib/graphql/stitching/planner_step.rb +++ b/lib/graphql/stitching/planner_step.rb @@ -43,7 +43,7 @@ def to_plan_op variables: rendered_variables, path: @path, if_type: type_condition, - resolver: @resolver, + resolver: @resolver&.version, ) end diff --git a/lib/graphql/stitching/resolver.rb b/lib/graphql/stitching/resolver.rb index 0f36afc5..cfa5fb6b 100644 --- a/lib/graphql/stitching/resolver.rb +++ b/lib/graphql/stitching/resolver.rb @@ -1,47 +1,68 @@ # frozen_string_literal: true +require_relative "./resolver/arguments" +require_relative "./resolver/keys" + module GraphQL module Stitching # Defines a root resolver query that provides direct access to an entity type. - Resolver = Struct.new( + class Resolver + extend ArgumentsParser + extend KeysParser + # location name providing the resolver query. - :location, + attr_reader :location # name of merged type fulfilled through this resolver. - :type_name, + attr_reader :type_name + + # name of the root field to query. + attr_reader :field # a key field to select from prior locations, sent as resolver argument. - :key, + attr_reader :key - # name of the root field to query. - :field, + # parsed resolver Argument structures. + attr_reader :arguments - # specifies when the resolver is a list query. - :list, + def initialize( + location:, + type_name: nil, + list: false, + field: nil, + key: nil, + arguments: nil + ) + @location = location + @type_name = type_name + @list = list + @field = field + @key = key + @arguments = arguments + end - # name of the root field argument used to send the key. - :arg, + # specifies when the resolver is a list query. + def list? + @list + end - # type name of the root field argument used to send the key. - :arg_type_name, + def version + @version ||= Digest::SHA2.hexdigest(as_json.to_json) + end - # specifies that keys should be sent as JSON representations with __typename and key. - :representations, - keyword_init: true - ) do - alias_method :list?, :list - alias_method :representations?, :representations + def ==(other) + self.class == other.class && self.as_json == other.as_json + end def as_json { location: location, type_name: type_name, - key: key, + list: list?, field: field, - list: list, - arg: arg, - arg_type_name: arg_type_name, - representations: representations, + key: key.to_definition, + arguments: arguments.map(&:to_definition).join(", "), + argument_types: arguments.map(&:to_type_definition).join(", "), }.tap(&:compact!) end end diff --git a/lib/graphql/stitching/resolver/arguments.rb b/lib/graphql/stitching/resolver/arguments.rb new file mode 100644 index 00000000..f20173b6 --- /dev/null +++ b/lib/graphql/stitching/resolver/arguments.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +module GraphQL::Stitching + class Resolver + # Defines a single resolver argument structure + # @api private + class Argument + attr_reader :name + attr_reader :value + attr_reader :type_name + + def initialize(name:, value:, list: false, type_name: nil) + @name = name + @value = value + @list = list + @type_name = type_name + end + + def list? + @list + end + + def key? + value.key? + end + + def verify_key(key) + if key? + value.verify_key(self, key) + true + else + false + end + end + + def ==(other) + self.class == other.class && + @name == other.name && + @value == other.value && + @type_name == other.type_name && + @list == other.list? + end + + def build(origin_obj) + value.build(origin_obj) + end + + def print + "#{name}: #{value.print}" + end + + def to_definition + print.gsub(%|"|, "'") + end + + alias_method :to_s, :to_definition + + def to_type_definition + "#{name}: #{to_type_signature}" + end + + def to_type_signature + # need to derive nullability... + list? ? "[#{@type_name}!]!" : "#{@type_name}!" + end + end + + # An abstract argument input value + # @api private + class ArgumentValue + attr_reader :value + + def initialize(value) + @value = value + end + + def key? + false + end + + def verify_key(arg, key) + nil + end + + def ==(other) + self.class == other.class && value == other.value + end + + def build(origin_obj) + value + end + + def print + value + end + end + + # An object input value + # @api private + class ObjectArgumentValue < ArgumentValue + def key? + value.any?(&:key?) + end + + def verify_key(arg, key) + value.each { _1.verify_key(key) } + end + + def build(origin_obj) + value.each_with_object({}) do |arg, memo| + memo[arg.name] = arg.build(origin_obj) + end + end + + def print + "{#{value.map(&:print).join(", ")}}" + end + end + + # A key input value + # @api private + class KeyArgumentValue < ArgumentValue + def initialize(value) + super(Array(value)) + end + + def key? + true + end + + def verify_key(arg, key) + key_field = value.reduce(Resolver::KeyField.new("", inner: key)) do |field, ns| + if ns == Resolver::TYPE_NAME + Resolver::KeyField.new(Resolver::TYPE_NAME) + elsif field + field.inner.find { _1.name == ns } + end + end + + # still not capturing enough type information to accurately compare key/arg types... + # best we can do for now is to verify the argument insertion matches a key path. + if key_field.nil? + raise CompositionError, "Argument `#{arg.name}: #{print}` cannot insert key `#{key.to_definition}`." + end + end + + def build(origin_obj) + value.each_with_index.reduce(origin_obj) do |obj, (ns, idx)| + obj[idx.zero? ? Resolver.export_key(ns) : ns] + end + end + + def print + "$.#{value.join(".")}" + end + end + + # A typed enum input value + # @api private + class EnumArgumentValue < ArgumentValue + end + + # A primitive input value literal + # @api private + class LiteralArgumentValue < ArgumentValue + def print + JSON.generate(value) + end + end + + # Parser for building argument templates into resolver structures + # @api private + module ArgumentsParser + # Parses an argument template string into resolver arguments via schema casting. + # @param template [String] the template string to parse. + # @param field_def [GraphQL::Schema::FieldDefinition] a field definition providing arguments schema. + # @return [[GraphQL::Stitching::Resolver::Argument]] an array of resolver arguments. + def parse_arguments_with_field(template, field_def) + ast = parse_arg_defs(template) + args = build_argument_set(ast, field_def.arguments) + args.each do |arg| + next unless arg.key? + + if field_def.type.list? && !arg.list? + raise CompositionError, "Cannot use repeatable key for `#{field_def.owner.graphql_name}.#{field_def.graphql_name}` " \ + "in non-list argument `#{arg.name}`." + elsif !field_def.type.list? && arg.list? + raise CompositionError, "Cannot use non-repeatable key for `#{field_def.owner.graphql_name}.#{field_def.graphql_name}` " \ + "in list argument `#{arg.name}`." + end + end + + args + end + + # Parses an argument template string into resolver arguments via SDL casting. + # @param template [String] the template string to parse. + # @param type_defs [String] the type definition string declaring argument types. + # @return [[GraphQL::Stitching::Resolver::Argument]] an array of resolver arguments. + def parse_arguments_with_type_defs(template, type_defs) + type_map = parse_type_defs(type_defs) + parse_arg_defs(template).map { build_argument(_1, type_struct: type_map[_1.name]) } + end + + private + + def parse_arg_defs(template) + template = template + .gsub("'", %|"|) # 'sfoo' -> "sfoo" + .gsub(/(\$[\w\.]+)/) { %|"#{_1}"| } # $.key -> "$.key" + .tap(&:strip!) + + template = template[1..-2] if template.start_with?("(") && template.end_with?(")") + + GraphQL.parse("{ f(#{template}) }") + .definitions.first + .selections.first + .arguments + end + + def parse_type_defs(template) + GraphQL.parse("type T { #{template} }") + .definitions.first + .fields.each_with_object({}) do |node, memo| + memo[node.name] = GraphQL::Stitching::Util.flatten_ast_type_structure(node.type) + end + end + + def build_argument_set(nodes, argument_defs) + if argument_defs + argument_defs.each_value do |argument_def| + if argument_def.type.non_null? && !nodes.find { _1.name == argument_def.graphql_name } + raise CompositionError, "Required argument `#{argument_def.graphql_name}` has no input." + end + end + end + + nodes.map do |node| + argument_def = if argument_defs + arg = argument_defs[node.name] + raise CompositionError, "Input `#{node.name}` is not a valid argument." unless arg + arg + end + + build_argument(node, argument_def: argument_def) + end + end + + def build_argument(node, argument_def: nil, type_struct: nil) + value = if node.value.is_a?(GraphQL::Language::Nodes::InputObject) + build_object_value(node.value, argument_def ? argument_def.type.unwrap : nil) + elsif node.value.is_a?(GraphQL::Language::Nodes::Enum) + EnumArgumentValue.new(node.value.name) + elsif node.value.is_a?(String) && node.value.start_with?("$.") + KeyArgumentValue.new(node.value.sub(/^\$\./, "").split(".")) + else + LiteralArgumentValue.new(node.value) + end + + Argument.new( + name: node.name, + value: value, + # doesn't support nested lists...? + list: argument_def ? argument_def.type.list? : (type_struct&.first&.list? || false), + type_name: argument_def ? argument_def.type.unwrap.graphql_name : type_struct&.last&.name, + ) + end + + def build_object_value(node, object_def) + if object_def + if !object_def.kind.input_object? && !object_def.kind.scalar? + raise CompositionError, "Objects can only be built into input object and scalar positions." + elsif object_def.kind.scalar? && GraphQL::Schema::BUILT_IN_TYPES[object_def.graphql_name] + raise CompositionError, "Objects can only be built into custom scalar types." + elsif object_def.kind.scalar? + object_def = nil + end + end + + ObjectArgumentValue.new(build_argument_set(node.arguments, object_def&.arguments)) + end + end + end +end diff --git a/lib/graphql/stitching/resolver/keys.rb b/lib/graphql/stitching/resolver/keys.rb new file mode 100644 index 00000000..5de218b5 --- /dev/null +++ b/lib/graphql/stitching/resolver/keys.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +module GraphQL::Stitching + class Resolver + EXPORT_PREFIX = "_export_" + TYPE_NAME = "__typename" + + class FieldNode + # GraphQL Ruby changed the argument assigning Field.alias from + # a generic `alias` hash key to a structured `field_alias` kwarg + # in https://github.com/rmosolgo/graphql-ruby/pull/4718. + # This adapts to the library implementation present. + GRAPHQL_RUBY_FIELD_ALIAS_KWARG = !GraphQL::Language::Nodes::Field.new(field_alias: "a").alias.nil? + + class << self + def build(field_name:, field_alias: nil, selections: GraphQL::Stitching::EMPTY_ARRAY) + if GRAPHQL_RUBY_FIELD_ALIAS_KWARG + GraphQL::Language::Nodes::Field.new( + field_alias: field_alias, + name: field_name, + selections: selections, + ) + else + GraphQL::Language::Nodes::Field.new( + alias: field_alias, + name: field_name, + selections: selections, + ) + end + end + end + end + + class KeyFieldSet < Array + def initialize(fields) + super(fields.sort_by(&:name)) + @to_definition = nil + @export_nodes = nil + end + + def ==(other) + to_definition == other.to_definition + end + + def default_argument_name + length == 1 ? first.name : nil + end + + def to_definition + @to_definition ||= map(&:to_definition).join(" ").freeze + end + + alias_method :to_s, :to_definition + + def export_nodes + @export_nodes ||= map(&:export_node) + end + end + + EMPTY_FIELD_SET = KeyFieldSet.new(GraphQL::Stitching::EMPTY_ARRAY) + TYPENAME_EXPORT_NODE = FieldNode.build( + field_alias: "#{EXPORT_PREFIX}#{TYPE_NAME}", + field_name: TYPE_NAME, + ) + + class Key < KeyFieldSet + attr_reader :locations + + def initialize(fields, locations: GraphQL::Stitching::EMPTY_ARRAY) + super(fields) + @locations = locations + to_definition + export_nodes + freeze + end + + def export_nodes + @export_nodes ||= begin + nodes = map(&:export_node) + nodes << TYPENAME_EXPORT_NODE + nodes + end + end + end + + class KeyField + # name of the key, may be a field alias + attr_reader :name + + # inner key selections + attr_reader :inner + + # optional information about location and typing, used during composition + attr_accessor :type_name + attr_accessor :list + alias_method :list?, :list + + def initialize(name, root: false, inner: EMPTY_FIELD_SET) + @name = name + @inner = inner + @root = root + end + + def to_definition + @inner.empty? ? @name : "#{@name} { #{@inner.to_definition} }" + end + + def export_node + FieldNode.build( + field_alias: @root ? "#{EXPORT_PREFIX}#{@name}" : nil, + field_name: @name, + selections: @inner.export_nodes, + ) + end + end + + module KeysParser + def export_key(name) + "#{EXPORT_PREFIX}#{name}" + end + + def export_key?(name) + return false unless name + + name.start_with?(EXPORT_PREFIX) + end + + def parse_key(template, locations = GraphQL::Stitching::EMPTY_ARRAY) + Key.new(parse_field_set(template), locations: locations) + end + + def parse_key_with_types(template, subgraph_types_by_location) + field_set = parse_field_set(template) + locations = subgraph_types_by_location.filter_map do |location, subgraph_type| + location if field_set_matches_type?(field_set, subgraph_type) + end + + if locations.none? + message = "Key `#{field_set.to_definition}` does not exist in any location." + message += " Composite key selections may not be distributed." if field_set.length > 1 + raise CompositionError, message + end + + assign_field_set_info!(field_set, subgraph_types_by_location[locations.first]) + Key.new(field_set, locations: locations) + end + + private + + def parse_field_set(template) + template = template.strip + template = template[1..-2] if template.start_with?("{") && template.end_with?("}") + + ast = GraphQL.parse("{ #{template} }").definitions.first.selections + build_field_set(ast, root: true) + end + + def build_field_set(selections, root: false) + return EMPTY_FIELD_SET if selections.empty? + + fields = selections.map do |node| + raise CompositionError, "Key selections must be fields." unless node.is_a?(GraphQL::Language::Nodes::Field) + raise CompositionError, "Key fields may not specify aliases." unless node.alias.nil? + + KeyField.new(node.name, inner: build_field_set(node.selections), root: root) + end + + KeyFieldSet.new(fields) + end + + def field_set_matches_type?(field_set, subgraph_type) + subgraph_type = subgraph_type.unwrap + field_set.all? do |field| + # fixme: union doesn't have fields, but may support these selections... + next true if subgraph_type.kind.union? + field_matches_type?(field, subgraph_type.get_field(field.name)&.type&.unwrap) + end + end + + def field_matches_type?(field, subgraph_type) + return false if subgraph_type.nil? + + if field.inner.empty? && subgraph_type.kind.composite? + raise CompositionError, "Composite key fields must contain nested selections." + end + + field.inner.empty? || field_set_matches_type?(field.inner, subgraph_type) + end + + def assign_field_set_info!(field_set, subgraph_type) + subgraph_type = subgraph_type.unwrap + field_set.each do |field| + # fixme: union doesn't have fields, but may support these selections... + next if subgraph_type.kind.union? + assign_field_info!(field, subgraph_type.get_field(field.name).type) + end + end + + def assign_field_info!(field, subgraph_type) + field.list = subgraph_type.list? + field.type_name = subgraph_type.unwrap.graphql_name + assign_field_set_info!(field.inner, subgraph_type) + end + end + end +end diff --git a/lib/graphql/stitching/shaper.rb b/lib/graphql/stitching/shaper.rb index 8de2edbf..fa4a40d2 100644 --- a/lib/graphql/stitching/shaper.rb +++ b/lib/graphql/stitching/shaper.rb @@ -23,8 +23,8 @@ def perform!(raw) def resolve_object_scope(raw_object, parent_type, selections, typename = nil) return nil if raw_object.nil? - typename ||= raw_object[ExportSelection.typename_node.alias] - raw_object.reject! { |key, _v| ExportSelection.key?(key) } + typename ||= raw_object[Resolver::TYPENAME_EXPORT_NODE.alias] + raw_object.reject! { |key, _v| Resolver.export_key?(key) } selections.each do |node| case node @@ -64,7 +64,7 @@ def resolve_object_scope(raw_object, parent_type, selections, typename = nil) return nil if result.nil? else - raise "Unexpected node of type #{node.class.name} in selection set." + raise StitchingError, "Unexpected node of type #{node.class.name} in selection set." end end diff --git a/lib/graphql/stitching/skip_include.rb b/lib/graphql/stitching/skip_include.rb index 1d2bc71f..15b107dd 100644 --- a/lib/graphql/stitching/skip_include.rb +++ b/lib/graphql/stitching/skip_include.rb @@ -40,7 +40,7 @@ def render_node(parent_node, variables) end if filtered_selections.none? - filtered_selections << ExportSelection.typename_node + filtered_selections << Resolver::TYPENAME_EXPORT_NODE end if changed diff --git a/lib/graphql/stitching/supergraph.rb b/lib/graphql/stitching/supergraph.rb index 57a4eb5d..e1818a7a 100644 --- a/lib/graphql/stitching/supergraph.rb +++ b/lib/graphql/stitching/supergraph.rb @@ -1,78 +1,12 @@ # frozen_string_literal: true -require_relative "./supergraph/resolver_directive" -require_relative "./supergraph/source_directive" +require_relative "./supergraph/to_definition" module GraphQL module Stitching class Supergraph SUPERGRAPH_LOCATION = "__super" - class << self - def validate_executable!(location, executable) - return true if executable.is_a?(Class) && executable <= GraphQL::Schema - return true if executable && executable.respond_to?(:call) - raise StitchingError, "Invalid executable provided for location `#{location}`." - end - - def from_definition(schema, executables:) - schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String) - field_map = {} - resolver_map = {} - possible_locations = {} - introspection_types = schema.introspection_system.types.keys - - schema.types.each do |type_name, type| - next if introspection_types.include?(type_name) - - type.directives.each do |directive| - next unless directive.graphql_name == ResolverDirective.graphql_name - - kwargs = directive.arguments.keyword_arguments - resolver_map[type_name] ||= [] - resolver_map[type_name] << Resolver.new( - type_name: kwargs.fetch(:type_name, type_name), - location: kwargs[:location], - key: kwargs[:key], - field: kwargs[:field], - list: kwargs[:list] || false, - arg: kwargs[:arg], - arg_type_name: kwargs[:arg_type_name], - representations: kwargs[:representations] || false, - ) - end - - next unless type.kind.fields? - - type.fields.each do |field_name, field| - field.directives.each do |d| - next unless d.graphql_name == SourceDirective.graphql_name - - location = d.arguments.keyword_arguments[:location] - field_map[type_name] ||= {} - field_map[type_name][field_name] ||= [] - field_map[type_name][field_name] << location - possible_locations[location] = true - end - end - end - - executables = possible_locations.keys.each_with_object({}) do |location, memo| - executable = executables[location] || executables[location.to_sym] - if validate_executable!(location, executable) - memo[location] = executable - end - end - - new( - schema: schema, - fields: field_map, - resolvers: resolver_map, - executables: executables, - ) - end - end - # @return [GraphQL::Schema] the composed schema for the supergraph. attr_reader :schema @@ -86,6 +20,7 @@ def initialize(schema:, fields: {}, resolvers: {}, executables: {}) @schema.use(GraphQL::Schema::AlwaysVisible) @resolvers = resolvers + @resolvers_by_version = nil @fields_by_type_and_location = nil @locations_by_type = nil @memoized_introspection_types = nil @@ -112,66 +47,17 @@ def initialize(schema:, fields: {}, resolvers: {}, executables: {}) end.freeze end - def to_definition - if @schema.directives[ResolverDirective.graphql_name].nil? - @schema.directive(ResolverDirective) - end - if @schema.directives[SourceDirective.graphql_name].nil? - @schema.directive(SourceDirective) - end - - @schema.types.each do |type_name, type| - if resolvers_for_type = @resolvers.dig(type_name) - resolvers_for_type.each do |resolver| - existing = type.directives.find do |d| - kwargs = d.arguments.keyword_arguments - d.graphql_name == ResolverDirective.graphql_name && - kwargs[:location] == resolver.location && - kwargs[:key] == resolver.key && - kwargs[:field] == resolver.field && - kwargs[:arg] == resolver.arg && - kwargs.fetch(:list, false) == resolver.list && - kwargs.fetch(:representations, false) == resolver.representations - end - - type.directive(ResolverDirective, **{ - type_name: (resolver.type_name if resolver.type_name != type_name), - location: resolver.location, - key: resolver.key, - field: resolver.field, - list: resolver.list || nil, - arg: resolver.arg, - arg_type_name: resolver.arg_type_name, - representations: resolver.representations || nil, - }.tap(&:compact!)) if existing.nil? - end - end - - next unless type.kind.fields? - - type.fields.each do |field_name, field| - locations_for_field = @locations_by_type_and_field.dig(type_name, field_name) - next if locations_for_field.nil? - - locations_for_field.each do |location| - existing = field.directives.find do |d| - d.graphql_name == SourceDirective.graphql_name && - d.arguments.keyword_arguments[:location] == location - end - - field.directive(SourceDirective, location: location) if existing.nil? - end - end - end - - @schema.to_definition - end - # @return [GraphQL::StaticValidation::Validator] static validator for the supergraph schema. def static_validator @static_validator ||= @schema.static_validator end + def resolvers_by_version + @resolvers_by_version ||= resolvers.values.tap(&:flatten!).each_with_object({}) do |resolver, memo| + memo[resolver.version] = resolver + end + end + def fields @locations_by_type_and_field.reject { |k, _v| memoized_introspection_types[k] } end @@ -245,24 +131,23 @@ def locations_by_type end # collects all possible resolver keys for a given type - # ("Type") => ["id", ...] + # ("Type") => [Key("id"), ...] def possible_keys_for_type(type_name) @possible_keys_by_type[type_name] ||= begin if type_name == @schema.query.graphql_name GraphQL::Stitching::EMPTY_ARRAY else - @resolvers[type_name].map(&:key).tap(&:uniq!) + @resolvers[type_name].map(&:key).uniq(&:to_definition) end end end # collects possible resolver keys for a given type and location - # ("Type", "location") => ["id", ...] + # ("Type", "location") => [Key("id"), ...] def possible_keys_for_type_and_location(type_name, location) possible_keys_by_type = @possible_keys_by_type_and_location[type_name] ||= {} - possible_keys_by_type[location] ||= begin - location_fields = fields_by_type_and_location[type_name][location] || [] - location_fields & possible_keys_for_type(type_name) + possible_keys_by_type[location] ||= possible_keys_for_type(type_name).select do |key| + key.locations.include?(location) end end diff --git a/lib/graphql/stitching/supergraph/key_directive.rb b/lib/graphql/stitching/supergraph/key_directive.rb new file mode 100644 index 00000000..334ba944 --- /dev/null +++ b/lib/graphql/stitching/supergraph/key_directive.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module GraphQL::Stitching + class Supergraph + class KeyDirective < GraphQL::Schema::Directive + graphql_name "key" + locations OBJECT, INTERFACE, UNION + argument :key, String, required: true + argument :location, String, required: true + repeatable true + end + end +end diff --git a/lib/graphql/stitching/supergraph/resolver_directive.rb b/lib/graphql/stitching/supergraph/resolver_directive.rb index e9c5ee2a..3c8ec8e8 100644 --- a/lib/graphql/stitching/supergraph/resolver_directive.rb +++ b/lib/graphql/stitching/supergraph/resolver_directive.rb @@ -5,14 +5,13 @@ class Supergraph class ResolverDirective < GraphQL::Schema::Directive graphql_name "resolver" locations OBJECT, INTERFACE, UNION - argument :type_name, String, required: false argument :location, String, required: true + argument :list, Boolean, required: false argument :key, String, required: true argument :field, String, required: true - argument :arg, String, required: true - argument :arg_type_name, String, required: true - argument :list, Boolean, required: false - argument :representations, Boolean, required: false + argument :arguments, String, required: true + argument :argument_types, String, required: true + argument :type_name, String, required: false repeatable true end end diff --git a/lib/graphql/stitching/supergraph/to_definition.rb b/lib/graphql/stitching/supergraph/to_definition.rb new file mode 100644 index 00000000..bddde7f1 --- /dev/null +++ b/lib/graphql/stitching/supergraph/to_definition.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true +require_relative "./key_directive" +require_relative "./resolver_directive" +require_relative "./source_directive" + +module GraphQL::Stitching + class Supergraph + class << self + def validate_executable!(location, executable) + return true if executable.is_a?(Class) && executable <= GraphQL::Schema + return true if executable && executable.respond_to?(:call) + raise StitchingError, "Invalid executable provided for location `#{location}`." + end + + def from_definition(schema, executables:) + schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String) + field_map = {} + resolver_map = {} + possible_locations = {} + introspection_types = schema.introspection_system.types.keys + + schema.types.each do |type_name, type| + next if introspection_types.include?(type_name) + + # Collect/build key definitions for each type + locations_by_key = type.directives.each_with_object({}) do |directive, memo| + next unless directive.graphql_name == KeyDirective.graphql_name + + kwargs = directive.arguments.keyword_arguments + memo[kwargs[:key]] ||= [] + memo[kwargs[:key]] << kwargs[:location] + end + + key_definitions = locations_by_key.each_with_object({}) do |(key, locations), memo| + memo[key] = Resolver.parse_key(key, locations) + end + + # Collect/build resolver definitions for each type + type.directives.each do |directive| + next unless directive.graphql_name == ResolverDirective.graphql_name + + kwargs = directive.arguments.keyword_arguments + resolver_map[type_name] ||= [] + resolver_map[type_name] << Resolver.new( + location: kwargs[:location], + type_name: kwargs.fetch(:type_name, type_name), + field: kwargs[:field], + list: kwargs[:list] || false, + key: key_definitions[kwargs[:key]], + arguments: Resolver.parse_arguments_with_type_defs(kwargs[:arguments], kwargs[:argument_types]), + ) + end + + next unless type.kind.fields? + + type.fields.each do |field_name, field| + # Collection locations for each field definition + field.directives.each do |d| + next unless d.graphql_name == SourceDirective.graphql_name + + location = d.arguments.keyword_arguments[:location] + field_map[type_name] ||= {} + field_map[type_name][field_name] ||= [] + field_map[type_name][field_name] << location + possible_locations[location] = true + end + end + end + + executables = possible_locations.keys.each_with_object({}) do |location, memo| + executable = executables[location] || executables[location.to_sym] + if validate_executable!(location, executable) + memo[location] = executable + end + end + + new( + schema: schema, + fields: field_map, + resolvers: resolver_map, + executables: executables, + ) + end + end + + def to_definition + if @schema.directives[KeyDirective.graphql_name].nil? + @schema.directive(KeyDirective) + end + if @schema.directives[ResolverDirective.graphql_name].nil? + @schema.directive(ResolverDirective) + end + if @schema.directives[SourceDirective.graphql_name].nil? + @schema.directive(SourceDirective) + end + + @schema.types.each do |type_name, type| + if resolvers_for_type = @resolvers.dig(type_name) + # Apply key directives for each unique type/key/location + # (this allows keys to be composite selections and/or omitted from the supergraph schema) + keys_for_type = resolvers_for_type.each_with_object({}) do |resolver, memo| + memo[resolver.key.to_definition] ||= Set.new + memo[resolver.key.to_definition].merge(resolver.key.locations) + end + + keys_for_type.each do |key, locations| + locations.each do |location| + params = { key: key, location: location } + + unless has_directive?(type, KeyDirective.graphql_name, params) + type.directive(KeyDirective, **params) + end + end + end + + # Apply resolver directives for each unique query resolver + resolvers_for_type.each do |resolver| + params = { + location: resolver.location, + field: resolver.field, + list: resolver.list? || nil, + key: resolver.key.to_definition, + arguments: resolver.arguments.map(&:to_definition).join(", "), + argument_types: resolver.arguments.map(&:to_type_definition).join(", "), + type_name: (resolver.type_name if resolver.type_name != type_name), + } + + unless has_directive?(type, ResolverDirective.graphql_name, params) + type.directive(ResolverDirective, **params.tap(&:compact!)) + end + end + end + + next unless type.kind.fields? + + type.fields.each do |field_name, field| + locations_for_field = @locations_by_type_and_field.dig(type_name, field_name) + next if locations_for_field.nil? + + # Apply source directives to annotate the possible locations of each field + locations_for_field.each do |location| + params = { location: location } + + unless has_directive?(field, SourceDirective.graphql_name, params) + field.directive(SourceDirective, **params) + end + end + end + end + + @schema.to_definition + end + + private + + def has_directive?(element, directive_name, params) + existing = element.directives.find do |d| + kwargs = d.arguments.keyword_arguments + d.graphql_name == directive_name && params.all? { |k, v| kwargs[k] == v } + end + + !existing.nil? + end + end +end diff --git a/lib/graphql/stitching/util.rb b/lib/graphql/stitching/util.rb index 934a3851..ea32c257 100644 --- a/lib/graphql/stitching/util.rb +++ b/lib/graphql/stitching/util.rb @@ -47,6 +47,34 @@ def flatten_type_structure(type) structure end + # builds a single-dimensional representation of a wrapped type structure from AST + def flatten_ast_type_structure(ast, structure: []) + null = true + + while ast.is_a?(GraphQL::Language::Nodes::NonNullType) + ast = ast.of_type + null = false + end + + if ast.is_a?(GraphQL::Language::Nodes::ListType) + structure << TypeStructure.new( + list: true, + null: null, + name: nil, + ) + + flatten_ast_type_structure(ast.of_type, structure: structure) + else + structure << TypeStructure.new( + list: false, + null: null, + name: ast.name, + ) + end + + structure + end + # expands interfaces and unions to an array of their memberships # like `schema.possible_types`, but includes child interfaces def expand_abstract_type(schema, parent_type) diff --git a/lib/graphql/stitching/version.rb b/lib/graphql/stitching/version.rb index 43680645..edbc9626 100644 --- a/lib/graphql/stitching/version.rb +++ b/lib/graphql/stitching/version.rb @@ -2,6 +2,6 @@ module GraphQL module Stitching - VERSION = "1.3.0" + VERSION = "1.4.0" end end diff --git a/test/graphql/stitching/composer/configuration_test.rb b/test/graphql/stitching/composer/configuration_test.rb index acb03da2..ad87011f 100644 --- a/test/graphql/stitching/composer/configuration_test.rb +++ b/test/graphql/stitching/composer/configuration_test.rb @@ -37,7 +37,7 @@ def test_perform_with_static_resolver_config bravo: { schema: GraphQL::Schema.from_definition(bravo), stitch: [ - { field_name: "productB", key: "key:id" }, + { field_name: "productB", key: "id", arguments: "key: $.id" }, ] } }) @@ -47,26 +47,42 @@ def test_perform_with_static_resolver_config GraphQL::Stitching::Resolver.new( location: "alpha", type_name: "Product", - field: "productA", - key: "id", - arg: "id", - arg_type_name: "ID", list: false, - representations: false, + field: "productA", + key: GraphQL::Stitching::Resolver.parse_key("id"), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("id: $.id", "id: ID"), ), GraphQL::Stitching::Resolver.new( location: "bravo", type_name: "Product", - field: "productB", - key: "id", - arg: "key", - arg_type_name: "ID", list: false, - representations: false, + field: "productB", + key: GraphQL::Stitching::Resolver.parse_key("id"), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("key: $.id", "key: ID"), ), ] } assert_equal expected_resolvers, supergraph.resolvers end + + def test_perform_federation_schema + schema = %| + directive @key(fields: String!) repeatable on OBJECT + type Product @key(fields: "id sku") { id: ID! sku: String! price: Float! } + union _Entity = Product + scalar _Any + type Query { _entities(representations: [_Any!]!): [_Entity]! } + | + + configs = GraphQL::Stitching::Composer::ResolverConfig.extract_federation_entities( + GraphQL::Schema.from_definition(schema), + "alpha", + ) + + resolver_config = configs["alpha._entities"].first + assert_equal "Product", resolver_config.type_name + assert_equal "id sku", resolver_config.key + assert_equal "representations: { id: $.id, sku: $.sku, __typename: $.__typename }", resolver_config.arguments + end end diff --git a/test/graphql/stitching/composer/merge_arguments_test.rb b/test/graphql/stitching/composer/merge_arguments_test.rb index 4459e6eb..c9da021c 100644 --- a/test/graphql/stitching/composer/merge_arguments_test.rb +++ b/test/graphql/stitching/composer/merge_arguments_test.rb @@ -26,7 +26,7 @@ def test_merged_object_arguments_must_have_matching_named_types a = "input Test { arg:Int } type Query { test(arg:Test):String }" b = "input Test { arg:String } type Query { test(arg:Test):String }" - assert_error('Cannot compose mixed types at `Test.arg`', ComposerError) do + assert_error('Cannot compose mixed types at `Test.arg`', CompositionError) do compose_definitions({ "a" => a, "b" => b }) end end @@ -35,7 +35,7 @@ def test_merged_field_arguments_must_have_matching_named_types a = "type Query { test(arg:Int):String }" b = "type Query { test(arg:String):String }" - assert_error('Cannot compose mixed types at `Query.test.arg`', ComposerError) do + assert_error('Cannot compose mixed types at `Query.test.arg`', CompositionError) do compose_definitions({ "a" => a, "b" => b }) end end @@ -71,7 +71,7 @@ def test_merged_object_arguments_must_have_matching_list_structures a = "input Test { arg:[[String!]] } type Query { test:Test }" b = "input Test { arg:[String!] } type Query { test:Test }" - assert_error('Cannot compose mixed list structures at `Test.arg`.', ComposerError) do + assert_error('Cannot compose mixed list structures at `Test.arg`.', CompositionError) do compose_definitions({ "a" => a, "b" => b }) end end @@ -80,7 +80,7 @@ def test_merged_field_arguments_must_have_matching_list_structures a = "type Query { test(arg:[String]):String }" b = "type Query { test(arg:[[String]]):String }" - assert_error('Cannot compose mixed list structures at `Query.test.arg`.', ComposerError) do + assert_error('Cannot compose mixed list structures at `Query.test.arg`.', CompositionError) do compose_definitions({ "a" => a, "b" => b }) end end @@ -141,7 +141,7 @@ def test_fails_to_merge_isolated_required_object_arguments a = "input Test { arg1:String! } type Query { test(arg:Test):String }" b = "input Test { arg2:String } type Query { test(arg:Test):String }" - assert_error('Required argument `Test.arg1` must be defined in all locations.', ComposerError) do + assert_error('Required argument `Test.arg1` must be defined in all locations.', CompositionError) do compose_definitions({ "a" => a, "b" => b }) end end @@ -150,7 +150,7 @@ def test_fails_to_merge_isolated_required_field_arguments a = "type Query { test(arg1:String):String }" b = "type Query { test(arg2:String!):String }" - assert_error('Required argument `Query.test.arg2` must be defined in all locations.', ComposerError) do + assert_error('Required argument `Query.test.arg2` must be defined in all locations.', CompositionError) do compose_definitions({ "a" => a, "b" => b }) end end diff --git a/test/graphql/stitching/composer/merge_fields_test.rb b/test/graphql/stitching/composer/merge_fields_test.rb index 7f449cbd..2032b578 100644 --- a/test/graphql/stitching/composer/merge_fields_test.rb +++ b/test/graphql/stitching/composer/merge_fields_test.rb @@ -64,7 +64,7 @@ def test_merged_fields_must_have_matching_named_types a = "type Test { field: String } type Query { test:Test }" b = "type Test { field: Int } type Query { test:Test }" - assert_error "Cannot compose mixed types at `Test.field`", ComposerError do + assert_error "Cannot compose mixed types at `Test.field`", CompositionError do compose_definitions({ "a" => a, "b" => b }) end end @@ -98,7 +98,7 @@ def test_merged_fields_must_have_matching_list_structures a = "type Test { field: [[String!]] } type Query { test:Test }" b = "type Test { field: [String!] } type Query { test:Test }" - assert_error "Cannot compose mixed list structures at `Test.field`", ComposerError do + assert_error "Cannot compose mixed list structures at `Test.field`", CompositionError do compose_definitions({ "a" => a, "b" => b }) end end diff --git a/test/graphql/stitching/composer/merge_resolvers_test.rb b/test/graphql/stitching/composer/merge_resolvers_test.rb index fea4f626..48ac7cfb 100644 --- a/test/graphql/stitching/composer/merge_resolvers_test.rb +++ b/test/graphql/stitching/composer/merge_resolvers_test.rb @@ -12,23 +12,19 @@ def test_creates_resolver_map "Test" => [ GraphQL::Stitching::Resolver.new( location: "a", - key: "id", - field: "a", - arg: "id", - arg_type_name: "ID", + type_name: "Test", list: false, - representations: false, - type_name: "Test" + field: "a", + key: GraphQL::Stitching::Resolver.parse_key("id"), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("id: $.id", "id: ID"), ), GraphQL::Stitching::Resolver.new( location: "b", - key: "id", - field: "b", - arg: "ids", - arg_type_name: "ID", + type_name: "Test", list: true, - representations: false, - type_name: "Test" + field: "b", + key: GraphQL::Stitching::Resolver.parse_key("id"), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("ids: $.id", "ids: [ID]"), ), ], } @@ -46,7 +42,7 @@ def test_merges_resolvers_with_multiple_keys | b = %| type T { id:ID! upc:ID! } - type Query { b(id: ID, code: ID):T @stitch(key: "id") @stitch(key: "code:upc") } + type Query { b(id: ID, code: ID):T @stitch(key: "id") @stitch(key: "upc", arguments: "code: $.upc") } | c = %| type T { id:ID! } @@ -122,7 +118,6 @@ def test_expands_union_resolver_accessors_to_relevant_types assert_resolver(supergraph, "Apple", location: "b", key: "id", field: "a", arg: "id") end - def test_builds_union_resolvers_for_select_typenames a = %| type Apple { id:ID! name:String } @@ -132,7 +127,7 @@ def test_builds_union_resolvers_for_select_typenames type Query { fruitA(id:ID!):Fruit @stitch(key: "id", typeName: "Apple") - @stitch(key: "id", typeName: "Banana", representations: true) + @stitch(key: "id", typeName: "Banana") coconut(id: ID!): Coconut @stitch(key: "id") } @@ -152,9 +147,21 @@ def test_builds_union_resolvers_for_select_typenames assert_equal ["fruitA", "fruitB"], supergraph.resolvers["Banana"].map(&:field).sort assert_equal ["coconut", "fruitB"], supergraph.resolvers["Coconut"].map(&:field).sort assert_equal ["fruitB"], supergraph.resolvers["Fruit"].map(&:field).sort + end - assert_equal false, supergraph.resolvers["Apple"].find { _1.location == "a" }.representations? - assert_equal true, supergraph.resolvers["Banana"].find { _1.location == "a" }.representations? + def test_raises_when_resolver_specifies_typename_for_non_abstract + a = %| + type Apple { id:ID! name:String } + type Query { appleA(id: ID!): Apple @stitch(key: "id", typeName: "Banana") } + | + b = %| + type Apple { id:ID! color:String } + type Query { appleB(id: ID!): Apple @stitch(key: "id") } + | + + assert_error("may only specify a type name for abstract resolvers") do + compose_definitions({ "a" => a, "b" => b }) + end end def test_raises_when_given_typename_is_not_a_possible_type @@ -176,11 +183,37 @@ def test_raises_when_given_typename_is_not_a_possible_type } | - assert_error "`Banana` is not a possible return type" do + assert_error("`Banana` is not a possible return type") do compose_definitions({ "a" => a, "b" => b }) end end + def test_multiple_arguments_selects_matched_key_name + a = %| + type Apple { id: ID! upc: ID! name: String } + type Query { appleA(id: ID, upc: ID): Apple @stitch(key: "id") } + | + b = %| + type Apple { id: ID! upc: ID! color: String } + type Query { appleB(id: ID, upc: ID): Apple @stitch(key: "upc") } + | + + supergraph = compose_definitions({ "a" => a, "b" => b }) + assert_resolver(supergraph, "Apple", location: "a", key: "id", field: "appleA", arg: "id") + assert_resolver(supergraph, "Apple", location: "b", key: "upc", field: "appleB", arg: "upc") + end + + def test_raises_for_multiple_arguments_with_no_matched_key_names + a = %| + type Apple { id: ID! upc: ID! name: String } + type Query { appleA(theId: ID, theUpc: ID): Apple @stitch(key: "id") } + | + + assert_error("No resolver argument matched for `Query.appleA`") do + compose_definitions({ "a" => a }) + end + end + private def assert_resolver(supergraph, type_name, location:, key: nil, field: nil, arg: nil) @@ -188,8 +221,9 @@ def assert_resolver(supergraph, type_name, location:, key: nil, field: nil, arg: conditions = [] conditions << (b.location == location) conditions << (b.field == field) if field - conditions << (b.arg == arg) if arg - conditions << (b.key == key) if key + conditions << (b.arguments.first.name == arg) if arg + conditions << (b.arguments.first.value == GraphQL::Stitching::Resolver::KeyArgumentValue.new(key)) if key + conditions << (b.key == GraphQL::Stitching::Resolver.parse_key(key)) if key conditions.all? end assert resolver, "No resolver found for #{[location, type_name, key, field, arg].join(".")}" diff --git a/test/graphql/stitching/composer/merge_root_objects_test.rb b/test/graphql/stitching/composer/merge_root_objects_test.rb index 609223df..fe2019d5 100644 --- a/test/graphql/stitching/composer/merge_root_objects_test.rb +++ b/test/graphql/stitching/composer/merge_root_objects_test.rb @@ -40,7 +40,7 @@ def test_merges_fields_of_root_scopes_into_custom_names def test_errors_for_subscription a = "type Query { a:String } type Mutation { a:String } type Subscription { b:String }" - assert_error('subscription operation is not supported', ComposerError) do + assert_error('subscription operation is not supported', CompositionError) do compose_definitions({ "a" => a }) end end @@ -48,7 +48,7 @@ def test_errors_for_subscription def test_errors_for_query_type_name_conflict a = "type Query { a:String } type Boom { a:String }" - assert_error('Query name "Boom" is used', ComposerError) do + assert_error('Query name "Boom" is used', CompositionError) do compose_definitions({ "a" => a }, { query_name: "Boom" }) end end @@ -56,7 +56,7 @@ def test_errors_for_query_type_name_conflict def test_errors_for_mutation_type_name_conflict a = "type Query { a:String } type Mutation { a:String } type Boom { a:String }" - assert_error('Mutation name "Boom" is used', ComposerError) do + assert_error('Mutation name "Boom" is used', CompositionError) do compose_definitions({ "a" => a }, { mutation_name: "Boom" }) end end diff --git a/test/graphql/stitching/composer/validate_composition_test.rb b/test/graphql/stitching/composer/validate_composition_test.rb index ab050b5f..83fd1279 100644 --- a/test/graphql/stitching/composer/validate_composition_test.rb +++ b/test/graphql/stitching/composer/validate_composition_test.rb @@ -7,7 +7,7 @@ def test_errors_for_merged_types_of_different_kinds a = "type Query { a:Boom } type Boom { a:String }" b = "type Query { b:Boom } interface Boom { b:String }" - assert_error('Cannot merge different kinds for `Boom`. Found: OBJECT, INTERFACE', ComposerError) do + assert_error('Cannot merge different kinds for `Boom`. Found: OBJECT, INTERFACE', CompositionError) do compose_definitions({ "a" => a, "b" => b }) end end diff --git a/test/graphql/stitching/composer/validate_resolvers_test.rb b/test/graphql/stitching/composer/validate_resolvers_test.rb index 73470e3a..242dc9e0 100644 --- a/test/graphql/stitching/composer/validate_resolvers_test.rb +++ b/test/graphql/stitching/composer/validate_resolvers_test.rb @@ -5,15 +5,20 @@ describe 'GraphQL::Stitching::Composer, validate resolvers' do def test_validates_only_one_resolver_query_per_type_location_key - a = %{ + a = %| interface I { id:ID! } type T implements I { id:ID! name:String } type Query { t(id: ID!):T @stitch(key: "id") i(id: ID!):I @stitch(key: "id") } - } - b = %{type T { id:ID! size:Float } type Query { b:T }} + | + b = %| + type T { id:ID! size:Float } + type Query { + t(id: ID!):T @stitch(key: "id") + } + | assert_error("Multiple resolver queries for `T.id` found in a", ValidationError) do compose_definitions({ "a" => a, "b" => b }) @@ -21,28 +26,28 @@ def test_validates_only_one_resolver_query_per_type_location_key end def test_permits_multiple_resolver_query_keys_per_type_location - a = %{ + a = %| type T { upc:ID! name:String } type Query { a(upc:ID!):T @stitch(key: "upc") } - } - b = %{ + | + b = %| type T { id:ID! upc:ID! } type Query { b1(upc:ID!):T @stitch(key: "upc") b2(id:ID!):T @stitch(key: "id") } - } - c = %{ + | + c = %| type T { id:ID! size:Int } type Query { c(id:ID!):T @stitch(key: "id") } - } + | assert compose_definitions({ "a" => a, "b" => b, "c" => c }) end def test_validates_resolver_present_when_providing_unique_fields - a = %{type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }} - b = %{type T { id:ID! size:Float } type Query { b:T }} + a = %|type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }| + b = %|type T { id:ID! size:Float } type Query { b:T }| assert_error("A resolver query is required for `T` in b", ValidationError) do compose_definitions({ "a" => a, "b" => b }) @@ -50,9 +55,9 @@ def test_validates_resolver_present_when_providing_unique_fields end def test_validates_resolver_present_in_multiple_locations_when_providing_unique_fields - a = %{type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }} - b = %{type T { id:ID! size:Float } type Query { b:T }} - c = %{type T { id:ID! size:Float } type Query { c:T }} + a = %|type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }| + b = %|type T { id:ID! size:Float } type Query { b:T }| + c = %|type T { id:ID! size:Float } type Query { c:T }| assert_error("A resolver query is required for `T` in one of b, c locations", ValidationError) do compose_definitions({ "a" => a, "b" => b, "c" => c }) @@ -60,42 +65,42 @@ def test_validates_resolver_present_in_multiple_locations_when_providing_unique_ end def test_permits_no_resolver_query_for_types_that_can_be_fully_resolved_elsewhere - a = %{type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }} - b = %{type T { id:ID! size:Float } type Query { b(id: ID!):T @stitch(key: "id") }} - c = %{type T { id:ID! size:Float name:String } type Query { c:T }} + a = %|type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }| + b = %|type T { id:ID! size:Float } type Query { b(id: ID!):T @stitch(key: "id") }| + c = %|type T { id:ID! size:Float name:String } type Query { c:T }| assert compose_definitions({ "a" => a, "b" => b, "c" => c }) end def test_permits_no_resolver_query_for_key_only_types - a = %{type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }} - b = %{type T { id:ID! } type Query { b:T }} + a = %|type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }| + b = %|type T { id:ID! } type Query { b:T }| assert compose_definitions({ "a" => a, "b" => b }) end def test_validates_subset_types_have_a_key - a = %{type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }} - b = %{type T { name:String } type Query { b:T }} + a = %|type T { id:ID! name:String } type Query { a(id: ID!):T @stitch(key: "id") }| + b = %|type T { name:String } type Query { b:T }| assert_error("A resolver key is required for `T` in b", ValidationError) do compose_definitions({ "a" => a, "b" => b }) end end - def test_validates_bidirection_types_are_mutually_accessible - a = %{ + def test_validates_inbound_types_are_mutually_accessible + a = %| type T { upc:ID! name:String } type Query { a(upc:ID!):T @stitch(key: "upc") } - } - b = %{ + | + b = %| type T { id:ID! weight:Int } type Query { b(id:ID!):T @stitch(key: "id") } - } - c = %{ + | + c = %| type T { id:ID! size:Int } type Query { c(id:ID!):T @stitch(key: "id") } - } + | assert_error("Cannot route `T` resolvers in a", ValidationError) do compose_definitions({ "a" => a, "b" => b, "c" => c }) @@ -122,18 +127,18 @@ def test_validates_key_only_types_are_mutually_accessible end def test_validates_outbound_types_can_access_all_bidirection_types - a = %{ + a = %| type T { upc:ID! } type Query { a:T } - } - b = %{ + | + b = %| type T { upc:ID! name:String } type Query { b(upc:ID!):T @stitch(key: "upc") } - } - c = %{ + | + c = %| type T { id:ID! size:Int } type Query { c(id:ID!):T @stitch(key: "id") } - } + | assert_error("Cannot route `T` resolvers in a", ValidationError) do compose_definitions({ "a" => a, "b" => b, "c" => c }) @@ -141,15 +146,15 @@ def test_validates_outbound_types_can_access_all_bidirection_types end def test_permits_shared_types_across_locations_with_matching_compositions - a = %{type T { id:ID! name: String } type Query { a:T }} - b = %{type T { id:ID! name: String } type Query { b:T }} + a = %|type T { id:ID! name: String } type Query { a:T }| + b = %|type T { id:ID! name: String } type Query { b:T }| assert compose_definitions({ "a" => a, "b" => b }) end def test_validates_shared_types_across_locations_must_have_matching_compositions - a = %{type T { id:ID! name: String extra: String } type Query { a:T }} - b = %{type T { id:ID! name: String } type Query { b:T }} + a = %|type T { id:ID! name: String extra: String } type Query { a:T }| + b = %|type T { id:ID! name: String } type Query { b:T }| assert_error("Shared type `T` must have consistent fields", ValidationError) do assert compose_definitions({ "a" => a, "b" => b }) diff --git a/test/graphql/stitching/executor/executor_test.rb b/test/graphql/stitching/executor/executor_test.rb index e0ef1df8..0d16c7cf 100644 --- a/test/graphql/stitching/executor/executor_test.rb +++ b/test/graphql/stitching/executor/executor_test.rb @@ -50,18 +50,18 @@ def test_with_batching query{ featured { _export_id: id _export___typename: __typename } } | expected_source2 = %| - query($_0_0_key:ID!,$_0_1_key:ID!,$_0_2_key:ID!){ - _0_0_result: product(id:$_0_0_key) { name } - _0_1_result: product(id:$_0_1_key) { name } - _0_2_result: product(id:$_0_2_key) { name } + query($_0_0_key_0:ID!,$_0_1_key_0:ID!,$_0_2_key_0:ID!){ + _0_0_result: product(id:$_0_0_key_0) { name } + _0_1_result: product(id:$_0_1_key_0) { name } + _0_2_result: product(id:$_0_2_key_0) { name } } | expected_vars1 = {} expected_vars2 = { - "_0_0_key" => "1", - "_0_1_key" => "2", - "_0_2_key" => "3", + "_0_0_key_0" => "1", + "_0_1_key_0" => "2", + "_0_2_key_0" => "3", } execs = mock_execs(req, [ @@ -97,12 +97,12 @@ def test_with_operation_name_and_directives query Test_1 @inContext(lang: "EN") { featured { _export_id: id _export___typename: __typename } } | expected_source2 = %| - query Test_2($_0_0_key:ID!) @inContext(lang: "EN") { _0_0_result: product(id:$_0_0_key) { name } } + query Test_2($_0_0_key_0:ID!) @inContext(lang: "EN") { _0_0_result: product(id:$_0_0_key_0) { name } } | expected_vars1 = {} expected_vars2 = { - "_0_0_key" => "1", + "_0_0_key_0" => "1", } execs = mock_execs(req, [ diff --git a/test/graphql/stitching/executor/resolver_source_test.rb b/test/graphql/stitching/executor/resolver_source_test.rb index 34b563c2..8add8f9d 100644 --- a/test/graphql/stitching/executor/resolver_source_test.rb +++ b/test/graphql/stitching/executor/resolver_source_test.rb @@ -4,6 +4,23 @@ describe "GraphQL::Stitching::Executor, ResolverSource" do def setup + @resolver1 = GraphQL::Stitching::Resolver.new( + location: "products", + type_name: "Storefront", + list: true, + field: "storefronts", + key: GraphQL::Stitching::Resolver.parse_key("id"), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("ids: $.id", "ids: [ID]"), + ) + @resolver2 = GraphQL::Stitching::Resolver.new( + location: "products", + type_name: "Product", + list: false, + field: "product", + key: GraphQL::Stitching::Resolver.parse_key("upc"), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("upc: $.upc", "upc: ID"), + ) + @op1 = GraphQL::Stitching::Plan::Op.new( step: 2, after: 1, @@ -13,15 +30,7 @@ def setup if_type: "Storefront", selections: "{ name(lang:$lang) }", variables: { "lang" => "String!" }, - resolver: GraphQL::Stitching::Resolver.new( - location: "products", - field: "storefronts", - arg: "ids", - arg_type_name: "ID", - key: "id", - list: true, - type_name: "Storefront" - ) + resolver: @resolver1.version, ) @op2 = GraphQL::Stitching::Plan::Op.new( step: 3, @@ -32,18 +41,16 @@ def setup if_type: "Product", selections: "{ price(currency:$currency) }", variables: { "currency" => "Currency!" }, - resolver: GraphQL::Stitching::Resolver.new( - location: "products", - field: "product", - arg: "upc", - arg_type_name: "ID", - key: "upc", - list: false, - type_name: "Product" - ) + resolver: @resolver2.version, ) - @source = GraphQL::Stitching::Executor::ResolverSource.new({}, "products") + supergraph = GraphQL::Stitching::Supergraph.new(schema: GraphQL::Schema, resolvers: { + "Storefront" => [@resolver1], + "Product" => [@resolver2], + }) + request = GraphQL::Stitching::Request.new(supergraph, "{ test }") + executor = GraphQL::Stitching::Executor.new(request) + @source = GraphQL::Stitching::Executor::ResolverSource.new(executor, "products") @origin_sets_by_operation = { @op1 => [{ "_export_id" => "7" }, { "_export_id" => "8" }], @op2 => [{ "_export_upc" => "abc" }, { "_export_upc" => "xyz" }], @@ -54,10 +61,10 @@ def test_builds_document_for_operation_batch query_document, variable_names = @source.build_document(@origin_sets_by_operation) expected = %| - query($lang:String!,$_0_key:[ID!]!,$currency:Currency!,$_1_0_key:ID!,$_1_1_key:ID!){ - _0_result: storefronts(ids:$_0_key) { name(lang:$lang) } - _1_0_result: product(upc:$_1_0_key) { price(currency:$currency) } - _1_1_result: product(upc:$_1_1_key) { price(currency:$currency) } + query($lang:String!,$_0_key_0:[ID!]!,$currency:Currency!,$_1_0_key_0:ID!,$_1_1_key_0:ID!){ + _0_result: storefronts(ids:$_0_key_0) { name(lang:$lang) } + _1_0_result: product(upc:$_1_0_key_0) { price(currency:$currency) } + _1_1_result: product(upc:$_1_1_key_0) { price(currency:$currency) } } | @@ -69,10 +76,10 @@ def test_builds_document_with_operation_name query_document, variable_names = @source.build_document(@origin_sets_by_operation, "MyOperation") expected = %| - query MyOperation_2_3($lang:String!,$_0_key:[ID!]!,$currency:Currency!,$_1_0_key:ID!,$_1_1_key:ID!){ - _0_result: storefronts(ids:$_0_key) { name(lang:$lang) } - _1_0_result: product(upc:$_1_0_key) { price(currency:$currency) } - _1_1_result: product(upc:$_1_1_key) { price(currency:$currency) } + query MyOperation_2_3($lang:String!,$_0_key_0:[ID!]!,$currency:Currency!,$_1_0_key_0:ID!,$_1_1_key_0:ID!){ + _0_result: storefronts(ids:$_0_key_0) { name(lang:$lang) } + _1_0_result: product(upc:$_1_0_key_0) { price(currency:$currency) } + _1_1_result: product(upc:$_1_1_key_0) { price(currency:$currency) } } | @@ -88,10 +95,10 @@ def test_builds_document_with_operation_directives ) expected = %| - query MyOperation_2_3($lang:String!,$_0_key:[ID!]!,$currency:Currency!,$_1_0_key:ID!,$_1_1_key:ID!) @inContext(lang: "EN") { - _0_result: storefronts(ids:$_0_key) { name(lang:$lang) } - _1_0_result: product(upc:$_1_0_key) { price(currency:$currency) } - _1_1_result: product(upc:$_1_1_key) { price(currency:$currency) } + query MyOperation_2_3($lang:String!,$_0_key_0:[ID!]!,$currency:Currency!,$_1_0_key_0:ID!,$_1_1_key_0:ID!) @inContext(lang: "EN") { + _0_result: storefronts(ids:$_0_key_0) { name(lang:$lang) } + _1_0_result: product(upc:$_1_0_key_0) { price(currency:$currency) } + _1_1_result: product(upc:$_1_1_key_0) { price(currency:$currency) } } | diff --git a/test/graphql/stitching/export_selection_test.rb b/test/graphql/stitching/export_selection_test.rb deleted file mode 100644 index 18cb0251..00000000 --- a/test/graphql/stitching/export_selection_test.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -describe "GraphQL::Stitching::ExportSelection" do - def test_identifies_selection_hint_keys - assert GraphQL::Stitching::ExportSelection.key?("_export_beep") - assert GraphQL::Stitching::ExportSelection.key?("_export___typename") - - assert_equal false, GraphQL::Stitching::ExportSelection.key?("beep") - assert_equal false, GraphQL::Stitching::ExportSelection.key?("__typename") - assert_equal false, GraphQL::Stitching::ExportSelection.key?(nil) - end - - def test_builds_selection_hint_keys - assert_equal "_export_beep", GraphQL::Stitching::ExportSelection.key("beep") - end - - def test_builds_selection_hint_nodes - node = GraphQL::Stitching::ExportSelection.key_node("beep") - assert_equal "_export_beep", node.alias - assert_equal "beep", node.name - end - - def test_provides_typename_hint_node - node = GraphQL::Stitching::ExportSelection.typename_node - assert_equal "_export___typename", node.alias - assert_equal "__typename", node.name - end -end diff --git a/test/graphql/stitching/integration/arguments_test.rb b/test/graphql/stitching/integration/arguments_test.rb new file mode 100644 index 00000000..6306aa8b --- /dev/null +++ b/test/graphql/stitching/integration/arguments_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../../../schemas/arguments" + +describe 'GraphQL::Stitching, arguments' do + def setup + @supergraph = compose_definitions({ + "args1" => Schemas::Arguments::Arguments1, + "args2" => Schemas::Arguments::Arguments2, + }) + end + + def test_stitches_with_enum_argument + query = %|{ allMovies { id status } }| + result = plan_and_execute(@supergraph, query) + expected = { + "allMovies" => [ + { "id" => "1", "status" => "STREAMING" }, + { "id" => "2", "status" => nil }, + { "id" => "3", "status" => "STREAMING" }, + ], + } + + assert_equal expected, result["data"] + end + + def test_stitches_with_input_object_key + query = %|{ allMovies { id director { name } } }| + result = plan_and_execute(@supergraph, query) + expected = { + "allMovies" => [ + { "id" => "1", "director" => { "name" => "Steven Spielberg" } }, + { "id" => "2", "director" => { "name" => "Steven Spielberg" } }, + { "id" => "3", "director" => { "name" => "Christopher Nolan" } }, + ], + } + + assert_equal expected, result["data"] + end + + def test_stitches_with_scalar_key + query = %|{ allMovies { id studio { name } } }| + result = plan_and_execute(@supergraph, query) + expected = { + "allMovies" => [ + { "id" => "1", "studio" => { "name" => "Universal" } }, + { "id" => "2", "studio" => { "name" => "Lucasfilm" } }, + { "id" => "3", "studio" => { "name" => "Syncopy" } }, + ], + } + + assert_equal expected, result["data"] + end + + def test_stitches_with_literal_arguments + query = %|{ allMovies { id genres { name } } }| + result = plan_and_execute(@supergraph, query) + expected = { + "allMovies" => [ + { "id" => "1", "genres" => [{ "name" => "action/adventure" }, { "name" => "action/sci-fi" }] }, + { "id" => "2", "genres" => [{ "name" => "action" }, { "name" => "action/adventure" }] }, + { "id" => "3", "genres" => [{ "name" => "action" }, { "name" => "action/thriller" }] }, + ], + } + + assert_equal expected, result["data"] + end +end diff --git a/test/graphql/stitching/integration/composite_keys_test.rb b/test/graphql/stitching/integration/composite_keys_test.rb new file mode 100644 index 00000000..b18935fb --- /dev/null +++ b/test/graphql/stitching/integration/composite_keys_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../../../schemas/composite_keys" + +describe 'GraphQL::Stitching, composite keys' do + def test_queries_through_multiple_composite_keys_from_outer_edge + @supergraph = compose_definitions({ + "a" => Schemas::CompositeKeys::PagesById, + "b" => Schemas::CompositeKeys::PagesBySku, + "c" => Schemas::CompositeKeys::PagesByScopedHandle, + "d" => Schemas::CompositeKeys::PagesByOwner, + }) + + # id > sku > handle scope > owner { id type } + query = %|{ pagesById(ids: ["1", "2"]) { id a b c d title } }| + result = plan_and_execute(@supergraph, query) + expected = { + "pagesById" => [ + { "id" => "1", "a" => "a1", "b" => "b1", "c" => "c1", "d" => "d1", "title" => "Mercury, Planet" }, + { "id" => "2", "a" => "a2", "b" => "b2", "c" => "c2", "d" => "d2", "title" => "Mercury, Element" }, + ], + } + + assert_equal expected, result["data"] + end + + def test_queries_through_multiple_composite_keys_from_center + @supergraph = compose_definitions({ + "a" => Schemas::CompositeKeys::PagesById, + "b" => Schemas::CompositeKeys::PagesBySku, + "c" => Schemas::CompositeKeys::PagesByScopedHandle, + "d" => Schemas::CompositeKeys::PagesByOwner, + }) + + # id < sku < handle scope > owner { id type } + query = %|{ + pagesByHandle(keys: [ + { handle: "mercury", scope: "planet" }, + { handle: "mercury", scope: "automobile" }, + ]) { id a b c d title } + }| + + result = plan_and_execute(@supergraph, query) + expected = { + "pagesByHandle" => [ + { "id" => "1", "a" => "a1", "b" => "b1", "c" => "c1", "d" => "d1", "title" => "Mercury, Planet" }, + { "id" => "3", "a" => "a3", "b" => "b3", "c" => "c3", "d" => "d3", "title" => "Mercury, Automobile" }, + ], + } + + assert_equal expected, result["data"] + end + + def test_queries_through_single_composite_key + @supergraph = compose_definitions({ + "c" => Schemas::CompositeKeys::PagesByScopedHandle, + "e" => { + schema: Schemas::CompositeKeys::PagesByScopedHandleOrOwner, + stitch: [{ + field_name: "pagesByHandle2", + key: "handle scope", + arguments: "keys: { handle: $.handle, scope: $.scope }", + }], + } + }) + + # "handle scope" > "handle scope" + query = %|{ + pagesByHandle(keys: [ + { handle: "mercury", scope: "planet" }, + { handle: "mercury", scope: "automobile" }, + ]) { c e title } + }| + + result = plan_and_execute(@supergraph, query) + expected = { + "pagesByHandle" => [ + { "c" => "c1", "e" => "e1", "title" => "Mercury, Planet" }, + { "c" => "c3", "e" => "e3", "title" => "Mercury, Automobile" }, + ], + } + + assert_equal expected, result["data"] + end + + def test_queries_through_single_composite_key_with_nesting + @supergraph = compose_definitions({ + "d" => Schemas::CompositeKeys::PagesByOwner, + "e" => { + schema: Schemas::CompositeKeys::PagesByScopedHandleOrOwner, + stitch: [{ + field_name: "pagesByOwner2", + key: "owner { id type }", + arguments: "keys: { id: $.owner.id, type: $.owner.type }", + }], + } + }) + + # "owner { id type }" > "owner { id type }" + query = %|{ + pagesByOwner(keys: [ + { id: "1", type: "Planet" }, + { id: "1", type: "Element" }, + ]) { d e title } + }| + + result = plan_and_execute(@supergraph, query) + expected = { + "pagesByOwner" => [ + { "d" => "d1", "e" => "e1", "title" => "Mercury, Planet" }, + { "d" => "d2", "e" => "e2", "title" => "Mercury, Element" }, + ], + } + + assert_equal expected, result["data"] + end +end diff --git a/test/graphql/stitching/plan_test.rb b/test/graphql/stitching/plan_test.rb index 7ef20c1d..2d2d6d66 100644 --- a/test/graphql/stitching/plan_test.rb +++ b/test/graphql/stitching/plan_test.rb @@ -6,11 +6,11 @@ def setup @resolver = GraphQL::Stitching::Resolver.new( location: "products", - field: "storefronts", - arg: "ids", - key: "id", + type_name: "Storefront", list: true, - type_name: "Storefront" + field: "storefronts", + key: GraphQL::Stitching::Resolver.parse_key("id"), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("ids: $.id", "ids: [ID]"), ) @op = GraphQL::Stitching::Plan::Op.new( @@ -22,7 +22,7 @@ def setup if_type: "Storefront", selections: "{ name(lang:$lang) }", variables: { "lang" => "String!" }, - resolver: @resolver, + resolver: @resolver.version, ) @plan = GraphQL::Stitching::Plan.new(ops: [@op]) @@ -37,14 +37,7 @@ def setup "variables" => {"lang" => "String!"}, "path" => ["storefronts"], "if_type" => "Storefront", - "resolver" => { - "location" => "products", - "type_name" => "Storefront", - "key" => "id", - "field" => "storefronts", - "arg" => "ids", - "list" => true, - }, + "resolver" => @resolver.version, }], } end @@ -56,6 +49,6 @@ def test_as_json_serializes_a_plan def test_from_json_deserialized_a_plan plan = GraphQL::Stitching::Plan.from_json(@serialized) assert_equal [@op], plan.ops - assert_equal @resolver, plan.ops.first.resolver + assert_equal @resolver.version, plan.ops.first.resolver end end diff --git a/test/graphql/stitching/planner/plan_abstracts_test.rb b/test/graphql/stitching/planner/plan_abstracts_test.rb index 125d4d06..ae175f2c 100644 --- a/test/graphql/stitching/planner/plan_abstracts_test.rb +++ b/test/graphql/stitching/planner/plan_abstracts_test.rb @@ -77,10 +77,11 @@ def test_expands_interface_selections_for_target_location selections: "{ name price }", path: ["buyable"], if_type: "Product", - resolver: { + resolver: resolver_version("Product", { + location: "a", field: "products", key: "id", - }, + }), } end @@ -262,10 +263,10 @@ def test_plan_merged_union_types selections: "{ b }", path: ["fruit"], if_type: "Apple", - resolver: { + resolver: resolver_version("Apple", { location: "b", key: "id", - }, + }), } assert_keys plan.ops[2].as_json, { @@ -274,10 +275,10 @@ def test_plan_merged_union_types selections: "{ c }", path: ["fruit"], if_type: "Apple", - resolver: { + resolver: resolver_version("Apple", { location: "c", key: "id", - }, + }), } assert_keys plan.ops[3].as_json, { @@ -286,10 +287,19 @@ def test_plan_merged_union_types selections: "{ b }", path: ["fruit"], if_type: "Banana", - resolver: { + resolver: resolver_version("Banana", { location: "b", key: "id", - }, + }), } end + + private + + def resolver_version(type_name, criteria) + @supergraph.resolvers[type_name].find do |resolver| + json = resolver.as_json + criteria.all? { |k, v| json[k] == v } + end.version + end end diff --git a/test/graphql/stitching/planner/plan_resolvers_test.rb b/test/graphql/stitching/planner/plan_resolvers_test.rb index a4b820ea..49af4dab 100644 --- a/test/graphql/stitching/planner/plan_resolvers_test.rb +++ b/test/graphql/stitching/planner/plan_resolvers_test.rb @@ -47,7 +47,7 @@ def build_sample_graph } | - compose_definitions({ + @supergraph = compose_definitions({ "storefronts" => @storefronts_sdl, "products" => @products_sdl, "manufacturers" => @manufacturers_sdl, @@ -91,10 +91,11 @@ def test_collects_unique_fields_across_resolver_locations operation_type: "query", selections: %|{ name manufacturer { products { name } _export_id: id _export___typename: __typename } }|, path: ["storefront", "products"], - resolver: { + resolver: resolver_version("Product", { + location: "products", field: "product", key: "upc", - }, + }), } assert_keys plan.ops[2].as_json, { @@ -103,10 +104,11 @@ def test_collects_unique_fields_across_resolver_locations operation_type: "query", selections: %|{ address }|, path: ["storefront", "products", "manufacturer"], - resolver: { + resolver: resolver_version("Manufacturer", { + location: "manufacturers", field: "manufacturer", key: "id", - }, + }), } end @@ -136,10 +138,11 @@ def test_collects_common_fields_from_first_available_location operation_type: "query", selections: %|{ products { name } }|, path: ["manufacturer"], - resolver: { + resolver: resolver_version("Manufacturer", { + location: "products", field: "productsManufacturer", key: "id", - }, + }), } assert_keys plan2.ops[0].as_json, { @@ -165,10 +168,10 @@ def test_expands_selections_targeting_interface_locations type Query { node(id:ID!):Node @stitch(key:"id") } | - supergraph = compose_definitions({ "a" => a, "b" => b }) + @supergraph = compose_definitions({ "a" => a, "b" => b }) plan = GraphQL::Stitching::Request.new( - supergraph, + @supergraph, %|{ apple(id:"1") { id name weight } }|, ).plan @@ -189,10 +192,11 @@ def test_expands_selections_targeting_interface_locations operation_type: "query", selections: %|{ ... on Apple { weight } }|, path: ["apple"], - resolver: { + resolver: resolver_version("Apple", { + location: "b", field: "node", key: "id", - }, + }), } end @@ -209,10 +213,10 @@ def test_expands_selections_targeting_union_locations type Query { node(id:ID!):Node @stitch(key:"id") } | - supergraph = compose_definitions({ "a" => a, "b" => b }) + @supergraph = compose_definitions({ "a" => a, "b" => b }) plan = GraphQL::Stitching::Request.new( - supergraph, + @supergraph, %|{ apple(id:"1") { id name weight } }|, ).plan @@ -233,10 +237,11 @@ def test_expands_selections_targeting_union_locations operation_type: "query", selections: %|{ ... on Apple { weight } }|, path: ["apple"], - resolver: { + resolver: resolver_version("Apple", { + location: "b", field: "node", key: "id", - }, + }), } end @@ -254,10 +259,10 @@ def test_expands_selections_for_abstracts_targeting_abstract_locations type Query { fruit(id:ID!):Fruit @stitch(key:"id") } | - supergraph = compose_definitions({ "a" => a, "b" => b }) + @supergraph = compose_definitions({ "a" => a, "b" => b }) plan = GraphQL::Stitching::Request.new( - supergraph, + @supergraph, %|{ node(id:"1") { id ...on Apple { name weight } } }|, ).plan @@ -278,10 +283,20 @@ def test_expands_selections_for_abstracts_targeting_abstract_locations operation_type: "query", selections: %|{ ... on Apple { weight } }|, path: ["node"], - resolver: { + resolver: resolver_version("Apple", { + location: "b", field: "fruit", key: "id", - }, + }), } end + + private + + def resolver_version(type_name, criteria) + @supergraph.resolvers[type_name].find do |resolver| + json = resolver.as_json + criteria.all? { |k, v| json[k] == v } + end.version + end end diff --git a/test/graphql/stitching/resolver/arguments_test.rb b/test/graphql/stitching/resolver/arguments_test.rb new file mode 100644 index 00000000..460c07be --- /dev/null +++ b/test/graphql/stitching/resolver/arguments_test.rb @@ -0,0 +1,437 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Stitching::Resolver::ArgumentsTest < Minitest::Test + Argument = GraphQL::Stitching::Resolver::Argument + ObjectArgumentValue = GraphQL::Stitching::Resolver::ObjectArgumentValue + LiteralArgumentValue = GraphQL::Stitching::Resolver::LiteralArgumentValue + EnumArgumentValue = GraphQL::Stitching::Resolver::EnumArgumentValue + KeyArgumentValue = GraphQL::Stitching::Resolver::KeyArgumentValue + + class TestSchema < GraphQL::Schema + class TestEnum < GraphQL::Schema::Enum + value "YES" + end + + class ObjectKey < GraphQL::Schema::InputObject + graphql_name "ObjectKey" + + argument :slug, String, required: true + argument :namespace, String, required: false + argument :nested, self, required: false + argument :nested_list, [self], required: false + end + + class ScalarKey < GraphQL::Schema::Scalar + graphql_name "ScalarKey" + end + + class Query < GraphQL::Schema::Object + field :object_key, Boolean, null: false do |f| + f.argument(:key, ObjectKey, required: true) + f.argument(:other, String, required: false) + f.argument(:list, [String], required: false) + end + + field :object_list_key, [Boolean], null: false do |f| + f.argument(:keys, [ObjectKey]) + f.argument(:other, String, required: false) + end + + field :scalar_key, Boolean, null: false do |f| + f.argument(:key, ScalarKey) + end + + field :builtin_scalar_key, Boolean, null: false do |f| + f.argument(:key, String) + end + + field :enum_key, Boolean, null: false do |f| + f.argument(:key, TestEnum) + end + + field :basic_key, Boolean, null: false do |f| + f.argument(:key, ID, required: true) + f.argument(:scope, String, required: false) + f.argument(:mode, TestEnum, required: false) + end + end + + query Query + end + + def test_builds_flat_object_key_into_matching_input_object + template = "key: {slug: $.slug, namespace: 'sfoo'}, other: $.slug" + expected = [Argument.new( + name: "key", + type_name: "ObjectKey", + list: false, + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + type_name: "String", + value: KeyArgumentValue.new(["slug"]), + ), + Argument.new( + name: "namespace", + type_name: "String", + value: LiteralArgumentValue.new("sfoo"), + ), + ]), + ), + Argument.new( + name: "other", + type_name: "String", + value: KeyArgumentValue.new(["slug"]), + )] + + assert_equal expected, GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("objectKey")) + end + + def test_builds_nested_object_key_into_matching_input_objects + template = "key: {slug: $.slug, nested:{slug: $.slug, namespace: 'sfoo'}}" + expected = [Argument.new( + name: "key", + type_name: "ObjectKey", + list: false, + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + type_name: "String", + value: KeyArgumentValue.new(["slug"]), + ), + Argument.new( + name: "nested", + type_name: "ObjectKey", + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + type_name: "String", + value: KeyArgumentValue.new(["slug"]), + ), + Argument.new( + name: "namespace", + type_name: "String", + value: LiteralArgumentValue.new("sfoo"), + ), + ]), + ), + ]), + )] + + assert_equal expected, GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("objectKey")) + end + + def test_builds_nested_key_paths + template = "key: {slug: $.ref.slug}" + expected = [Argument.new( + name: "key", + type_name: "ObjectKey", + list: false, + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + type_name: "String", + value: KeyArgumentValue.new(["ref", "slug"]), + ), + ]), + )] + + assert_equal expected, GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("objectKey")) + end + + def test_builds_object_list_keys_into_matching_inputs + template = "keys: {slug: $.slug, nestedList: {slug: $.ref.slug}}" + expected = [Argument.new( + name: "keys", + type_name: "ObjectKey", + list: true, + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + type_name: "String", + value: KeyArgumentValue.new(["slug"]), + ), + Argument.new( + name: "nestedList", + type_name: "ObjectKey", + list: true, + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + type_name: "String", + value: KeyArgumentValue.new(["ref", "slug"]), + ), + ]), + ), + ]), + )] + + assert_equal expected, GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("objectListKey")) + end + + def test_builds_objects_into_custom_scalar_with_no_typing + template = "key: {slug: $.slug, nested: {slug: $.slug}}" + expected = [Argument.new( + name: "key", + type_name: "ScalarKey", + list: false, + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + type_name: nil, + value: KeyArgumentValue.new(["slug"]), + ), + Argument.new( + name: "nested", + type_name: nil, + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + type_name: nil, + value: KeyArgumentValue.new(["slug"]), + ), + ]), + ), + ]), + )] + + assert_equal expected, GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("scalarKey")) + end + + def test_errors_for_building_objects_into_builtin_scalars + assert_error "can only be built into custom scalar types" do + template = "key: {slug: $.slug, namespace: $.namespace}" + GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("builtinScalarKey")) + end + end + + def test_errors_for_building_objects_into_non_object_non_scalars + assert_error "can only be built into input object and scalar positions" do + template = "key: {slug: $.slug, namespace: $.namespace}" + GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("enumKey")) + end + end + + def test_errors_building_invalid_root_keys + assert_error "`invalid` is not a valid argument" do + template = "key: {slug: $.slug, namespace: $.namespace}, invalid: true" + GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("objectKey")) + end + end + + def test_errors_building_invalid_object_keys + assert_error "`invalid` is not a valid argument" do + template = "key: {slug: $.slug, namespace: $.namespace, invalid: true}" + GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("objectKey")) + end + end + + def test_errors_omitting_a_required_root_argument + assert_error "Required argument `key` has no input" do + GraphQL::Stitching::Resolver.parse_arguments_with_field(%|other:"test"|, get_field("objectKey")) + end + end + + def test_errors_omitting_a_required_object_argument + assert_error "Required argument `slug` has no input" do + GraphQL::Stitching::Resolver.parse_arguments_with_field(%|key: {namespace: $.namespace}|, get_field("objectKey")) + end + end + + def test_errors_building_keys_into_non_list_arguments_for_list_fields + assert_error "Cannot use repeatable key for `Query.objectListKey` in non-list argument `other`" do + GraphQL::Stitching::Resolver.parse_arguments_with_field(%|keys: {slug: $.slug} other: $.slug|, get_field("objectListKey")) + end + end + + def test_errors_building_keys_into_list_arguments_for_non_list_fields + assert_error "Cannot use non-repeatable key for `Query.objectKey` in list argument `list`" do + GraphQL::Stitching::Resolver.parse_arguments_with_field(%|key: {slug: $.slug}, list: $.slug|, get_field("objectKey")) + end + end + + def test_arguments_build_expected_value_structure + template = "key: {slug: $.name, namespace: 'sol', nested:{slug: $.outer.name, namespace: $.outer.galaxy}}" + arg = GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("objectKey")).first + + origin_obj = { + "_export_name" => "neptune", + "_export_outer" => { + "name" => "saturn", + "galaxy" => "milkyway", + } + } + + expected = { + "slug" => "neptune", + "namespace" => "sol", + "nested" => { + "slug" => "saturn", + "namespace" => "milkyway", + } + } + + assert_equal expected, arg.build(origin_obj) + end + + def test_arguments_build_primitive_keys + template = "key: $.key, scope: 'foo', mode: YES" + args = GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("basicKey")) + origin_obj = { "_export_key" => "123" } + + assert_equal ["123", "foo", "YES"], args.map { _1.build(origin_obj) } + end + + def test_arguments_allows_wrapping_parenthesis + template = "(key: $.key)" + args = GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("basicKey")) + origin_obj = { "_export_key" => "123" } + + assert_equal ["123"], args.map { _1.build(origin_obj) } + end + + def test_prints_argument_object_structures + template = "key: {slug: $.name, namespace: 'sol', nested: {slug: $.outer.name, namespace: $.outer.galaxy}}" + arg = GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("objectKey")).first + + assert_equal template, arg.to_definition + assert_equal template.gsub("'", %|"|), arg.print + assert_equal "key: ObjectKey!", arg.to_type_definition + end + + def test_prints_primitive_argument_types + template = "key: $.key, scope: 'foo', mode: YES" + args = GraphQL::Stitching::Resolver.parse_arguments_with_field(template, get_field("basicKey")) + + assert_equal template, args.map(&:to_definition).join(", ") + assert_equal template.gsub("'", %|"|), args.map(&:print).join(", ") + assert_equal "key: ID!, scope: String!, mode: TestEnum!", args.map(&:to_type_definition).join(", ") + end + + def test_parse_arguments_with_type_defs + template = "keys: {slug: $.name, namespace: 'beep'}, other: 'boom'" + type_defs = "keys: [ObjectKey], other: String" + expected = [Argument.new( + name: "keys", + type_name: "ObjectKey", + list: true, + value: ObjectArgumentValue.new([ + Argument.new( + name: "slug", + value: KeyArgumentValue.new(["name"]), + ), + Argument.new( + name: "namespace", + value: LiteralArgumentValue.new("beep"), + ), + ]), + ), + Argument.new( + name: "other", + type_name: "String", + value: LiteralArgumentValue.new("boom"), + )] + + assert_equal expected, GraphQL::Stitching::Resolver.parse_arguments_with_type_defs(template, type_defs) + end + + def test_checks_primitive_values_for_presence_of_keys + assert_equal false, Argument.new(name: "test", value: LiteralArgumentValue.new("boom")).key? + assert_equal false, Argument.new(name: "test", value: EnumArgumentValue.new("YES")).key? + assert Argument.new(name: "test", value: KeyArgumentValue.new("id")).key? + end + + def test_checks_composite_values_for_presence_of_keys + assert_equal false, Argument.new( + name: "test", + value: ObjectArgumentValue.new([ + Argument.new( + name: "first", + value: EnumArgumentValue.new(["YES"]), + ), + Argument.new( + name: "second", + value: LiteralArgumentValue.new("boom"), + ), + ]), + ).key? + + assert Argument.new( + name: "test", + value: ObjectArgumentValue.new([ + Argument.new( + name: "first", + value: EnumArgumentValue.new(["YES"]), + ), + Argument.new( + name: "second", + value: LiteralArgumentValue.new("boom"), + ), + Argument.new( + name: "third", + value: KeyArgumentValue.new("id"), + ), + ]), + ).key? + end + + def test_verifies_key_insertion_paths_against_flat_key_selections + arg = Argument.new(name: "id", value: KeyArgumentValue.new("id")) + + assert arg.verify_key(GraphQL::Stitching::Resolver.parse_key("id")) + + assert_error("Argument `id: $.id` cannot insert key `sfoo`") do + arg.verify_key(GraphQL::Stitching::Resolver.parse_key("sfoo")) + end + end + + def test_verifies_key_insertion_paths_against_nested_key_selections + arg = Argument.new(name: "ownerId", value: KeyArgumentValue.new(["owner", "id"])) + + assert arg.verify_key(GraphQL::Stitching::Resolver.parse_key("owner { id }")) + + assert_error("Argument `ownerId: $.owner.id` cannot insert key `owner { sfoo }`") do + arg.verify_key(GraphQL::Stitching::Resolver.parse_key("owner { sfoo }")) + end + end + + def test_verifies_key_insertion_paths_for_typename_as_valid + arg = Argument.new(name: "type", value: KeyArgumentValue.new("__typename")) + assert arg.verify_key(GraphQL::Stitching::Resolver.parse_key("id")) + end + + def test_no_key_verification_for_non_key_values + arg = Argument.new(name: "secret", value: LiteralArgumentValue.new("boo")) + assert !arg.verify_key(GraphQL::Stitching::Resolver.parse_key("id")) + end + + def test_verifies_key_insertion_paths_through_input_object_scopes + arg = Argument.new( + name: "test", + value: ObjectArgumentValue.new([ + Argument.new( + name: "flat", + value: KeyArgumentValue.new("id"), + ), + Argument.new( + name: "nested", + value: KeyArgumentValue.new(["owner", "id"]), + ), + Argument.new( + name: "nested_type_name", + value: KeyArgumentValue.new(["owner", "__typename"]), + ), + ]), + ) + + assert arg.verify_key(GraphQL::Stitching::Resolver.parse_key("id owner { id }")) + end + + private + + def get_field(field_name) + TestSchema.query.get_field(field_name) + end +end diff --git a/test/graphql/stitching/resolver/keys_test.rb b/test/graphql/stitching/resolver/keys_test.rb new file mode 100644 index 00000000..1cb2f5ee --- /dev/null +++ b/test/graphql/stitching/resolver/keys_test.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require "test_helper" + +class GraphQL::Stitching::Resolver::KeysTest < Minitest::Test + Key = GraphQL::Stitching::Resolver::Key + KeyFieldSet = GraphQL::Stitching::Resolver::KeyFieldSet + KeyField = GraphQL::Stitching::Resolver::KeyField + FieldNode = GraphQL::Stitching::Resolver::FieldNode + + def test_formats_export_keys + assert_equal "_export_id", GraphQL::Stitching::Resolver.export_key("id") + end + + def test_identifies_export_keys + assert GraphQL::Stitching::Resolver.export_key?("_export_id") + assert !GraphQL::Stitching::Resolver.export_key?("id") + end + + def test_parses_key_with_locations + key = GraphQL::Stitching::Resolver.parse_key("id reference { id __typename }", ["a", "b"]) + expected = Key.new([ + KeyField.new("id"), + KeyField.new( + "reference", + inner: KeyFieldSet.new([ + KeyField.new("id"), + KeyField.new("__typename"), + ]), + ), + ]) + assert_equal expected, key + assert_equal ["a", "b"], key.locations + end + + def test_fulfills_key_set_uniqueness + keys = [ + GraphQL::Stitching::Resolver.parse_key("id"), + GraphQL::Stitching::Resolver.parse_key("id"), + GraphQL::Stitching::Resolver.parse_key("sku id"), + GraphQL::Stitching::Resolver.parse_key("id sku"), + GraphQL::Stitching::Resolver.parse_key("ref { a b } c"), + GraphQL::Stitching::Resolver.parse_key("c ref { b a }"), + ].uniq(&:to_definition) + + assert_equal ["id", "id sku", "c ref { a b }"], keys.map(&:to_definition) + end + + def test_matches_basic_keys + key1 = GraphQL::Stitching::Resolver.parse_key("id") + key2 = GraphQL::Stitching::Resolver.parse_key("id") + key3 = GraphQL::Stitching::Resolver.parse_key("sku") + assert key1 == key2 + assert key2 == key1 + assert key1 != key3 + assert key3 != key1 + end + + def test_matches_key_sets + key1 = GraphQL::Stitching::Resolver.parse_key("id sku") + key2 = GraphQL::Stitching::Resolver.parse_key("sku id") + key3 = GraphQL::Stitching::Resolver.parse_key("id upc") + key4 = GraphQL::Stitching::Resolver.parse_key("id") + assert key1 == key2 + assert key2 == key1 + assert key1 != key3 + assert key1 != key4 + end + + def test_matches_nested_key_sets + key1 = GraphQL::Stitching::Resolver.parse_key("id ref { ns key }") + key2 = GraphQL::Stitching::Resolver.parse_key("ref { key ns } id") + key3 = GraphQL::Stitching::Resolver.parse_key("id ref { key }") + key4 = GraphQL::Stitching::Resolver.parse_key("ref { key ns }") + assert key1 == key2 + assert key2 == key1 + assert key1 != key3 + assert key1 != key4 + end + + def test_matches_nested_and_unnested_key_names_dont_match + key1 = GraphQL::Stitching::Resolver.parse_key("ref { key }") + key2 = GraphQL::Stitching::Resolver.parse_key("ref") + assert key1 != key2 + assert key2 != key1 + end + + def test_prints_a_basic_key + key1 = GraphQL::Stitching::Resolver.parse_key("id") + key2 = GraphQL::Stitching::Resolver.parse_key("ref id") + key3 = GraphQL::Stitching::Resolver.parse_key("ref { ns key } id") + + assert_equal "id", key1.to_definition + assert_equal "id ref", key2.to_definition + assert_equal "id ref { key ns }", key3.to_definition + end + + def test_errors_for_non_field_keys + assert_error("selections must be fields") do + GraphQL::Stitching::Resolver.parse_key("...{ id }") + end + end + + def test_errors_for_aliased_keys + assert_error("may not specify aliases") do + GraphQL::Stitching::Resolver.parse_key("id: key") + end + end + + def test_formats_flat_export_nodes + key = GraphQL::Stitching::Resolver.parse_key("id") + expected = [ + FieldNode.build( + field_alias: "_export_id", + field_name: "id", + selections: [], + ), + FieldNode.build( + field_alias: "_export___typename", + field_name: "__typename", + selections: [], + ), + ] + + assert_equal expected, key.export_nodes + end + + def test_formats_nested_export_nodes + key = GraphQL::Stitching::Resolver.parse_key("ref { key } id") + expected = [ + FieldNode.build( + field_alias: "_export_id", + field_name: "id", + selections: [], + ), + FieldNode.build( + field_alias: "_export_ref", + field_name: "ref", + selections: [ + FieldNode.build( + field_alias: nil, + field_name: "key", + selections: [], + ), + ], + ), + FieldNode.build( + field_alias: "_export___typename", + field_name: "__typename", + selections: [], + ), + ] + + assert_equal expected, key.export_nodes + end + + def test_builds_keys_with_type_mapping + a = %| + type Test { id: ID! test: Test list: [Test] } + type Query { test(id: ID): Test @stitch(key: "id") } + | + b = %| + type Test { id: ID! } + type Query { test(id: ID): Test @stitch(key: "id") } + | + + compose_definitions({ a: a, b: b }) do |composer| + field = GraphQL::Stitching::Resolver.parse_key_with_types( + "test { id list { id } }", + composer.subgraph_types_by_name_and_location["Test"], + ).first + + assert_equal "Test", field.type_name + assert_equal false, field.list? + + id_field = field.inner[0] + assert_equal "ID", id_field.type_name + assert_equal false, id_field.list? + + list_field = field.inner[1] + assert_equal "Test", list_field.type_name + assert_equal true, list_field.list? + end + end + + def test_composite_keys_must_contain_inner_selections + a = %| + type Test { id: ID! test: Test } + type Query { test(id: ID): Test @stitch(key: "id") } + | + b = %| + type Test { id: ID! } + type Query { test(id: ID): Test @stitch(key: "id") } + | + + compose_definitions({ a: a, b: b }) do |composer| + assert_error("Composite key fields must contain nested selections") do + GraphQL::Stitching::Resolver.parse_key_with_types( + "test", + composer.subgraph_types_by_name_and_location["Test"], + ) + end + end + end + + def test_keys_must_be_fully_available_in_at_least_one_location + a = %| + type Test { id: ID! a: String! test: Test } + type Query { testA(id: ID!): Test @stitch(key: "id") } + | + b = %| + type Test { id: ID! sku: ID! } + type Query { testB(id: ID, sku: ID): Test @stitch(key: "id") @stitch(key: "sku") } + | + c = %| + type Test { sku: ID! b: String! test: Test } + type Query { testC(sku: ID!): Test @stitch(key: "sku") } + | + + compose_definitions({ a: a, b: b, c: c }) do |composer| + ["id", "sku", "id sku", "id test { a }", "sku test { b }"].each do |key| + assert GraphQL::Stitching::Resolver.parse_key_with_types(key, composer.subgraph_types_by_name_and_location["Test"]) + end + + ["id test { b }", "sku test { a }", "test { a b }"].each do |key| + assert_error("Key `#{key}` does not exist in any location") do + GraphQL::Stitching::Resolver.parse_key_with_types(key, composer.subgraph_types_by_name_and_location["Test"]) + end + end + end + end + + def test_keys_assign_locations + a = %| + type Test { id: ID! a: String! test: Test } + type Query { testA(id: ID!): Test @stitch(key: "id") } + | + b = %| + type Test { id: ID! sku: ID! } + type Query { testB(id: ID, sku: ID): Test @stitch(key: "id") @stitch(key: "sku") } + | + c = %| + type Test { sku: ID! b: String! test: Test } + type Query { testC(sku: ID!): Test @stitch(key: "sku") } + | + + compose_definitions({ a: a, b: b, c: c }) do |composer| + k1 = GraphQL::Stitching::Resolver.parse_key_with_types("id", composer.subgraph_types_by_name_and_location["Test"]) + assert_equal ["a", "b"], k1.locations + + k2 = GraphQL::Stitching::Resolver.parse_key_with_types("sku", composer.subgraph_types_by_name_and_location["Test"]) + assert_equal ["b", "c"], k2.locations + + k3 = GraphQL::Stitching::Resolver.parse_key_with_types("id test { a }", composer.subgraph_types_by_name_and_location["Test"]) + assert_equal ["a"], k3.locations + + k4 = GraphQL::Stitching::Resolver.parse_key_with_types("id sku", composer.subgraph_types_by_name_and_location["Test"]) + assert_equal ["b"], k4.locations + + k5 = GraphQL::Stitching::Resolver.parse_key_with_types("sku test { b }", composer.subgraph_types_by_name_and_location["Test"]) + assert_equal ["c"], k5.locations + end + end +end diff --git a/test/graphql/stitching/supergraph_test.rb b/test/graphql/stitching/supergraph_test.rb index f4a07b1f..f0922f18 100644 --- a/test/graphql/stitching/supergraph_test.rb +++ b/test/graphql/stitching/supergraph_test.rb @@ -86,28 +86,25 @@ class Query < GraphQL::Schema::Object "Manufacturer" => [ GraphQL::Stitching::Resolver.new( location: "manufacturers", - key: "id", field: "manufacturer", - arg: "id", - arg_type_name: "ID", + key: GraphQL::Stitching::Resolver.parse_key("id", FIELDS_MAP.dig("Manufacturer", "id")), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("id: $.id", "id: ID"), ), ], "Product" => [ GraphQL::Stitching::Resolver.new( location: "products", - key: "upc", field: "products", - arg: "upc", - arg_type_name: "ID", + key: GraphQL::Stitching::Resolver.parse_key("upc", FIELDS_MAP.dig("Product", "upc")), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("upc: $.upc", "upc: ID"), ), ], "Storefront" => [ GraphQL::Stitching::Resolver.new( location: "storefronts", - key: "id", field: "storefronts", - arg: "id", - arg_type_name: "ID", + key: GraphQL::Stitching::Resolver.parse_key("id", FIELDS_MAP.dig("Storefront", "id")), + arguments: GraphQL::Stitching::Resolver.parse_arguments_with_type_defs("id: $.id", "id: ID"), ), ], } @@ -145,8 +142,8 @@ def test_possible_keys_for_type_and_location resolvers: RESOLVERS_MAP, ) - assert_equal ["upc"], supergraph.possible_keys_for_type_and_location("Product", "products") - assert_equal ["upc"], supergraph.possible_keys_for_type_and_location("Product", "storefronts") + assert_equal ["upc"], supergraph.possible_keys_for_type_and_location("Product", "products").map(&:to_definition) + assert_equal ["upc"], supergraph.possible_keys_for_type_and_location("Product", "storefronts").map(&:to_definition) assert_equal [], supergraph.possible_keys_for_type_and_location("Product", "manufacturers") end @@ -240,16 +237,16 @@ def test_route_type_to_locations_connects_types_across_locations supergraph = compose_definitions({ "a" => a, "b" => b, "c" => c }) routes = supergraph.route_type_to_locations("T", "a", ["b", "c"]) - assert_equal ["b"], routes["b"].map { _1["location"] } - assert_equal ["b", "c"], routes["c"].map { _1["location"] } + assert_equal ["b"], routes["b"].map { _1.location } + assert_equal ["b", "c"], routes["c"].map { _1.location } routes = supergraph.route_type_to_locations("T", "b", ["a", "c"]) - assert_equal ["a"], routes["a"].map { _1["location"] } - assert_equal ["c"], routes["c"].map { _1["location"] } + assert_equal ["a"], routes["a"].map { _1.location } + assert_equal ["c"], routes["c"].map { _1.location } routes = supergraph.route_type_to_locations("T", "c", ["a", "b"]) - assert_equal ["b", "a"], routes["a"].map { _1["location"] } - assert_equal ["b"], routes["b"].map { _1["location"] } + assert_equal ["b", "a"], routes["a"].map { _1.location } + assert_equal ["b"], routes["b"].map { _1.location } end def test_route_type_to_locations_favors_longer_paths_through_necessary_locations @@ -289,8 +286,8 @@ def test_route_type_to_locations_favors_longer_paths_through_necessary_locations supergraph = compose_definitions({ "a" => a, "b" => b, "c" => c, "d" => d, "e" => e }) routes = supergraph.route_type_to_locations("T", "a", ["b", "c", "d"]) - assert_equal ["b", "c", "d"], routes["d"].map { _1["location"] } - assert routes.none? { |_key, path| path.any? { _1["location"] == "e" } } + assert_equal ["b", "c", "d"], routes["d"].map { _1.location } + assert routes.none? { |_key, path| path.any? { _1.location == "e" } } end describe "#to_definition / #from_definition" do @@ -311,14 +308,20 @@ def setup def test_to_definition_annotates_schema @schema_sdl = squish_string(@schema_sdl) + assert @schema_sdl.include?("directive @key") assert @schema_sdl.include?("directive @resolver") assert @schema_sdl.include?("directive @source") assert @schema_sdl.include?(squish_string(%| - interface I @resolver(location: "alpha", key: "id", field: "a", arg: "id", argTypeName: "ID") { + interface I + @key(key: "id", location: "alpha") + @resolver(location: "alpha", key: "id", field: "a", arguments: "id: $.id", argumentTypes: "id: ID!") { |)) assert @schema_sdl.include?(squish_string(%| - type T implements I @resolver(location: "bravo", key: "id", field: "b", arg: "id", argTypeName: "ID") - @resolver(typeName: "I", location: "alpha", key: "id", field: "a", arg: "id", argTypeName: "ID") { + type T implements I + @key(key: "id", location: "alpha") + @key(key: "id", location: "bravo") + @resolver(location: "bravo", key: "id", field: "b", arguments: "id: $.id", argumentTypes: "id: ID!") + @resolver(location: "alpha", key: "id", field: "a", arguments: "id: $.id", argumentTypes: "id: ID!", typeName: "I") { |)) assert @schema_sdl.include?(%|id: ID! @source(location: "alpha") @source(location: "bravo")|) assert @schema_sdl.include?(%|a: String @source(location: "alpha")|) @@ -329,11 +332,11 @@ def test_to_definition_annotates_schema def test_to_definition_annotations_are_idempotent @supergraph.to_definition - assert_equal 2, @supergraph.schema.get_type("T").directives.length + assert_equal 4, @supergraph.schema.get_type("T").directives.length assert_equal 2, @supergraph.schema.get_type("T").get_field("id").directives.length @supergraph.to_definition - assert_equal 2, @supergraph.schema.get_type("T").directives.length + assert_equal 4, @supergraph.schema.get_type("T").directives.length assert_equal 2, @supergraph.schema.get_type("T").get_field("id").directives.length end @@ -344,7 +347,6 @@ def test_from_definition_restores_supergraph }) assert_equal @supergraph.fields, supergraph_import.fields - assert_equal @supergraph.resolvers, supergraph_import.resolvers assert_equal ["alpha", "bravo"], supergraph_import.locations.sort assert_equal @supergraph.schema.types.keys.sort, supergraph_import.schema.types.keys.sort assert_equal @supergraph.resolvers, supergraph_import.resolvers diff --git a/test/schemas/arguments.rb b/test/schemas/arguments.rb new file mode 100644 index 00000000..64b0e7d2 --- /dev/null +++ b/test/schemas/arguments.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module Schemas + module Arguments + class StitchingResolver < GraphQL::Schema::Directive + graphql_name "stitch" + locations FIELD_DEFINITION + argument :key, String, required: true + argument :arguments, String, required: false + repeatable true + end + + DIRECTORS = [ + { id: "1", name: "Steven Spielberg" }, + { id: "2", name: "Christopher Nolan" }, + ].freeze + + STUDIOS = [ + { id: "1", name: "Universal" }, + { id: "2", name: "Lucasfilm" }, + { id: "3", name: "Syncopy" }, + ].freeze + + GENRES = [ + { id: "1", name: "action" }, + { id: "2", name: "adventure" }, + { id: "3", name: "sci-fi" }, + { id: "4", name: "thriller" }, + ].freeze + + MOVIES = [ + { + id: "1", + title: "Jurassic Park", + status: "STREAMING", + genres: [GENRES[1], GENRES[2]], + director: DIRECTORS[0], + studio: STUDIOS[0], + }, + { + id: "2", + title: "Indiana Jones: Raiders of the Lost Arc", + status: "IN_THEATERS", + genres: [GENRES[0], GENRES[1]], + director: DIRECTORS[0], + studio: STUDIOS[1], + }, + { + id: "3", + title: "Inception", + status: "STREAMING", + genres: [GENRES[0], GENRES[3]], + director: DIRECTORS[1], + studio: STUDIOS[2], + }, + ].freeze + + class Arguments1 < GraphQL::Schema + class Director < GraphQL::Schema::Object + field :id, ID, null: false + end + + class Genre < GraphQL::Schema::Object + field :id, ID, null: false + end + + class Studio < GraphQL::Schema::Object + field :id, ID, null: false + end + + class Movie < GraphQL::Schema::Object + field :id, ID, null: false + field :title, String, null: false + field :director, Director, null: false + field :genres, [Genre], null: false + field :studio, Studio, null: false + end + + class Query < GraphQL::Schema::Object + field :movies, [Movie, null: true], null: false do + directive StitchingResolver, key: "id" + argument :ids, [ID], required: true + end + + def movies(ids:) + ids.map { |id| MOVIES.find { _1[:id] == id } } + end + + field :all_movies, [Movie], null: false + + def all_movies + MOVIES + end + end + + query Query + end + + class Arguments2 < GraphQL::Schema + class Director < GraphQL::Schema::Object + field :id, ID, null: false + field :name, String, null: false + end + + class Studio < GraphQL::Schema::Object + field :id, ID, null: false + field :name, String, null: false + end + + class Genre < GraphQL::Schema::Object + field :id, ID, null: false + field :name, String, null: false + end + + class MovieStatus < GraphQL::Schema::Enum + value "IN_THEATERS" + value "STREAMING" + end + + class Movie < GraphQL::Schema::Object + field :id, ID, null: false + field :status, MovieStatus, null: true + end + + class ComplexKey < GraphQL::Schema::InputObject + argument :id, String, required: false + argument :subkey, ComplexKey, required: false + end + + class ScalarKey < GraphQL::Schema::Scalar + graphql_name "_Any" + end + + class Query < GraphQL::Schema::Object + field :movies2, [Movie, null: true], null: false do + directive StitchingResolver, key: "id", arguments: "ids: $.id, status: STREAMING" + argument :ids, [ID], required: true + argument :status, MovieStatus, required: true + end + + def movies2(ids:, status: nil) + visible_movies = MOVIES.filter { _1[:status] == status } + ids.map { |id| visible_movies.find { _1[:id] == id } } + end + + field :director, Director, null: false do + directive StitchingResolver, key: "id", arguments: "key: { subkey: { id: $.id } }" + argument :key, ComplexKey, required: true + end + + def director(key:) + DIRECTORS.find { _1[:id] == key.dig(:subkey, :id) } + end + + field :studios, [Studio, null: true], null: false do + directive StitchingResolver, key: "id", arguments: "keys: { subkey: { id: $.id } }" + argument :keys, [ScalarKey], required: true + end + + def studios(keys:) + keys.map { |key| STUDIOS.find { |s| s[:id] == key.dig("subkey", "id") } } + end + + field :genres, [Genre, null: true], null: false do + directive StitchingResolver, key: "id", arguments: "keys: $.id, prefix: 'action'" + argument :keys, [ID], required: true + argument :prefix, String, required: false + end + + def genres(keys:, prefix:) + keys.map do |key| + genre = GENRES.find { _1[:id] == key } + if genre && genre[:name] != prefix + genre.merge(name: "#{prefix}/#{genre[:name]}") + else + genre + end + end + end + end + + query Query + end + end +end diff --git a/test/schemas/composite_keys.rb b/test/schemas/composite_keys.rb new file mode 100644 index 00000000..8839a782 --- /dev/null +++ b/test/schemas/composite_keys.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +module Schemas + module CompositeKeys + class StitchingResolver < GraphQL::Schema::Directive + graphql_name "stitch" + locations FIELD_DEFINITION + argument :key, String, required: true + argument :arguments, String, required: false + repeatable true + end + + PAGES = [ + { + id: '1', + sku: 'p1', + handle: 'mercury', + scope: 'planet', + title: 'Mercury, Planet', + owner: { id: '1', type: 'Planet' }, + }, + { + id: '2', + sku: '80', + handle: 'mercury', + scope: 'element', + title: 'Mercury, Element', + owner: { id: '1', type: 'Element' }, + }, + { + id: '3', + sku: 'c1939', + handle: 'mercury', + scope: 'automobile', + title: 'Mercury, Automobile', + owner: { id: '1', type: 'Automobile' }, + }, + ].freeze + + class PagesById < GraphQL::Schema + class Page < GraphQL::Schema::Object + field :id, ID, null: false + field :sku, ID, null: false + field :title, String, null: false + field :a, String, null: false + + def a + "a#{object[:id]}" + end + end + + class Query < GraphQL::Schema::Object + field :pages_by_id, [Page, null: true], null: false do + directive StitchingResolver, key: "id" + argument :ids, [ID], required: true + end + + def pages_by_id(ids:) + ids.map { |id| PAGES.find { _1[:id] == id } } + end + end + + query Query + end + + class PagesBySku < GraphQL::Schema + class Page < GraphQL::Schema::Object + field :id, ID, null: false + field :sku, ID, null: false + field :handle, String, null: false + field :scope, String, null: false + field :b, String, null: false + + def b + "b#{object[:id]}" + end + end + + class Query < GraphQL::Schema::Object + field :pages_by_sku, [Page, null: true], null: false do + directive StitchingResolver, key: "sku" + argument :skus, [ID], required: true + end + + def pages_by_sku(skus:) + skus.map { |sku| PAGES.find { _1[:sku] == sku } } + end + end + + query Query + end + + class PagesByScopedHandle < GraphQL::Schema + class PageOwner < GraphQL::Schema::Object + field :id, ID, null: false + field :type, String, null: false + end + + class Page < GraphQL::Schema::Object + field :sku, ID, null: false + field :handle, String, null: false + field :scope, String, null: false + field :owner, PageOwner, null: false + field :c, String, null: false + + def c + "c#{object[:id]}" + end + end + + class PageHandleKey < GraphQL::Schema::InputObject + argument :handle, String, required: true + argument :scope, String, required: true + end + + class Query < GraphQL::Schema::Object + field :pages_by_handle, [Page, null: true], null: false do + directive StitchingResolver, key: "handle scope", arguments: "keys: { handle: $.handle, scope: $.scope }" + argument :keys, [PageHandleKey], required: true + end + + def pages_by_handle(keys:) + keys.map do |key| + PAGES.find { _1[:handle] == key.handle && _1[:scope] == key.scope } + end + end + end + + query Query + end + + class PagesByOwner < GraphQL::Schema + class PageOwner < GraphQL::Schema::Object + field :id, ID, null: false + field :type, String, null: false + end + + class Page < GraphQL::Schema::Object + field :handle, String, null: false + field :scope, String, null: false + field :owner, PageOwner, null: false + field :d, String, null: false + + def d + "d#{object[:id]}" + end + end + + class PageOwnerKey < GraphQL::Schema::InputObject + argument :id, ID, required: true + argument :type, String, required: true + end + + class Query < GraphQL::Schema::Object + field :pages_by_owner, [Page, null: true], null: false do + directive StitchingResolver, key: "owner { id type }", arguments: "keys: { id: $.owner.id, type: $.owner.type }" + argument :keys, [PageOwnerKey], required: true + end + + def pages_by_owner(keys:) + keys.map do |key| + PAGES.find { _1.dig(:owner, :id) == key.id && _1.dig(:owner, :type) == key.type } + end + end + end + + query Query + end + + class PagesByScopedHandleOrOwner < GraphQL::Schema + class PageOwner < GraphQL::Schema::Object + field :id, ID, null: false + field :type, String, null: false + end + + class Page < GraphQL::Schema::Object + field :handle, String, null: false + field :scope, String, null: false + field :owner, PageOwner, null: false + field :title, String, null: false + field :e, String, null: false + + def e + "e#{object[:id]}" + end + end + + class PageHandleKey < GraphQL::Schema::InputObject + argument :handle, String, required: true + argument :scope, String, required: true + end + + class PageOwnerKey < GraphQL::Schema::InputObject + argument :id, ID, required: true + argument :type, String, required: true + end + + class Query < GraphQL::Schema::Object + field :pages_by_handle2, [Page, null: true], null: false do + argument :keys, [PageHandleKey], required: true + end + + def pages_by_handle2(keys:) + keys.map do |key| + PAGES.find { _1[:handle] == key.handle && _1[:scope] == key.scope } + end + end + + field :pages_by_owner2, [Page, null: true], null: false do + argument :keys, [PageOwnerKey], required: true + end + + def pages_by_owner2(keys:) + keys.map do |key| + PAGES.find { _1.dig(:owner, :id) == key.id && _1.dig(:owner, :type) == key.type } + end + end + end + + query Query + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 6fbe7933..92798265 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,9 +16,9 @@ require 'minitest/autorun' require 'graphql/stitching' -ComposerError = GraphQL::Stitching::Composer::ComposerError -ValidationError = GraphQL::Stitching::Composer::ValidationError -STITCH_DEFINITION = "directive @stitch(key: String!, typeName: String, representations: Boolean=false) repeatable on FIELD_DEFINITION\n" +CompositionError = GraphQL::Stitching::CompositionError +ValidationError = GraphQL::Stitching::ValidationError +STITCH_DEFINITION = "directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITION\n" def squish_string(str) str.gsub(/\s+/, " ").strip @@ -37,16 +37,20 @@ def minimum_graphql_version?(versioning) end def compose_definitions(locations, options={}) - locations = locations.each_with_object({}) do |(location, schema_or_sdl), memo| - schema = if schema_or_sdl.is_a?(String) - schema_or_sdl = STITCH_DEFINITION + schema_or_sdl if schema_or_sdl.include?("@stitch") - GraphQL::Schema.from_definition(schema_or_sdl) + locations = locations.each_with_object({}) do |(location, schema_config), memo| + memo[location.to_s] = if schema_config.is_a?(Hash) + schema_config + elsif schema_config.is_a?(String) + schema_config = STITCH_DEFINITION + schema_config if schema_config.include?("@stitch") + { schema: GraphQL::Schema.from_definition(schema_config) } else - schema_or_sdl + { schema: schema_config } end - memo[location.to_s] = { schema: schema } end - GraphQL::Stitching::Composer.new(**options).perform(locations) + composer = GraphQL::Stitching::Composer.new(**options) + supergraph = composer.perform(locations) + yield(composer, supergraph) if block_given? + supergraph end def supergraph_from_schema(schema, fields: {}, resolvers: {}, executables: {})