From f98a7a1f38bc9b9b9cadd9509144e850e224611a Mon Sep 17 00:00:00 2001 From: Davor Ocelic Date: Fri, 9 Oct 2026 20:07:02 +0200 Subject: [PATCH 1/2] release: 0.9.4 --- CHANGELOG.md | 2 ++ shard.yml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 515fb87..9a0726f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.4] - 2026-10-09 + ### Fixed - Objects implementing several interfaces are printed as diff --git a/shard.yml b/shard.yml index d37e772..68ac0d5 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: graphql -version: 0.9.3 +version: 0.9.4 authors: - Jakob Gillich license: MIT From f4f14667cd81e1f4d649f27f24399408827575f7 Mon Sep 17 00:00:00 2001 From: Davor Ocelic Date: Fri, 9 Oct 2026 20:34:35 +0200 Subject: [PATCH 2/2] perf: cut per-request and per-object allocations Measured with the benchmark's new allocation column (heap bytes per request from GC.stats), before and after on the same machine: scenario alloc/req req/s small -34% +46% flat_list -41% +37% nested_tree -26% +32% nested_concurrent -21% +28% union_fragments -25% +18% mutations -25% +18% introspection -27% +30% Per request: nodes keep their source offset and derive line and column only when an error needs them, instead of the parser building a line table for every query; list parsing takes blocks instead of allocating a Proc per call; nodes without arguments, directives, selections or variables share frozen empty arrays; node constructors re-map an array only when its element type must widen; operations are found by scanning definitions rather than visiting the whole tree into an array; and a request without a `$` skips variable resolution and substitution. Per object: plain selection sets (fields only, no directives, distinct keys) are resolved directly without collecting into a map, and the collection step that remains uses an array; leaf fields, leaf lists and successful fragments return one shared empty error array instead of allocating their own; sequential list elements are buffered inline without a closure, a pending slot or a path array each. --- CHANGELOG.md | 10 ++ bench/bench.cr | 23 +++- src/graphql/language/ast.cr | 45 ++++++- src/graphql/language/parser_context.cr | 93 ++++++------- src/graphql/object_type.cr | 177 +++++++++++++++++-------- src/graphql/schema.cr | 50 ++++--- src/graphql/subscription_type.cr | 4 +- 7 files changed, 265 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0726f..e9be87f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Fewer allocations per request: positions for error locations are derived + on demand, nodes without arguments or directives share empty arrays, plain + selection sets are resolved without an intermediate field map, requests + without variables skip variable resolution, leaf fields and list elements + allocate no error arrays, and sequential list elements no longer create a + closure each. The benchmark reports 25 to 45 percent fewer bytes per request + and 18 to 46 percent more throughput across its scenarios + ## [0.9.4] - 2026-10-09 ### Fixed diff --git a/bench/bench.cr b/bench/bench.cr index 034167e..1025abe 100644 --- a/bench/bench.cr +++ b/bench/bench.cr @@ -229,9 +229,14 @@ module Bench Scenario.new("introspection", GraphQL::INTROSPECTION_QUERY), ] - record Result, name : String, bytes : Int32, requests : Int32, seconds : Float64 do + record Result, name : String, bytes : Int32, requests : Int32, seconds : Float64, allocated : Int64 do include JSON::Serializable + # heap bytes allocated per request, as reported by the GC + def allocated_per_request : Float64 + allocated.to_f / requests + end + def per_second : Float64 requests / seconds end @@ -255,12 +260,15 @@ module Bench requests = 0 elapsed = Time::Span.zero + GC.collect + allocated_before = GC.stats.total_bytes while elapsed < budget elapsed += Time.measure { SCHEMA.execute(scenario.query, scenario.variables, nil, scenario.context) } requests += 1 end + allocated = (GC.stats.total_bytes - allocated_before).to_i64 - Result.new(scenario.name, response.bytesize, requests, elapsed.total_seconds) + Result.new(scenario.name, response.bytesize, requests, elapsed.total_seconds, allocated) end def self.main @@ -284,17 +292,18 @@ module Bench puts "crystal #{Crystal::VERSION}, #{budget.total_seconds}s per scenario, release build: #{{{ flag?(:release) }}}" puts - header = "%-18s %10s %10s %10s %8s" % ["scenario", "bytes", "req/s", "ms/req", "MB/s"] - header += " %10s" % "vs before" if baseline + header = "%-18s %10s %10s %10s %8s %12s" % ["scenario", "bytes", "req/s", "ms/req", "MB/s", "alloc/req"] + header += " %10s %10s" % ["req/s Δ", "alloc Δ"] if baseline puts header puts "-" * header.size results = scenarios.map do |scenario| result = run(scenario, budget) - line = "%-18s %10d %10.1f %10.3f %8.1f" % [result.name, result.bytes, result.per_second, result.ms_per_request, result.mb_per_second] + line = "%-18s %10d %10.1f %10.3f %8.1f %12s" % [result.name, result.bytes, result.per_second, result.ms_per_request, result.mb_per_second, result.allocated_per_request.round.to_i64.humanize_bytes] if baseline && (before = baseline[result.name]?) - change = (result.per_second / before.per_second - 1) * 100 - line += " %+9.1f%%" % change + speed = (result.per_second / before.per_second - 1) * 100 + alloc = (result.allocated_per_request / before.allocated_per_request - 1) * 100 + line += " %+9.1f%% %+9.1f%%" % [speed, alloc] end puts line result diff --git a/src/graphql/language/ast.cr b/src/graphql/language/ast.cr index 9cab6e8..2b0bfd5 100644 --- a/src/graphql/language/ast.cr +++ b/src/graphql/language/ast.cr @@ -1,12 +1,43 @@ module GraphQL module Language abstract class ASTNode - # 1-based position in the query source, set by the parser on the - # nodes that errors refer to. - property line : Int32? - property column : Int32? + # Where the node starts in the query source, set by the parser on the + # nodes errors refer to. Line and column are derived on demand, since + # almost no node ever appears in an error. + @source : String? + @start : Int32? + @line : Int32? + @column : Int32? - def initialize(@line : Int32? = nil, @column : Int32? = nil) + def initialize(@source : String? = nil, @start : Int32? = nil) + end + + # 1-based line of the node in the query, if known. + def line : Int32? + locate + @line + end + + # 1-based column of the node in the query, if known. + def column : Int32? + locate + @column + end + + private def locate : Nil + return if @line + return unless (source = @source) && (start = @start) + line = 1 + line_start = 0 + source.each_char_with_index do |char, index| + break if index >= start + if char == '\n' + line += 1 + line_start = index + 1 + end + end + @line = line + @column = start - line_start + 1 end macro values(args) @@ -18,8 +49,10 @@ module GraphQL {% assignments = args.map do |k, v| if v.is_a?(Generic) && v.name.id == "Array" + # widen e.g. Array(Field) to Array(Selection); an array that + # already has the declared type is kept as is type = v.type_vars.first.id - "@#{k.id} = #{k.id}.map(&.as(#{type}))" + "@#{k.id} = #{k.id}.is_a?(#{v.id}) ? #{k.id} : #{k.id}.map(&.as(#{type}))" else "@#{k.id} = #{k.id}" end diff --git a/src/graphql/language/parser_context.cr b/src/graphql/language/parser_context.cr index 83748cd..82bea2e 100644 --- a/src/graphql/language/parser_context.cr +++ b/src/graphql/language/parser_context.cr @@ -1,7 +1,13 @@ require "./token" class GraphQL::Language::ParserContext - @line_starts : Array(Int32)? + # Shared by every node without arguments, directives, selections or + # variables; never mutated after parsing. + EMPTY_ARGUMENTS = [] of Language::Argument + EMPTY_DIRECTIVES = [] of Language::Directive + EMPTY_SELECTIONS = [] of Language::Selection + EMPTY_VARIABLE_DEFINITIONS = [] of Language::VariableDefinition + @current_token : Token @descriptions = [] of String @@ -42,14 +48,15 @@ class GraphQL::Language::ParserContext parse_type end - private def any(open : Token::Kind, next_proc : Proc(T), close : Token::Kind) : Array(T) forall T + # Zero or more items between `open` and `close`, each parsed by the block. + private def any(open : Token::Kind, close : Token::Kind, & : -> T) : Array(T) forall T expect(open) parse_comment nodes = [] of T while !skip(close) - nodes.push(next_proc.call) + nodes.push(yield) end nodes @@ -62,74 +69,58 @@ class GraphQL::Language::ParserContext ) end - # Line and column (1-based) of a character offset in the source. - private def location(start : Int32) : {Int32, Int32} - line_starts = @line_starts ||= begin - starts = [0] - @source.each_char_with_index { |c, i| starts << i + 1 if c == '\n' } - starts - end - line = line_starts.bsearch_index { |s| s > start } || line_starts.size - {line, start - line_starts[line - 1] + 1} - end - private def create_field(start : Int32, name : String, f_alias) : Language::Field - line, column = location(start) Language::Field.new( name: name, _alias: f_alias, arguments: parse_arguments, directives: parse_directives, - selections: peek(Token::Kind::BRACE_L) ? parse_selection_set : [] of String, - line: line, - column: column, + selections: peek(Token::Kind::BRACE_L) ? parse_selection_set : EMPTY_SELECTIONS, + source: @source, + start: start, ) end private def create_graphql_fragment_spread(start) - line, column = location(start) Language::FragmentSpread.new( parse_fragment_name || raise(ParserError.new("Expected fragment name, found #{@current_token.kind}")), parse_directives, - line: line, - column: column, + source: @source, + start: start, ) end private def create_inline_fragment(start) - line, column = location(start) Language::InlineFragment.new( get_type_condition, parse_directives, parse_selection_set, - line: line, - column: column, + source: @source, + start: start, ) end private def create_operation_definition(start, operation, name) - line, column = location(start) Language::OperationDefinition.new( operation_type: operation, name: name, variables: parse_variable_definitions, directives: parse_directives, selections: parse_selection_set, - line: line, - column: column, + source: @source, + start: start, ) end private def create_operation_definition(start) - line, column = location(start) Language::OperationDefinition.new( operation_type: "query", name: nil, - variables: parse_variable_definitions, - directives: [] of Language::Directive, + variables: EMPTY_VARIABLE_DEFINITIONS, + directives: EMPTY_DIRECTIVES, selections: parse_selection_set, - line: line, - column: column, + source: @source, + start: start, ) end @@ -188,14 +179,15 @@ class GraphQL::Language::ParserContext type_condition end - private def many(open, next_proc, close) + # One or more items between `open` and `close`, each parsed by the block. + private def many(open : Token::Kind, close : Token::Kind, & : -> T) : Array(T) forall T expect(open) parse_comment - nodes = [next_proc.call] + nodes = [yield] while !skip(close) - nodes.push(next_proc.call) + nodes.push(yield) end nodes @@ -213,11 +205,11 @@ class GraphQL::Language::ParserContext return [] of Language::InputValueDefinition end - many(Token::Kind::PAREN_L, -> { parse_input_value_def }, Token::Kind::PAREN_R) + many(Token::Kind::PAREN_L, Token::Kind::PAREN_R) { parse_input_value_def } end private def parse_arguments - peek(Token::Kind::PAREN_L) ? many(Token::Kind::PAREN_L, -> { parse_argument }, Token::Kind::PAREN_R) : [] of Language::Argument + peek(Token::Kind::PAREN_L) ? many(Token::Kind::PAREN_L, Token::Kind::PAREN_R) { parse_argument } : EMPTY_ARGUMENTS end private def parse_boolean_value(token) @@ -338,6 +330,8 @@ class GraphQL::Language::ParserContext end private def parse_directives + return EMPTY_DIRECTIVES unless peek(Token::Kind::AT) + directives = [] of Language::Directive while peek(Token::Kind::AT) directives.push(parse_directive) @@ -360,7 +354,7 @@ class GraphQL::Language::ParserContext Language::EnumTypeDefinition.new( name: get_name!, directives: parse_directives, - fvalues: many(Token::Kind::BRACE_L, -> { parse_enum_value_definition }, Token::Kind::BRACE_R), + fvalues: many(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_enum_value_definition }, description: description, ) end @@ -473,7 +467,7 @@ class GraphQL::Language::ParserContext Language::InputObjectTypeDefinition.new( name: get_name!, directives: parse_directives(), - fields: any(Token::Kind::BRACE_L, -> { parse_input_value_def }, Token::Kind::BRACE_R), + fields: any(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_input_value_def }, description: description, ) end @@ -506,16 +500,17 @@ class GraphQL::Language::ParserContext Language::InterfaceTypeDefinition.new( name: get_name!, directives: parse_directives, - fields: any(Token::Kind::BRACE_L, -> { parse_field_definition }, Token::Kind::BRACE_R), + fields: any(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_field_definition }, description: description, ) end private def parse_list(is_constant) : Language::ArgumentValue - constant = Proc(Language::ArgumentValue).new { parse_constant_value } - value = Proc(Language::ArgumentValue).new { parse_value_value } - - nested { any(Token::Kind::BRACKET_L, is_constant ? constant : value, Token::Kind::BRACKET_R) } + nested do + any(Token::Kind::BRACKET_L, Token::Kind::BRACKET_R) do + is_constant ? parse_constant_value : parse_value_value + end + end end private def parse_name : String? @@ -610,7 +605,7 @@ class GraphQL::Language::ParserContext description: description, interfaces: parse_implements_interfaces, directives: parse_directives, - fields: any(Token::Kind::BRACE_L, -> { parse_field_definition }, Token::Kind::BRACE_R), + fields: any(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_field_definition }, ) end @@ -654,7 +649,7 @@ class GraphQL::Language::ParserContext private def parse_schema_definition expect_keyword("schema") directives = parse_directives - definitions = many(Token::Kind::BRACE_L, -> { parse_operation_type_definition }, Token::Kind::BRACE_R) + definitions = many(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_operation_type_definition } definitions = definitions.as(Array).reduce(Hash(String, String).new) do |memo, pair| pair.as(Tuple(String, GraphQL::Language::TypeName)).tap { |p| memo[p[0]] = p[1].name } @@ -674,7 +669,7 @@ class GraphQL::Language::ParserContext end private def parse_selection_set - nested { many(Token::Kind::BRACE_L, -> { parse_selection }, Token::Kind::BRACE_R) } + nested { many(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_selection } } end private def parse_string(is_constant) @@ -782,9 +777,9 @@ class GraphQL::Language::ParserContext private def parse_variable_definitions : Array(Language::VariableDefinition) if peek(Token::Kind::PAREN_L) - many(Token::Kind::PAREN_L, -> { parse_variable_definition }, Token::Kind::PAREN_R) + many(Token::Kind::PAREN_L, Token::Kind::PAREN_R) { parse_variable_definition } else - [] of Language::VariableDefinition + EMPTY_VARIABLE_DEFINITIONS end end diff --git a/src/graphql/object_type.cr b/src/graphql/object_type.cr index daba3c4..436df32 100644 --- a/src/graphql/object_type.cr +++ b/src/graphql/object_type.cr @@ -7,6 +7,11 @@ module GraphQL::ObjectType # :nodoc: record JSONFragment, json : String, errors : Array(::GraphQL::Error) + # :nodoc: + # Returned where nothing went wrong, so leaf fields allocate no error + # array. Never mutated: callers only read it or concat it elsewhere. + NO_ERRORS = [] of ::GraphQL::Error + # :nodoc: alias PendingFragment = Channel(JSONFragment | ::Exception) | JSONFragment | ::Exception @@ -292,7 +297,7 @@ module GraphQL::ObjectType seen.includes?(n) ? false : (seen << n; true) end %} - errors = [] of ::GraphQL::Error + errors = NO_ERRORS path = field._alias || field.name case field.name @@ -312,11 +317,11 @@ module GraphQL::ObjectType raise ::GraphQL::TypeError.new("field #{field.name} must not have a selection since its type has no subfields") unless field.selections.empty? {% end %} raise ::GraphQL::TypeError.new("unknown argument #{field.arguments.first.name} on field #{field.name}") unless field.arguments.empty? - errors.concat _graphql_serialize(context, field, self.{{ var.name.id }}, json) + errors = _graphql_serialize(context, field, self.{{ var.name.id }}, json) {% end %} {% for method in methods %} when {{ method.annotation(::GraphQL::Field)["name"] || method.name.id.stringify.camelcase(lower: true) }} - errors.concat _graphql_serialize(context, field, _graphql_call_{{ method.name.id }}(context, field), json) + errors = _graphql_serialize(context, field, _graphql_call_{{ method.name.id }}(context, field), json) {% end %} when "__typename" raise ::GraphQL::TypeError.new("field __typename must not have a selection since its type has no subfields") unless field.selections.empty? @@ -326,7 +331,7 @@ module GraphQL::ObjectType raise ::GraphQL::TypeError.new("field __schema must have a selection of subfields") if field.selections.empty? json.object do introspection = ::GraphQL::Introspection::Schema.new(context.document.not_nil!, _graphql_type, context.mutation_type, context.subscription_type) - errors.concat introspection._graphql_resolve(context, field.selections, json) + errors = introspection._graphql_resolve(context, field.selections, json) end when "__type" raise ::GraphQL::TypeError.new("field __type must have a selection of subfields") if field.selections.empty? @@ -335,7 +340,7 @@ module GraphQL::ObjectType document = context.document.not_nil! if definition = document.definitions.find { |d| d.is_a?(::GraphQL::Language::TypeDefinition) && d.name == type_name } json.object do - errors.concat ::GraphQL::Introspection::Type.new(document, definition.as(::GraphQL::Language::TypeDefinition))._graphql_resolve(context, field.selections, json) + errors = ::GraphQL::Introspection::Type.new(document, definition.as(::GraphQL::Language::TypeDefinition))._graphql_resolve(context, field.selections, json) end else json.null @@ -344,7 +349,8 @@ module GraphQL::ObjectType else raise ::GraphQL::TypeError.new("Field is not defined: #{field.name}") end - errors.map &.with_path(path) + errors.each &.with_path(path) + errors {% end %} end @@ -386,49 +392,73 @@ module GraphQL::ObjectType # Serializes `value` into `json`. Error paths are relative to the field: # the caller prepends the field's response key. private def _graphql_serialize(context, field : ::GraphQL::Language::Field, value, json : ::JSON::Builder) : Array(::GraphQL::Error) - errors = [] of ::GraphQL::Error path = field._alias || field.name case value when ::GraphQL::ObjectType + errors = NO_ERRORS json.object do - errors.concat value._graphql_resolve(context, field.selections, json) + errors = value._graphql_resolve(context, field.selections, json) end + return errors when Array if context.max_concurrency == 0 && _graphql_leaf_elements?(value) && _graphql_finite?(value) json.array do - value.each { |v| errors.concat _graphql_serialize(context, field, v, json) } + value.each { |v| _graphql_serialize(context, field, v, json) } end - return errors + return NO_ERRORS end + errors = [] of ::GraphQL::Error json.array do - pending = value.map_with_index do |v, i| - _graphql_fork(context) do - _graphql_build_json_fragment(context, [i] of String | Int32, field) do |element_json| - _graphql_serialize(context, field, v, element_json).map &.with_path(i) - end - end - end - # `first` is never called; `typeof` only inspects the element type. element_nullable = typeof(value.first).nilable? propagate = false - pending.each do |item| - fragment = _graphql_await(item) - errors.concat fragment.errors + if context.max_concurrency == 0 + # one buffer per element, nothing else + value.each_with_index do |v, i| + fragment = _graphql_build_json_fragment(context, i, field) do |element_json| + element_errors = _graphql_serialize(context, field, v, element_json) + element_errors.each &.with_path(i) + element_errors + end + errors.concat fragment.errors - if fragment.json.empty? - propagate = true unless element_nullable - json.null - else - json.raw fragment.json + if fragment.json.empty? + propagate = true unless element_nullable + json.null + else + json.raw fragment.json + end + end + else + pending = value.map_with_index do |v, i| + _graphql_fork(context) do + _graphql_build_json_fragment(context, i, field) do |element_json| + element_errors = _graphql_serialize(context, field, v, element_json) + element_errors.each &.with_path(i) + element_errors + end + end + end + + pending.each do |item| + fragment = _graphql_await(item) + errors.concat fragment.errors + + if fragment.json.empty? + propagate = true unless element_nullable + json.null + else + json.raw fragment.json + end end end raise NullPropagation.new(errors) if propagate end + return errors when ::Enum json.string value when Float64 @@ -445,7 +475,7 @@ module GraphQL::ObjectType raise ::GraphQL::TypeError.new("no serialization found for field #{path} on #{_graphql_type}") end - errors + NO_ERRORS end # :nodoc: @@ -495,19 +525,19 @@ module GraphQL::ObjectType # Collects the fields selected by `selections` in query order, following # fragment spreads and inline fragments. Fields that share a response key # are merged into one entry, as the spec's CollectFields requires. - private def _graphql_collect_fields(context, selections : Array(::GraphQL::Language::Selection), fields : Hash(String, ::GraphQL::Language::Field), errors : Array(::GraphQL::Error), visited_fragments = [] of String) : Nil + private def _graphql_collect_fields(context, selections : Array(::GraphQL::Language::Selection), fields : Array(::GraphQL::Language::Field), errors : Array(::GraphQL::Error), visited_fragments = [] of String) : Nil selections.each do |selection| case selection when ::GraphQL::Language::Field path = selection._alias || selection.name next if _graphql_skip?(selection, selection.directives, [path] of String | Int32, errors) - if existing = fields[path]? + if index = fields.index { |f| (f._alias || f.name) == path } next if selection.selections.empty? - merged = existing.dup - merged.selections = existing.selections + selection.selections - fields[path] = merged + merged = fields[index].dup + merged.selections = fields[index].selections + selection.selections + fields[index] = merged else - fields[path] = selection + fields << selection end when ::GraphQL::Language::FragmentSpread next if _graphql_skip?(selection, selection.directives, [selection.name] of String | Int32, errors) @@ -534,16 +564,43 @@ module GraphQL::ObjectType # :nodoc: # With `serial` each field is fully resolved before the next one starts, # as the spec requires for the root fields of a mutation. + # :nodoc: + # Whether `selections` can be used as the field list as is: only fields, + # no directives, no two fields sharing a response key. Most selection + # sets qualify, and skipping the collection step saves an array per + # object. + private def _graphql_plain_fields?(selections : Array(::GraphQL::Language::Selection)) : Bool + return false if selections.size > 8 + + selections.each_with_index do |selection, i| + return false unless selection.is_a?(::GraphQL::Language::Field) && selection.directives.empty? + key = selection._alias || selection.name + i.times do |j| + earlier = selections[j].as(::GraphQL::Language::Field) + return false if (earlier._alias || earlier.name) == key + end + end + + true + end + protected def _graphql_resolve(context, selections : Array(::GraphQL::Language::Selection), json : JSON::Builder, serial : Bool = false) : Array(::GraphQL::Error) errors = [] of ::GraphQL::Error - fields = Hash(String, ::GraphQL::Language::Field).new - _graphql_collect_fields(context, selections, fields, errors) + fields = if _graphql_plain_fields?(selections) + selections + else + collected = [] of ::GraphQL::Language::Field + _graphql_collect_fields(context, selections, collected, errors) + collected + end return _graphql_resolve_sequentially(context, fields, json, errors) if serial || context.max_concurrency == 0 - pending = Hash(String, PendingFragment).new - fields.each do |path, field| - pending[path] = _graphql_fork(context, serial) do + pending = Array(PendingFragment).new(fields.size) + fields.each do |selection| + field = selection.as(::GraphQL::Language::Field) + path = field._alias || field.name + pending << _graphql_fork(context, serial) do _graphql_build_json_fragment(context, path, field) do |field_json| _graphql_resolve(context, field, field_json) end @@ -552,12 +609,14 @@ module GraphQL::ObjectType propagate = false - pending.each do |path, item| - fragment = _graphql_await(item) + fields.each_with_index do |selection, i| + field = selection.as(::GraphQL::Language::Field) + path = field._alias || field.name + fragment = _graphql_await(pending[i]) errors.concat fragment.errors if fragment.json.empty? - propagate = true unless _graphql_field_nullable?(fields[path].name) + propagate = true unless _graphql_field_nullable?(field.name) json.field(path) { json.null } else json.field(path) { json.raw fragment.json } @@ -573,10 +632,12 @@ module GraphQL::ObjectType # straight into `json` once resolved, since nothing can fail after their # key is emitted; object values still go through a buffer so a failing # non-null descendant can discard the partial output. - private def _graphql_resolve_sequentially(context, fields : Hash(String, ::GraphQL::Language::Field), json : JSON::Builder, errors : Array(::GraphQL::Error)) : Array(::GraphQL::Error) + private def _graphql_resolve_sequentially(context, fields : Array(::GraphQL::Language::Selection) | Array(::GraphQL::Language::Field), json : JSON::Builder, errors : Array(::GraphQL::Error)) : Array(::GraphQL::Error) propagate = false - fields.each do |path, field| + fields.each do |selection| + field = selection.as(::GraphQL::Language::Field) + path = field._alias || field.name failed = false if _graphql_leaf_field?(field.name) @@ -604,7 +665,9 @@ module GraphQL::ObjectType if value.is_a?(Array) && !_graphql_finite?(value) # the buffered path reports and nulls the offending elements fragment = _graphql_build_json_fragment(context, path, field) do |field_json| - _graphql_serialize(context, field, value, field_json).map &.with_path(path) + list_errors = _graphql_serialize(context, field, value, field_json) + list_errors.each &.with_path(path) + list_errors end errors.concat fragment.errors failed = fragment.json.empty? @@ -615,7 +678,13 @@ module GraphQL::ObjectType json.field(path) { json.null } true else - json.field(path) { errors.concat _graphql_serialize(context, field, value, json).map(&.with_path(path)) } + json.field(path) do + leaf_errors = _graphql_serialize(context, field, value, json) + unless leaf_errors.empty? + leaf_errors.each &.with_path(path) + errors.concat leaf_errors + end + end false end end @@ -659,7 +728,7 @@ module GraphQL::ObjectType # null. def _graphql_execute(context, selections : Array(::GraphQL::Language::Selection), serial : Bool = false) : JSONFragment _graphql_build_json_fragment(context, [] of String | Int32) do |json| - errors = [] of ::GraphQL::Error + errors = NO_ERRORS json.object do errors = _graphql_resolve(context, selections, json, serial) end @@ -710,24 +779,24 @@ module GraphQL::ObjectType # fragment, which the caller renders as null or propagates further up. # `path` is the prefix the caller would otherwise add to the block's # errors, so exceptions can be reported at the same place. - private def _graphql_build_json_fragment(context, path : String | Array(Int32 | String), node : ::GraphQL::Language::ASTNode? = nil, & : JSON::Builder -> Array(::GraphQL::Error)) : JSONFragment - errors = [] of ::GraphQL::Error + private def _graphql_build_json_fragment(context, path : String | Int32 | Array(Int32 | String), node : ::GraphQL::Language::ASTNode? = nil, & : JSON::Builder -> Array(::GraphQL::Error)) : JSONFragment + errors = NO_ERRORS failed = false json = String.build do |io| builder = JSON::Builder.new(io) builder.document do - errors.concat yield builder + errors = yield builder end rescue e : NullPropagation failed = true e.errors.each { |error| _graphql_prefix_path(error, path) } - errors.concat e.errors + errors = e.errors rescue e failed = true if message = context.handle_exception(e) - error_path = path.is_a?(String) ? [path] of String | Int32 : path - errors << ::GraphQL::Error.new(message, error_path.empty? ? nil : error_path, node) + error_path = path.is_a?(Array) ? path : [path] of String | Int32 + errors = [::GraphQL::Error.new(message, error_path.empty? ? nil : error_path, node)] end end @@ -736,9 +805,9 @@ module GraphQL::ObjectType end # :nodoc: - private def _graphql_prefix_path(error : ::GraphQL::Error, path : String | Array(Int32 | String)) : Nil + private def _graphql_prefix_path(error : ::GraphQL::Error, path : String | Int32 | Array(Int32 | String)) : Nil case path - when String + when String, Int32 error.with_path(path) else path.reverse_each { |segment| error.with_path(segment) } diff --git a/src/graphql/schema.cr b/src/graphql/schema.cr index b5dd2f4..71c55e6 100644 --- a/src/graphql/schema.cr +++ b/src/graphql/schema.cr @@ -39,6 +39,8 @@ module GraphQL # for nullable variables. Variables the operation uses without declaring # them are looked up directly in `variables` when substituted. private def resolve_variables(operation : Language::OperationDefinition, variables : Hash(String, JSON::Any)?, errors) : Hash(String, Language::FValue) + return EMPTY_VARIABLES if operation.variables.empty? + resolved = Hash(String, Language::FValue).new operation.variables.each do |definition| @@ -142,6 +144,9 @@ module GraphQL end end + # For operations that declare no variables; never mutated. + EMPTY_VARIABLES = {} of String => Language::FValue + def initialize(@query : QueryType, @mutation : MutationType? = nil, @subscription : SubscriptionType? = nil) @document = @query._graphql_document {@mutation, @subscription}.each do |root| @@ -165,44 +170,51 @@ module GraphQL return end - operations = [] of Language::OperationDefinition - context.query_type = @query._graphql_type context.mutation_type = @mutation.try &._graphql_type context.subscription_type = @subscription.try &._graphql_type context.document = @document - document.visit(->(node : Language::ASTNode) { - case node + first_operation = nil + named_operation = nil + operation_count = 0 + + document.definitions.each do |definition| + case definition when Language::OperationDefinition - operations << node + operation_count += 1 + first_operation ||= definition + named_operation = definition if operation_name && definition.name == operation_name when Language::FragmentDefinition - context.fragments << node + context.fragments << definition end - }) + end - operation = if operations.empty? + operation = if operation_count == 0 errors << Error.new("query does not contain an operation") return - elsif operation_name.nil? && operations.size == 1 - operations.first + elsif (single = first_operation) && operation_name.nil? && operation_count == 1 + single elsif operation_name.nil? errors << Error.new("sent more than one operation but did not set operation name") return - elsif op = operations.find { |q| q.name == operation_name } - op + elsif named_operation + named_operation else errors << Error.new("could not find operation with name #{operation_name}") return end - resolved = resolve_variables(operation, variables, errors) - substitute = ->(node : Language::ASTNode) { - substitute_variables(node, resolved, variables, errors) if node.is_a?(Language::Argument) - nil - } - operation.visit(substitute) - context.fragments.each &.visit(substitute) + # a query without a `$` cannot reference a variable + if query.includes?('$') + resolved = resolve_variables(operation, variables, errors) + substitute = ->(node : Language::ASTNode) { + substitute_variables(node, resolved, variables, errors) if node.is_a?(Language::Argument) + nil + } + operation.visit(substitute) + context.fragments.each &.visit(substitute) + end context.complexity = complexity(operation.selections, context.fragments) if (max = context.max_complexity) && context.complexity > max diff --git a/src/graphql/subscription_type.cr b/src/graphql/subscription_type.cr index a47b44e..8bc6f2c 100644 --- a/src/graphql/subscription_type.cr +++ b/src/graphql/subscription_type.cr @@ -11,7 +11,7 @@ module GraphQL::SubscriptionType # subscription that is already closed. def _graphql_subscribe(context, selections : Array(::GraphQL::Language::Selection)) : ::GraphQL::Subscription errors = [] of ::GraphQL::Error - fields = Hash(String, ::GraphQL::Language::Field).new + fields = [] of ::GraphQL::Language::Field _graphql_collect_fields(context, selections, fields, errors) return ::GraphQL::Subscription.failed(errors) unless errors.empty? @@ -20,7 +20,7 @@ module GraphQL::SubscriptionType return ::GraphQL::Subscription.failed([::GraphQL::Error.new("subscription operations must select exactly one root field")]) end - field = fields.first_value + field = fields.first begin _graphql_subscribe_field(context, field) rescue ex : ::GraphQL::Exception