graph_weaver 0.7.3 → 0.7.4
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/Gemfile.lock +2 -2
- data/docs/federation.md +3 -2
- data/docs/generated_modules.md +49 -8
- data/docs/getting_started.md +15 -0
- data/docs/i18n.md +4 -4
- data/docs/scalars.md +123 -32
- data/docs/testing.md +21 -2
- data/docs/upgrading.md +31 -5
- data/examples/github/generated/star_mutation.rb +24 -2
- data/examples/github/generated/stargazers_query.rb +61 -5
- data/examples/github/generated/starred_query.rb +33 -3
- data/lib/graph_weaver/client.rb +23 -0
- data/lib/graph_weaver/codegen/emit.rb +22 -7
- data/lib/graph_weaver/codegen/enum_type.rb +132 -19
- data/lib/graph_weaver/codegen/nodes.rb +20 -9
- data/lib/graph_weaver/codegen/scalar_type.rb +72 -18
- data/lib/graph_weaver/codegen/type_helpers.rb +71 -13
- data/lib/graph_weaver/codegen.rb +61 -16
- data/lib/graph_weaver/coerce.rb +24 -5
- data/lib/graph_weaver/graph.rb +4 -1
- data/lib/graph_weaver/hints.rb +4 -1
- data/lib/graph_weaver/input_struct.rb +13 -7
- data/lib/graph_weaver/internal/values.rb +5 -2
- data/lib/graph_weaver/internal.rb +67 -0
- data/lib/graph_weaver/testing.rb +101 -0
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +67 -67
- metadata +2 -2
data/lib/graph_weaver/testing.rb
CHANGED
|
@@ -388,6 +388,107 @@ module GraphWeaver
|
|
|
388
388
|
# The configured directory as a real path — a rake task or an rspec run
|
|
389
389
|
# starts from wherever it starts from; the cassettes don't move.
|
|
390
390
|
def cassette_dir = Internal::Util.resolve(config.cassette_dir)
|
|
391
|
+
|
|
392
|
+
# Whether a scalar's two definitions agree: the server's
|
|
393
|
+
# coerce_input/coerce_result, and your register_scalar. No schema
|
|
394
|
+
# carries the server's half — a scalar's SDL is its name and a url —
|
|
395
|
+
# so nothing `verify`, `schema:diff` or `generate` reads can say. A
|
|
396
|
+
# schema CLASS carries both, and this runs them against each other.
|
|
397
|
+
#
|
|
398
|
+
# Per scalar the schema declares and your app registered: fabricate a
|
|
399
|
+
# value the way :fake does, cast it, send it back out through
|
|
400
|
+
# `serialize:`, through the server's `coerce_input` and `coerce_result`,
|
|
401
|
+
# and back through `cast:`. Raises naming every scalar that disagreed
|
|
402
|
+
# and how; silent when they all agree.
|
|
403
|
+
#
|
|
404
|
+
# Pass the schema CLASS. A dump's scalars pass values through, so
|
|
405
|
+
# against one this checks only that a registration's `cast:` accepts
|
|
406
|
+
# what its own `serialize:` writes — which is worth knowing, and is not
|
|
407
|
+
# the same question.
|
|
408
|
+
#
|
|
409
|
+
# The fabricated value is what the check has to work with, so pin the
|
|
410
|
+
# one that matters where it matters —
|
|
411
|
+
# `config.overrides = { "Decimal" => "123456789.123456789" }` is how
|
|
412
|
+
# the precision case gets exercised at all.
|
|
413
|
+
def check_scalars!(schema)
|
|
414
|
+
registry = Internal::Util.registry_for(schema)
|
|
415
|
+
values = Internal::Values.new(seed: 0, schema:, registry:)
|
|
416
|
+
context = GraphQL::Query.new(schema, "{ __typename }").context
|
|
417
|
+
|
|
418
|
+
disagreed = schema.types.values.sort_by(&:graphql_name).filter_map do |type|
|
|
419
|
+
next unless type.kind.name == "SCALAR"
|
|
420
|
+
# the built-in entries are the library's own; it is your
|
|
421
|
+
# registration that can be wrong about this server
|
|
422
|
+
next if registry.builtin_scalar?(type.graphql_name) ||
|
|
423
|
+
!registry.scalar_registry.key?(type.graphql_name)
|
|
424
|
+
|
|
425
|
+
disagreement(registry.scalar(type.graphql_name), type, values, context)
|
|
426
|
+
end
|
|
427
|
+
return if disagreed.empty?
|
|
428
|
+
|
|
429
|
+
raise GraphWeaver::Error, "#{disagreed.size} scalar(s) disagree with #{schema}:\n" +
|
|
430
|
+
disagreed.map { |line| " #{line}" }.join("\n")
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
private
|
|
434
|
+
|
|
435
|
+
# One scalar's verdict, or nil when the two halves agree. Each step is
|
|
436
|
+
# a different mistake, so each says which.
|
|
437
|
+
def disagreement(scalar, type, values, context)
|
|
438
|
+
name = type.graphql_name
|
|
439
|
+
# a scalar registered as your own class has no fabricable value, and
|
|
440
|
+
# it says how to pin one — reported here rather than raised, so one
|
|
441
|
+
# unpinned scalar doesn't hide the verdict on all the others
|
|
442
|
+
begin
|
|
443
|
+
wire = values.scalar(name, name)
|
|
444
|
+
rescue GraphWeaver::Error => e
|
|
445
|
+
return "#{name}: #{e.message}"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
cast = cast_proc(scalar)
|
|
449
|
+
begin
|
|
450
|
+
sample = cast.call(wire)
|
|
451
|
+
rescue StandardError => e
|
|
452
|
+
return "#{name}: cast: can't read #{wire.inspect}, the value fabricated for it (#{e.message}) " \
|
|
453
|
+
"— pin the form this server sends: overrides: { #{name.inspect} => ... }"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
if scalar.serialize? && !scalar.serialize_value?
|
|
457
|
+
return "#{name}: serialize: is a Proc, which builds source for the generated file rather " \
|
|
458
|
+
"than converting a value, so there is nothing here to run it against"
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
out = scalar.serialize_value(sample)
|
|
462
|
+
refused = "#{name}: the server refused #{out.inspect}, the wire form serialize: writes"
|
|
463
|
+
begin
|
|
464
|
+
received = type.coerce_input(out, context)
|
|
465
|
+
rescue StandardError => e
|
|
466
|
+
return "#{refused} (#{e.message})"
|
|
467
|
+
end
|
|
468
|
+
return "#{refused} (coerce_input returned nil)" if received.nil? && !out.nil?
|
|
469
|
+
|
|
470
|
+
result = type.coerce_result(received, context)
|
|
471
|
+
begin
|
|
472
|
+
back = cast.call(result)
|
|
473
|
+
rescue StandardError => e
|
|
474
|
+
return "#{name}: cast: refused #{result.inspect}, the result form the server's " \
|
|
475
|
+
"coerce_result writes (#{e.message})"
|
|
476
|
+
end
|
|
477
|
+
return if back == sample
|
|
478
|
+
|
|
479
|
+
"#{name}: round-trips lossily — sent #{sample.inspect}, got back #{back.inspect}"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# The registration's `cast:`, RUN rather than emitted. A cast builds
|
|
483
|
+
# SOURCE for the generated file, so evaluating it is the only way to
|
|
484
|
+
# run one — at the top level, where a generated file's own constants
|
|
485
|
+
# resolve from.
|
|
486
|
+
def cast_proc(scalar)
|
|
487
|
+
source = scalar.cast("wire")
|
|
488
|
+
return ->(wire) { wire } if source.nil?
|
|
489
|
+
|
|
490
|
+
eval("->(wire) { #{source} }", TOPLEVEL_BINDING, __FILE__, __LINE__) # rubocop:disable Security/Eval
|
|
491
|
+
end
|
|
391
492
|
end
|
|
392
493
|
end
|
|
393
494
|
end
|
data/lib/graph_weaver/version.rb
CHANGED
data/lib/graph_weaver.rb
CHANGED
|
@@ -29,7 +29,11 @@ module GraphWeaver
|
|
|
29
29
|
# How far into a file to look for it: the header sits under the `typed:` and
|
|
30
30
|
# `frozen_string_literal:` magic comments, never deeper.
|
|
31
31
|
HEADER_SCAN_LINES = 10
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
# Where a graph declares the modules its extend_type blocks mint — an .rbi,
|
|
34
|
+
# so the declaration reaches `srb tc` and nothing else (see helpers_rbi).
|
|
35
|
+
HELPERS_RBI = "type_helpers.rbi"
|
|
36
|
+
private_constant :GENERATED_HEADER, :HEADER_SCAN_LINES, :HELPERS_RBI
|
|
33
37
|
|
|
34
38
|
class << self
|
|
35
39
|
# A client for one GraphQL server — transport, schema, and scoped
|
|
@@ -484,12 +488,13 @@ module GraphWeaver
|
|
|
484
488
|
end
|
|
485
489
|
private :orphaned
|
|
486
490
|
|
|
487
|
-
# Every
|
|
491
|
+
# Every file under output that GraphWeaver wrote, identified by the header
|
|
488
492
|
# it emits. The header — not a *_query.rb glob — is what makes pruning
|
|
489
493
|
# safe: this is a real directory, and a hand-written file in it must
|
|
490
|
-
# survive regeneration.
|
|
494
|
+
# survive regeneration. .rbi too: a stale type-helper declaration would
|
|
495
|
+
# keep an app's srb tc green over an include that is gone.
|
|
491
496
|
def generated_files(output)
|
|
492
|
-
Dir[File.join(Internal::Util.resolve(output), "**/*.rb")].sort.select do |path|
|
|
497
|
+
Dir[File.join(Internal::Util.resolve(output), "**/*.{rb,rbi}")].sort.select do |path|
|
|
493
498
|
File.foreach(path).first(HEADER_SCAN_LINES).any? { |line| line.start_with?(GENERATED_HEADER) }
|
|
494
499
|
end
|
|
495
500
|
end
|
|
@@ -618,6 +623,9 @@ module GraphWeaver
|
|
|
618
623
|
# (products, reviews)", plus a "subgraphs" key — since knowing whose
|
|
619
624
|
# code to look at is half the answer. A plain schema is unaffected.
|
|
620
625
|
#
|
|
626
|
+
# One query you have as a *string* is Client#check_query — the same
|
|
627
|
+
# entries, against that client's own schema.
|
|
628
|
+
#
|
|
621
629
|
# A different question from verify_generated!, which asks whether the
|
|
622
630
|
# committed Ruby matches the committed schema. `rake
|
|
623
631
|
# graph_weaver:queries:check` prints this and exits non-zero.
|
|
@@ -628,7 +636,7 @@ module GraphWeaver
|
|
|
628
636
|
checked = checked_schema(graph)
|
|
629
637
|
table = checked_routing_table(graph)
|
|
630
638
|
Internal::Util.query_files(graph.queries).each do |path|
|
|
631
|
-
errors =
|
|
639
|
+
errors = Internal::QueryCheck.errors(checked, File.read(path), shared, table)
|
|
632
640
|
next if errors.empty?
|
|
633
641
|
|
|
634
642
|
# keyed by file, as it has always been — and two graphs may share a
|
|
@@ -647,20 +655,11 @@ module GraphWeaver
|
|
|
647
655
|
def checked_schema(graph) = graph.named_schema? ? graph.schema : refreshed_schema
|
|
648
656
|
private :checked_schema
|
|
649
657
|
|
|
650
|
-
# The routing table behind the schema check_queries is about to use,
|
|
651
|
-
#
|
|
652
|
-
#
|
|
653
|
-
# for every other source — a plain schema is entirely unaffected — and
|
|
654
|
-
# nil when a live schema class is what gets checked, since the dump then
|
|
655
|
-
# isn't what the errors came from.
|
|
658
|
+
# The routing table behind the schema check_queries is about to use, when
|
|
659
|
+
# there is one — nil when a live schema class is what gets checked, since
|
|
660
|
+
# the dump then isn't what the errors came from.
|
|
656
661
|
def checked_routing_table(graph)
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
path = graph.dump_path
|
|
660
|
-
return unless path&.end_with?(".graphql", ".gql")
|
|
661
|
-
|
|
662
|
-
sdl = File.read(path)
|
|
663
|
-
SchemaLoader.routing_table(sdl) if SchemaLoader.federation_sdl?(sdl)
|
|
662
|
+
Internal::QueryCheck.routing_table_for(graph.dump_path) unless graph.live_schema
|
|
664
663
|
end
|
|
665
664
|
private :checked_routing_table
|
|
666
665
|
|
|
@@ -686,53 +685,6 @@ module GraphWeaver
|
|
|
686
685
|
end
|
|
687
686
|
private :refreshed_schema
|
|
688
687
|
|
|
689
|
-
# One query's schema-validation errors as JSON-ready hashes, with the
|
|
690
|
-
# source position graphql-ruby reports. Unparseable counts as an error
|
|
691
|
-
# too — it doesn't validate either, and inline_fragments (which parses
|
|
692
|
-
# first) has already branded it with its position.
|
|
693
|
-
def validation_errors(schema, source, shared, table = nil)
|
|
694
|
-
# path omitted: the caller keys the report by file, so branding the
|
|
695
|
-
# message with it too would just print the path twice
|
|
696
|
-
schema.validate(Codegen.inline_fragments(source, shared)).map do |error|
|
|
697
|
-
detail = error.to_h
|
|
698
|
-
location = detail["locations"]&.first || {}
|
|
699
|
-
subgraphs = table ? attribute(table, detail["extensions"]) : []
|
|
700
|
-
entry = {
|
|
701
|
-
"message" => subgraphs.empty? ? error.message : "#{error.message} (#{subgraphs.join(", ")})",
|
|
702
|
-
"line" => location["line"],
|
|
703
|
-
"column" => location["column"],
|
|
704
|
-
}
|
|
705
|
-
subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
|
|
706
|
-
end
|
|
707
|
-
rescue GraphWeaver::QueryValidationError => e
|
|
708
|
-
# an unparseable query: codegen folds the position (and the file) into
|
|
709
|
-
# the message, and this report keeps them separate — same splitter the
|
|
710
|
-
# rendered error uses, so the two can't drift apart
|
|
711
|
-
e.errors.map do |detail|
|
|
712
|
-
_path, _position, message = QueryValidationError.split(detail)
|
|
713
|
-
detail.transform_keys(&:to_s).merge("message" => message)
|
|
714
|
-
end
|
|
715
|
-
end
|
|
716
|
-
private :validation_errors
|
|
717
|
-
|
|
718
|
-
# Which subgraphs a validation error is about, on a federated schema:
|
|
719
|
-
# "Field 'weight' doesn't exist on type 'Product'" is much less useful
|
|
720
|
-
# than the same line plus "(products)" — whose code to look at, whose
|
|
721
|
-
# team to talk to. graphql-ruby reports the coordinate structurally, so
|
|
722
|
-
# this is a lookup rather than message parsing. Both halves of the
|
|
723
|
-
# coordinate are required: an argument error reports typeName "Field"
|
|
724
|
-
# (the AST node kind, not a type), and looking that up would attribute
|
|
725
|
-
# confidently and wrongly.
|
|
726
|
-
def attribute(table, extensions)
|
|
727
|
-
return [] unless extensions
|
|
728
|
-
|
|
729
|
-
type_name, field_name = extensions.values_at("typeName", "fieldName")
|
|
730
|
-
return [] unless type_name && field_name
|
|
731
|
-
|
|
732
|
-
table.responsible(type_name, field_name)
|
|
733
|
-
end
|
|
734
|
-
private :attribute
|
|
735
|
-
|
|
736
688
|
# Load the generated modules — one line in an initializer or spec
|
|
737
689
|
# helper (loading happens only when you call this; skip it and
|
|
738
690
|
# require files yourself if you'd rather):
|
|
@@ -863,6 +815,7 @@ module GraphWeaver
|
|
|
863
815
|
|
|
864
816
|
used = { inputs: [], enums: [], mapped: [] }
|
|
865
817
|
used_unions = []
|
|
818
|
+
helpers = []
|
|
866
819
|
shared = Codegen.load_fragments(fragments)
|
|
867
820
|
|
|
868
821
|
refusals = []
|
|
@@ -886,6 +839,7 @@ module GraphWeaver
|
|
|
886
839
|
codegen.variable_type_names.each { |kind, names| used[kind] |= names }
|
|
887
840
|
found.concat(codegen.untyped_scalars).uniq!
|
|
888
841
|
used_unions |= codegen.used_union_names
|
|
842
|
+
helpers |= codegen.block_helpers
|
|
889
843
|
[filename, out]
|
|
890
844
|
rescue GraphWeaver::Error => e
|
|
891
845
|
# collected, not raised: nothing is written either way, and an adopter
|
|
@@ -903,16 +857,52 @@ module GraphWeaver
|
|
|
903
857
|
unions: used_unions, fragments: shared,
|
|
904
858
|
)
|
|
905
859
|
found.concat(codegen.untyped_scalars).uniq!
|
|
860
|
+
helpers |= codegen.block_helpers
|
|
906
861
|
# these land in the graph's output like any other file, so they collide
|
|
907
862
|
# with another graph's the same way
|
|
908
863
|
types.each_key { |filename| refuse_duplicate_file!(seen, filename, graph, graph.types_module) }
|
|
909
864
|
plan = types.to_a + plan
|
|
910
865
|
end
|
|
911
866
|
|
|
867
|
+
if helpers.any?
|
|
868
|
+
refuse_duplicate_file!(seen, HELPERS_RBI, graph, "the extend_type blocks")
|
|
869
|
+
plan = [[HELPERS_RBI, helpers_rbi(helpers)]] + plan
|
|
870
|
+
end
|
|
871
|
+
|
|
912
872
|
plan
|
|
913
873
|
end
|
|
914
874
|
private :generation_plan
|
|
915
875
|
|
|
876
|
+
# One rule: generation declares every constant it includes. A block-form
|
|
877
|
+
# extend_type mints its mixin at registration, so no source file declares
|
|
878
|
+
# GraphWeaver::TypeHelpers::Pet — and an app's `srb tc` failed on every
|
|
879
|
+
# generated include of one ("Unable to resolve constant ...").
|
|
880
|
+
#
|
|
881
|
+
# An .rbi rather than Ruby, because Ruby never loads one: the include stays
|
|
882
|
+
# the only thing that resolves the constant at runtime, which keeps a
|
|
883
|
+
# dropped registration loud (see load_generated!) instead of silently
|
|
884
|
+
# handing the struct an empty module.
|
|
885
|
+
def helpers_rbi(names)
|
|
886
|
+
# `module A::B` does not define A, so each outer segment is opened first
|
|
887
|
+
declared = names.flat_map { |name|
|
|
888
|
+
segments = name.split("::")
|
|
889
|
+
(1...segments.size).map { |i| segments.first(i + 1).join("::") }
|
|
890
|
+
}.uniq.sort
|
|
891
|
+
# assembled line by line, not from a heredoc (as Emit does): a `# typed:`
|
|
892
|
+
# sigil at the start of a line is the sigil srb reads for THIS file
|
|
893
|
+
lines = [
|
|
894
|
+
"# typed: strict",
|
|
895
|
+
"",
|
|
896
|
+
"#{GENERATED_HEADER} #{VERSION} — do not edit. The modules this graph's",
|
|
897
|
+
"# extend_type blocks mint, declared so `srb tc` can resolve the includes",
|
|
898
|
+
"# in the generated code. Ruby never loads an .rbi; the registrations do",
|
|
899
|
+
"# the real work.",
|
|
900
|
+
"",
|
|
901
|
+
]
|
|
902
|
+
(lines + declared.map { |mod| "module #{mod}; end" }).join("\n") + "\n"
|
|
903
|
+
end
|
|
904
|
+
private :helpers_rbi
|
|
905
|
+
|
|
916
906
|
# Every query that refused, in one error. One refusal is re-raised as
|
|
917
907
|
# itself, so a single bad file reads exactly as it always has — class,
|
|
918
908
|
# message and all; several become one list, because clearing them a file
|
|
@@ -1054,8 +1044,18 @@ module GraphWeaver
|
|
|
1054
1044
|
# strict), requires: names files the generated code should require.
|
|
1055
1045
|
# Generation fails naming any schema value that doesn't resolve —
|
|
1056
1046
|
# exhaustiveness checked ahead of runtime.
|
|
1057
|
-
|
|
1058
|
-
|
|
1047
|
+
#
|
|
1048
|
+
# alias: says two of the schema's wire values are one value — both cast,
|
|
1049
|
+
# the target is what goes back on the wire. It is the whole registration
|
|
1050
|
+
# when there is no enum of your own to map onto, which is what a schema
|
|
1051
|
+
# mid-rename needs:
|
|
1052
|
+
#
|
|
1053
|
+
# GraphWeaver.register_enum("Status", alias: { "legacy_mode" => "LEGACY_MODE" })
|
|
1054
|
+
def register_enum(graphql_name, type = nil, positional_map = nil, map: nil, fallback: nil, requires: nil,
|
|
1055
|
+
alias: nil)
|
|
1056
|
+
# `alias` is a Ruby keyword, so the parameter is only readable through binding
|
|
1057
|
+
Codegen.register_enum(graphql_name, type, positional_map, map:, fallback:, requires:,
|
|
1058
|
+
alias: binding.local_variable_get(:alias))
|
|
1059
1059
|
end
|
|
1060
1060
|
|
|
1061
1061
|
# Include app-owned helper modules into every struct generated from a
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graph_weaver
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Pepper
|
|
@@ -363,7 +363,7 @@ licenses:
|
|
|
363
363
|
- MIT
|
|
364
364
|
metadata:
|
|
365
365
|
bug_tracker_uri: https://github.com/dpep/graph_weaver/issues
|
|
366
|
-
changelog_uri: https://github.com/dpep/graph_weaver/blob/v0.7.
|
|
366
|
+
changelog_uri: https://github.com/dpep/graph_weaver/blob/v0.7.4/CHANGELOG.md
|
|
367
367
|
documentation_uri: https://github.com/dpep/graph_weaver/tree/main/docs
|
|
368
368
|
rubygems_mfa_required: 'true'
|
|
369
369
|
source_code_uri: https://github.com/dpep/graph_weaver
|