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
@@ -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