graph_weaver 0.5.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +409 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +6 -1
- data/docs/editors.md +3 -1
- data/docs/errors.md +73 -16
- data/docs/federation.md +201 -151
- data/docs/generated_modules.md +222 -165
- data/docs/getting_started.md +105 -81
- data/docs/logging.md +34 -4
- data/docs/scalars.md +119 -24
- data/docs/testing.md +191 -151
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +210 -11
- data/lib/generators/graph_weaver/install_generator.rb +16 -1
- data/lib/graph_weaver/client.rb +46 -13
- data/lib/graph_weaver/codegen/aliases.rb +5 -4
- data/lib/graph_weaver/codegen/emit.rb +96 -39
- data/lib/graph_weaver/codegen/enum_type.rb +3 -0
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
- data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
- data/lib/graph_weaver/codegen.rb +284 -84
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +30 -7
- data/lib/graph_weaver/federation.rb +6 -5
- data/lib/graph_weaver/hints.rb +76 -2
- data/lib/graph_weaver/in_process.rb +11 -8
- data/lib/graph_weaver/inflect.rb +2 -0
- data/lib/graph_weaver/input_struct.rb +115 -12
- data/lib/graph_weaver/internal/overrides.rb +101 -0
- data/lib/graph_weaver/internal/planner.rb +868 -0
- data/lib/graph_weaver/internal/schemas.rb +50 -0
- data/lib/graph_weaver/internal/selection.rb +127 -0
- data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
- data/lib/graph_weaver/internal/values.rb +181 -0
- data/lib/graph_weaver/internal.rb +206 -0
- data/lib/graph_weaver/logging.rb +108 -20
- data/lib/graph_weaver/parsing.rb +5 -4
- data/lib/graph_weaver/query_module.rb +2 -0
- data/lib/graph_weaver/railtie.rb +113 -14
- data/lib/graph_weaver/representation.rb +30 -2
- data/lib/graph_weaver/response.rb +15 -0
- data/lib/graph_weaver/retry.rb +54 -22
- data/lib/graph_weaver/rspec.rb +50 -11
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +96 -29
- data/lib/graph_weaver/tasks.rb +78 -29
- data/lib/graph_weaver/testing/cassette.rb +49 -65
- data/lib/graph_weaver/testing/coverage.rb +5 -4
- data/lib/graph_weaver/testing/failure.rb +10 -6
- data/lib/graph_weaver/testing/fake_client.rb +253 -60
- data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
- data/lib/graph_weaver/testing/router.rb +94 -808
- data/lib/graph_weaver/testing.rb +35 -84
- data/lib/graph_weaver/transport/faraday.rb +1 -1
- data/lib/graph_weaver/transport/http.rb +29 -12
- data/lib/graph_weaver/transport.rb +11 -34
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +188 -110
- metadata +10 -5
- data/lib/graph_weaver/schemas.rb +0 -48
- data/lib/graph_weaver/selection.rb +0 -120
- 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 "
|
|
7
|
+
require_relative "schemas"
|
|
8
8
|
|
|
9
9
|
module GraphWeaver
|
|
10
|
-
module
|
|
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
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
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
|
-
#
|
|
33
|
-
#
|
|
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,23 +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
53
|
named = table.named_subgraphs(given)
|
|
49
|
-
searched = schemas ||
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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?
|
|
67
|
+
end
|
|
68
|
+
resolution
|
|
54
69
|
end
|
|
55
70
|
|
|
56
71
|
# every loaded schema that defines what the table says `name` resolves
|
|
57
|
-
def candidates(table, name, schemas =
|
|
58
|
-
|
|
72
|
+
def candidates(table, name, schemas = Schemas.loaded)
|
|
73
|
+
wanted = expected(table, name)
|
|
74
|
+
schemas.select { |schema| wanted.all? { |coordinate| Schemas.defines?(schema, coordinate) } }
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
# The schema coordinates the supergraph says `name` resolves — "Type"
|
|
@@ -72,37 +88,19 @@ module GraphWeaver
|
|
|
72
88
|
|
|
73
89
|
# which of them `schema` doesn't define
|
|
74
90
|
def missing(table, name, schema)
|
|
75
|
-
expected(table, name).reject { |coordinate|
|
|
91
|
+
expected(table, name).reject { |coordinate| Schemas.defines?(schema, coordinate) }
|
|
76
92
|
end
|
|
77
93
|
|
|
78
94
|
# Is this subgraph served in this process? Exactly one candidate is
|
|
79
95
|
# what that means: none is somebody else's service, and several is a
|
|
80
96
|
# question only the caller can answer, so neither is something a suite
|
|
81
|
-
# can run against.
|
|
82
|
-
|
|
83
|
-
# same verdict phrased for something that never raises.
|
|
84
|
-
def served?(table, name, schemas = GraphWeaver::Schemas.loaded)
|
|
97
|
+
# can run against.
|
|
98
|
+
def served?(table, name, schemas = Schemas.loaded)
|
|
85
99
|
candidates(table, name, schemas).one?
|
|
86
100
|
end
|
|
87
101
|
|
|
88
102
|
private
|
|
89
103
|
|
|
90
|
-
# The schema serving `name`, or nil when nothing here does — the
|
|
91
|
-
# subgraph is somebody else's, which is not an error until a query
|
|
92
|
-
# asks for it.
|
|
93
|
-
def detect(table, name, schemas)
|
|
94
|
-
found = candidates(table, name, schemas)
|
|
95
|
-
return found.first if found.one?
|
|
96
|
-
return if found.empty?
|
|
97
|
-
|
|
98
|
-
# by name: a dev reload leaves two class objects spelled the same,
|
|
99
|
-
# and naming one of them twice reads as a bug in the message
|
|
100
|
-
names = found.map(&:name).uniq.sort
|
|
101
|
-
raise GraphWeaver::ConfigurationError, "#{names.size} loaded schemas define everything the " \
|
|
102
|
-
"supergraph says #{name.inspect} resolves (#{names.join(", ")}) — pass subgraphs: naming " \
|
|
103
|
-
"the one you mean"
|
|
104
|
-
end
|
|
105
|
-
|
|
106
104
|
def check!(table, name, schema)
|
|
107
105
|
return FAKE if schema == FAKE
|
|
108
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
|