From 18bee786c3cac7dd93000520fa2a450b7b605cdb Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Mon, 24 May 2021 16:11:22 -0400 Subject: [PATCH 1/3] Extract Schema::Addition for running the traversal --- lib/graphql/schema.rb | 222 ++++---------------------------- lib/graphql/schema/addition.rb | 228 +++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 200 deletions(-) create mode 100644 lib/graphql/schema/addition.rb diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 934d36fb4c..9c6ce69e85 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require "graphql/schema/addition" require "graphql/schema/base_64_encoder" require "graphql/schema/catchall_middleware" require "graphql/schema/default_parse_error" @@ -1582,10 +1583,7 @@ def directives(*new_directives) # @param new_directive [Class] # @return void def directive(new_directive) - own_directives[new_directive.graphql_name] ||= begin - add_type_and_traverse(new_directive, root: false) - new_directive - end + add_type_and_traverse(new_directive, root: false) end def default_directives @@ -1709,6 +1707,26 @@ def query_stack_error(query, err) private + # @param t [Module, Array] + # @return [void] + def add_type_and_traverse(t, root:) + if root + @root_types ||= [] + @root_types << t + end + new_types = Array(t) + addition = Schema::Addition.new(schema: self, own_types: own_types, new_types: new_types) + own_types.merge!(addition.types) + own_possible_types.merge!(addition.possible_types) { |key, old_val, new_val| old_val + new_val } + own_union_memberships.merge!(addition.union_memberships) + + addition.references.each { |thing, pointers| + pointers.each { |pointer| references_to(thing, from: pointer) } + } + + addition.directives.each { |dir_class| own_directives[dir_class.graphql_name] = dir_class } + end + def lazy_methods if !defined?(@lazy_methods) if inherited_map = find_inherited_value(:lazy_methods) @@ -1774,202 +1792,6 @@ def own_middleware def own_multiplex_analyzers @own_multiplex_analyzers ||= [] end - - # @param t [Module, Array] - # @return [void] - def add_type_and_traverse(t, root:) - if root - @root_types ||= [] - @root_types << t - end - late_types = [] - new_types = Array(t) - new_types.each { |t| add_type(t, owner: nil, late_types: late_types, path: [t.graphql_name]) } - missed_late_types = 0 - while (late_type_vals = late_types.shift) - type_owner, lt = late_type_vals - if lt.is_a?(String) - type = Member::BuildType.constantize(lt) - # Reset the counter, since we might succeed next go-round - missed_late_types = 0 - update_type_owner(type_owner, type) - add_type(type, owner: type_owner, late_types: late_types, path: [type.graphql_name]) - elsif lt.is_a?(LateBoundType) - if (type = get_type(lt.graphql_name)) - # Reset the counter, since we might succeed next go-round - missed_late_types = 0 - update_type_owner(type_owner, type) - add_type(type, owner: type_owner, late_types: late_types, path: [type.graphql_name]) - else - missed_late_types += 1 - # Add it back to the list, maybe we'll be able to resolve it later. - late_types << [type_owner, lt] - if missed_late_types == late_types.size - # We've looked at all of them and haven't resolved one. - raise UnresolvedLateBoundTypeError.new(type: lt) - else - # Try the next one - end - end - else - raise ArgumentError, "Unexpected late type: #{lt.inspect}" - end - end - nil - end - - def update_type_owner(owner, type) - case owner - when Class - if owner.kind.union? - # It's a union with possible_types - # Replace the item by class name - owner.assign_type_membership_object_type(type) - own_possible_types[owner.graphql_name] = owner.possible_types - elsif type.kind.interface? && owner.kind.object? - new_interfaces = [] - owner.interfaces.each do |int_t| - if int_t.is_a?(String) && int_t == type.graphql_name - new_interfaces << type - elsif int_t.is_a?(LateBoundType) && int_t.graphql_name == type.graphql_name - new_interfaces << type - else - # Don't re-add proper interface definitions, - # they were probably already added, maybe with options. - end - end - owner.implements(*new_interfaces) - new_interfaces.each do |int| - pt = own_possible_types[int.graphql_name] ||= [] - if !pt.include?(owner) - pt << owner - end - end - end - - when nil - # It's a root type - own_types[type.graphql_name] = type - when GraphQL::Schema::Field, GraphQL::Schema::Argument - orig_type = owner.type - # Apply list/non-null wrapper as needed - if orig_type.respond_to?(:of_type) - transforms = [] - while (orig_type.respond_to?(:of_type)) - if orig_type.kind.non_null? - transforms << :to_non_null_type - elsif orig_type.kind.list? - transforms << :to_list_type - else - raise "Invariant: :of_type isn't non-null or list" - end - orig_type = orig_type.of_type - end - transforms.reverse_each { |t| type = type.public_send(t) } - end - owner.type = type - else - raise "Unexpected update: #{owner.inspect} #{type.inspect}" - end - end - - def add_type(type, owner:, late_types:, path:) - if type.respond_to?(:metadata) && type.metadata.is_a?(Hash) - type_class = type.metadata[:type_class] - if type_class.nil? - raise ArgumentError, "Can't add legacy type: #{type} (#{type.class})" - else - type = type_class - end - elsif type.is_a?(String) || type.is_a?(GraphQL::Schema::LateBoundType) - late_types << [owner, type] - return - end - - if owner.is_a?(Class) && owner < GraphQL::Schema::Union - um = own_union_memberships[type.graphql_name] ||= [] - um << owner - end - - if (prev_type = own_types[type.graphql_name]) - if prev_type != type - raise DuplicateTypeNamesError.new( - type_name: type.graphql_name, - first_definition: prev_type, - second_definition: type, - path: path, - ) - else - # This type was already added - end - elsif type.is_a?(Class) && type < GraphQL::Schema::Directive - type.arguments.each do |name, arg| - arg_type = arg.type.unwrap - references_to(arg_type, from: arg) - add_type(arg_type, owner: arg, late_types: late_types, path: path + [name]) - end - else - own_types[type.graphql_name] = type - add_directives_from(type) - if type.kind.fields? - type.fields.each do |name, field| - field_type = field.type.unwrap - references_to(field_type, from: field) - field_path = path + [name] - add_type(field_type, owner: field, late_types: late_types, path: field_path) - add_directives_from(field) - field.arguments.each do |arg_name, arg| - add_directives_from(arg) - arg_type = arg.type.unwrap - references_to(arg_type, from: arg) - add_type(arg_type, owner: arg, late_types: late_types, path: field_path + [arg_name]) - end - end - end - if type.kind.input_object? - type.arguments.each do |arg_name, arg| - add_directives_from(arg) - arg_type = arg.type.unwrap - references_to(arg_type, from: arg) - add_type(arg_type, owner: arg, late_types: late_types, path: path + [arg_name]) - end - end - if type.kind.union? - own_possible_types[type.graphql_name] = type.possible_types - type.possible_types.each do |t| - add_type(t, owner: type, late_types: late_types, path: path + ["possible_types"]) - end - end - if type.kind.interface? - type.orphan_types.each do |t| - add_type(t, owner: type, late_types: late_types, path: path + ["orphan_types"]) - end - end - if type.kind.object? - own_possible_types[type.graphql_name] = [type] - type.interface_type_memberships.each do |interface_type_membership| - case interface_type_membership - when Schema::TypeMembership - interface_type = interface_type_membership.abstract_type - # We can get these now; we'll have to get late-bound types later - if interface_type.is_a?(Module) - implementers = own_possible_types[interface_type.graphql_name] ||= [] - implementers << type - end - when String, Schema::LateBoundType - interface_type = interface_type_membership - else - raise ArgumentError, "Invariant: unexpected type membership for #{type.graphql_name}: #{interface_type_membership.class} (#{interface_type_membership.inspect})" - end - add_type(interface_type, owner: type, late_types: late_types, path: path + ["implements"]) - end - end - end - end - - def add_directives_from(owner) - owner.directives.each { |dir| directive(dir.class) } - end end def dataloader_class diff --git a/lib/graphql/schema/addition.rb b/lib/graphql/schema/addition.rb new file mode 100644 index 0000000000..fe08d45975 --- /dev/null +++ b/lib/graphql/schema/addition.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +module GraphQL + class Schema + class Addition + attr_reader :directives, :possible_types, :types, :union_memberships, :references + + def initialize(schema:, own_types:, new_types:) + @schema = schema + @own_types = own_types + @directives = Set.new + @possible_types = {} + @types = {} + @union_memberships = {} + @references = Hash.new { |h, k| h[k] = [] } + add_type_and_traverse(new_types) + end + + private + + def references_to(thing, from:) + @references[thing] << from + end + + def get_type(name) + @types[name] || @schema.get_type(name) + end + + # Lookup using `own_types` here because it's ok to override + # inherited types by name + def get_local_type(name) + @types[name] || @own_types[name] + end + + def add_directives_from(owner) + dirs = owner.directives.map(&:class) + @directives.merge(dirs) + add_type_and_traverse(dirs) + end + + def add_type_and_traverse(new_types) + late_types = [] + new_types.each { |t| add_type(t, owner: nil, late_types: late_types, path: [t.graphql_name]) } + missed_late_types = 0 + while (late_type_vals = late_types.shift) + type_owner, lt = late_type_vals + if lt.is_a?(String) + type = Member::BuildType.constantize(lt) + # Reset the counter, since we might succeed next go-round + missed_late_types = 0 + update_type_owner(type_owner, type) + add_type(type, owner: type_owner, late_types: late_types, path: [type.graphql_name]) + elsif lt.is_a?(LateBoundType) + if (type = get_type(lt.name)) + # Reset the counter, since we might succeed next go-round + missed_late_types = 0 + update_type_owner(type_owner, type) + add_type(type, owner: type_owner, late_types: late_types, path: [type.graphql_name]) + else + missed_late_types += 1 + # Add it back to the list, maybe we'll be able to resolve it later. + late_types << [type_owner, lt] + if missed_late_types == late_types.size + # We've looked at all of them and haven't resolved one. + raise UnresolvedLateBoundTypeError.new(type: lt) + else + # Try the next one + end + end + else + raise ArgumentError, "Unexpected late type: #{lt.inspect}" + end + end + nil + end + + def update_type_owner(owner, type) + case owner + when Class + if owner.kind.union? + # It's a union with possible_types + # Replace the item by class name + owner.assign_type_membership_object_type(type) + @possible_types[owner.graphql_name] = owner.possible_types + elsif type.kind.interface? && owner.kind.object? + new_interfaces = [] + owner.interfaces.each do |int_t| + if int_t.is_a?(String) && int_t == type.graphql_name + new_interfaces << type + elsif int_t.is_a?(LateBoundType) && int_t.graphql_name == type.graphql_name + new_interfaces << type + else + # Don't re-add proper interface definitions, + # they were probably already added, maybe with options. + end + end + owner.implements(*new_interfaces) + new_interfaces.each do |int| + pt = @possible_types[int.graphql_name] ||= [] + if !pt.include?(owner) + pt << owner + end + end + end + + when nil + # It's a root type + @types[type.graphql_name] = type + when GraphQL::Schema::Field, GraphQL::Schema::Argument + orig_type = owner.type + # Apply list/non-null wrapper as needed + if orig_type.respond_to?(:of_type) + transforms = [] + while (orig_type.respond_to?(:of_type)) + if orig_type.kind.non_null? + transforms << :to_non_null_type + elsif orig_type.kind.list? + transforms << :to_list_type + else + raise "Invariant: :of_type isn't non-null or list" + end + orig_type = orig_type.of_type + end + transforms.reverse_each { |t| type = type.public_send(t) } + end + owner.type = type + else + raise "Unexpected update: #{owner.inspect} #{type.inspect}" + end + end + + def add_type(type, owner:, late_types:, path:) + if type.respond_to?(:metadata) && type.metadata.is_a?(Hash) + type_class = type.metadata[:type_class] + if type_class.nil? + raise ArgumentError, "Can't add legacy type: #{type} (#{type.class})" + else + type = type_class + end + elsif type.is_a?(String) || type.is_a?(GraphQL::Schema::LateBoundType) + late_types << [owner, type] + return + end + + if owner.is_a?(Class) && owner < GraphQL::Schema::Union + um = @union_memberships[type.graphql_name] ||= [] + um << owner + end + + if (prev_type = get_local_type(type.graphql_name)) + if prev_type != type + raise DuplicateTypeNamesError.new( + type_name: type.graphql_name, + first_definition: prev_type, + second_definition: type, + path: path, + ) + else + # This type was already added + end + elsif type.is_a?(Class) && type < GraphQL::Schema::Directive + @directives << type + type.arguments.each do |name, arg| + arg_type = arg.type.unwrap + references_to(arg_type, from: arg) + add_type(arg_type, owner: arg, late_types: late_types, path: path + [name]) + end + else + @types[type.graphql_name] = type + add_directives_from(type) + if type.kind.fields? + type.fields.each do |name, field| + field_type = field.type.unwrap + references_to(field_type, from: field) + field_path = path + [name] + add_type(field_type, owner: field, late_types: late_types, path: field_path) + add_directives_from(field) + field.arguments.each do |arg_name, arg| + add_directives_from(arg) + arg_type = arg.type.unwrap + references_to(arg_type, from: arg) + add_type(arg_type, owner: arg, late_types: late_types, path: field_path + [arg_name]) + end + end + end + if type.kind.input_object? + type.arguments.each do |arg_name, arg| + add_directives_from(arg) + arg_type = arg.type.unwrap + references_to(arg_type, from: arg) + add_type(arg_type, owner: arg, late_types: late_types, path: path + [arg_name]) + end + end + if type.kind.union? + @possible_types[type.graphql_name] = type.possible_types + type.possible_types.each do |t| + add_type(t, owner: type, late_types: late_types, path: path + ["possible_types"]) + end + end + if type.kind.interface? + type.orphan_types.each do |t| + add_type(t, owner: type, late_types: late_types, path: path + ["orphan_types"]) + end + end + if type.kind.object? + @possible_types[type.graphql_name] = [type] + type.interface_type_memberships.each do |interface_type_membership| + case interface_type_membership + when Schema::TypeMembership + interface_type = interface_type_membership.abstract_type + # We can get these now; we'll have to get late-bound types later + if interface_type.is_a?(Module) + implementers = @possible_types[interface_type.graphql_name] ||= [] + implementers << type + end + when String, Schema::LateBoundType + interface_type = interface_type_membership + else + raise ArgumentError, "Invariant: unexpected type membership for #{type.graphql_name}: #{interface_type_membership.class} (#{interface_type_membership.inspect})" + end + add_type(interface_type, owner: type, late_types: late_types, path: path + ["implements"]) + end + end + end + end + end + end +end From 6a7efe6ecace9855aa0515d65de994c04bc31b71 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Wed, 26 May 2021 15:07:57 -0400 Subject: [PATCH 2/3] Add an argument validation step --- lib/graphql/schema.rb | 4 +++ lib/graphql/schema/addition.rb | 12 ++++++++- lib/graphql/schema/argument.rb | 16 ++++++++++++ spec/graphql/schema/argument_spec.rb | 37 ++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index 9c6ce69e85..705336c34a 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1725,6 +1725,10 @@ def add_type_and_traverse(t, root:) } addition.directives.each { |dir_class| own_directives[dir_class.graphql_name] = dir_class } + + addition.arguments_with_default_values.each do |arg| + arg.validate_default_value + end end def lazy_methods diff --git a/lib/graphql/schema/addition.rb b/lib/graphql/schema/addition.rb index fe08d45975..2c417c925c 100644 --- a/lib/graphql/schema/addition.rb +++ b/lib/graphql/schema/addition.rb @@ -3,7 +3,7 @@ module GraphQL class Schema class Addition - attr_reader :directives, :possible_types, :types, :union_memberships, :references + attr_reader :directives, :possible_types, :types, :union_memberships, :references, :arguments_with_default_values def initialize(schema:, own_types:, new_types:) @schema = schema @@ -13,6 +13,7 @@ def initialize(schema:, own_types:, new_types:) @types = {} @union_memberships = {} @references = Hash.new { |h, k| h[k] = [] } + @arguments_with_default_values = [] add_type_and_traverse(new_types) end @@ -164,6 +165,9 @@ def add_type(type, owner:, late_types:, path:) arg_type = arg.type.unwrap references_to(arg_type, from: arg) add_type(arg_type, owner: arg, late_types: late_types, path: path + [name]) + if arg.default_value? + @arguments_with_default_values << arg + end end else @types[type.graphql_name] = type @@ -180,6 +184,9 @@ def add_type(type, owner:, late_types:, path:) arg_type = arg.type.unwrap references_to(arg_type, from: arg) add_type(arg_type, owner: arg, late_types: late_types, path: field_path + [arg_name]) + if arg.default_value? + @arguments_with_default_values << arg + end end end end @@ -189,6 +196,9 @@ def add_type(type, owner:, late_types:, path:) arg_type = arg.type.unwrap references_to(arg_type, from: arg) add_type(arg_type, owner: arg, late_types: late_types, path: path + [arg_name]) + if arg.default_value? + @arguments_with_default_values << arg + end end end if type.kind.union? diff --git a/lib/graphql/schema/argument.rb b/lib/graphql/schema/argument.rb index be2f52c361..0ddba34d15 100644 --- a/lib/graphql/schema/argument.rb +++ b/lib/graphql/schema/argument.rb @@ -295,6 +295,22 @@ def coerce_into_values(parent_object, values, context, argument_values) end end + # @api private + def validate_default_value + coerced_default_value = type.coerce_isolated_result(default_value) unless default_value.nil? + res = type.valid_isolated_input?(coerced_default_value) + if !res + raise InvalidDefaultValueError.new(self) + end + end + + class InvalidDefaultValueError < GraphQL::Error + def initialize(argument) + message = "`#{argument.path}` has an invalid default value: `#{argument.default_value.inspect}` isn't accepted by `#{argument.type.to_type_signature}`; update the default value or the argument type." + super(message) + end + end + private def validate_input_type(input_type) diff --git a/spec/graphql/schema/argument_spec.rb b/spec/graphql/schema/argument_spec.rb index bfc823fff9..1c76554b32 100644 --- a/spec/graphql/schema/argument_spec.rb +++ b/spec/graphql/schema/argument_spec.rb @@ -396,4 +396,41 @@ class InvalidLazyArgumentObject < GraphQL::Schema::Object assert_equal expected_message, err.message end end + + describe "validating default values" do + it "raises when field argument default values are invalid" do + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Query" + field :f1, Integer, null: false do + argument :arg1, Integer, default_value: nil, required: true + end + end + + err = assert_raises GraphQL::Schema::Argument::InvalidDefaultValueError do + Class.new(GraphQL::Schema) do + query(query_type) + end + end + expected_message = "`Query.f1.arg1` has an invalid default value: `nil` isn't accepted by `Int!`; update the default value or the argument type." + assert_equal expected_message, err.message + end + + it "TODO: raises when input argument default values are invalid" + + it "TODO: raises when directive argument default values are invalid" + + it "raises when parsing a schema from a string" do + schema_str = <<-GRAPHQL + type Query { + f1(arg1: Int! = null): Int! + } + GRAPHQL + + err = assert_raises GraphQL::Schema::Argument::InvalidDefaultValueError do + GraphQL::Schema.from_definition(schema_str) + end + expected_message = "`Query.f1.arg1` has an invalid default value: `nil` isn't accepted by `Int!`; update the default value or the argument type." + assert_equal expected_message, err.message + end + end end From ce873388b54c6394077ac42d1ca6b40a04366456 Mon Sep 17 00:00:00 2001 From: Robert Mosolgo Date: Fri, 28 May 2021 08:53:13 -0400 Subject: [PATCH 3/3] Add more tests --- lib/graphql/schema/argument.rb | 7 ++- spec/graphql/schema/argument_spec.rb | 82 +++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/lib/graphql/schema/argument.rb b/lib/graphql/schema/argument.rb index 0ddba34d15..fce25df7f3 100644 --- a/lib/graphql/schema/argument.rb +++ b/lib/graphql/schema/argument.rb @@ -297,7 +297,12 @@ def coerce_into_values(parent_object, values, context, argument_values) # @api private def validate_default_value - coerced_default_value = type.coerce_isolated_result(default_value) unless default_value.nil? + coerced_default_value = begin + type.coerce_isolated_result(default_value) unless default_value.nil? + rescue GraphQL::Schema::Enum::UnresolvedValueError + # It raises this, which is helpful at runtime, but not here... + default_value + end res = type.valid_isolated_input?(coerced_default_value) if !res raise InvalidDefaultValueError.new(self) diff --git a/spec/graphql/schema/argument_spec.rb b/spec/graphql/schema/argument_spec.rb index 1c76554b32..4712fff537 100644 --- a/spec/graphql/schema/argument_spec.rb +++ b/spec/graphql/schema/argument_spec.rb @@ -415,9 +415,51 @@ class InvalidLazyArgumentObject < GraphQL::Schema::Object assert_equal expected_message, err.message end - it "TODO: raises when input argument default values are invalid" + it "raises when input argument default values are invalid" do + input_obj = Class.new(GraphQL::Schema::InputObject) do + graphql_name "InputObj" + argument :arg1, [String, null: false], default_value: [nil], required: false + end + + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Query" + field :f1, Integer, null: false do + argument :input, input_obj, required: true + end + end + + err = assert_raises GraphQL::Schema::Argument::InvalidDefaultValueError do + Class.new(GraphQL::Schema) do + query(query_type) + end + end + + expected_message = "`InputObj.arg1` has an invalid default value: `[nil]` isn't accepted by `[String!]`; update the default value or the argument type." + assert_equal expected_message, err.message + end + + it "raises when directive argument default values are invalid" do + lang = Class.new(GraphQL::Schema::Enum) do + graphql_name "Language" + value "EN" + value "JA" + end + + localize = Class.new(GraphQL::Schema::Directive) do + graphql_name "localize" + locations GraphQL::Schema::Directive::FIELD + argument :lang, lang, default_value: "ZH", required: false + end + + err = assert_raises GraphQL::Schema::Argument::InvalidDefaultValueError do + Class.new(GraphQL::Schema) do + directive(localize) + end + end - it "TODO: raises when directive argument default values are invalid" + expected_message = "`@localize.lang` has an invalid default value: `\"ZH\"` isn't accepted by `Language`; update the default value or the argument type." + assert_equal expected_message, err.message + end it "raises when parsing a schema from a string" do schema_str = <<-GRAPHQL @@ -431,6 +473,42 @@ class InvalidLazyArgumentObject < GraphQL::Schema::Object end expected_message = "`Query.f1.arg1` has an invalid default value: `nil` isn't accepted by `Int!`; update the default value or the argument type." assert_equal expected_message, err.message + + directive_schema_str = <<-GRAPHQL + enum Language { + EN + JA + } + directive @localize(lang: Language = "ZH") on FIELD + + type Query { + f1: Int + } + GRAPHQL + + + err2 = assert_raises GraphQL::Schema::Argument::InvalidDefaultValueError do + GraphQL::Schema.from_definition(directive_schema_str) + end + expected_message = "`@localize.lang` has an invalid default value: `\"ZH\"` isn't accepted by `Language`; update the default value or the argument type." + assert_equal expected_message, err2.message + + input_obj_schema_str = <<-GRAPHQL + input InputObj { + arg1: [String!] = [null] + } + + type Query { + f1(arg1: InputObj): Int + } + GRAPHQL + + + err3 = assert_raises GraphQL::Schema::Argument::InvalidDefaultValueError do + GraphQL::Schema.from_definition(input_obj_schema_str) + end + expected_message = "`InputObj.arg1` has an invalid default value: `[nil]` isn't accepted by `[String!]`; update the default value or the argument type." + assert_equal expected_message, err3.message end end end