graph_weaver 0.5.0 → 0.6.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +537 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +29 -4
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +75 -16
  8. data/docs/federation.md +206 -155
  9. data/docs/generated_modules.md +223 -166
  10. data/docs/getting_started.md +106 -82
  11. data/docs/logging.md +35 -5
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +196 -155
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +243 -22
  16. data/graph_weaver.gemspec +16 -2
  17. data/lib/generators/graph_weaver/install_generator.rb +31 -16
  18. data/lib/graph_weaver/client.rb +52 -15
  19. data/lib/graph_weaver/codegen/aliases.rb +15 -8
  20. data/lib/graph_weaver/codegen/emit.rb +107 -42
  21. data/lib/graph_weaver/codegen/enum_type.rb +4 -3
  22. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  23. data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
  24. data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
  25. data/lib/graph_weaver/codegen.rb +382 -105
  26. data/lib/graph_weaver/coerce.rb +113 -0
  27. data/lib/graph_weaver/errors.rb +57 -13
  28. data/lib/graph_weaver/federation.rb +10 -22
  29. data/lib/graph_weaver/hints.rb +76 -2
  30. data/lib/graph_weaver/in_process.rb +11 -8
  31. data/lib/graph_weaver/inflect.rb +2 -0
  32. data/lib/graph_weaver/input_struct.rb +115 -12
  33. data/lib/graph_weaver/internal/overrides.rb +101 -0
  34. data/lib/graph_weaver/internal/planner.rb +868 -0
  35. data/lib/graph_weaver/internal/schemas.rb +50 -0
  36. data/lib/graph_weaver/internal/selection.rb +127 -0
  37. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +45 -43
  38. data/lib/graph_weaver/internal/values.rb +181 -0
  39. data/lib/graph_weaver/internal.rb +206 -0
  40. data/lib/graph_weaver/logging.rb +108 -20
  41. data/lib/graph_weaver/parsing.rb +6 -13
  42. data/lib/graph_weaver/query_module.rb +2 -0
  43. data/lib/graph_weaver/railtie.rb +113 -14
  44. data/lib/graph_weaver/representation.rb +30 -2
  45. data/lib/graph_weaver/response.rb +15 -0
  46. data/lib/graph_weaver/retry.rb +54 -22
  47. data/lib/graph_weaver/rspec.rb +63 -18
  48. data/lib/graph_weaver/schema_diff.rb +293 -0
  49. data/lib/graph_weaver/schema_loader.rb +126 -35
  50. data/lib/graph_weaver/tasks.rb +88 -36
  51. data/lib/graph_weaver/testing/cassette.rb +131 -78
  52. data/lib/graph_weaver/testing/coverage.rb +11 -15
  53. data/lib/graph_weaver/testing/failure.rb +14 -8
  54. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  55. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  56. data/lib/graph_weaver/testing/router.rb +147 -840
  57. data/lib/graph_weaver/testing.rb +40 -83
  58. data/lib/graph_weaver/transport/faraday.rb +1 -1
  59. data/lib/graph_weaver/transport/http.rb +29 -12
  60. data/lib/graph_weaver/transport.rb +11 -34
  61. data/lib/graph_weaver/version.rb +1 -1
  62. data/lib/graph_weaver.rb +221 -118
  63. metadata +17 -13
  64. data/CLAUDE.md +0 -161
  65. data/DECISIONS.md +0 -309
  66. data/Makefile +0 -23
  67. data/NOTES.md +0 -182
  68. data/PLAN.md +0 -115
  69. data/REVIEW.md +0 -946
  70. data/lib/graph_weaver/schemas.rb +0 -46
  71. data/lib/graph_weaver/selection.rb +0 -120
  72. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -0,0 +1,50 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ module GraphWeaver
7
+ module Internal
8
+ # The graphql-ruby schema classes already in this process, and what each
9
+ # one defines. Where {SchemaLoader} *builds* a schema from a source — a
10
+ # path, SDL, an introspection dump — this reads classes the app loaded
11
+ # itself.
12
+ #
13
+ # Two features ask exactly these two questions, and match a schema on the
14
+ # coordinates it defines rather than on its class name: {Internal::Subgraphs}
15
+ # (which schema serves which subgraph) and {Federation::Drift} (has a
16
+ # subgraph changed without a recompose). What they share is this evidence,
17
+ # not the verdict: Subgraphs wants every type AND field, Drift only the
18
+ # types — a schema that lost a field is not a candidate to run against, but
19
+ # is exactly the one Drift has to recognize to report the loss.
20
+ module Schemas
21
+ class << self
22
+ # Every named GraphQL::Schema in the process. An anonymous one is
23
+ # graphql-ruby building from SDL — the router's own view of the
24
+ # supergraph is one — and never an app's subgraph.
25
+ def loaded
26
+ descendants(GraphQL::Schema).select(&:name)
27
+ end
28
+
29
+ # Does this schema carry the coordinate — "Type", or "Type.field"?
30
+ def defines?(schema, coordinate)
31
+ type_name, field_name = coordinate.split(".", 2)
32
+ type = schema.get_type(type_name) or return false
33
+ return true unless field_name
34
+
35
+ return type.fields.key?(field_name) if type.respond_to?(:fields)
36
+ # an input object's members are arguments, not fields
37
+ return type.arguments.key?(field_name) if type.respond_to?(:arguments)
38
+
39
+ false
40
+ end
41
+
42
+ private
43
+
44
+ def descendants(klass)
45
+ klass.subclasses.flat_map { |subclass| [subclass] + descendants(subclass) }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,127 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql"
5
+
6
+ module GraphWeaver
7
+ module Internal
8
+ # Shared query-selection walking — the rules Codegen, FakeClient, and
9
+ # the cassette Anonymizer all follow, in one place so they can't drift:
10
+ # how fragments flatten into selections, and when a type condition
11
+ # applies. Hosts set @schema and call load_operation before walking.
12
+ module Selection
13
+ include Kernel # for sorbet: hosts are Objects
14
+
15
+ # Every method here becomes an instance method of its host (Codegen,
16
+ # FakeClient, the cassette Anonymizer) — private so the walk stays the
17
+ # host's own business rather than part of its API.
18
+ private
19
+
20
+ # Parse a query, stash its fragment definitions for the walk, and
21
+ # return the operation.
22
+ def load_operation(query)
23
+ doc = GraphQL.parse(query)
24
+ @fragments = doc.definitions
25
+ .grep(GraphQL::Language::Nodes::FragmentDefinition)
26
+ .to_h { |fragment| [fragment.name, fragment] }
27
+
28
+ operations = doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
29
+ # One file, one operation. Requests do carry operationName now, so a
30
+ # second operation would run fine on the wire — what has no answer is
31
+ # naming: a module is named after its FILE (person.graphql =>
32
+ # PersonQuery), and one file can't name two. A convention, not a limit.
33
+ if operations.size > 1
34
+ names = operations.map { |op| op.name ? "'#{op.name}'" : "an anonymous operation" }
35
+ raise GraphWeaver::Error,
36
+ "document defines #{operations.size} operations (#{names.join(", ")}) — " \
37
+ "split them into one file each, since a module is named after its file"
38
+ end
39
+
40
+ operations.first
41
+ end
42
+
43
+ # The schema type an operation's selections start from.
44
+ def operation_root_type(operation)
45
+ case operation&.operation_type
46
+ when "query", nil then @schema.query
47
+ when "mutation" then @schema.mutation
48
+ else raise GraphWeaver::Error, "unsupported operation: #{operation.operation_type}"
49
+ end
50
+ end
51
+
52
+ # Flatten a selection set as seen by `type`, yielding (result_key,
53
+ # field_node, conditional) per field: plain fields yield directly; inline
54
+ # fragments and named spreads recurse when their type condition applies.
55
+ # `conditional` is true when any fragment on the way down carried
56
+ # @skip/@include — the whole block may be absent from the response, so
57
+ # everything under it is as optional as a directly-skipped field.
58
+ def each_field(type, selections, visiting = Set.new, conditional: false, &block)
59
+ selections.each do |selection|
60
+ case selection
61
+ when GraphQL::Language::Nodes::Field
62
+ yield(selection.alias || selection.name, selection, conditional)
63
+ when GraphQL::Language::Nodes::InlineFragment
64
+ next unless applies?(selection.type&.name, type)
65
+
66
+ each_field(type, selection.selections, visiting,
67
+ conditional: conditional || conditional?(selection), &block)
68
+ when GraphQL::Language::Nodes::FragmentSpread
69
+ fragment = @fragments.fetch(selection.name) do
70
+ raise ArgumentError, "unknown fragment: #{selection.name}"
71
+ end
72
+ if visiting.include?(selection.name)
73
+ raise GraphWeaver::Error, "fragment cycle through #{selection.name}"
74
+ end
75
+
76
+ if applies?(fragment.type.name, type)
77
+ # the directive rides on the SPREAD, not the definition it names
78
+ each_field(type, fragment.selections, visiting | [selection.name],
79
+ conditional: conditional || conditional?(selection), &block)
80
+ end
81
+ else
82
+ raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
83
+ end
84
+ end
85
+ end
86
+
87
+ # each_field grouped by result key: repeated selections of one field
88
+ # (`a { x } a { y }`, or the same field reached through two fragments)
89
+ # collect together, so callers MERGE their sub-selections rather than
90
+ # last-writer-wins. Codegen relies on this; FakeClient/Anonymizer must too,
91
+ # or they'd fabricate/keep a shape the generated struct can't cast.
92
+ def gather(type, selections)
93
+ gather_conditional(type, selections).transform_values { |occurrences| occurrences.map(&:first) }
94
+ end
95
+
96
+ # gather, keeping each occurrence's [field_node, conditional] — the wire
97
+ # key is guaranteed only when SOME occurrence is unconditional.
98
+ def gather_conditional(type, selections)
99
+ out = {}
100
+ each_field(type, selections) { |key, node, conditional| (out[key] ||= []) << [node, conditional] }
101
+ out
102
+ end
103
+
104
+ # Directives that can drop a selection from the response whatever the
105
+ # schema says — the reason a conditional field's generated type is nilable.
106
+ CONDITIONAL_DIRECTIVES = %w[skip include].freeze
107
+
108
+ # Is this AST node (field, inline fragment, or spread) behind @skip/@include?
109
+ def conditional?(node)
110
+ node.directives.any? { |directive| CONDITIONAL_DIRECTIVES.include?(directive.name) }
111
+ end
112
+
113
+ # A fragment's type condition applies when it names this type exactly,
114
+ # or an interface/union this type belongs to (`... on Named { ... }`).
115
+ def applies?(condition, type)
116
+ return true if condition.nil? || condition == type.graphql_name
117
+
118
+ condition_type = @schema.get_type(condition)
119
+ return false unless condition_type
120
+
121
+ kind = condition_type.kind.name
122
+ (kind == "INTERFACE" || kind == "UNION") &&
123
+ @schema.possible_types(condition_type).include?(type)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -4,10 +4,10 @@
4
4
  require "graphql"
5
5
 
6
6
  require_relative "../schema_loader"
7
- require_relative "../schemas"
7
+ require_relative "schemas"
8
8
 
9
9
  module GraphWeaver
10
- module Testing
10
+ module Internal
11
11
  # Which Ruby schema serves which subgraph — for the subgraphs this
12
12
  # process serves at all.
13
13
  #
@@ -19,19 +19,20 @@ module GraphWeaver
19
19
  # `AccountsSchema`, `Subgraphs::Accounts`), and a wrong guess points a
20
20
  # suite at the wrong resolvers and still passes.
21
21
  #
22
- # Two matches refuse, naming both both fit the evidence, so picking
23
- # either would be the guess this module exists to avoid. **No match is
24
- # not a refusal**: a supergraph is routinely only partly local, the rest
25
- # served by another process, so a subgraph nothing here defines is left
26
- # out of the map. Only a query that reaches its fields fails, at plan
27
- # time see {Router}.
22
+ # Neither of the two ways detection can come up short is a reason to
23
+ # refuse a whole suite, because neither is a fact about the query in
24
+ # hand. **No match** means the subgraph runs in another process, which is
25
+ # routine mid-migration. **Two matches** means two loaded classes fit the
26
+ # evidence equally, and picking one would be the guess this module exists
27
+ # to avoid. Both leave the subgraph unserved, and {Router} refuses the
28
+ # query that reaches its fields — each with its own fix.
28
29
  #
29
30
  # `"reviews" => :fake` asks for schema-correct fabricated data instead of
30
31
  # that refusal (see {FakeSubgraph}).
31
32
  #
32
- # The same check runs over a map you pass explicitly, which is how a
33
- # swapped pair fails at construction rather than as a mystery three
34
- # fetches later.
33
+ # A map you pass explicitly is a claim rather than a derivation, so it
34
+ # goes through the same check *and refuses at construction* a swapped
35
+ # pair fails there rather than as a mystery three fetches later.
35
36
  module Subgraphs
36
37
  # how many coordinates a message names before it says "and N more"
37
38
  SAMPLE = 5
@@ -39,29 +40,38 @@ module GraphWeaver
39
40
  # answer this subgraph with fabricated data rather than refusing
40
41
  FAKE = :fake
41
42
 
43
+ # What detection settled and what it couldn't: `served` is
44
+ # { "accounts" => Accounts::Schema, … } (with FAKE for a faked one),
45
+ # `ambiguous` is { "reviews" => ["App::Reviews::Schema", …] } for the
46
+ # ones several loaded classes fit. A subgraph in neither is absent.
47
+ Resolution = Struct.new(:served, :ambiguous)
48
+
42
49
  class << self
43
- # { "accounts" => Accounts::Schema, … } for the subgraphs this
44
- # process serves — one nothing defines is absent, and left out.
45
50
  # Names in `given` skip detection (:fake included); the rest are
46
51
  # derived, and both go through the same check.
47
52
  def resolve(table, given = nil, schemas: nil)
48
- named = (given || {}).to_h { |name, schema| [name.to_s, schema] }
49
- unknown = named.keys - table.subgraphs
50
- if unknown.any?
51
- raise GraphWeaver::ConfigurationError, "subgraphs: names #{unknown.join(", ")}, which " \
52
- "this supergraph doesn't have (its subgraphs are #{table.subgraphs.join(", ")})"
53
+ named = table.named_subgraphs(given)
54
+ searched = schemas || Schemas.loaded
55
+ resolution = Resolution.new({}, {})
56
+ table.subgraphs.each do |name|
57
+ if named.key?(name)
58
+ resolution.served[name] = check!(table, name, named[name])
59
+ next
60
+ end
61
+
62
+ found = candidates(table, name, searched)
63
+ next resolution.served[name] = found.first if found.one?
64
+ # by name: a dev reload leaves two class objects spelled the same,
65
+ # and naming one of them twice reads as a bug in the message
66
+ resolution.ambiguous[name] = found.map(&:name).uniq.sort if found.any?
53
67
  end
54
-
55
- searched = schemas || GraphWeaver::Schemas.loaded
56
- table.subgraphs.filter_map do |name|
57
- served = named.key?(name) ? check!(table, name, named[name]) : detect(table, name, searched)
58
- [name, served] if served
59
- end.to_h
68
+ resolution
60
69
  end
61
70
 
62
71
  # every loaded schema that defines what the table says `name` resolves
63
- def candidates(table, name, schemas = GraphWeaver::Schemas.loaded)
64
- schemas.select { |schema| missing(table, name, schema).empty? }
72
+ def candidates(table, name, schemas = Schemas.loaded)
73
+ wanted = expected(table, name)
74
+ schemas.select { |schema| wanted.all? { |coordinate| Schemas.defines?(schema, coordinate) } }
65
75
  end
66
76
 
67
77
  # The schema coordinates the supergraph says `name` resolves — "Type"
@@ -78,27 +88,19 @@ module GraphWeaver
78
88
 
79
89
  # which of them `schema` doesn't define
80
90
  def missing(table, name, schema)
81
- expected(table, name).reject { |coordinate| GraphWeaver::Schemas.defines?(schema, coordinate) }
91
+ expected(table, name).reject { |coordinate| Schemas.defines?(schema, coordinate) }
82
92
  end
83
93
 
84
- private
85
-
86
- # The schema serving `name`, or nil when nothing here does the
87
- # subgraph is somebody else's, which is not an error until a query
88
- # asks for it.
89
- def detect(table, name, schemas)
90
- found = candidates(table, name, schemas)
91
- return found.first if found.one?
92
- return if found.empty?
93
-
94
- # by name: a dev reload leaves two class objects spelled the same,
95
- # and naming one of them twice reads as a bug in the message
96
- names = found.map(&:name).uniq.sort
97
- raise GraphWeaver::ConfigurationError, "#{names.size} loaded schemas define everything the " \
98
- "supergraph says #{name.inspect} resolves (#{names.join(", ")}) — pass subgraphs: naming " \
99
- "the one you mean"
94
+ # Is this subgraph served in this process? Exactly one candidate is
95
+ # what that means: none is somebody else's service, and several is a
96
+ # question only the caller can answer, so neither is something a suite
97
+ # can run against.
98
+ def served?(table, name, schemas = Schemas.loaded)
99
+ candidates(table, name, schemas).one?
100
100
  end
101
101
 
102
+ private
103
+
102
104
  def check!(table, name, schema)
103
105
  return FAKE if schema == FAKE
104
106
 
@@ -0,0 +1,181 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+
6
+ # The value engine behind FakeClient and Cassette#anonymize!: seeded,
7
+ # type-correct scalar generation with optional faker-backed semantics
8
+ # matched on field names — strings (name/email/url/...) and numbers
9
+ # (age/price/count/latitude/...) alike. Keeps a consistent id mapping so
10
+ # the same original id always anonymizes to the same fake id.
11
+ class GraphWeaver::Internal::Values
12
+ include GraphWeaver::Inflect
13
+
14
+ STRING_SEMANTICS = {
15
+ /email/ => -> { ::Faker::Internet.email },
16
+ /(^|_)first_name$/ => -> { ::Faker::Name.first_name },
17
+ /(^|_)last_name$/ => -> { ::Faker::Name.last_name },
18
+ /(^|_)(full_)?name$/ => -> { ::Faker::Name.name },
19
+ /(^|_)(url|website|link)$/ => -> { ::Faker::Internet.url },
20
+ /phone/ => -> { ::Faker::PhoneNumber.phone_number },
21
+ /(^|_)address$/ => -> { ::Faker::Address.full_address },
22
+ /(^|_)(city)$/ => -> { ::Faker::Address.city },
23
+ /(^|_)(title|description)$/ => -> { ::Faker::Lorem.sentence(word_count: 3) },
24
+ }.freeze
25
+
26
+ NUMBER_SEMANTICS = {
27
+ /(^|_)age$/ => ->(rng) { rng.rand(1..99) },
28
+ /(^|_)(price|amount|cost|total)(_cents)?$/ => ->(rng) { (rng.rand(1.0..10_000.0) * 100).round / 100.0 },
29
+ /(^|_)(count|quantity|size)$/ => ->(rng) { rng.rand(0..100) },
30
+ /latitude/ => ->(rng) { rng.rand(-90.0..90.0).round(6) },
31
+ /longitude/ => ->(rng) { rng.rand(-180.0..180.0).round(6) },
32
+ /(^|_)year$/ => ->(rng) { rng.rand(1970..2030) },
33
+ }.freeze
34
+
35
+ # The Ruby shape a fabricated value has to take, keyed by the class the
36
+ # scalar registry says the scalar deserializes into. That registry is what
37
+ # codegen emitted the prop and its cast from, so a scalar registered as
38
+ # Time needs an iso8601 string whatever the schema happens to call it.
39
+ REGISTERED_SHAPES = {
40
+ "String" => :string,
41
+ "Integer" => :integer,
42
+ "Float" => :float,
43
+ "T::Boolean" => :boolean,
44
+ "Date" => :date,
45
+ "Time" => :time,
46
+ "DateTime" => :time,
47
+ }.freeze
48
+
49
+ # What Codegen.scalar reports for a scalar nobody registered
50
+ UNREGISTERED = "T.untyped"
51
+
52
+ # The fallback, for a scalar nobody registered: its prop is T.untyped, so
53
+ # anything holds and a plausible shape beats a placeholder.
54
+ NAMED_SHAPES = {
55
+ "ID" => :id,
56
+ "String" => :string,
57
+ "Int" => :integer,
58
+ "Float" => :float,
59
+ "Boolean" => :boolean,
60
+ "Date" => :date,
61
+ "DateTime" => :time,
62
+ "Time" => :time,
63
+ "ISO8601DateTime" => :time,
64
+ }.freeze
65
+
66
+ attr_reader :rng
67
+
68
+ # pins: what the fake uses instead of inventing a value, keyed by schema
69
+ # name. Only the scalar-type keys ("Money") are read here — a coordinate
70
+ # pin is resolved against the query, which is the fake's job. Left unsaid
71
+ # they are the suite's, so a scalar only the app can write for is
72
+ # fabricable from the cassette anonymizer too.
73
+ def initialize(seed: nil, values: nil, pins: nil)
74
+ @rng = Random.new(seed || GraphWeaver::Testing.config.seed || Random.new_seed)
75
+ @pins = (pins || GraphWeaver::Testing.config.overrides).transform_keys(&:to_s)
76
+ @style = resolve_style(values)
77
+ @sequence = 0
78
+ @id_map = {}
79
+ @resolved = {}
80
+ end
81
+
82
+ # coordinate: the "Type.field" this value is for, so a per-field
83
+ # register_scalar resolves the way codegen resolved it when it emitted the
84
+ # cast — and so a refusal can advise an override key that pins this field
85
+ # alone. at: where the walk is ("reader.orders.0.total"), for that refusal;
86
+ # a walk that doesn't track one leaves it unsaid.
87
+ def scalar(type_name, field_name, coordinate = nil, at: nil)
88
+ return GraphWeaver::Internal::Overrides.resolve(@pins[type_name], rng) if @pins.key?(type_name)
89
+
90
+ registered, shape = resolve(type_name, coordinate)
91
+ prop = underscore(field_name)
92
+
93
+ if @style == :faker
94
+ # rebind per call: several Values instances may interleave (e.g. two
95
+ # seeded fakes), and faker's rng is global
96
+ ::Faker::Config.random = @rng
97
+ case shape
98
+ when :string
99
+ STRING_SEMANTICS.each { |pattern, faker| return faker.call if pattern.match?(prop) }
100
+ when :integer, :float
101
+ NUMBER_SEMANTICS.each do |pattern, gen|
102
+ next unless pattern.match?(prop)
103
+
104
+ value = gen.call(@rng)
105
+ return (shape == :integer) ? value.to_i : value.to_f
106
+ end
107
+ end
108
+ end
109
+
110
+ case shape
111
+ when :id then (@sequence += 1).to_s
112
+ when :string then "#{field_name}-#{@sequence += 1}"
113
+ when :integer then @rng.rand(0..1_000)
114
+ when :float then @rng.rand(0.0..1_000.0).round(2)
115
+ when :boolean then [true, false].sample(random: @rng)
116
+ when :date then (Date.new(2020, 1, 1) + @rng.rand(0..2_000)).iso8601
117
+ when :time then Time.at(1_600_000_000 + @rng.rand(0..100_000_000)).utc.iso8601
118
+ when :unregistered then "#{type_name}-#{@sequence += 1}" # nobody registered it: prop is T.untyped
119
+ else unfakeable!(type_name, field_name, registered, coordinate, at)
120
+ end
121
+ end
122
+
123
+ # same original id => same fake id, so relationships survive anonymization
124
+ def mapped_id(original)
125
+ @id_map[original] ||= (@sequence += 1).to_s
126
+ end
127
+
128
+ private
129
+
130
+ # The registration in play and the shape it wants, memoized per scalar (or
131
+ # per coordinate, where a field-level registration overrides it).
132
+ def resolve(type_name, coordinate)
133
+ @resolved[coordinate || type_name] ||= begin
134
+ registered = GraphWeaver::Codegen.scalar(type_name, coordinate)
135
+ [registered, shape_of(type_name, registered.type)]
136
+ end
137
+ end
138
+
139
+ # ID asks by name as well as by class: it registers as String, and an id
140
+ # repeated across a list breaks a `find` or `group_by` in the code under
141
+ # test. Registered as anything else, it takes that type's treatment.
142
+ def shape_of(type_name, ruby_type)
143
+ if type_name == "ID" && ruby_type == "String"
144
+ :id
145
+ elsif (shape = REGISTERED_SHAPES[ruby_type])
146
+ shape
147
+ elsif ruby_type == UNREGISTERED
148
+ NAMED_SHAPES[type_name] || :unregistered
149
+ else
150
+ :unfakeable
151
+ end
152
+ end
153
+
154
+ # An app class is the one Ruby type nothing here can invent a wire value
155
+ # for — `Money.parse` accepts what its author decided it accepts — and
156
+ # guessing hands the generated cast a placeholder, which fails deep inside
157
+ # from_h blaming the codec.
158
+ def unfakeable!(type_name, field_name, registered, coordinate, at)
159
+ raise GraphWeaver::Error, "can't fabricate a #{type_name} #{at ? "at #{at}" : "for #{field_name.inspect}"}: " \
160
+ "it deserializes into #{registered.type}, and only you know what wire value that accepts. " \
161
+ "Pin the type — overrides: { #{type_name.inspect} => ... } — or this one field: " \
162
+ "overrides: { #{(coordinate || field_name).inspect} => ... }. Suite-wide, that's " \
163
+ "GraphWeaver::Testing.config.overrides."
164
+ end
165
+
166
+ # :faker is an explicit ask — fail loudly when the gem is missing; auto
167
+ # (nil) quietly falls back to :literal
168
+ def resolve_style(style)
169
+ case style
170
+ when :faker
171
+ raise ArgumentError, "values: :faker requires the faker gem (add it to your Gemfile's test group)" unless defined?(::Faker)
172
+
173
+ :faker
174
+ when :literal then :literal
175
+ when nil then defined?(::Faker) ? :faker : :literal
176
+ else
177
+ raise ArgumentError, "values: must be one of #{GraphWeaver::Testing::VALUE_STYLES.inspect} " \
178
+ "(or nil for auto), got #{style.inspect}"
179
+ end
180
+ end
181
+ end