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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/Gemfile.lock +19 -19
  4. data/README.md +74 -53
  5. data/docs/cassettes.md +6 -1
  6. data/docs/editors.md +3 -1
  7. data/docs/errors.md +73 -16
  8. data/docs/federation.md +201 -151
  9. data/docs/generated_modules.md +222 -165
  10. data/docs/getting_started.md +105 -81
  11. data/docs/logging.md +34 -4
  12. data/docs/scalars.md +119 -24
  13. data/docs/testing.md +191 -151
  14. data/docs/transports.md +47 -19
  15. data/docs/upgrading.md +210 -11
  16. data/lib/generators/graph_weaver/install_generator.rb +16 -1
  17. data/lib/graph_weaver/client.rb +46 -13
  18. data/lib/graph_weaver/codegen/aliases.rb +5 -4
  19. data/lib/graph_weaver/codegen/emit.rb +96 -39
  20. data/lib/graph_weaver/codegen/enum_type.rb +3 -0
  21. data/lib/graph_weaver/codegen/nodes.rb +42 -21
  22. data/lib/graph_weaver/codegen/scalar_type.rb +82 -79
  23. data/lib/graph_weaver/codegen/type_helpers.rb +1 -0
  24. data/lib/graph_weaver/codegen.rb +284 -84
  25. data/lib/graph_weaver/coerce.rb +113 -0
  26. data/lib/graph_weaver/errors.rb +30 -7
  27. data/lib/graph_weaver/federation.rb +6 -5
  28. data/lib/graph_weaver/hints.rb +76 -2
  29. data/lib/graph_weaver/in_process.rb +11 -8
  30. data/lib/graph_weaver/inflect.rb +2 -0
  31. data/lib/graph_weaver/input_struct.rb +115 -12
  32. data/lib/graph_weaver/internal/overrides.rb +101 -0
  33. data/lib/graph_weaver/internal/planner.rb +868 -0
  34. data/lib/graph_weaver/internal/schemas.rb +50 -0
  35. data/lib/graph_weaver/internal/selection.rb +127 -0
  36. data/lib/graph_weaver/{testing → internal}/subgraphs.rb +39 -41
  37. data/lib/graph_weaver/internal/values.rb +181 -0
  38. data/lib/graph_weaver/internal.rb +206 -0
  39. data/lib/graph_weaver/logging.rb +108 -20
  40. data/lib/graph_weaver/parsing.rb +5 -4
  41. data/lib/graph_weaver/query_module.rb +2 -0
  42. data/lib/graph_weaver/railtie.rb +113 -14
  43. data/lib/graph_weaver/representation.rb +30 -2
  44. data/lib/graph_weaver/response.rb +15 -0
  45. data/lib/graph_weaver/retry.rb +54 -22
  46. data/lib/graph_weaver/rspec.rb +50 -11
  47. data/lib/graph_weaver/schema_diff.rb +293 -0
  48. data/lib/graph_weaver/schema_loader.rb +96 -29
  49. data/lib/graph_weaver/tasks.rb +78 -29
  50. data/lib/graph_weaver/testing/cassette.rb +49 -65
  51. data/lib/graph_weaver/testing/coverage.rb +5 -4
  52. data/lib/graph_weaver/testing/failure.rb +10 -6
  53. data/lib/graph_weaver/testing/fake_client.rb +253 -60
  54. data/lib/graph_weaver/testing/fake_subgraph.rb +19 -8
  55. data/lib/graph_weaver/testing/router.rb +94 -808
  56. data/lib/graph_weaver/testing.rb +35 -84
  57. data/lib/graph_weaver/transport/faraday.rb +1 -1
  58. data/lib/graph_weaver/transport/http.rb +29 -12
  59. data/lib/graph_weaver/transport.rb +11 -34
  60. data/lib/graph_weaver/version.rb +1 -1
  61. data/lib/graph_weaver.rb +188 -110
  62. metadata +10 -5
  63. data/lib/graph_weaver/schemas.rb +0 -48
  64. data/lib/graph_weaver/selection.rb +0 -120
  65. data/lib/graph_weaver/testing/values.rb +0 -98
@@ -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