Skip to content

Commit

Permalink
Merge pull request #4184 from rmosolgo/one-of-input-object
Browse files Browse the repository at this point in the history
Add @OneOf for input objects
  • Loading branch information
rmosolgo authored Sep 8, 2022
2 parents 99e7906 + 352cd6b commit d6c40fa
Show file tree
Hide file tree
Showing 23 changed files with 643 additions and 11 deletions.
20 changes: 20 additions & 0 deletions guides/type_definitions/input_objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,23 @@ class Types::CalendarType < Types::BaseObject
end
end
```

## `@oneOf`

You can make input objects that require _exactly one_ field to be provided using `one_of`:

```ruby
class FindUserInput < Types::BaseInput
one_of
# Either `{ id: ... }` or `{ username: ... }` may be given,
# but not both -- and one of them _must_ be given.
argument :id, ID, required: false
argument :username, String, required: false
end
```

An input object with `one_of` will require exactly one given argument and it will require that the given argument's value is not `nil`. With `one_of`, arguments must have `required: false`, since any _individual_ argument is not required.

When you use `one_of`, it will appear in schema print-outs with `input ... @oneOf` and you can query it using `{ __type(name: $typename) { isOneOf } }`.

This behavior is described in a [proposed change](https://github.com/graphql/graphql-spec/pull/825) to the GraphQL specification.
3 changes: 2 additions & 1 deletion lib/graphql/introspection.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module GraphQL
module Introspection
def self.query(include_deprecated_args: false, include_schema_description: false, include_is_repeatable: false, include_specified_by_url: false)
def self.query(include_deprecated_args: false, include_schema_description: false, include_is_repeatable: false, include_specified_by_url: false, include_is_one_of: false)
# The introspection query to end all introspection queries, copied from
# https://github.com/graphql/graphql-js/blob/master/src/utilities/introspectionQuery.js
<<-QUERY
Expand Down Expand Up @@ -30,6 +30,7 @@ def self.query(include_deprecated_args: false, include_schema_description: false
name
description
#{include_specified_by_url ? "specifiedByURL" : ""}
#{include_is_one_of ? "isOneOf" : ""}
fields(includeDeprecated: true) {
name
description
Expand Down
7 changes: 7 additions & 0 deletions lib/graphql/introspection/type_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ class TypeType < Introspection::BaseObject

field :specifiedByURL, String, resolver_method: :specified_by_url

field :is_one_of, Boolean, null: false

def is_one_of
object.kind.input_object? &&
object.directives.any? { |d| d.graphql_name == "oneOf" }
end

def specified_by_url
if object.kind.scalar?
object.specified_by_url
Expand Down
2 changes: 2 additions & 0 deletions lib/graphql/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
require "graphql/schema/directive"
require "graphql/schema/directive/deprecated"
require "graphql/schema/directive/include"
require "graphql/schema/directive/one_of"
require "graphql/schema/directive/skip"
require "graphql/schema/directive/feature"
require "graphql/schema/directive/flagged"
Expand Down Expand Up @@ -913,6 +914,7 @@ def default_directives
"include" => GraphQL::Schema::Directive::Include,
"skip" => GraphQL::Schema::Directive::Skip,
"deprecated" => GraphQL::Schema::Directive::Deprecated,
"oneOf" => GraphQL::Schema::Directive::OneOf,
}.freeze
end

Expand Down
3 changes: 1 addition & 2 deletions lib/graphql/schema/build_from_definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,13 @@ def build(document, default_resolve:, using: {}, relay:)
end
})

directives.merge!(GraphQL::Schema.default_directives)
document.definitions.each do |definition|
if definition.is_a?(GraphQL::Language::Nodes::DirectiveDefinition)
directives[definition.name] = build_directive(definition, directive_type_resolver)
end
end

directives = GraphQL::Schema.default_directives.merge(directives)

# In case any directives referenced built-in types for their arguments:
replace_late_bound_types_with_built_in(types)

Expand Down
12 changes: 12 additions & 0 deletions lib/graphql/schema/directive/one_of.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true
module GraphQL
class Schema
class Directive < GraphQL::Schema::Member
class OneOf < GraphQL::Schema::Directive
description "Requires that exactly one field must be supplied and that field must not be `null`."
locations(GraphQL::Schema::Directive::INPUT_OBJECT)
default_directive true
end
end
end
end
35 changes: 35 additions & 0 deletions lib/graphql/schema/input_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ def self.authorized?(obj, value, ctx)
true
end

def self.one_of
if !one_of?
if all_argument_definitions.any? { |arg| arg.type.non_null? }
raise ArgumentError, "`one_of` may not be used with required arguments -- add `required: false` to argument definitions to use `one_of`"
end
directive(GraphQL::Schema::Directive::OneOf)
end
end

def self.one_of?
directives.any? { |d| d.is_a?(GraphQL::Schema::Directive::OneOf) }
end

def unwrap_value(value)
case value
when Array
Expand Down Expand Up @@ -109,6 +122,14 @@ def to_kwargs
class << self
def argument(*args, **kwargs, &block)
argument_defn = super(*args, **kwargs, &block)
if one_of?
if argument_defn.type.non_null?
raise ArgumentError, "Argument '#{argument_defn.path}' must be nullable because it is part of a OneOf type, add `required: false`."
end
if argument_defn.default_value?
raise ArgumentError, "Argument '#{argument_defn.path}' cannot have a default value because it is part of a OneOf type, remove `default_value: ...`."
end
end
# Add a method access
method_name = argument_defn.keyword
class_eval <<-RUBY, __FILE__, __LINE__
Expand Down Expand Up @@ -166,6 +187,20 @@ def validate_non_null_input(input, ctx, max_errors: nil)
end
end

if one_of?
if input.size == 1
input.each do |name, value|
if value.nil?
result ||= Query::InputValidationResult.new
result.add_problem("'#{graphql_name}' requires exactly one argument, but '#{name}' was `null`.")
end
end
else
result ||= Query::InputValidationResult.new
result.add_problem("'#{graphql_name}' requires exactly one argument, but #{input.size} were provided.")
end
end

result
end

Expand Down
4 changes: 4 additions & 0 deletions lib/graphql/schema/late_bound_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def inspect
"#<LateBoundType @name=#{name}>"
end

def non_null?
false
end

alias :to_s :inspect
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/static_validation/all_rules.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ module StaticValidation
GraphQL::StaticValidation::QueryRootExists,
GraphQL::StaticValidation::SubscriptionRootExists,
GraphQL::StaticValidation::InputObjectNamesAreUnique,
GraphQL::StaticValidation::OneOfInputObjectsAreValid,
]
end
end
4 changes: 4 additions & 0 deletions lib/graphql/static_validation/literal_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ def required_input_fields_are_present(type, ast_node)
arg_type = @warden.get_argument(type, name).type
recursively_validate(GraphQL::Language::Nodes::NullValue.new(name: name), arg_type)
end

if type.one_of? && ast_node.arguments.size != 1
results << Query::InputValidationResult.from_problem("`#{type.graphql_name}` is a OneOf type, so only one argument may be given (instead of #{ast_node.arguments.size})")
end
merge_results(results)
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true
module GraphQL
module StaticValidation
module OneOfInputObjectsAreValid
def on_input_object(node, parent)
return super unless parent.is_a?(GraphQL::Language::Nodes::Argument)

parent_type = get_parent_type(context, parent)
return super unless parent_type && parent_type.kind.input_object? && parent_type.one_of?

validate_one_of_input_object(node, context, parent_type)
super
end

private

def validate_one_of_input_object(ast_node, context, parent_type)
present_fields = ast_node.arguments.map(&:name)
input_object_type = parent_type.to_type_signature

if present_fields.count != 1
add_error(
OneOfInputObjectsAreValidError.new(
"OneOf Input Object '#{input_object_type}' must specify exactly one key.",
path: context.path,
nodes: ast_node,
input_object_type: input_object_type
)
)
return
end

field = present_fields.first
value = ast_node.arguments.first.value

if value.is_a?(GraphQL::Language::Nodes::NullValue)
add_error(
OneOfInputObjectsAreValidError.new(
"Argument '#{input_object_type}.#{field}' must be non-null.",
path: [*context.path, field],
nodes: ast_node.arguments.first,
input_object_type: input_object_type
)
)
return
end

if value.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
variable_name = value.name
variable_type = @declared_variables[variable_name].type

unless variable_type.is_a?(GraphQL::Language::Nodes::NonNullType)
add_error(
OneOfInputObjectsAreValidError.new(
"Variable '#{variable_name}' must be non-nullable to be used for OneOf Input Object '#{input_object_type}'.",
path: [*context.path, field],
nodes: ast_node,
input_object_type: input_object_type
)
)
end
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true
module GraphQL
module StaticValidation
class OneOfInputObjectsAreValidError < StaticValidation::Error
attr_reader :input_object_type

def initialize(message, path:, nodes:, input_object_type:)
super(message, path: path, nodes: nodes)
@input_object_type = input_object_type
end

# A hash representation of this Message
def to_h
extensions = {
"code" => code,
"inputObjectType" => input_object_type
}

super.merge({
"extensions" => extensions
})
end

def code
"invalidOneOfInputObject"
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def on_variable_definition(node, parent)
problems = validation_result.problems
first_problem = problems && problems.first
if first_problem
error_message = first_problem["message"]
error_message = first_problem["explanation"]
end

error_message ||= "Default value for $#{node.name} doesn't match type #{type.to_type_signature}"
Expand Down
9 changes: 9 additions & 0 deletions spec/graphql/introspection/directive_type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@
"onFragment" => false,
"onOperation" => false,
},
{
"name" => "oneOf",
"args" => [],
"locations"=>["INPUT_OBJECT"],
"isRepeatable" => false,
"onField" => false,
"onFragment" => false,
"onOperation" => false,
},
{
"name"=>"doStuff",
"args"=>[],
Expand Down
2 changes: 1 addition & 1 deletion spec/graphql/introspection/schema_type_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def self.visible?(context)
|}

it "only returns visible directives" do
expected_dirs = ['deprecated', 'include', 'skip', 'visibleDirective']
expected_dirs = ['deprecated', 'include', 'skip', 'oneOf', 'visibleDirective']
directives = result['data']['__schema']['directives'].map { |dir| dir.fetch('name') }
assert_equal(expected_dirs.sort, directives.sort)
end
Expand Down
4 changes: 2 additions & 2 deletions spec/graphql/schema/build_from_definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def assert_schema_and_compare_output(definition)

parsed_schema = GraphQL::Schema.from_definition(schema)
hello_type = parsed_schema.get_type("Hello")
assert_equal ["deprecated", "foo", "greeting", "greeting2", "hashed", "include", "language", "skip"], parsed_schema.directives.keys.sort
assert_equal ["deprecated", "foo", "greeting", "greeting2", "hashed", "include", "language", "oneOf", "skip"], parsed_schema.directives.keys.sort
parsed_schema.directives.values.each do |dir_class|
assert dir_class < GraphQL::Schema::Directive
end
Expand Down Expand Up @@ -268,7 +268,7 @@ def assert_schema_and_compare_output(definition)
SCHEMA

built_schema = GraphQL::Schema.from_definition(schema)
assert_equal ['deprecated', 'include', 'skip'], built_schema.directives.keys.sort
assert_equal ['deprecated', 'include', 'oneOf', 'skip'], built_schema.directives.keys.sort
end

it 'supports overriding built-in directives' do
Expand Down
Loading

0 comments on commit d6c40fa

Please sign in to comment.