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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +537 -0
- data/Gemfile.lock +19 -19
- data/README.md +74 -53
- data/docs/cassettes.md +29 -4
- data/docs/editors.md +3 -1
- data/docs/errors.md +75 -16
- data/docs/federation.md +206 -155
- data/docs/generated_modules.md +223 -166
- data/docs/getting_started.md +106 -82
- data/docs/logging.md +35 -5
- data/docs/scalars.md +119 -24
- data/docs/testing.md +196 -155
- data/docs/transports.md +47 -19
- data/docs/upgrading.md +243 -22
- data/graph_weaver.gemspec +16 -2
- data/lib/generators/graph_weaver/install_generator.rb +31 -16
- data/lib/graph_weaver/client.rb +52 -15
- data/lib/graph_weaver/codegen/aliases.rb +15 -8
- data/lib/graph_weaver/codegen/emit.rb +107 -42
- data/lib/graph_weaver/codegen/enum_type.rb +4 -3
- data/lib/graph_weaver/codegen/nodes.rb +42 -21
- data/lib/graph_weaver/codegen/scalar_type.rb +87 -83
- data/lib/graph_weaver/codegen/type_helpers.rb +2 -3
- data/lib/graph_weaver/codegen.rb +382 -105
- data/lib/graph_weaver/coerce.rb +113 -0
- data/lib/graph_weaver/errors.rb +57 -13
- data/lib/graph_weaver/federation.rb +10 -22
- 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 +45 -43
- 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 +6 -13
- 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 +63 -18
- data/lib/graph_weaver/schema_diff.rb +293 -0
- data/lib/graph_weaver/schema_loader.rb +126 -35
- data/lib/graph_weaver/tasks.rb +88 -36
- data/lib/graph_weaver/testing/cassette.rb +131 -78
- data/lib/graph_weaver/testing/coverage.rb +11 -15
- data/lib/graph_weaver/testing/failure.rb +14 -8
- 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 +147 -840
- data/lib/graph_weaver/testing.rb +40 -83
- 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 +221 -118
- metadata +17 -13
- data/CLAUDE.md +0 -161
- data/DECISIONS.md +0 -309
- data/Makefile +0 -23
- data/NOTES.md +0 -182
- data/PLAN.md +0 -115
- data/REVIEW.md +0 -946
- data/lib/graph_weaver/schemas.rb +0 -46
- data/lib/graph_weaver/selection.rb +0 -120
- data/lib/graph_weaver/testing/values.rb +0 -98
data/lib/graph_weaver/schemas.rb
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# typed: true
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require "graphql"
|
|
5
|
-
|
|
6
|
-
module GraphWeaver
|
|
7
|
-
# The graphql-ruby schema classes already in this process, and what each
|
|
8
|
-
# one defines. Where {SchemaLoader} *builds* a schema from a source — a
|
|
9
|
-
# path, SDL, an introspection dump — this reads classes the app loaded
|
|
10
|
-
# itself.
|
|
11
|
-
#
|
|
12
|
-
# Two features ask exactly these two questions, and match a schema on the
|
|
13
|
-
# coordinates it defines rather than on its class name: {Testing::Subgraphs}
|
|
14
|
-
# (which schema serves which subgraph) and {Federation::Drift} (has a
|
|
15
|
-
# subgraph changed without a recompose). They share the answers so the two
|
|
16
|
-
# can't drift apart.
|
|
17
|
-
module Schemas
|
|
18
|
-
class << self
|
|
19
|
-
# Every named GraphQL::Schema in the process. An anonymous one is
|
|
20
|
-
# graphql-ruby building from SDL — the router's own view of the
|
|
21
|
-
# supergraph is one — and never an app's subgraph.
|
|
22
|
-
def loaded
|
|
23
|
-
descendants(GraphQL::Schema).select(&:name)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Does this schema carry the coordinate — "Type", or "Type.field"?
|
|
27
|
-
def defines?(schema, coordinate)
|
|
28
|
-
type_name, field_name = coordinate.split(".", 2)
|
|
29
|
-
type = schema.get_type(type_name) or return false
|
|
30
|
-
return true unless field_name
|
|
31
|
-
|
|
32
|
-
return type.fields.key?(field_name) if type.respond_to?(:fields)
|
|
33
|
-
# an input object's members are arguments, not fields
|
|
34
|
-
return type.arguments.key?(field_name) if type.respond_to?(:arguments)
|
|
35
|
-
|
|
36
|
-
false
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
def descendants(klass)
|
|
42
|
-
klass.subclasses.flat_map { |subclass| [subclass] + descendants(subclass) }
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
# typed: true
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require "graphql"
|
|
5
|
-
|
|
6
|
-
module GraphWeaver
|
|
7
|
-
# Shared query-selection walking — the rules Codegen, FakeClient, and
|
|
8
|
-
# the cassette Anonymizer all follow, in one place so they can't drift:
|
|
9
|
-
# how fragments flatten into selections, and when a type condition
|
|
10
|
-
# applies. Hosts set @schema and call load_operation before walking.
|
|
11
|
-
module Selection
|
|
12
|
-
include Kernel # for sorbet: hosts are Objects
|
|
13
|
-
|
|
14
|
-
# Parse a query, stash its fragment definitions for the walk, and
|
|
15
|
-
# return the operation.
|
|
16
|
-
def load_operation(query)
|
|
17
|
-
doc = GraphQL.parse(query)
|
|
18
|
-
@fragments = doc.definitions
|
|
19
|
-
.grep(GraphQL::Language::Nodes::FragmentDefinition)
|
|
20
|
-
.to_h { |fragment| [fragment.name, fragment] }
|
|
21
|
-
|
|
22
|
-
operations = doc.definitions.grep(GraphQL::Language::Nodes::OperationDefinition)
|
|
23
|
-
# One file, one operation. Requests do carry operationName now, so a
|
|
24
|
-
# second operation would run fine on the wire — what has no answer is
|
|
25
|
-
# naming: a module is named after its FILE (person.graphql =>
|
|
26
|
-
# PersonQuery), and one file can't name two. A convention, not a limit.
|
|
27
|
-
if operations.size > 1
|
|
28
|
-
names = operations.map { |op| op.name ? "'#{op.name}'" : "an anonymous operation" }
|
|
29
|
-
raise GraphWeaver::Error,
|
|
30
|
-
"document defines #{operations.size} operations (#{names.join(", ")}) — " \
|
|
31
|
-
"split them into one file each, since a module is named after its file"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
operations.first
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# The schema type an operation's selections start from.
|
|
38
|
-
def operation_root_type(operation)
|
|
39
|
-
case operation&.operation_type
|
|
40
|
-
when "query", nil then @schema.query
|
|
41
|
-
when "mutation" then @schema.mutation
|
|
42
|
-
else raise GraphWeaver::Error, "unsupported operation: #{operation.operation_type}"
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Flatten a selection set as seen by `type`, yielding (result_key,
|
|
47
|
-
# field_node, conditional) per field: plain fields yield directly; inline
|
|
48
|
-
# fragments and named spreads recurse when their type condition applies.
|
|
49
|
-
# `conditional` is true when any fragment on the way down carried
|
|
50
|
-
# @skip/@include — the whole block may be absent from the response, so
|
|
51
|
-
# everything under it is as optional as a directly-skipped field.
|
|
52
|
-
def each_field(type, selections, visiting = Set.new, conditional: false, &block)
|
|
53
|
-
selections.each do |selection|
|
|
54
|
-
case selection
|
|
55
|
-
when GraphQL::Language::Nodes::Field
|
|
56
|
-
yield(selection.alias || selection.name, selection, conditional)
|
|
57
|
-
when GraphQL::Language::Nodes::InlineFragment
|
|
58
|
-
next unless applies?(selection.type&.name, type)
|
|
59
|
-
|
|
60
|
-
each_field(type, selection.selections, visiting,
|
|
61
|
-
conditional: conditional || conditional?(selection), &block)
|
|
62
|
-
when GraphQL::Language::Nodes::FragmentSpread
|
|
63
|
-
fragment = @fragments.fetch(selection.name) do
|
|
64
|
-
raise ArgumentError, "unknown fragment: #{selection.name}"
|
|
65
|
-
end
|
|
66
|
-
if visiting.include?(selection.name)
|
|
67
|
-
raise GraphWeaver::Error, "fragment cycle through #{selection.name}"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
if applies?(fragment.type.name, type)
|
|
71
|
-
# the directive rides on the SPREAD, not the definition it names
|
|
72
|
-
each_field(type, fragment.selections, visiting | [selection.name],
|
|
73
|
-
conditional: conditional || conditional?(selection), &block)
|
|
74
|
-
end
|
|
75
|
-
else
|
|
76
|
-
raise GraphWeaver::Error, "unsupported selection: #{selection.class}"
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# each_field grouped by result key: repeated selections of one field
|
|
82
|
-
# (`a { x } a { y }`, or the same field reached through two fragments)
|
|
83
|
-
# collect together, so callers MERGE their sub-selections rather than
|
|
84
|
-
# last-writer-wins. Codegen relies on this; FakeClient/Anonymizer must too,
|
|
85
|
-
# or they'd fabricate/keep a shape the generated struct can't cast.
|
|
86
|
-
def gather(type, selections)
|
|
87
|
-
gather_conditional(type, selections).transform_values { |occurrences| occurrences.map(&:first) }
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# gather, keeping each occurrence's [field_node, conditional] — the wire
|
|
91
|
-
# key is guaranteed only when SOME occurrence is unconditional.
|
|
92
|
-
def gather_conditional(type, selections)
|
|
93
|
-
out = {}
|
|
94
|
-
each_field(type, selections) { |key, node, conditional| (out[key] ||= []) << [node, conditional] }
|
|
95
|
-
out
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Directives that can drop a selection from the response whatever the
|
|
99
|
-
# schema says — the reason a conditional field's generated type is nilable.
|
|
100
|
-
CONDITIONAL_DIRECTIVES = %w[skip include].freeze
|
|
101
|
-
|
|
102
|
-
# Is this AST node (field, inline fragment, or spread) behind @skip/@include?
|
|
103
|
-
def conditional?(node)
|
|
104
|
-
node.directives.any? { |directive| CONDITIONAL_DIRECTIVES.include?(directive.name) }
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# A fragment's type condition applies when it names this type exactly,
|
|
108
|
-
# or an interface/union this type belongs to (`... on Named { ... }`).
|
|
109
|
-
def applies?(condition, type)
|
|
110
|
-
return true if condition.nil? || condition == type.graphql_name
|
|
111
|
-
|
|
112
|
-
condition_type = @schema.get_type(condition)
|
|
113
|
-
return false unless condition_type
|
|
114
|
-
|
|
115
|
-
kind = condition_type.kind.name
|
|
116
|
-
(kind == "INTERFACE" || kind == "UNION") &&
|
|
117
|
-
@schema.possible_types(condition_type).include?(type)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|
|
@@ -1,98 +0,0 @@
|
|
|
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::Testing::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
|
-
attr_reader :rng
|
|
36
|
-
|
|
37
|
-
def initialize(seed: nil, mode: nil)
|
|
38
|
-
config = GraphWeaver::Testing.config
|
|
39
|
-
@rng = Random.new(seed || config.seed || Random.new_seed)
|
|
40
|
-
@mode = resolve_mode(mode || config.mode)
|
|
41
|
-
@sequence = 0
|
|
42
|
-
@id_map = {}
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def scalar(type_name, field_name)
|
|
46
|
-
prop = underscore(field_name)
|
|
47
|
-
|
|
48
|
-
if @mode == :faker
|
|
49
|
-
# rebind per call: several Values instances may interleave (e.g. two
|
|
50
|
-
# seeded fakes), and faker's rng is global
|
|
51
|
-
::Faker::Config.random = @rng
|
|
52
|
-
case type_name
|
|
53
|
-
when "String"
|
|
54
|
-
STRING_SEMANTICS.each { |pattern, faker| return faker.call if pattern.match?(prop) }
|
|
55
|
-
when "Int", "Float"
|
|
56
|
-
NUMBER_SEMANTICS.each do |pattern, gen|
|
|
57
|
-
next unless pattern.match?(prop)
|
|
58
|
-
|
|
59
|
-
value = gen.call(@rng)
|
|
60
|
-
return type_name == "Int" ? value.to_i : value.to_f
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
case type_name
|
|
66
|
-
when "ID" then (@sequence += 1).to_s
|
|
67
|
-
when "String" then "#{field_name}-#{@sequence += 1}"
|
|
68
|
-
when "Int" then @rng.rand(0..1_000)
|
|
69
|
-
when "Float" then @rng.rand(0.0..1_000.0).round(2)
|
|
70
|
-
when "Boolean" then [true, false].sample(random: @rng)
|
|
71
|
-
when "Date" then (Date.new(2020, 1, 1) + @rng.rand(0..2_000)).iso8601
|
|
72
|
-
when "DateTime", "Time", "ISO8601DateTime" then Time.at(1_600_000_000 + @rng.rand(0..100_000_000)).utc.iso8601
|
|
73
|
-
else "#{type_name}-#{@sequence += 1}" # unknown custom scalar: override it
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# same original id => same fake id, so relationships survive anonymization
|
|
78
|
-
def mapped_id(original)
|
|
79
|
-
@id_map[original] ||= (@sequence += 1).to_s
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
private
|
|
83
|
-
|
|
84
|
-
# :faker is an explicit ask — fail loudly when the gem is missing; auto
|
|
85
|
-
# (nil) quietly falls back to :literal
|
|
86
|
-
def resolve_mode(mode)
|
|
87
|
-
case mode
|
|
88
|
-
when :faker
|
|
89
|
-
raise ArgumentError, "mode: :faker requires the faker gem (add it to your Gemfile's test group)" unless defined?(::Faker)
|
|
90
|
-
|
|
91
|
-
:faker
|
|
92
|
-
when :literal then :literal
|
|
93
|
-
when nil then defined?(::Faker) ? :faker : :literal
|
|
94
|
-
else
|
|
95
|
-
raise ArgumentError, "mode: must be one of #{GraphWeaver::Testing::MODES.inspect} (or nil for auto), got #{mode.inspect}"
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|