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.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module GraphWeaver
2
- VERSION = "0.7.3"
2
+ VERSION = "0.7.4"
3
3
  end
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
- private_constant :GENERATED_HEADER, :HEADER_SCAN_LINES
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 .rb under output that GraphWeaver wrote, identified by the header
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 = validation_errors(checked, File.read(path), shared, table)
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
- # when there is one: a composed supergraph dump says who resolves what,
652
- # so a validation error can name the subgraph whose code to look at. nil
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
- return if graph.live_schema
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
- def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
1058
- Codegen.register_enum(graphql_name, type, positional_map, map:, fallback:, requires:)
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.3
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.3/CHANGELOG.md
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