graph_weaver 0.4.0 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -175,14 +175,26 @@ class GraphWeaver::Codegen
175
175
  @fragments = fragments
176
176
 
177
177
  unions = names.uniq.sort.map do |name|
178
+ class_name = camelize(name)
179
+ # the query module aliases <class_name> = <unions module>::<class_name>;
180
+ # a name that camelizes to a generated module-level constant (the Result
181
+ # struct, the QUERY heredoc) would collide with that alias at load
182
+ if HOISTED_UNION_RESERVED.include?(class_name)
183
+ raise GraphWeaver::Error,
184
+ "shared fragment #{name.inspect} hoists to #{class_name}, which collides with a generated constant — rename the fragment"
185
+ end
178
186
  fragment = fragments.fetch(name)
179
187
  type = @schema.get_type(fragment.type.name)
180
- UnionNode.new(camelize(name), union_members(type, fragment.selections))
188
+ UnionNode.new(class_name, union_members(type, fragment.selections))
181
189
  end
182
190
 
183
191
  emit_unions_file(unions)
184
192
  end
185
193
 
194
+ # module-level constants every generated query module defines — a hoisted
195
+ # union aliased to one of these would clash at load
196
+ HOISTED_UNION_RESERVED = %w[Result QUERY].to_set.freeze
197
+
186
198
  VarDef = Struct.new(:kwarg, :wire, :node, :required)
187
199
 
188
200
  # Names that cannot appear bare in generated Ruby: keywords aren't
@@ -246,6 +258,15 @@ class GraphWeaver::Codegen
246
258
  VarDef.new(kwarg, var.name, node, required)
247
259
  end
248
260
 
261
+ # two variables that underscore to the same kwarg ($userId + $user_id) would
262
+ # silently drop one on the wire — flag it like a prop collision
263
+ collision = variables.group_by(&:kwarg).find { |_, vars| vars.size > 1 }
264
+ if collision
265
+ wire = collision.last.map { |var| "$#{var.wire}" }.join(", ")
266
+ raise GraphWeaver::Error,
267
+ "variables #{wire} both map to the kwarg '#{collision.first}:' — rename one"
268
+ end
269
+
249
270
  root = object_node(root_type, operation.selections, "Result")
250
271
 
251
272
  emit_module(root, variables)
@@ -270,8 +291,7 @@ class GraphWeaver::Codegen
270
291
 
271
292
  return if schema.get_type(name)
272
293
 
273
- suggestion = defined?(DidYouMean::SpellChecker) &&
274
- DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
294
+ suggestion = GraphWeaver.did_you_mean(schema.types.keys, name)
275
295
  hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
276
296
  # the type registry is reached via extend_type; scalars/enums via register_*
277
297
  method = kind == "type" ? "extend_type" : "register_#{kind}"
@@ -367,14 +387,6 @@ class GraphWeaver::Codegen
367
387
  end
368
388
 
369
389
 
370
- # Selection#each_field, collected by result key (codegen groups
371
- # repeated selections of one field so it can merge them)
372
- def gather(type, selections)
373
- out = {}
374
- each_field(type, selections) { |key, node| (out[key] ||= []) << node }
375
- out
376
- end
377
-
378
390
  def object_node(type, selections, class_name)
379
391
  node = ObjectNode.new(class_name)
380
392
  node.graphql_type = type.graphql_name
@@ -465,20 +477,165 @@ class GraphWeaver::Codegen
465
477
  node.fields << ObjectNode::Field.new(prop, key, child)
466
478
  end
467
479
 
480
+ node.aliases = resolve_aliases(node)
468
481
  node
469
482
  end
470
483
 
471
- # The concrete type conditions a selection mentions (inline fragments
472
- # and named spreads), minus conditions naming the abstract type itself.
473
- def concrete_conditions(core, selections)
474
- selections.filter_map do |selection|
484
+ # Resolve each registered alias (extend_type alias:) for this struct's type
485
+ # against its actual selection path -> a typed delegator emitted into the
486
+ # struct body. Validated here, per query, so an unselected or untraversable
487
+ # path fails at generation with a pointed message.
488
+ def resolve_aliases(node)
489
+ type_aliases(node.graphql_type).filter_map do |name, spec|
490
+ # a bad accessor name (reserved, or colliding with a real field) is a
491
+ # registration mistake — it fails for every query, so it always raises,
492
+ # even for optional aliases (which otherwise mask it as "doesn't fit").
493
+ check_alias_name!(node, name)
494
+ begin
495
+ resolve_alias(node, name, spec[:segments])
496
+ rescue GraphWeaver::Error
497
+ # a path that doesn't fit THIS query's selection: strict raises,
498
+ # optional simply omits the accessor
499
+ raise unless spec[:optional]
500
+ end
501
+ end
502
+ end
503
+
504
+ def check_alias_name!(node, name)
505
+ if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
506
+ raise GraphWeaver::Error,
507
+ "alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
508
+ end
509
+ end
510
+
511
+ # Registered aliases for a GraphQL type: global registry plus this client's
512
+ # overlay (client-scoped wins on a name clash).
513
+ def type_aliases(graphql_name)
514
+ global = GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
515
+ (global.merge(@types[graphql_name]&.dig(:aliases) || {}))
516
+ end
517
+
518
+ ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
519
+ # list selectors — pick one element out of a list-typed hop, always nilable
520
+ # (the list may be empty). Everything else is a field prop.
521
+ LIST_SELECTORS = %w[first last].freeze
522
+
523
+ # Walk a dotted path through this struct's selected shape, building the
524
+ # delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
525
+ # type. A segment is a field prop, or `first`/`last` to pick a list element.
526
+ # Everything is checked against the node tree: a field on a non-object, a
527
+ # selector on a non-list, or an unselected segment raises. Any nilable hop
528
+ # (a nullable field, or a list element) makes the accessor nilable.
529
+ def resolve_alias(node, name, segments)
530
+ cur = T.let(node, T.untyped) # the node the path has reached
531
+ cur_nilable = T.let(false, T::Boolean) # is the expression so far nilable
532
+ nilable = T.let(false, T::Boolean) # is the accessor overall nilable
533
+ containers = T.let([], T::Array[String]) # nested-struct class names on the way to the leaf
534
+ expr = +""
535
+
536
+ segments.each do |seg|
537
+ connector = expr.empty? ? "" : (cur_nilable ? "&." : ".")
538
+
539
+ # `first`/`last` select an element only when the current hop is actually a
540
+ # list; otherwise they're an ordinary field (a schema field named `first`)
541
+ if LIST_SELECTORS.include?(seg) && list_of(cur)
542
+ expr << connector << seg
543
+ cur = list_of(cur).of
544
+ cur_nilable = true # first/last is nil on an empty list
545
+ nilable = true
546
+ else
547
+ obj = object_of(cur)
548
+ unless obj
549
+ hint = if list_of(cur)
550
+ " — use .first or .last to pick an element"
551
+ elsif LIST_SELECTORS.include?(seg)
552
+ " — .#{seg} needs a list"
553
+ else
554
+ ""
555
+ end
556
+ raise GraphWeaver::Error,
557
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' can't be read here (not an object)#{hint}"
558
+ end
559
+ # the object a field is read from is the lexical container of its result
560
+ # (nested structs emit inside their parent); the aliased struct itself is
561
+ # the delegator's own scope, so it contributes no prefix
562
+ containers << obj.class_name unless obj.equal?(node)
563
+ field = obj.fields.find { |f| f.prop == seg }
564
+ unless field
565
+ props = obj.fields.map(&:prop)
566
+ suggestion = GraphWeaver.did_you_mean(props, seg)
567
+ hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
568
+ raise GraphWeaver::Error,
569
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
570
+ end
571
+ expr << connector << seg
572
+ cur = field.node
573
+ cur_nilable = !field.node.non_null?
574
+ nilable ||= cur_nilable
575
+ end
576
+ end
577
+
578
+ leaf = qualified_alias_type(cur, containers)
579
+ type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
580
+ ObjectNode::Alias.new(name, expr, type)
581
+ end
582
+
583
+ # The leaf's Sorbet type as referenced from the aliased struct. Generated
584
+ # nested constants (structs, enums, unions) must carry the container path,
585
+ # since the delegator's `sig` is emitted in an outer struct where a bare
586
+ # `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
587
+ # already top-level. `containers` is the class-name chain to the leaf.
588
+ def qualified_alias_type(node, containers)
589
+ node = node.of if node.is_a?(NonNull)
590
+ prefix = containers.empty? ? "" : "#{containers.join("::")}::"
591
+
592
+ case node
593
+ when List
594
+ element = node.of.is_a?(NonNull) ? qualified_alias_type(node.of, containers) : begin
595
+ inner = qualified_alias_type(node.of, containers)
596
+ inner == "T.untyped" ? inner : "T.nilable(#{inner})"
597
+ end
598
+ "T::Array[#{element}]"
599
+ when ObjectNode, EnumNode, NarrowedNode then "#{prefix}#{node.class_name}"
600
+ when UnionNode then "#{prefix}#{node.bare_type}"
601
+ else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
602
+ end
603
+ end
604
+
605
+ # the List a node wraps (through NON_NULL), or nil
606
+ def list_of(node)
607
+ node = T.let(node, T.untyped)
608
+ node = node.of while node.is_a?(NonNull)
609
+ node if node.is_a?(List)
610
+ end
611
+
612
+ # the ObjectNode a node resolves to for field access (through NON_NULL and a
613
+ # narrowed abstract member), or nil — unions/scalars/lists can't be read into
614
+ def object_of(node)
615
+ node = T.let(node, T.untyped)
616
+ node = node.of while node.is_a?(NonNull)
617
+ node = node.nested if node.is_a?(NarrowedNode)
618
+ node if node.is_a?(ObjectNode)
619
+ end
620
+
621
+ # The concrete type conditions a selection mentions, minus conditions naming
622
+ # the abstract type itself — recursing into named-fragment and inline bodies,
623
+ # so a `... on X` nested inside a spread (`{ ...NodeFields }` where NodeFields
624
+ # holds `... on X`) still drives dispatch instead of being silently dropped.
625
+ def concrete_conditions(core, selections, visiting = Set.new)
626
+ selections.flat_map do |selection|
475
627
  case selection
476
628
  when GraphQL::Language::Nodes::InlineFragment
477
- selection.type&.name
629
+ [selection.type&.name, *concrete_conditions(core, selection.selections, visiting)]
478
630
  when GraphQL::Language::Nodes::FragmentSpread
479
- @fragments.fetch(selection.name).type.name
631
+ next [] if visiting.include?(selection.name)
632
+
633
+ fragment = @fragments.fetch(selection.name)
634
+ [fragment.type.name, *concrete_conditions(core, fragment.selections, visiting | [selection.name])]
635
+ else
636
+ []
480
637
  end
481
- end.uniq - [core.graphql_name]
638
+ end.compact.uniq - [core.graphql_name]
482
639
  end
483
640
 
484
641
  # result keys selected as plain fields (outside any type condition)
@@ -28,8 +28,8 @@ module GraphWeaver
28
28
  prop = GraphWeaver::Inflect.underscore(key)
29
29
  suggestion = if known.include?(prop)
30
30
  prop # a wire-cased key — the exact snake_case prop exists
31
- elsif defined?(DidYouMean::SpellChecker)
32
- DidYouMean::SpellChecker.new(dictionary: known).correct(prop).first
31
+ else
32
+ GraphWeaver.did_you_mean(known, prop)
33
33
  end
34
34
  suggestion ? "#{key} (did you mean '#{suggestion}'?)" : key
35
35
  end
@@ -56,12 +56,10 @@ module GraphWeaver
56
56
  return "GraphQL fields generate snake_case props; use '#{prop}'"
57
57
  end
58
58
 
59
- return unless defined?(DidYouMean::SpellChecker)
60
-
61
59
  # a guess, not a mapping — spellcheck the (underscored) miss
62
60
  # against the props that exist, so typos in either casing land
63
61
  props = T.unsafe(self.class).props.keys.map(&:to_s)
64
- suggestion = DidYouMean::SpellChecker.new(dictionary: props).correct(prop).first
62
+ suggestion = GraphWeaver.did_you_mean(props, prop)
65
63
  "did you mean '#{suggestion}'?" if suggestion
66
64
  end
67
65
  end
@@ -46,6 +46,13 @@ module GraphWeaver
46
46
  def coerce(value)
47
47
  return value if value.is_a?(self)
48
48
 
49
+ # a caller passing a non-Hash (a bare string, or a Hash where a nested
50
+ # list was expected) is bad input — surface a branded 422, not a raw
51
+ # NoMethodError from validate_keys!'s `.keys`
52
+ unless value.is_a?(Hash)
53
+ raise GraphWeaver::InputError.new("expected a Hash or #{self}, got #{value.class}", struct: self)
54
+ end
55
+
49
56
  # a typo'd key must not silently drop off the wire
50
57
  GraphWeaver::Hints.validate_keys!(self, value)
51
58
 
@@ -49,7 +49,14 @@ module GraphWeaver
49
49
  sig { returns(Data) }
50
50
  def data!
51
51
  raise GraphWeaver::QueryError.new(errors, data: data, extensions: extensions) unless errors.empty?
52
- T.must(data)
52
+
53
+ # a well-formed GraphQL response always pairs null data with errors; a
54
+ # server (or an errors-stripping proxy) that returns neither is broken —
55
+ # brand it rather than leaking a bare `T.must` TypeError
56
+ data || raise(GraphWeaver::QueryError.new(
57
+ [GraphWeaver::GraphQLError.new(message: "response carried neither data nor errors")],
58
+ extensions: extensions,
59
+ ))
53
60
  end
54
61
  end
55
62
  end
@@ -26,19 +26,179 @@ module GraphWeaver::SchemaLoader
26
26
  raise ArgumentError, "unsupported schema content: #{source.lstrip[0, 80].inspect}"
27
27
  end
28
28
 
29
- GraphQL::Schema.from_definition(source)
29
+ build_sdl(source)
30
30
  else # a file path
31
31
  case File.extname(source)
32
32
  when ".json"
33
33
  GraphQL::Schema.from_introspection(JSON.parse(File.read(source)))
34
34
  when ".graphql", ".gql"
35
- GraphQL::Schema.from_definition(File.read(source))
35
+ build_sdl(File.read(source))
36
36
  else
37
37
  raise ArgumentError, "unsupported schema format: #{source}"
38
38
  end
39
39
  end
40
40
  end
41
41
 
42
+ # Build a schema from SDL, first stripping Apollo Federation composition
43
+ # machinery when the SDL is a composed supergraph — so a supergraph dump
44
+ # (often the only artifact for the merged graph, and what the router
45
+ # actually serves) loads like any schema, with the federation plumbing
46
+ # gone rather than leaked into schema.types. Plain SDL passes through.
47
+ def self.build_sdl(sdl)
48
+ GraphQL::Schema.from_definition(federation_sdl?(sdl) ? strip_federation(sdl) : sdl)
49
+ end
50
+ private_class_method :build_sdl
51
+
52
+ # A composed Fed2 supergraph is marked by @join__* directives (every merged
53
+ # type carries them); a plain schema has none.
54
+ def self.federation_sdl?(sdl)
55
+ sdl.match?(/@join__\w/)
56
+ end
57
+
58
+ FEDERATION_PREFIXES = %w[join__ link__ core__].freeze
59
+ FEDERATION_DIRECTIVES = %w[link core inaccessible].to_set.freeze
60
+
61
+ # Synthetic composition TYPES are always prefixed (join__Graph, link__Import).
62
+ # The bare names (link/core/inaccessible) are DIRECTIVES only — a user type
63
+ # literally named `link` (Hasura-style lowercase) must not be dropped.
64
+ def self.federation_type_name?(name)
65
+ !!name && name.start_with?(*FEDERATION_PREFIXES)
66
+ end
67
+ private_class_method :federation_type_name?
68
+
69
+ def self.federation_directive_name?(name)
70
+ !!name && (name.start_with?(*FEDERATION_PREFIXES) || FEDERATION_DIRECTIVES.include?(name))
71
+ end
72
+ private_class_method :federation_directive_name?
73
+
74
+ # Drop the composition machinery from supergraph SDL: the synthetic
75
+ # join__*/link__* type and directive definitions, and every @join__*/@link
76
+ # application on the types that remain. What's left is the merged graph's
77
+ # ordinary type shapes — exactly what codegen reads. Parsing is lenient (it's
78
+ # schema *building* that rejects the join directives), so we parse, filter the
79
+ # AST, and reprint clean SDL for from_definition — no graphql-ruby monkeypatch
80
+ # and no join__* leaking into schema.types.
81
+ def self.strip_federation(sdl)
82
+ doc = GraphQL.parse(sdl)
83
+ defs = remove_inaccessible(doc.definitions)
84
+ .reject { |defn| federation_definition?(defn) }
85
+ .map { |defn| strip_federation_directives(defn) }
86
+
87
+ if defs.none? { |d| d.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) }
88
+ raise GraphWeaver::Error,
89
+ "supergraph has no object types left after stripping the federation machinery — " \
90
+ "is the whole schema behind @inaccessible?"
91
+ end
92
+
93
+ GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
94
+ end
95
+
96
+ # a synthetic composition definition to drop: a federation directive
97
+ # definition (by name), or a synthetic join__*/link__* type (by prefix)
98
+ def self.federation_definition?(defn)
99
+ return false unless defn.respond_to?(:name)
100
+
101
+ if defn.is_a?(GraphQL::Language::Nodes::DirectiveDefinition)
102
+ federation_directive_name?(defn.name)
103
+ else
104
+ federation_type_name?(defn.name)
105
+ end
106
+ end
107
+ private_class_method :federation_definition?
108
+
109
+ # Derive the API schema by dropping every element marked @inaccessible —
110
+ # present in the federated graph but hidden from the public API the router
111
+ # serves (its common use is safely rolling out a field on a shared type).
112
+ # Cascades: a field/argument/union-member/implements referencing a removed
113
+ # type goes too, and a type left with no fields/values/members is itself
114
+ # removed — repeated to a fixpoint. So codegen matches exactly what clients
115
+ # can query, without the over-permitting a raw supergraph would allow and
116
+ # without Apollo's JS tooling to subtract the API schema.
117
+ def self.remove_inaccessible(definitions)
118
+ removed = definitions.select { |d| type_definition?(d) && inaccessible?(d) }.map(&:name).to_set
119
+ loop do
120
+ survivors = definitions
121
+ .reject { |d| type_definition?(d) && removed.include?(d.name) }
122
+ .map { |d| prune_inaccessible(d, removed) }
123
+ # survivors already exclude `removed`, so anything newly emptied is fresh
124
+ newly = survivors.select { |d| type_definition?(d) && type_emptied?(d) }.map(&:name)
125
+ return survivors if newly.empty?
126
+
127
+ removed.merge(newly)
128
+ end
129
+ end
130
+ private_class_method :remove_inaccessible
131
+
132
+ # A type-system type definition (object/interface/union/enum/input/scalar) —
133
+ # not a directive or schema definition.
134
+ def self.type_definition?(node)
135
+ node.respond_to?(:name) &&
136
+ !node.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) &&
137
+ node.class.name.end_with?("TypeDefinition")
138
+ end
139
+ private_class_method :type_definition?
140
+
141
+ def self.inaccessible?(node)
142
+ node.respond_to?(:directives) && node.directives.any? { |d| d.name == "inaccessible" }
143
+ end
144
+ private_class_method :inaccessible?
145
+
146
+ # Whether pruning left the type with nothing the SDL grammar allows to be
147
+ # empty — a fieldless object/interface/input, a valueless enum, a memberless
148
+ # union — so it must be removed and its references cascaded.
149
+ def self.type_emptied?(node)
150
+ (node.respond_to?(:fields) && node.fields && node.fields.empty?) ||
151
+ (node.is_a?(GraphQL::Language::Nodes::EnumTypeDefinition) && node.values.empty?) ||
152
+ (node.is_a?(GraphQL::Language::Nodes::UnionTypeDefinition) && node.types.empty?)
153
+ end
154
+ private_class_method :type_emptied?
155
+
156
+ # the unwrapped (through NON_NULL/LIST) type name a field or argument references
157
+ def self.unwrapped_type_name(node)
158
+ type = node.type
159
+ type = type.of_type while type.respond_to?(:of_type)
160
+ type.name
161
+ end
162
+ private_class_method :unwrapped_type_name
163
+
164
+ # Remove @inaccessible children and children referencing a removed type,
165
+ # from a type's fields (and their arguments), enum values, union members,
166
+ # and implemented interfaces.
167
+ def self.prune_inaccessible(node, removed)
168
+ gone = lambda do |child|
169
+ inaccessible?(child) || (child.respond_to?(:type) && removed.include?(unwrapped_type_name(child)))
170
+ end
171
+
172
+ changes = {}
173
+ if node.respond_to?(:fields) && node.fields
174
+ changes[:fields] = node.fields.reject(&gone).map do |field|
175
+ args = field.respond_to?(:arguments) && field.arguments
176
+ args && args.any? ? field.merge(arguments: args.reject(&gone)) : field
177
+ end
178
+ end
179
+ changes[:values] = node.values.reject(&gone) if node.respond_to?(:values) && node.values
180
+ changes[:types] = node.types.reject { |t| removed.include?(t.name) } if node.respond_to?(:types) && node.types
181
+ if node.respond_to?(:interfaces) && node.interfaces
182
+ changes[:interfaces] = node.interfaces.reject { |i| removed.include?(i.name) }
183
+ end
184
+ changes.empty? ? node : node.merge(changes)
185
+ end
186
+ private_class_method :prune_inaccessible
187
+
188
+ # Recursively remove @join__*/@link applications from a definition and its
189
+ # fields, arguments, and enum values.
190
+ def self.strip_federation_directives(node)
191
+ changes = {}
192
+ if node.respond_to?(:directives) && node.directives
193
+ changes[:directives] = node.directives.reject { |d| federation_directive_name?(d.name) }
194
+ end
195
+ changes[:fields] = node.fields.map { |c| strip_federation_directives(c) } if node.respond_to?(:fields) && node.fields
196
+ changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c) } if node.respond_to?(:arguments) && node.arguments
197
+ changes[:values] = node.values.map { |c| strip_federation_directives(c) } if node.respond_to?(:values) && node.values
198
+ changes.empty? ? node : node.merge(changes)
199
+ end
200
+ private_class_method :strip_federation_directives
201
+
42
202
  # Run the standard introspection query through a transport and build a
43
203
  # schema from the result:
44
204
  #
@@ -34,24 +34,41 @@ module GraphWeaver
34
34
  # Flatten a selection set as seen by `type`, yielding (result_key,
35
35
  # field_node) per field: plain fields yield directly; inline fragments
36
36
  # and named spreads recurse when their type condition applies.
37
- def each_field(type, selections, &block)
37
+ def each_field(type, selections, visiting = Set.new, &block)
38
38
  selections.each do |selection|
39
39
  case selection
40
40
  when GraphQL::Language::Nodes::Field
41
41
  yield(selection.alias || selection.name, selection)
42
42
  when GraphQL::Language::Nodes::InlineFragment
43
- each_field(type, selection.selections, &block) if applies?(selection.type&.name, type)
43
+ each_field(type, selection.selections, visiting, &block) if applies?(selection.type&.name, type)
44
44
  when GraphQL::Language::Nodes::FragmentSpread
45
45
  fragment = @fragments.fetch(selection.name) do
46
46
  raise ArgumentError, "unknown fragment: #{selection.name}"
47
47
  end
48
- each_field(type, fragment.selections, &block) if applies?(fragment.type.name, type)
48
+ if visiting.include?(selection.name)
49
+ raise GraphWeaver::Error, "fragment cycle through #{selection.name}"
50
+ end
51
+
52
+ if applies?(fragment.type.name, type)
53
+ each_field(type, fragment.selections, visiting | [selection.name], &block)
54
+ end
49
55
  else
50
56
  raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
51
57
  end
52
58
  end
53
59
  end
54
60
 
61
+ # each_field grouped by result key: repeated selections of one field
62
+ # (`a { x } a { y }`, or the same field reached through two fragments)
63
+ # collect together, so callers MERGE their sub-selections rather than
64
+ # last-writer-wins. Codegen relies on this; FakeClient/Anonymizer must too,
65
+ # or they'd fabricate/keep a shape the generated struct can't cast.
66
+ def gather(type, selections)
67
+ out = {}
68
+ each_field(type, selections) { |key, node| (out[key] ||= []) << node }
69
+ out
70
+ end
71
+
55
72
  # A fragment's type condition applies when it names this type exactly,
56
73
  # or an interface/union this type belongs to (`... on Named { ... }`).
57
74
  def applies?(condition, type)
@@ -70,7 +70,7 @@ module GraphWeaver
70
70
  entry = {
71
71
  "key" => self.class.key(query, variables),
72
72
  "query" => query,
73
- "variables" => variables,
73
+ "variables" => self.class.normalize_variables(variables),
74
74
  "response" => response,
75
75
  }
76
76
  @entries.reject! { |existing| existing["key"] == entry["key"] }
@@ -92,7 +92,14 @@ module GraphWeaver
92
92
  end
93
93
 
94
94
  def self.key(query, variables)
95
- { "query" => query.gsub(/\s+/, " ").strip, "variables" => variables || {} }
95
+ { "query" => query.gsub(/\s+/, " ").strip, "variables" => normalize_variables(variables) }
96
+ end
97
+
98
+ # JSON round-trip so symbol keys become strings — otherwise YAML.dump
99
+ # writes Ruby symbols the safe loader rejects on the next run, and lookup
100
+ # keys stay stable across processes
101
+ def self.normalize_variables(variables)
102
+ JSON.parse(JSON.generate(variables || {}))
96
103
  end
97
104
 
98
105
  private
@@ -167,6 +174,19 @@ module GraphWeaver
167
174
 
168
175
  private
169
176
 
177
+ # Anonymization walks recorded data, not a live dispatch. When the query
178
+ # narrows an abstract type without selecting __typename (`named { name
179
+ # ... on Pet { species } }`), the recorded data has no type tag, so the
180
+ # strict applies? would drop the `... on Pet` fields. Treat any concrete
181
+ # member condition as applying; object_value's `data.key?(key)` guard
182
+ # discards fields the actual member's response didn't carry.
183
+ def applies?(condition, type)
184
+ return true if super
185
+
186
+ member = @schema.get_type(condition)
187
+ !!member && @schema.possible_types(type).include?(member)
188
+ end
189
+
170
190
  def object_value(type, selections, data)
171
191
  return data if data.nil?
172
192
 
@@ -190,7 +210,12 @@ module GraphWeaver
190
210
  end
191
211
 
192
212
  def field_value(parent_type, node, value)
193
- type_value(@schema.get_field(parent_type.graphql_name, node.name).type, node, value)
213
+ # a field from a `... on Member` fragment lives on the member, not the
214
+ # abstract type we're walking (no __typename to narrow by), so fall back
215
+ # to whichever possible type declares it
216
+ field = @schema.get_field(parent_type.graphql_name, node.name) ||
217
+ @schema.possible_types(parent_type).filter_map { |t| @schema.get_field(t.graphql_name, node.name) }.first
218
+ type_value(field.type, node, value)
194
219
  end
195
220
 
196
221
  def type_value(type, node, value)
@@ -73,6 +73,8 @@ class GraphWeaver::Testing::FakeClient
73
73
 
74
74
  @path = []
75
75
  @failures = []
76
+ # fail_at fires once per execute, not once per client lifetime
77
+ @fail_at.each { |spec| spec.delete("triggered") }
76
78
  data = object_value(root_type, operation.selections)
77
79
  data = nil if data.equal?(NULL_BUBBLE) # total propagation, like a real server
78
80
 
@@ -104,9 +106,12 @@ class GraphWeaver::Testing::FakeClient
104
106
 
105
107
  def object_value(type, selections)
106
108
  result = {}
107
- each_field(type, selections) do |key, node|
109
+ # gather (not each_field) so a field selected twice — `a { x } a { y }` —
110
+ # fabricates the MERGED shape codegen's struct expects, not last-writer-wins
111
+ gather(type, selections).each do |key, nodes|
112
+ node = nodes.first
108
113
  @path.push(key)
109
- value = node.name == "__typename" ? type.graphql_name : field_value(type, node)
114
+ value = node.name == "__typename" ? type.graphql_name : field_value(type, node, nodes.flat_map(&:selections))
110
115
  @path.pop
111
116
 
112
117
  if value.equal?(NULL_BUBBLE)
@@ -127,7 +132,7 @@ class GraphWeaver::Testing::FakeClient
127
132
  @schema.get_field(type.graphql_name, node.name).type.kind.name == "NON_NULL"
128
133
  end
129
134
 
130
- def field_value(parent_type, node)
135
+ def field_value(parent_type, node, selections)
131
136
  if (spec = matching_failure)
132
137
  @failures << {
133
138
  "message" => spec["message"] || "simulated failure",
@@ -148,7 +153,7 @@ class GraphWeaver::Testing::FakeClient
148
153
  return corrupt_value(field_type)
149
154
  end
150
155
 
151
- type_value(field_type, node)
156
+ type_value(field_type, node, selections)
152
157
  end
153
158
 
154
159
  # a value casting can't accept, derived from the field's own type — and
@@ -181,17 +186,18 @@ class GraphWeaver::Testing::FakeClient
181
186
  argument = node.arguments.find { |arg| %w[first last limit].include?(arg.name) }
182
187
  return argument.value if argument && argument.value.is_a?(Integer)
183
188
 
184
- rng.rand(@list_size)
189
+ # an Integer list_size means exactly that many; a Range randomizes within it
190
+ @list_size.is_a?(Range) ? rng.rand(@list_size) : @list_size
185
191
  end
186
192
 
187
- def type_value(type, node, non_null: false)
193
+ def type_value(type, node, selections, non_null: false)
188
194
  case type.kind.name
189
195
  when "NON_NULL"
190
- type_value(type.of_type, node, non_null: true)
196
+ type_value(type.of_type, node, selections, non_null: true)
191
197
  when "LIST"
192
198
  elements = Array.new(list_length(node)) do |index|
193
199
  @path.push(index)
194
- element = type_value(type.of_type, node)
200
+ element = type_value(type.of_type, node, selections)
195
201
  @path.pop
196
202
  element
197
203
  end
@@ -206,21 +212,21 @@ class GraphWeaver::Testing::FakeClient
206
212
  else
207
213
  return if !non_null && rng.rand < @null_chance
208
214
 
209
- core_value(type, node)
215
+ core_value(type, node, selections)
210
216
  end
211
217
  end
212
218
 
213
- def core_value(type, node)
219
+ def core_value(type, node, selections)
214
220
  case type.kind.name
215
221
  when "SCALAR"
216
222
  @values.scalar(type.graphql_name, node.name)
217
223
  when "ENUM"
218
224
  type.values.keys.sort.sample(random: rng)
219
225
  when "OBJECT"
220
- object_value(type, node.selections)
226
+ object_value(type, selections)
221
227
  when "UNION", "INTERFACE"
222
228
  member = @schema.possible_types(type).sort_by(&:graphql_name).sample(random: rng)
223
- object_value(member, node.selections)
229
+ object_value(member, selections)
224
230
  else
225
231
  raise NotImplementedError, "cannot fake kind: #{type.kind.name}"
226
232
  end