Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fallback_value option to Field #4069

Merged
merged 7 commits into from May 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions guides/fields/introduction.md
Expand Up @@ -87,6 +87,7 @@ By default, fields return values by:

- Trying to call a method on the underlying object; _OR_
- If the underlying object is a `Hash`, lookup a key in that hash.
- An optional `:fallback_value` can be supplied that will be used if the above fail.

The method name or hash key corresponds to the field name, so in this example:

Expand Down
22 changes: 17 additions & 5 deletions lib/graphql/schema/field.rb
Expand Up @@ -214,7 +214,8 @@ def method_conflict_warning?
# @param ast_node [Language::Nodes::FieldDefinition, nil] If this schema was parsed from definition, this AST node defined the field
# @param method_conflict_warning [Boolean] If false, skip the warning if this field's method conflicts with a built-in method
# @param validates [Array<Hash>] Configurations for validating this field
def initialize(type: nil, name: nil, owner: nil, null: nil, description: :not_given, deprecation_reason: nil, method: nil, hash_key: nil, dig: nil, resolver_method: nil, connection: nil, max_page_size: :not_given, scope: nil, introspection: false, camelize: true, trace: nil, complexity: nil, ast_node: nil, extras: EMPTY_ARRAY, extensions: EMPTY_ARRAY, connection_extension: self.class.connection_extension, resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, method_conflict_warning: true, broadcastable: nil, arguments: EMPTY_HASH, directives: EMPTY_HASH, validates: EMPTY_ARRAY, &definition_block)
# @fallback_value [Object] A fallback value if the method is not defined
def initialize(type: nil, name: nil, owner: nil, null: nil, description: :not_given, deprecation_reason: nil, method: nil, hash_key: nil, dig: nil, resolver_method: nil, connection: nil, max_page_size: :not_given, scope: nil, introspection: false, camelize: true, trace: nil, complexity: nil, ast_node: nil, extras: EMPTY_ARRAY, extensions: EMPTY_ARRAY, connection_extension: self.class.connection_extension, resolver_class: nil, subscription_scope: nil, relay_node_field: false, relay_nodes_field: false, method_conflict_warning: true, broadcastable: nil, arguments: EMPTY_HASH, directives: EMPTY_HASH, validates: EMPTY_ARRAY, fallback_value: :not_given, &definition_block)
if name.nil?
raise ArgumentError, "missing first `name` argument or keyword `name:`"
end
Expand Down Expand Up @@ -280,6 +281,7 @@ def initialize(type: nil, name: nil, owner: nil, null: nil, description: :not_gi
@relay_nodes_field = relay_nodes_field
@ast_node = ast_node
@method_conflict_warning = method_conflict_warning
@fallback_value = fallback_value

arguments.each do |name, arg|
case arg
Expand Down Expand Up @@ -643,7 +645,7 @@ def resolve(object, args, query_ctx)
inner_object = obj.object

if defined?(@hash_key)
inner_object[@hash_key] || inner_object[@hash_key_str]
inner_object[@hash_key] || inner_object[@hash_key_str] || (@fallback_value != :not_given ? @fallback_value : nil)
elsif obj.respond_to?(resolver_method)
method_to_call = resolver_method
method_receiver = obj
Expand All @@ -659,13 +661,21 @@ def resolve(object, args, query_ctx)
elsif defined?(@hash_key)
if inner_object.key?(@hash_key)
inner_object[@hash_key]
else
elsif inner_object.key?(@hash_key_str)
inner_object[@hash_key_str]
elsif @fallback_value != :not_given
@fallback_value
else
nil
end
elsif inner_object.key?(@method_sym)
inner_object[@method_sym]
else
elsif inner_object.key?(@method_str)
inner_object[@method_str]
elsif @fallback_value != :not_given
@fallback_value
else
nil
end
elsif inner_object.respond_to?(@method_sym)
method_to_call = @method_sym
Expand All @@ -675,6 +685,8 @@ def resolve(object, args, query_ctx)
else
inner_object.public_send(@method_sym)
end
elsif @fallback_value != :not_given
@fallback_value
else
raise <<-ERR
Failed to implement #{@owner.graphql_name}.#{@name}, tried:
Expand All @@ -683,7 +695,7 @@ def resolve(object, args, query_ctx)
- `#{inner_object.class}##{@method_sym}`, which did not exist
- Looking up hash key `#{@method_sym.inspect}` or `#{@method_str.inspect}` on `#{inner_object}`, but it wasn't a Hash

To implement this field, define one of the methods above (and check for typos)
To implement this field, define one of the methods above (and check for typos), or supply a `fallback_value`.
ERR
end
end
Expand Down
1 change: 0 additions & 1 deletion spec/graphql/schema/field_spec.rb
@@ -1,6 +1,5 @@
# frozen_string_literal: true
require "spec_helper"

describe GraphQL::Schema::Field do
describe "graphql definition" do
let(:object_class) { Jazz::Query }
Expand Down
144 changes: 140 additions & 4 deletions spec/graphql/schema/interface_spec.rb
Expand Up @@ -282,7 +282,7 @@ def thing
{
thing {
...on Node { id }
...on Named {
...on Named {
nid: id name
...on Node { nnid: id }
}
Expand All @@ -292,7 +292,7 @@ def thing
GRAPHQL

thing2 = result2.dig("data", "thing")

assert_equal "id", thing2["id"]
assert_equal "id", thing2["nid"]
assert_equal "id", thing2["tid"]
Expand Down Expand Up @@ -331,15 +331,151 @@ def thing

it "only lists each implemented interface once when introspecting" do
introspection = TransitiveInterfaceSchema.as_json
thing_type = introspection.dig("data", "__schema", "types").find do |type|
thing_type = introspection.dig("data", "__schema", "types").find do |type|
type["name"] == "Thing"
end
interfaces_names = thing_type["interfaces"].map { |i| i["name"] }.sort

assert_equal interfaces_names, ["Named", "Node", "Timestamped"]
end
end

describe "supplying a fallback_value to a field" do
DATABASE = [
{id: "1", name: "Hash thing"},
{id: "2"},
{id: "3", name: nil},
OpenStruct.new(id: "4", name: "OpenStruct thing"),
OpenStruct.new(id: "5"),
{id: "6", custom_name: "Hash Key Name"}
]

class FallbackValueSchema < GraphQL::Schema
module NodeWithFallbackInterface
include GraphQL::Schema::Interface

field :id, ID, null: false
field :name, String, fallback_value: "fallback"
end

module NodeWithHashKeyFallbackInterface
include GraphQL::Schema::Interface

field :id, ID, null: false
field :name, String, hash_key: :custom_name, fallback_value: "hash-key-fallback"
end

module NodeWithoutFallbackInterface
include GraphQL::Schema::Interface

field :id, ID, null: false
field :name, String
end

module NodeWithNilFallbackInterface
include GraphQL::Schema::Interface

field :id, ID, null: false
field :name, String, fallback_value: nil
end

class NodeWithFallbackType < GraphQL::Schema::Object
implements NodeWithFallbackInterface
end

class NodeWithHashKeyFallbackType < GraphQL::Schema::Object
implements NodeWithHashKeyFallbackInterface
end

class NodeWithNilFallbackType < GraphQL::Schema::Object
implements NodeWithNilFallbackInterface
end

class NodeWithoutFallbackType < GraphQL::Schema::Object
implements NodeWithoutFallbackInterface
end

class Query < GraphQL::Schema::Object
field :fallback, [NodeWithFallbackType]
def fallback
DATABASE
end

field :hash_key_fallback, [NodeWithHashKeyFallbackType]
def hash_key_fallback
DATABASE
end

field :no_fallback, [NodeWithoutFallbackType]
def no_fallback
DATABASE
end

field :nil_fallback, [NodeWithNilFallbackType]
def nil_fallback
DATABASE
end
end

query(Query)
end

it "uses fallback_value if supplied, but only if other ways don't work" do
result = FallbackValueSchema.execute("{ fallback { id name } }")
data = result["data"]["fallback"]
expected = [
{"id"=>"1", "name"=>"Hash thing"},
{"id"=>"2", "name"=>"fallback"},
{"id"=>"3", "name"=>nil},
{"id"=>"4", "name"=>"OpenStruct thing"},
{"id"=>"5", "name"=>"fallback"},
{"id"=>"6", "name"=>"fallback"},
]

assert_equal expected, data
end

it "uses fallback_value if supplied when hash key isn't present" do
result = FallbackValueSchema.execute("{ hashKeyFallback { id name } }")
data = result["data"]["hashKeyFallback"]
expected = [
{"id"=>"1", "name"=>"hash-key-fallback"},
{"id"=>"2", "name"=>"hash-key-fallback"},
{"id"=>"3", "name"=>"hash-key-fallback"},
{"id"=>"4", "name"=>"hash-key-fallback"},
{"id"=>"5", "name"=>"hash-key-fallback"},
{"id"=>"6", "name"=>"Hash Key Name"},
]

assert_equal expected, data
end

it "allows nil as fallback_value" do
result = FallbackValueSchema.execute("{ nilFallback { id name } }")
data = result["data"]["nilFallback"]
expected = [
{"id"=>"1", "name"=>"Hash thing"},
{"id"=>"2", "name"=>nil},
{"id"=>"3", "name"=>nil},
{"id"=>"4", "name"=>"OpenStruct thing"},
{"id"=>"5", "name"=>nil},
{"id"=>"6", "name"=>nil},
]

assert_equal expected, data
end

it "errors if no fallback_value is supplied and other ways don't work" do
err = assert_raises RuntimeError do
FallbackValueSchema.execute("{ noFallback { id name } }")
end

assert_includes err.message, "Failed to implement"
# Doesn't error until it gets to the OpenStructs.
assert_includes err.message, "OpenStruct"
end
end

describe "migrated legacy tests" do
let(:interface) { Dummy::Edible }

Expand Down