graph_weaver 0.4.4 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fac36591b869f046295946094078847ed33a1aa15aa8a2402e6a46f6e17ebaa0
4
- data.tar.gz: c366d845da86029839f57214ba6216b78f2a870b67873851c2514d3d00aaa78d
3
+ metadata.gz: f01ed46a3a7d500364530c522e28f5b1f70f9db803988f940e487630bca0f10c
4
+ data.tar.gz: ee2e237e936ca60054b6465f62e14714c359235abfab376fb3b14e232ef851f9
5
5
  SHA512:
6
- metadata.gz: 37c814f83659a3189407b4c8e4531db4782fc4f38cd0e62df4f8e473a75c713adc3d0bb43de62a51854c1d6f0aeea1195e89848e83bd61c74018a840bfe56924
7
- data.tar.gz: 787508b524cb027fb31281379f64bc566b1bb23709a79883928005ced38cf478c6c90b62a09d2de66317a6d7415eea77014667a5f543472a6f42d8ad043634de
6
+ metadata.gz: bb3adc8c4d4f3164a34a650fe989c785d45559a78c52b10bc93abe21e4ab26c9fb3892b76694c563cbc62b2e342b2bd49b87cf2f7119afe9439facc8dc837e94
7
+ data.tar.gz: ae4521a62bb818a9d8da0053533c4749a56e021f88c343acd25123b04016176a942da9e824bcb41df32083ae1c28db6a81b32079490c15014940bb016d821be6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,46 @@
1
+ ### v0.4.6 (2026-07-30)
2
+ Bug fixes from a full-library review (all with regression coverage):
3
+ - alias: a nested-object/enum leaf (`meta.sub`) now qualifies its constant
4
+ (`Meta::Sub`) instead of emitting a bare `Sub` that raised NameError; alias
5
+ names/segments are validated as identifiers (were interpolated verbatim,
6
+ allowing injection); `optional:` no longer swallows a reserved-name/collision
7
+ mistake; a real field named `first`/`last` resolves as a field.
8
+ - Shared unions: a hoisted member selecting a mapped enum now emits its
9
+ `<NAME>_FROM_WIRE` table into `unions.rb` (was a NameError at `from_h`); a
10
+ fragment whose name collides with `Result`/`QUERY` is refused.
11
+ - A named interface fragment holding inline `... on X` conditions now dispatches
12
+ instead of silently dropping those fields; fragment cycles raise a clear error
13
+ in the FakeClient/Anonymizer walkers instead of `SystemStackError`.
14
+ - Malformed responses/inputs stay under `GraphWeaver::Error`: a non-2xx
15
+ `errors: null` body keeps its status; a 2xx non-object body is a `ServerError`;
16
+ `data!` on null-data/no-errors raises `QueryError`; `coerce(non-Hash)` raises
17
+ `InputError`.
18
+ - Testing harness: FakeClient/Anonymizer merge duplicate result keys (were
19
+ fabricating shapes the generated struct couldn't cast); `fail_at` fires every
20
+ execute; symbol-keyed cassette variables no longer crash on reload; the
21
+ Anonymizer keeps concrete-fragment fields when data lacks `__typename`.
22
+ - Federation: a user type named `link` is no longer dropped; an all-`@inaccessible`
23
+ schema raises a pointed error. Client accepts `retries: nil` on a schema
24
+ source; `register_scalar` rejects an anonymous class.
25
+ - FakeClient: an Integer `list_size` now means exactly that length (a Range
26
+ randomizes within it). Codegen rejects two variables that underscore to the
27
+ same kwarg (`$userId` + `$user_id`). The Faraday transport raises rather than
28
+ silently dropping `headers:`/block when handed a prebuilt connection.
29
+
30
+ ### v0.4.5 (2026-07-30)
31
+ - `alias:` paths gain list-element selectors: a `first`/`last` segment picks one
32
+ element out of a list hop, always nilable — `alias: { entity: "_entities.first" }`
33
+ yields `def entity = _entities&.first`, and navigation continues into the
34
+ element (`_entities.first.name`). Typed from the selection: a single inline
35
+ fragment lands on the concrete member (`T.nilable(Widget)`), a multi-fragment
36
+ selection on the union. Selectors are checked against the node shape — `.first`
37
+ on a non-list raises. Cleanly retires the `result._entities&.first&.field`
38
+ boilerplate of single-entity federation `_entities` queries.
39
+ - `extend_type(..., optional: true)` makes its aliases lenient: a query whose
40
+ selection doesn't fit the path omits the accessor instead of failing
41
+ generation. For an alias on a universal type (a `Query` accessor a strict alias
42
+ would force every query to satisfy), or one that only fits some selections.
43
+
1
44
  ### v0.4.4 (2026-07-30)
2
45
  - Supergraph loading now derives the **API schema**: `@inaccessible` elements
3
46
  (present in the federated graph but hidden from what the router serves) are
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graph_weaver (0.4.4)
4
+ graph_weaver (0.4.6)
5
5
  graphql (>= 2.6.7)
6
6
  sorbet-runtime
7
7
 
@@ -176,7 +176,7 @@ CHECKSUMS
176
176
  google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b
177
177
  google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee
178
178
  google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd
179
- graph_weaver (0.4.4)
179
+ graph_weaver (0.4.6)
180
180
  graphql (2.6.7) sha256=759755ce5819c965b6459c5039605cf2e4d876bb8ba5e03451676870ea787c45
181
181
  i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
182
182
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
data/docs/scalars.md CHANGED
@@ -232,8 +232,26 @@ alias: { label: "name", tag: "meta.tag" }
232
232
  The path is the Ruby accessor chain (`meta.tag`), typed from the selection: any
233
233
  nullable hop makes the accessor nilable and inserts `&.`; the leaf can be a
234
234
  scalar, enum, or nested struct. It's validated against each query at generation —
235
- an unselected or misspelled segment (`did you mean 'tag'?`), a path through a
236
- list, or a name that collides with a real field all fail with a pointed error.
237
- Registrations stack and are client-scopable, like the mixin forms. For anything
238
- beyond a passthrough projection — real logic, still typed — reopen the generated
239
- struct in your own file and add sig'd methods; Sorbet merges the bodies.
235
+ an unselected or misspelled segment (`did you mean 'tag'?`), a selector on a
236
+ non-list, or a name that collides with a real field all fail with a pointed
237
+ error. Registrations stack and are client-scopable, like the mixin forms.
238
+
239
+ A segment can also be `first` or `last` to pick one element out of a list hop —
240
+ always nilable, since the list may be empty. This is what turns an
241
+ `_entities`-style "array that logically holds one thing" into a clean accessor:
242
+
243
+ ```ruby
244
+ GraphWeaver.extend_type("Query", alias: { entity: "_entities.first" }, optional: true)
245
+
246
+ # sig { returns(T.nilable(Widget)) } # concrete, when the selection is one `... on Widget`
247
+ # def entity = _entities&.first # (a multi-fragment selection types it as the union)
248
+ ```
249
+
250
+ `optional: true` makes the aliases *lenient*: a query whose selection doesn't fit
251
+ the path just omits the accessor instead of failing generation. Reach for it when
252
+ the alias lives on a universal type like `Query` — where a strict alias would
253
+ force *every* query to select the path — or when it only fits some selections.
254
+
255
+ For anything beyond a passthrough projection — real logic, still typed — reopen
256
+ the generated struct in your own file and add sig'd methods; Sorbet merges the
257
+ bodies.
@@ -35,7 +35,7 @@ class GraphWeaver::Client
35
35
 
36
36
  @transport = wrap_retries(build_transport(source, auth:, headers:, &middleware), retries)
37
37
  else
38
- if auth || middleware || retries != false
38
+ if auth || middleware || retries
39
39
  raise ArgumentError, "auth:/retries:/middleware apply to a url — got a schema source"
40
40
  end
41
41
  if cache || ttl
@@ -194,6 +194,13 @@ class GraphWeaver::Codegen
194
194
  end
195
195
  out << "module #{@module_name}"
196
196
  out << " extend T::Sig" << "" if GraphWeaver.extend_t_sig?
197
+ # a member selecting a mapped enum (register_enum) reads its module-level
198
+ # <NAME>_FROM_WIRE table — emit those here so from_h resolves them, the
199
+ # same way emit_variable_types does for the query module
200
+ @mapped_enums.each_value do |mapped|
201
+ emit_mapped_enum(mapped, out, 1)
202
+ out << ""
203
+ end
197
204
  unions.each do |union|
198
205
  emit_union(union, out, 1)
199
206
  out << ""
@@ -110,30 +110,56 @@ class GraphWeaver::Codegen
110
110
  # GraphWeaver.extend_type("Widget", alias: { tag: "meta.tag" })
111
111
  # GraphWeaver.extend_type("Widget", alias: "meta.tag") # accessor named `tag`
112
112
  # GraphWeaver.extend_type("Widget", alias: ["meta.tag", "meta.color"])
113
+ #
114
+ # A path segment is a field, or `first`/`last` to pick one element out of a
115
+ # list hop (always nilable): `alias: { entity: "_entities.first" }`.
116
+ #
117
+ # optional: true makes the aliases lenient — a query whose selection doesn't
118
+ # fit the path just omits the accessor instead of failing generation. Use it
119
+ # for a root-type accessor (a Query alias every query would otherwise have to
120
+ # satisfy) or one that only fits some selections.
113
121
  def extend_type(graphql_name, *mixins, requires: nil, **kw, &block)
114
122
  aliases = take_aliases(kw)
115
123
  entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [], aliases: {} }
116
124
  add_type_helpers(entry, graphql_name, mixins, requires, block, aliases)
117
125
  end
118
126
 
119
- # Pull alias: out of the keyword rest and normalize it; any other keyword
120
- # is a typo worth flagging rather than silently dropping.
127
+ # Pull alias:/optional: out of the keyword rest and normalize; any other
128
+ # keyword is a typo worth flagging rather than silently dropping.
121
129
  def take_aliases(kw)
122
- aliases = normalize_aliases(kw.delete(:alias))
130
+ aliases = normalize_aliases(kw.delete(:alias), optional: !!kw.delete(:optional))
123
131
  raise ArgumentError, "unknown keyword: #{kw.keys.first}" unless kw.empty?
124
132
  aliases
125
133
  end
126
134
 
127
- # { accessor => [path, segments] } from a path string (accessor named
128
- # after the last segment), an array of such, or an { accessor => path } hash.
129
- def normalize_aliases(input)
130
- case input
131
- when nil then {}
132
- when String then { input.split(".").last => input.split(".") }
133
- when Array then input.to_h { |path| [path.split(".").last, path.split(".")] }
134
- when Hash then input.to_h { |name, path| [name.to_s, path.to_s.split(".")] }
135
+ # accessor names and path segments are interpolated verbatim into generated
136
+ # source, so like module_name they must be plain identifiers, never
137
+ # arbitrary text that could inject code
138
+ ALIAS_NAME = /\A[a-zA-Z_]\w*[?!]?\z/
139
+ ALIAS_SEGMENT = /\A[a-zA-Z_]\w*\z/
140
+
141
+ # { accessor => { segments:, optional: } } from a path string (accessor
142
+ # named after the last segment), an array of such, or an { accessor => path }
143
+ # hash. `optional:` marks every alias in this registration as lenient.
144
+ def normalize_aliases(input, optional:)
145
+ pairs = case input
146
+ when nil then []
147
+ when String then [[input.split(".").last, input.split(".")]]
148
+ when Array then input.map { |path| [path.split(".").last, path.split(".")] }
149
+ when Hash then input.map { |name, path| [name.to_s, path.to_s.split(".")] }
135
150
  else raise ArgumentError, "alias: expects a String, Array, or Hash, got #{input.class}"
136
151
  end
152
+ pairs.to_h do |name, segments|
153
+ unless name.to_s.match?(ALIAS_NAME)
154
+ raise ArgumentError, "alias name #{name.inspect} is not a valid method name"
155
+ end
156
+ raise ArgumentError, "alias #{name.inspect} has an empty path" if segments.empty?
157
+
158
+ bad = segments.reject { |seg| seg.match?(ALIAS_SEGMENT) }
159
+ raise ArgumentError, "alias #{name.inspect} has an invalid path segment: #{bad.first.inspect}" if bad.any?
160
+
161
+ [name, { segments:, optional: }]
162
+ end
137
163
  end
138
164
 
139
165
  def type_registry
@@ -123,7 +123,10 @@ class GraphWeaver::Codegen
123
123
 
124
124
  def type_name(type)
125
125
  case type
126
- when Module then type.name
126
+ when Module
127
+ # an anonymous class has no name to emit — it would land as a literal
128
+ # `nil` in generated source
129
+ type.name || raise(ArgumentError, "type: must be a named class/module, got an anonymous one")
127
130
  when String then type
128
131
  else raise ArgumentError, "type: must be a class/module or String, got #{type.inspect}"
129
132
  end
@@ -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
@@ -474,8 +486,25 @@ class GraphWeaver::Codegen
474
486
  # struct body. Validated here, per query, so an unselected or untraversable
475
487
  # path fails at generation with a pointed message.
476
488
  def resolve_aliases(node)
477
- type_aliases(node.graphql_type).map do |name, segments|
478
- resolve_alias(node, name, segments)
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"
479
508
  end
480
509
  end
481
510
 
@@ -487,61 +516,126 @@ class GraphWeaver::Codegen
487
516
  end
488
517
 
489
518
  ALIAS_RESERVED = (%w[from_h serialize to_h].to_set + RUBY_KEYWORDS).freeze
490
-
491
- # Walk a dotted path (Ruby prop names) through this struct's selected fields,
492
- # building the delegator expression (`meta&.tag`) and its return type. Any hop
493
- # is nilable -> the accessor is nilable; a list hop or an unselected segment
494
- # raises. The leaf may be any node (scalar, enum, nested struct).
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.
495
529
  def resolve_alias(node, name, segments)
496
- if node.fields.any? { |f| f.prop == name } || ALIAS_RESERVED.include?(name)
497
- raise GraphWeaver::Error,
498
- "alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
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
499
576
  end
500
577
 
501
- current = T.let(node, T.untyped)
502
- parts = []
503
- nilable = T.let(false, T::Boolean)
504
- segments.each_with_index do |seg, i|
505
- field = current.fields.find { |f| f.prop == seg }
506
- unless field
507
- props = current.fields.map(&:prop)
508
- suggestion = defined?(DidYouMean::SpellChecker) &&
509
- DidYouMean::SpellChecker.new(dictionary: props).correct(seg).first
510
- hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
511
- raise GraphWeaver::Error,
512
- "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
513
- end
514
- nilable ||= !field.node.non_null?
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
515
582
 
516
- if i == segments.size - 1
517
- leaf = field.node.bare_type
518
- type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
519
- return ObjectNode::Alias.new(name, (parts << seg).join, type)
520
- end
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("::")}::"
521
591
 
522
- inner = T.let(field.node, T.untyped)
523
- inner = inner.of while inner.is_a?(NonNull)
524
- raise GraphWeaver::Error, "alias #{name.inspect}: cannot traverse list-typed '#{seg}'" if inner.is_a?(List)
525
- unless inner.is_a?(ObjectNode)
526
- raise GraphWeaver::Error, "alias #{name.inspect}: '#{seg}' is not an object to traverse into"
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})"
527
597
  end
528
-
529
- parts << seg << (field.node.non_null? ? "." : "&.")
530
- current = inner
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
531
602
  end
532
603
  end
533
604
 
534
- # The concrete type conditions a selection mentions (inline fragments
535
- # and named spreads), minus conditions naming the abstract type itself.
536
- def concrete_conditions(core, selections)
537
- selections.filter_map do |selection|
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|
538
627
  case selection
539
628
  when GraphQL::Language::Nodes::InlineFragment
540
- selection.type&.name
629
+ [selection.type&.name, *concrete_conditions(core, selection.selections, visiting)]
541
630
  when GraphQL::Language::Nodes::FragmentSpread
542
- @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
+ []
543
637
  end
544
- end.uniq - [core.graphql_name]
638
+ end.compact.uniq - [core.graphql_name]
545
639
  end
546
640
 
547
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
@@ -58,11 +58,18 @@ module GraphWeaver::SchemaLoader
58
58
  FEDERATION_PREFIXES = %w[join__ link__ core__].freeze
59
59
  FEDERATION_DIRECTIVES = %w[link core inaccessible].to_set.freeze
60
60
 
61
- # Whether a type/directive name belongs to the federation composition layer.
62
- def self.federation_name?(name)
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)
63
70
  !!name && (name.start_with?(*FEDERATION_PREFIXES) || FEDERATION_DIRECTIVES.include?(name))
64
71
  end
65
- private_class_method :federation_name?
72
+ private_class_method :federation_directive_name?
66
73
 
67
74
  # Drop the composition machinery from supergraph SDL: the synthetic
68
75
  # join__*/link__* type and directive definitions, and every @join__*/@link
@@ -74,11 +81,31 @@ module GraphWeaver::SchemaLoader
74
81
  def self.strip_federation(sdl)
75
82
  doc = GraphQL.parse(sdl)
76
83
  defs = remove_inaccessible(doc.definitions)
77
- .reject { |defn| defn.respond_to?(:name) && federation_name?(defn.name) }
84
+ .reject { |defn| federation_definition?(defn) }
78
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
+
79
93
  GraphQL::Language::Nodes::Document.new(definitions: defs).to_query_string
80
94
  end
81
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
+
82
109
  # Derive the API schema by dropping every element marked @inaccessible —
83
110
  # present in the federated graph but hidden from the public API the router
84
111
  # serves (its common use is safely rolling out a field on a shared type).
@@ -93,8 +120,9 @@ module GraphWeaver::SchemaLoader
93
120
  survivors = definitions
94
121
  .reject { |d| type_definition?(d) && removed.include?(d.name) }
95
122
  .map { |d| prune_inaccessible(d, removed) }
123
+ # survivors already exclude `removed`, so anything newly emptied is fresh
96
124
  newly = survivors.select { |d| type_definition?(d) && type_emptied?(d) }.map(&:name)
97
- return survivors if (newly - removed.to_a).empty?
125
+ return survivors if newly.empty?
98
126
 
99
127
  removed.merge(newly)
100
128
  end
@@ -162,7 +190,7 @@ module GraphWeaver::SchemaLoader
162
190
  def self.strip_federation_directives(node)
163
191
  changes = {}
164
192
  if node.respond_to?(:directives) && node.directives
165
- changes[:directives] = node.directives.reject { |d| federation_name?(d.name) }
193
+ changes[:directives] = node.directives.reject { |d| federation_directive_name?(d.name) }
166
194
  end
167
195
  changes[:fields] = node.fields.map { |c| strip_federation_directives(c) } if node.respond_to?(:fields) && node.fields
168
196
  changes[:arguments] = node.arguments.map { |c| strip_federation_directives(c) } if node.respond_to?(:arguments) && node.arguments
@@ -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
@@ -32,6 +32,13 @@ module GraphWeaver
32
32
  def initialize(url_or_connection, headers: {}, &block)
33
33
  @connection = case url_or_connection
34
34
  when ::Faraday::Connection
35
+ # a prebuilt connection carries its own headers/middleware, so
36
+ # headers:/block would be silently dropped — fail loudly instead
37
+ unless headers.empty? && block.nil?
38
+ raise ArgumentError,
39
+ "headers:/block are ignored when passing a prebuilt Faraday::Connection — configure them on it"
40
+ end
41
+
35
42
  url_or_connection
36
43
  else
37
44
  # Faraday appends the default adapter when the block doesn't set one
@@ -62,15 +62,18 @@ class GraphWeaver::Transport
62
62
  # envelope so QueryError machinery sees the structured errors; only
63
63
  # a body that isn't GraphQL (proxy pages, HTML 500s) is a ServerError.
64
64
  unless (200..299).cover?(status)
65
- return parsed if parsed.is_a?(Hash) && parsed.key?("errors")
65
+ # only a body carrying actual GraphQL errors flows through — a 4xx with
66
+ # `"errors": null` (or []) isn't a structured error response, so the
67
+ # status stays the signal
68
+ return parsed if parsed.is_a?(Hash) && parsed["errors"].is_a?(Array) && parsed["errors"].any?
66
69
 
67
70
  raise GraphWeaver::ServerError.new(status:, body: body.to_s)
68
71
  end
69
72
 
70
- unless parsed
71
- # a 200 that isn't GraphQL — an HTML error page from a proxy, a
72
- # captive portal: the server misbehaved, classify it that way
73
- raise GraphWeaver::ServerError.new(status:, body: "non-JSON response: #{body.to_s[0, 500]}")
73
+ unless parsed.is_a?(Hash)
74
+ # a 200 that isn't a GraphQL object — an HTML error page from a proxy, a
75
+ # captive portal, or a bare JSON array/string: the server misbehaved
76
+ raise GraphWeaver::ServerError.new(status:, body: "non-GraphQL response: #{body.to_s[0, 500]}")
74
77
  end
75
78
 
76
79
  parsed
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.4.4"
2
+ VERSION = "0.4.6"
3
3
  end
data/lib/graph_weaver.rb CHANGED
@@ -293,6 +293,15 @@ module GraphWeaver
293
293
  # (`class Module; include T::Sig`) — extracted so it's stubbable in tests.
294
294
  def global_tsig? = Module.include?(T::Sig)
295
295
 
296
+ # The closest entry in `dictionary` to `term` — a "did you mean" suggestion,
297
+ # or nil (also nil when did_you_mean isn't loadable). One home for the guard
298
+ # used by codegen validation, alias resolution, and the runtime prop hints.
299
+ def did_you_mean(dictionary, term)
300
+ return unless defined?(DidYouMean::SpellChecker)
301
+
302
+ DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
303
+ end
304
+
296
305
  # Teach the generator how a GraphQL custom scalar deserializes into a
297
306
  # rich Ruby object (and serializes back onto the wire when used as a
298
307
  # variable):
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graph_weaver
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.4
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper