graph_weaver 0.4.6 → 0.5.0

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1314 -0
  3. data/CLAUDE.md +100 -8
  4. data/DECISIONS.md +309 -0
  5. data/Gemfile.lock +23 -23
  6. data/NOTES.md +5 -5
  7. data/PLAN.md +106 -135
  8. data/README.md +115 -96
  9. data/REVIEW.md +946 -0
  10. data/docs/cassettes.md +75 -48
  11. data/docs/editors.md +82 -0
  12. data/docs/errors.md +32 -30
  13. data/docs/federation.md +520 -48
  14. data/docs/generated_modules.md +352 -137
  15. data/docs/getting_started.md +237 -67
  16. data/docs/logging.md +35 -6
  17. data/docs/real_world.md +21 -15
  18. data/docs/scalars.md +49 -154
  19. data/docs/testing.md +299 -52
  20. data/docs/transports.md +129 -30
  21. data/docs/upgrading.md +112 -0
  22. data/graph_weaver.gemspec +3 -1
  23. data/lib/generators/graph_weaver/install_generator.rb +259 -0
  24. data/lib/graph_weaver/client.rb +114 -111
  25. data/lib/graph_weaver/codegen/aliases.rb +217 -0
  26. data/lib/graph_weaver/codegen/emit.rb +272 -258
  27. data/lib/graph_weaver/codegen/enum_type.rb +27 -124
  28. data/lib/graph_weaver/codegen/nodes.rb +72 -13
  29. data/lib/graph_weaver/codegen/scalar_type.rb +68 -66
  30. data/lib/graph_weaver/codegen/type_helpers.rb +142 -0
  31. data/lib/graph_weaver/codegen.rb +593 -334
  32. data/lib/graph_weaver/errors.rb +127 -10
  33. data/lib/graph_weaver/federation.rb +272 -0
  34. data/lib/graph_weaver/hints.rb +9 -1
  35. data/lib/graph_weaver/in_process.rb +90 -0
  36. data/lib/graph_weaver/input_struct.rb +14 -2
  37. data/lib/graph_weaver/logging.rb +29 -0
  38. data/lib/graph_weaver/parsing.rb +67 -0
  39. data/lib/graph_weaver/query_module.rb +55 -0
  40. data/lib/graph_weaver/railtie.rb +23 -1
  41. data/lib/graph_weaver/representation.rb +74 -0
  42. data/lib/graph_weaver/response.rb +7 -0
  43. data/lib/graph_weaver/retry.rb +29 -8
  44. data/lib/graph_weaver/rspec.rb +214 -16
  45. data/lib/graph_weaver/schema_loader.rb +794 -59
  46. data/lib/graph_weaver/schemas.rb +46 -0
  47. data/lib/graph_weaver/selection.rb +43 -8
  48. data/lib/graph_weaver/tasks.rb +216 -21
  49. data/lib/graph_weaver/testing/cassette.rb +160 -61
  50. data/lib/graph_weaver/testing/coverage.rb +165 -0
  51. data/lib/graph_weaver/testing/failure.rb +10 -23
  52. data/lib/graph_weaver/testing/fake_client.rb +181 -21
  53. data/lib/graph_weaver/testing/fake_subgraph.rb +85 -0
  54. data/lib/graph_weaver/testing/router.rb +1431 -0
  55. data/lib/graph_weaver/testing/subgraphs.rb +130 -0
  56. data/lib/graph_weaver/testing.rb +204 -14
  57. data/lib/graph_weaver/transport/faraday.rb +28 -10
  58. data/lib/graph_weaver/transport/http.rb +99 -36
  59. data/lib/graph_weaver/transport.rb +67 -14
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +389 -170
  62. metadata +20 -3
@@ -0,0 +1,217 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # Registered aliases (extend_type alias:): dotted paths that project a nested
5
+ # selection onto the struct that owns it — `entity: "_entities.first"`,
6
+ # `tag: "meta.tag"` — as typed delegators. Resolved against the walked node
7
+ # tree, so a path the query doesn't select fails at generation.
8
+ #
9
+ # Mixed into Codegen — methods run with the generator instance state. The
10
+ # subsystem hangs off one seam: object_node's
11
+ # `node.aliases = resolve_aliases(node)`.
12
+
13
+ # the other half: extend_type, which populates the registry read here
14
+ require_relative "type_helpers"
15
+
16
+ class GraphWeaver::Codegen
17
+ module Aliases
18
+ include Kernel # for sorbet: hosts are Objects
19
+
20
+ private
21
+
22
+ # Resolve each registered alias (extend_type alias:) for this struct's type
23
+ # against its actual selection — path -> a typed delegator emitted into the
24
+ # struct body. Validated here, per query, so an unselected or untraversable
25
+ # path fails at generation with a pointed message.
26
+ def resolve_aliases(node)
27
+ type_aliases(node.graphql_type).filter_map do |name, spec|
28
+ # a bad accessor name (reserved, or colliding with a real field) is a
29
+ # registration mistake — it fails for every query, so it always raises,
30
+ # even for optional aliases (which otherwise mask it as "doesn't fit").
31
+ check_alias_name!(node, name)
32
+ begin
33
+ resolve_alias(node, name, spec[:segments])
34
+ rescue UnknownSegment => e
35
+ # names nothing in the schema, so no selection would fit — offering
36
+ # optional: as the way out would just hide the typo
37
+ raise e.class, qualify(node, e.message)
38
+ rescue GraphWeaver::Error => e
39
+ # a path that doesn't fit THIS query's selection: optional simply
40
+ # omits the accessor; strict breaks generation for every query on the
41
+ # type, so name the one that failed and the way out
42
+ next nil if spec[:optional]
43
+
44
+ raise e.class, "#{qualify(node, e.message)} " \
45
+ "— pass optional: true to skip selections that don't fit"
46
+ end
47
+ end
48
+ end
49
+
50
+ # Name the failing query module — unless the message already names it,
51
+ # since a module and the type it queries can share a name (module Query
52
+ # on type Query would otherwise stutter).
53
+ def qualify(node, message)
54
+ return message if @module_name.nil? || @module_name == node.graphql_type
55
+
56
+ "#{@module_name}: #{message}"
57
+ end
58
+
59
+ def check_alias_name!(node, name)
60
+ taken = node.fields.any? { |f| f.prop == name } ||
61
+ ALIAS_RESERVED.include?(name) || RUBY_KEYWORDS.include?(name)
62
+ return unless taken
63
+
64
+ raise GraphWeaver::Error,
65
+ "alias #{name.inspect} on #{node.graphql_type} collides with an existing field or method"
66
+ end
67
+
68
+ # Registered aliases for a GraphQL type (see extend_type alias:).
69
+ def type_aliases(graphql_name)
70
+ GraphWeaver::Codegen.type_registry[graphql_name]&.dig(:aliases) || {}
71
+ end
72
+
73
+ # methods every generated struct already answers to; Ruby keywords are
74
+ # checked alongside (RUBY_KEYWORDS is defined by the class this mixes into)
75
+ ALIAS_RESERVED = %w[from_h serialize to_h].to_set.freeze
76
+ # list selectors — pick one element out of a list-typed hop, always nilable
77
+ # (the list may be empty). Everything else is a field prop.
78
+ LIST_SELECTORS = %w[first last].freeze
79
+ # A segment naming no field of the GraphQL type at all — no selection could
80
+ # ever satisfy it, so it's a typo (or a wire-cased name), not a path that
81
+ # doesn't fit this query. optional: skips the latter, never this.
82
+ UnknownSegment = Class.new(GraphWeaver::Error)
83
+
84
+ # Walk a dotted path through this struct's selected shape, building the
85
+ # delegator expression (`meta&.tag`, `_entities.first&.name`) and its return
86
+ # type. A segment is a field prop, or `first`/`last` to pick a list element.
87
+ # Everything is checked against the node tree: a field on a non-object, a
88
+ # selector on a non-list, or an unselected segment raises. Any nilable hop
89
+ # (a nullable field, or a list element) makes the accessor nilable.
90
+ def resolve_alias(node, name, segments)
91
+ cur = T.let(node, T.untyped) # the node the path has reached
92
+ cur_nilable = T.let(false, T::Boolean) # is the expression so far nilable
93
+ nilable = T.let(false, T::Boolean) # is the accessor overall nilable
94
+ containers = T.let([], T::Array[String]) # nested-struct class names on the way to the leaf
95
+ expr = +""
96
+
97
+ segments.each do |seg|
98
+ # the first hop reads off the struct itself — spelled `self.` when the
99
+ # prop is a Ruby keyword (`self.next`), which bare would be the keyword
100
+ connector = if !expr.empty?
101
+ cur_nilable ? "&." : "."
102
+ else
103
+ RUBY_KEYWORDS.include?(seg) ? "self." : ""
104
+ end
105
+
106
+ # `first`/`last` select an element only when the current hop is actually a
107
+ # list; otherwise they're an ordinary field (a schema field named `first`)
108
+ if LIST_SELECTORS.include?(seg) && list_of(cur)
109
+ expr << connector << seg
110
+ cur = list_of(cur).of
111
+ cur_nilable = true # first/last is nil on an empty list
112
+ nilable = true
113
+ else
114
+ obj = object_of(cur)
115
+ unless obj
116
+ hint = if list_of(cur)
117
+ " — use .first or .last to pick an element"
118
+ elsif LIST_SELECTORS.include?(seg)
119
+ " — .#{seg} needs a list"
120
+ else
121
+ ""
122
+ end
123
+ raise GraphWeaver::Error,
124
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' can't be read here (not an object)#{hint}"
125
+ end
126
+ # the object a field is read from is the lexical container of its result
127
+ # (nested structs emit inside their parent); the aliased struct itself is
128
+ # the delegator's own scope, so it contributes no prefix
129
+ containers << obj.class_name unless obj.equal?(node)
130
+ field = obj.fields.find { |f| f.prop == seg }
131
+ unless field
132
+ check_segment_exists!(node, name, obj, seg)
133
+ props = obj.fields.map(&:prop)
134
+ suggestion = GraphWeaver.did_you_mean(props, seg)
135
+ hint = suggestion ? " — did you mean '#{suggestion}'?" : " (have: #{props.join(", ")})"
136
+ raise GraphWeaver::Error,
137
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a selected field#{hint}"
138
+ end
139
+ expr << connector << seg
140
+ cur = field.node
141
+ cur_nilable = !field.node.non_null?
142
+ nilable ||= cur_nilable
143
+ end
144
+ end
145
+
146
+ leaf = qualified_alias_type(cur, containers)
147
+ type = nilable && leaf != "T.untyped" ? "T.nilable(#{leaf})" : leaf
148
+ ObjectNode::Alias.new(name, expr, type)
149
+ end
150
+
151
+ # Separate "this query didn't select it" from "no query could": a segment
152
+ # the schema doesn't declare on the type is a mistake in the registration,
153
+ # so it raises even for an optional alias — which otherwise turns a typo
154
+ # (or a wire-cased 'findPets') into an accessor that silently vanishes.
155
+ def check_segment_exists!(node, name, obj, seg)
156
+ type = obj.graphql_type && @schema.get_type(obj.graphql_type)
157
+ return unless type.respond_to?(:fields)
158
+
159
+ known = type.fields.keys.map { |field| GraphWeaver::Inflect.underscore(field) }
160
+ return if seg == "__typename" || known.include?(seg)
161
+
162
+ prop = GraphWeaver::Inflect.underscore(seg)
163
+ hint = if prop != seg && known.include?(prop)
164
+ # paths are the Ruby prop chain, not the GraphQL one — the classic miss
165
+ " — GraphQL fields generate snake_case props; use '#{prop}'"
166
+ elsif (suggestion = GraphWeaver.did_you_mean(known, prop))
167
+ " — did you mean '#{suggestion}'?"
168
+ else
169
+ " (has: #{known.sort.join(", ")})"
170
+ end
171
+
172
+ raise UnknownSegment,
173
+ "alias #{name.inspect} on #{node.graphql_type}: '#{seg}' is not a field of #{obj.graphql_type}#{hint}"
174
+ end
175
+
176
+ # The leaf's Sorbet type as referenced from the aliased struct. Generated
177
+ # nested constants (structs, enums, unions) must carry the container path,
178
+ # since the delegator's `sig` is emitted in an outer struct where a bare
179
+ # `Sub` wouldn't resolve; scalars, mapped enums, and hoisted union refs are
180
+ # already top-level. `containers` is the class-name chain to the leaf.
181
+ def qualified_alias_type(node, containers)
182
+ node = node.of if node.is_a?(NonNull)
183
+ prefix = containers.empty? ? "" : "#{containers.join("::")}::"
184
+
185
+ case node
186
+ when List
187
+ element = node.of.is_a?(NonNull) ? qualified_alias_type(node.of, containers) : begin
188
+ inner = qualified_alias_type(node.of, containers)
189
+ inner == "T.untyped" ? inner : "T.nilable(#{inner})"
190
+ end
191
+ "T::Array[#{element}]"
192
+ when ObjectNode, NarrowedNode then "#{prefix}#{node.class_name}"
193
+ # enums are emitted at module level (see Emit#module_level?), or aliased
194
+ # there from the shared enums module — either way, no container prefix
195
+ when EnumNode then node.class_name
196
+ when UnionNode then "#{prefix}#{node.bare_type}"
197
+ else node.bare_type # Scalar, MappedEnum, UnionRefNode — already top-level
198
+ end
199
+ end
200
+
201
+ # the List a node wraps (through NON_NULL), or nil
202
+ def list_of(node)
203
+ node = T.let(node, T.untyped)
204
+ node = node.of while node.is_a?(NonNull)
205
+ node if node.is_a?(List)
206
+ end
207
+
208
+ # the ObjectNode a node resolves to for field access (through NON_NULL and a
209
+ # narrowed abstract member), or nil — unions/scalars/lists can't be read into
210
+ def object_of(node)
211
+ node = T.let(node, T.untyped)
212
+ node = node.of while node.is_a?(NonNull)
213
+ node = node.nested if node.is_a?(NarrowedNode)
214
+ node if node.is_a?(ObjectNode)
215
+ end
216
+ end
217
+ end