Skip to content

Commit

Permalink
Merge pull request #5137 from rmosolgo/dont-allow-empty-types
Browse files Browse the repository at this point in the history
Require object types to have fields
  • Loading branch information
rmosolgo authored Nov 20, 2024
2 parents cc66b4a + 9e07f52 commit 8a21eb1
Show file tree
Hide file tree
Showing 16 changed files with 207 additions and 37 deletions.
1 change: 1 addition & 0 deletions benchmark/run.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true
require "graphql"
ADD_WARDEN = false
require "jazz"
require "benchmark/ips"
require "stackprof"
Expand Down
2 changes: 2 additions & 0 deletions lib/graphql/schema/has_single_input_argument.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ def description(new_desc = nil)
super || "Autogenerated input type of #{self.mutation.graphql_name}"
end
end
# For compatibility, in case no arguments are defined:
has_no_arguments(true)
mutation(mutation_class)
# these might be inherited:
mutation_args.each do |arg|
Expand Down
81 changes: 54 additions & 27 deletions lib/graphql/schema/input_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ class InputObject < GraphQL::Schema::Member

include GraphQL::Dig

# Raised when an InputObject doesn't have any arguments defined and hasn't explicitly opted out of this requirement
class ArgumentsAreRequiredError < GraphQL::Error
def initialize(input_object_type)
message = "Input Object types must have arguments, but #{input_object_type.graphql_name} doesn't have any. Define an argument for this type, remove it from your schema, or add `has_no_arguments(true)` to its definition."
super(message)
end
end

# @return [GraphQL::Query::Context] The context for this query
attr_reader :context
# @return [GraphQL::Execution::Interpereter::Arguments] The underlying arguments instance
Expand Down Expand Up @@ -56,33 +64,6 @@ def prepare
end
end

def self.authorized?(obj, value, ctx)
# Authorize each argument (but this doesn't apply if `prepare` is implemented):
if value.respond_to?(:key?)
ctx.types.arguments(self).each do |input_obj_arg|
if value.key?(input_obj_arg.keyword) &&
!input_obj_arg.authorized?(obj, value[input_obj_arg.keyword], ctx)
return false
end
end
end
# It didn't early-return false:
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?
false # Re-defined when `OneOf` is added
end

def unwrap_value(value)
case value
when Array
Expand Down Expand Up @@ -121,6 +102,33 @@ def to_kwargs
end

class << self
def authorized?(obj, value, ctx)
# Authorize each argument (but this doesn't apply if `prepare` is implemented):
if value.respond_to?(:key?)
ctx.types.arguments(self).each do |input_obj_arg|
if value.key?(input_obj_arg.keyword) &&
!input_obj_arg.authorized?(obj, value[input_obj_arg.keyword], ctx)
return false
end
end
end
# It didn't early-return false:
true
end

def 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 one_of?
false # Re-defined when `OneOf` is added
end

def argument(*args, **kwargs, &block)
argument_defn = super(*args, **kwargs, &block)
if one_of?
Expand Down Expand Up @@ -246,6 +254,25 @@ def coerce_result(value, ctx)
result
end

# @param new_has_no_arguments [Boolean] Call with `true` to make this InputObject type ignore the requirement to have any defined arguments.
# @return [void]
def has_no_arguments(new_has_no_arguments)
@has_no_arguments = new_has_no_arguments
nil
end

# @return [Boolean] `true` if `has_no_arguments(true)` was configued
def has_no_arguments?
@has_no_arguments
end

def arguments(context = GraphQL::Query::NullContext.instance, require_defined_arguments = true)
if require_defined_arguments && !has_no_arguments? && !any_arguments?
warn(GraphQL::Schema::InputObject::ArgumentsAreRequiredError.new(self).message + "\n\nThis will raise an error in a future GraphQL-Ruby version.")
end
super(context, false)
end

private

# Suppress redefinition warning for objectId arguments
Expand Down
10 changes: 5 additions & 5 deletions lib/graphql/schema/member/has_arguments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def remove_argument(arg_defn)
end

# @return [Hash<String => GraphQL::Schema::Argument] Arguments defined on this thing, keyed by name. Includes inherited definitions
def arguments(context = GraphQL::Query::NullContext.instance)
def arguments(context = GraphQL::Query::NullContext.instance, _require_defined_arguments = nil)
if own_arguments.any?
own_arguments_that_apply = {}
own_arguments.each do |name, args_entry|
Expand All @@ -100,9 +100,9 @@ def inherited(child_class)
end

module InheritedArguments
def arguments(context = GraphQL::Query::NullContext.instance)
own_arguments = super
inherited_arguments = superclass.arguments(context)
def arguments(context = GraphQL::Query::NullContext.instance, require_defined_arguments = true)
own_arguments = super(context, require_defined_arguments)
inherited_arguments = superclass.arguments(context, false)

if own_arguments.any?
if inherited_arguments.any?
Expand Down Expand Up @@ -149,7 +149,7 @@ def get_argument(argument_name, context = GraphQL::Query::NullContext.instance)
end

module FieldConfigured
def arguments(context = GraphQL::Query::NullContext.instance)
def arguments(context = GraphQL::Query::NullContext.instance, _require_defined_arguments = nil)
own_arguments = super
if @resolver_class
inherited_arguments = @resolver_class.field_arguments(context)
Expand Down
18 changes: 18 additions & 0 deletions lib/graphql/schema/member/has_fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ def global_id_field(field_name, **kwargs)
end
end

# @param new_has_no_fields [Boolean] Call with `true` to make this Object type ignore the requirement to have any defined fields.
# @return [void]
def has_no_fields(new_has_no_fields)
@has_no_fields = new_has_no_fields
nil
end

# @return [Boolean] `true` if `has_no_fields(true)` was configued
def has_no_fields?
@has_no_fields
end

# @return [Hash<String => GraphQL::Schema::Field, Array<GraphQL::Schema::Field>>] Fields defined on this class _specifically_, not parent classes
def own_fields
@own_fields ||= {}
Expand Down Expand Up @@ -155,9 +167,11 @@ def fields(context = GraphQL::Query::NullContext.instance)
warden = Warden.from_context(context)
# Local overrides take precedence over inherited fields
visible_fields = {}
had_any_fields_at_all = false
for ancestor in ancestors
if ancestor.respond_to?(:own_fields) && visible_interface_implementation?(ancestor, context, warden)
ancestor.own_fields.each do |field_name, fields_entry|
had_any_fields_at_all = true
# Choose the most local definition that passes `.visible?` --
# stop checking for fields by name once one has been found.
if !visible_fields.key?(field_name) && (f = Warden.visible_entry?(:visible_field?, fields_entry, context, warden))
Expand All @@ -166,6 +180,9 @@ def fields(context = GraphQL::Query::NullContext.instance)
end
end
end
if !had_any_fields_at_all && !has_no_fields?
warn(GraphQL::Schema::Object::FieldsAreRequiredError.new(self).message + "\n\nThis will raise an error in a future GraphQL-Ruby version.")
end
visible_fields
end
end
Expand All @@ -188,6 +205,7 @@ def inherited(subclass)
subclass.class_eval do
@own_fields ||= nil
@field_class ||= nil
@has_no_fields ||= false
end
end

Expand Down
8 changes: 8 additions & 0 deletions lib/graphql/schema/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ class Object < GraphQL::Schema::Member
extend GraphQL::Schema::Member::HasFields
extend GraphQL::Schema::Member::HasInterfaces

# Raised when an Object doesn't have any field defined and hasn't explicitly opted out of this requirement
class FieldsAreRequiredError < GraphQL::Error
def initialize(object_type)
message = "Object types must have fields, but #{object_type.graphql_name} doesn't have any. Define a field for this type, remove it from your schema, or add `has_no_fields(true)` to its definition."
super(message)
end
end

# @return [Object] the application object this type is wrapping
attr_reader :object

Expand Down
2 changes: 2 additions & 0 deletions spec/graphql/logger_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ class Query < GraphQL::Schema::Object

def node(id:)
end

field :something_else, String
end
query(Query)
end
Expand Down
3 changes: 0 additions & 3 deletions spec/graphql/schema/build_from_definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,12 @@ def assert_schema_and_compare_output(definition)
query: HelloScalars
}
type EmptyType
type HelloScalars {
bool: Boolean
float: Float
id: ID
int: Int
str: String!
t: EmptyType
}
SCHEMA

Expand Down
2 changes: 2 additions & 0 deletions spec/graphql/schema/directive/flagged_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def antarctica; true; end
field :santas_workshop, Boolean, null: false,
directives: { GraphQL::Schema::Directive::Flagged => { by: ["northPole"] } }
def santas_workshop; true; end

field :something_not_flagged, String
end

query(Query)
Expand Down
1 change: 0 additions & 1 deletion spec/graphql/schema/has_single_input_argument_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ def resolve
name: 'name',
}
end

end
class Mutation < GraphQL::Schema::Object
field_class GraphQL::Schema::Field
Expand Down
54 changes: 54 additions & 0 deletions spec/graphql/schema/input_object_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1419,4 +1419,58 @@ def result(values:)
end
end
end

describe "when no arguments are defined" do
describe "when defined with no fields" do
class NoArgumentsSchema < GraphQL::Schema
class NoArgumentsInput < GraphQL::Schema::InputObject
end

class NoArgumentsCompatInput < GraphQL::Schema::InputObject
has_no_arguments(true)
end

class Query < GraphQL::Schema::Object
field :no_arguments, String, fallback_value: "NO_ARGS" do
argument :input, NoArgumentsInput
end

field :no_arguments_compat, String, fallback_value: "OK" do
argument :input, NoArgumentsCompatInput
end
end

query(Query)
end

it "raises an error at runtime and printing" do
refute NoArgumentsSchema::NoArgumentsInput.has_no_arguments?

expected_message = "Input Object types must have arguments, but NoArgumentsInput doesn't have any. Define an argument for this type, remove it from your schema, or add `has_no_arguments(true)` to its definition.
This will raise an error in a future GraphQL-Ruby version.
"
res = assert_warns(expected_message) do
NoArgumentsSchema.execute("{ noArguments(input: {}) }")
end
assert_equal "NO_ARGS", res["data"]["noArguments"]

assert_warns(expected_message) do
NoArgumentsSchema.to_definition
end

assert_warns(expected_message) do
NoArgumentsSchema.to_json
end
end

it "doesn't raise an error if has_no_arguments(true)" do
assert NoArgumentsSchema::NoArgumentsCompatInput.has_no_arguments?
res = assert_warns("") do
NoArgumentsSchema.execute("{ noArgumentsCompat(input: {}) }")
end
assert_equal "OK", res["data"]["noArgumentsCompat"]
end
end
end
end
48 changes: 48 additions & 0 deletions spec/graphql/schema/object_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -512,4 +512,52 @@ def self.wrap(obj, ctx)
assert_nil obj1.comment
end
end

describe "when defined with no fields" do
class NoFieldsSchema < GraphQL::Schema
class NoFieldsThing < GraphQL::Schema::Object
end

class NoFieldsCompatThing < GraphQL::Schema::Object
has_no_fields(true)
end

class Query < GraphQL::Schema::Object
field :no_fields_thing, NoFieldsThing
field :no_fields_compat_thing, NoFieldsCompatThing
end

query(Query)
end

it "raises an error at runtime and printing" do
refute NoFieldsSchema::NoFieldsThing.has_no_fields?

expected_message = "Object types must have fields, but NoFieldsThing doesn't have any. Define a field for this type, remove it from your schema, or add `has_no_fields(true)` to its definition.
This will raise an error in a future GraphQL-Ruby version.
"
res = assert_warns(expected_message) do
NoFieldsSchema.execute("{ noFieldsThing { blah } }")
end
assert_equal ["Field 'blah' doesn't exist on type 'NoFieldsThing'"], res["errors"].map { |err| err["message"] }

assert_warns(expected_message) do
NoFieldsSchema.to_definition
end

assert_warns(expected_message) do
NoFieldsSchema.to_json
end
end

it "doesn't raise an error if has_no_fields(true)" do
assert NoFieldsSchema::NoFieldsCompatThing.has_no_fields?

res = assert_warns "" do
NoFieldsSchema.execute("{ noFieldsCompatThing { blah } }")
end
assert_equal ["Field 'blah' doesn't exist on type 'NoFieldsCompatThing'"], res["errors"].map { |e| e["message"] }
end
end
end
4 changes: 3 additions & 1 deletion spec/graphql/schema/printer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,13 @@ class MediaRating < GraphQL::Schema::Enum
value :BOO_HISS
end


class NoFields < GraphQL::Schema::Object
has_no_fields(true)
end

class NoArguments < GraphQL::Schema::InputObject
has_no_arguments(true)
end

class Query < GraphQL::Schema::Object
Expand Down Expand Up @@ -710,7 +713,6 @@ def self.visible?(member, ctx)
assert_equal expected, custom_filter_schema.to_definition(context: context)
end


it "applies an `except` filter" do
expected = <<SCHEMA
type Audio {
Expand Down
Loading

0 comments on commit 8a21eb1

Please sign in to comment.