Skip to content

Optimize value coercion #5356

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

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions lib/graphql/execution/interpreter/arguments_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ def fetch(ast_node, argument_owner, parent_object)
@storage[argument_owner][parent_object][ast_node] = resolved_args
end
end

end

# @yield [Interpreter::Arguments, Lazy<Interpreter::Arguments>] The finally-loaded arguments
Expand All @@ -40,10 +39,25 @@ def dataload_for(ast_node, argument_owner, parent_object, &block)
if (args = arg_storage[ast_node])
yield(args)
else
args_hash = self.class.prepare_args_hash(@query, ast_node)
argument_owner.coerce_arguments(parent_object, args_hash, @query.context) do |resolved_args|
arg_storage[ast_node] = resolved_args
yield(resolved_args)
# Optimization: avoid arguments materialization for empty arguments
if argument_owner.any_arguments?
args_hash = self.class.prepare_args_hash(@query, ast_node)
# Fast path for empty arguments
if args_hash.empty? && !argument_owner.arguments_have_defaults?
args = GraphQL::Execution::Interpreter::Arguments::EMPTY
arg_storage[ast_node] = args
yield(args)
else
argument_owner.coerce_arguments(parent_object, args_hash, @query.context) do |resolved_args|
arg_storage[ast_node] = resolved_args
yield(resolved_args)
end
end
else
# No arguments defined on this field
args = GraphQL::Execution::Interpreter::Arguments::EMPTY
arg_storage[ast_node] = args
yield(args)
end
end
nil
Expand Down Expand Up @@ -97,4 +111,4 @@ def self.prepare_args_hash(query, ast_arg_or_hash_or_value)
end
end
end
end
end
58 changes: 52 additions & 6 deletions lib/graphql/schema/argument.rb
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,13 @@ def prepare_value(obj, value, context: nil)
end
end

# Optimization: Use a faster code path for simpler argument cases
# @api private
def coerce_into_values(parent_object, values, context, argument_values)
arg_name = graphql_name
arg_key = keyword
default_used = false

# Fast path for common case - extracting value from values hash
if values.key?(arg_name)
value = values[arg_name]
elsif values.key?(arg_key)
Expand All @@ -270,6 +271,25 @@ def coerce_into_values(parent_object, values, context, argument_values)
default_used = true
end

# The common case is direct coercion without loading or special preparation
if statically_coercible? && !loads
coerced_value = begin
type.coerce_input(value, context)
rescue StandardError => err
context.schema.handle_or_reraise(context, err)
end

# For simple cases we can avoid the lazy overhead completely
argument_values[arg_key] = GraphQL::Execution::Interpreter::ArgumentValue.new(
value: coerced_value,
original_value: coerced_value,
definition: self,
default_used: default_used || false,
)
return
end

# Handle complex cases with lazy values, loading, and preparation
loaded_value = nil
coerced_value = begin
type.coerce_input(value, context)
Expand Down Expand Up @@ -297,12 +317,11 @@ def coerce_into_values(parent_object, values, context, argument_values)

maybe_loaded_value = loaded_value || prepared_value
context.query.after_lazy(maybe_loaded_value) do |resolved_loaded_value|
# TODO code smell to access such a deeply-nested constant in a distant module
argument_values[arg_key] = GraphQL::Execution::Interpreter::ArgumentValue.new(
value: resolved_loaded_value,
original_value: resolved_coerced_value,
definition: self,
default_used: default_used,
default_used: default_used || false,
)
end
end
Expand Down Expand Up @@ -399,17 +418,44 @@ def initialize(argument)
private

def recursively_prepare_input_object(value, type, context)
# Quick early return for nil values
return value if value.nil?

if type.non_null?
type = type.of_type
end

if type.list? && !value.nil?
# Specialized handling for list types
if type.list?
# Get the inner type once to avoid repeated unwrapping
inner_type = type.of_type
value.map { |v| recursively_prepare_input_object(v, inner_type, context) }

# Fast path for empty arrays
if value.empty?
return []
end

# Fast path for arrays of simple scalar values without input objects
if value.is_a?(Array) &&
!inner_type.list? && # Not a nested list
!inner_type.kind.input_object? && # Not an input object type
value.none? { |v| v.is_a?(GraphQL::Schema::InputObject) } # No input objects in the array
return value.dup
end

# Standard case for lists - pre-allocate the result array
result = Array.new(value.size)
value.each_with_index do |item, idx|
result[idx] = recursively_prepare_input_object(item, inner_type, context)
end

return result
elsif value.is_a?(GraphQL::Schema::InputObject)
# Process input objects - combine validate and prepare steps
value.validate_for(context)
value.prepare
else
# Return other values as is
value
end
end
Expand All @@ -433,4 +479,4 @@ def validate_deprecated_or_optional(null:, deprecation_reason:)
end
end
end
end
end
54 changes: 51 additions & 3 deletions lib/graphql/schema/input_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,52 @@ def prepare
def unwrap_value(value)
case value
when Array
value.map { |item| unwrap_value(item) }
# Fast path for empty arrays
return [] if value.empty?

# Check if any item needs unwrapping (nested structures or InputObjects)
has_complex_items = false
value.each do |item|
if item.is_a?(Array) || item.is_a?(Hash) || item.is_a?(InputObject)
has_complex_items = true
break
end
end

if has_complex_items
# Complex case: create a new array and map values
result = Array.new(value.size)
value.each_with_index do |item, idx|
result[idx] = unwrap_value(item)
end
result
else
# Simple case: array of primitive values, just duplicate
value.dup
end
when Hash
value.reduce({}) do |h, (key, value)|
h.merge!(key => unwrap_value(value))
# Fast path for empty hashes
return {} if value.empty?

# Check if any value needs unwrapping
has_complex_values = false
value.each_value do |v|
if v.is_a?(Array) || v.is_a?(Hash) || v.is_a?(InputObject)
has_complex_values = true
break
end
end

if has_complex_values
# Complex case: create a new hash with unwrapped values
result = {}
value.each do |key, val|
result[key] = unwrap_value(val)
end
result
else
# Simple case: hash of primitive values, just duplicate
value.dup
end
when InputObject
value.to_h
Expand Down Expand Up @@ -228,6 +270,12 @@ def coerce_input(value, ctx)
if value.nil?
return nil
end

# Fast path for empty input (if we know there are no required arguments)
if value.respond_to?(:empty?) && value.empty? && !ctx.types.arguments(self).any? { |arg_defn| arg_defn.type.non_null? && !arg_defn.default_value? }
arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY
return self.new(arguments, ruby_kwargs: {}, context: ctx, defaults_used: nil)
end

arguments = coerce_arguments(nil, value, ctx)

Expand Down
10 changes: 8 additions & 2 deletions lib/graphql/schema/list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,17 @@ def coerce_result(value, ctx)
value.map { |i| i.nil? ? nil : of_type.coerce_result(i, ctx) }
end

# Optimization: avoid creating a new array if input is already an array
def coerce_input(value, ctx)
if value.nil?
nil
else
coerced = ensure_array(value).map { |item| item.nil? ? item : of_type.coerce_input(item, ctx) }
# Optimization: avoid Array() call and handle common case directly
coerced = if value.is_a?(Array)
value.map { |item| item.nil? ? item : of_type.coerce_input(item, ctx) }
else
[of_type.coerce_input(value, ctx)]
end
ctx.schema.after_any_lazies(coerced, &:itself)
end
end
Expand Down Expand Up @@ -83,4 +89,4 @@ def add_max_errors_reached_message(result)
end
end
end
end
end
90 changes: 60 additions & 30 deletions lib/graphql/schema/member/has_arguments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ def arguments(context = GraphQL::Query::NullContext.instance, _require_defined_a
# might be nil if there are actually no arguments
own_arguments_that_apply || own_arguments
end

# Check if any argument has default values defined
def arguments_have_defaults?
@arguments_have_defaults ||= all_argument_definitions.any?(&:default_value?)
end

def any_arguments?
!own_arguments.empty?
Expand Down Expand Up @@ -238,39 +243,64 @@ def coerce_arguments(parent_object, values, context, &block)
arg_defns = context.types.arguments(self)
total_args_count = arg_defns.size

# Optimization: skip the complex logic if there are no arguments
if total_args_count == 0
finished_args = GraphQL::Execution::Interpreter::Arguments::EMPTY
if block_given?
block.call(finished_args)
return nil
else
return finished_args
end
end

# Optimization: for small number of arguments, use a more direct approach
if total_args_count <= 2 && !block_given?
argument_values = {}
raised_error = false

arg_defns.each do |arg_defn|
begin
arg_defn.coerce_into_values(parent_object, values, context, argument_values)
rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err
raised_error = true
return err
end
end

# No lazy values for simple cases
return GraphQL::Execution::Interpreter::Arguments.new(
argument_values: argument_values,
)
end

# Complex case with multiple arguments or block given
finished_args = nil
prepare_finished_args = -> {
if total_args_count == 0
finished_args = GraphQL::Execution::Interpreter::Arguments::EMPTY
if block_given?
block.call(finished_args)
end
else
argument_values = {}
resolved_args_count = 0
raised_error = false
arg_defns.each do |arg_defn|
context.dataloader.append_job do
begin
arg_defn.coerce_into_values(parent_object, values, context, argument_values)
rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err
raised_error = true
finished_args = err
if block_given?
block.call(finished_args)
end
argument_values = {}
resolved_args_count = 0
raised_error = false
arg_defns.each do |arg_defn|
context.dataloader.append_job do
begin
arg_defn.coerce_into_values(parent_object, values, context, argument_values)
rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => err
raised_error = true
finished_args = err
if block_given?
block.call(finished_args)
end
end

resolved_args_count += 1
if resolved_args_count == total_args_count && !raised_error
finished_args = context.schema.after_any_lazies(argument_values.values) {
GraphQL::Execution::Interpreter::Arguments.new(
argument_values: argument_values,
)
}
if block_given?
block.call(finished_args)
end
resolved_args_count += 1
if resolved_args_count == total_args_count && !raised_error
finished_args = context.schema.after_any_lazies(argument_values.values) {
GraphQL::Execution::Interpreter::Arguments.new(
argument_values: argument_values,
)
}
if block_given?
block.call(finished_args)
end
end
end
Expand Down Expand Up @@ -425,4 +455,4 @@ def own_arguments
end
end
end
end
end
Loading