graph_weaver 0.7.2 → 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.
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
@@ -195,6 +199,7 @@ module GraphWeaver
195
199
 
196
200
  def generated_paths=(paths)
197
201
  @generated_paths = paths && Array(paths)
202
+ hide_from_autoloading(generated_paths)
198
203
  end
199
204
 
200
205
  def fragments_paths=(paths)
@@ -287,9 +292,24 @@ module GraphWeaver
287
292
  # generate! reports in however many times the initializer has re-run
288
293
  existing = @graphs.index { |candidate| candidate.name == name }
289
294
  existing ? @graphs[existing] = graph : @graphs << graph
295
+ hide_from_autoloading([graph.output])
290
296
  graph
291
297
  end
292
298
 
299
+ # Rails only: where a graph writes is hidden from Zeitwerk the moment the
300
+ # graph says it. Knowing it any later needed an initializer edge waiting on
301
+ # config/initializers, and such an edge reorders the whole app's boot (see
302
+ # the Railtie). Registered first, so a refusal can name the graph.
303
+ def hide_from_autoloading(paths)
304
+ # by name, not spelled: the railtie is Rails-only and `typed: ignore`, so
305
+ # sorbet has no constant to resolve
306
+ return unless const_defined?(:Railtie, false)
307
+
308
+ railtie = const_get(:Railtie, false)
309
+ paths.each { |path| railtie.ignore_output!(path) }
310
+ end
311
+ private :hide_from_autoloading
312
+
293
313
  # Every graph an entry point walks: the declared ones, or the single graph
294
314
  # the top-level settings describe. Never empty.
295
315
  def graphs = @graphs&.dup || [default_graph]
@@ -468,12 +488,13 @@ module GraphWeaver
468
488
  end
469
489
  private :orphaned
470
490
 
471
- # Every .rb under output that GraphWeaver wrote, identified by the header
491
+ # Every file under output that GraphWeaver wrote, identified by the header
472
492
  # it emits. The header — not a *_query.rb glob — is what makes pruning
473
493
  # safe: this is a real directory, and a hand-written file in it must
474
- # 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.
475
496
  def generated_files(output)
476
- 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|
477
498
  File.foreach(path).first(HEADER_SCAN_LINES).any? { |line| line.start_with?(GENERATED_HEADER) }
478
499
  end
479
500
  end
@@ -602,6 +623,9 @@ module GraphWeaver
602
623
  # (products, reviews)", plus a "subgraphs" key — since knowing whose
603
624
  # code to look at is half the answer. A plain schema is unaffected.
604
625
  #
626
+ # One query you have as a *string* is Client#check_query — the same
627
+ # entries, against that client's own schema.
628
+ #
605
629
  # A different question from verify_generated!, which asks whether the
606
630
  # committed Ruby matches the committed schema. `rake
607
631
  # graph_weaver:queries:check` prints this and exits non-zero.
@@ -612,7 +636,7 @@ module GraphWeaver
612
636
  checked = checked_schema(graph)
613
637
  table = checked_routing_table(graph)
614
638
  Internal::Util.query_files(graph.queries).each do |path|
615
- errors = validation_errors(checked, File.read(path), shared, table)
639
+ errors = Internal::QueryCheck.errors(checked, File.read(path), shared, table)
616
640
  next if errors.empty?
617
641
 
618
642
  # keyed by file, as it has always been — and two graphs may share a
@@ -631,20 +655,11 @@ module GraphWeaver
631
655
  def checked_schema(graph) = graph.named_schema? ? graph.schema : refreshed_schema
632
656
  private :checked_schema
633
657
 
634
- # The routing table behind the schema check_queries is about to use,
635
- # when there is one: a composed supergraph dump says who resolves what,
636
- # so a validation error can name the subgraph whose code to look at. nil
637
- # for every other source — a plain schema is entirely unaffected — and
638
- # nil when a live schema class is what gets checked, since the dump then
639
- # 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.
640
661
  def checked_routing_table(graph)
641
- return if graph.live_schema
642
-
643
- path = graph.dump_path
644
- return unless path&.end_with?(".graphql", ".gql")
645
-
646
- sdl = File.read(path)
647
- SchemaLoader.routing_table(sdl) if SchemaLoader.federation_sdl?(sdl)
662
+ Internal::QueryCheck.routing_table_for(graph.dump_path) unless graph.live_schema
648
663
  end
649
664
  private :checked_routing_table
650
665
 
@@ -670,53 +685,6 @@ module GraphWeaver
670
685
  end
671
686
  private :refreshed_schema
672
687
 
673
- # One query's schema-validation errors as JSON-ready hashes, with the
674
- # source position graphql-ruby reports. Unparseable counts as an error
675
- # too — it doesn't validate either, and inline_fragments (which parses
676
- # first) has already branded it with its position.
677
- def validation_errors(schema, source, shared, table = nil)
678
- # path omitted: the caller keys the report by file, so branding the
679
- # message with it too would just print the path twice
680
- schema.validate(Codegen.inline_fragments(source, shared)).map do |error|
681
- detail = error.to_h
682
- location = detail["locations"]&.first || {}
683
- subgraphs = table ? attribute(table, detail["extensions"]) : []
684
- entry = {
685
- "message" => subgraphs.empty? ? error.message : "#{error.message} (#{subgraphs.join(", ")})",
686
- "line" => location["line"],
687
- "column" => location["column"],
688
- }
689
- subgraphs.empty? ? entry : entry.merge("subgraphs" => subgraphs)
690
- end
691
- rescue GraphWeaver::QueryValidationError => e
692
- # an unparseable query: codegen folds the position (and the file) into
693
- # the message, and this report keeps them separate — same splitter the
694
- # rendered error uses, so the two can't drift apart
695
- e.errors.map do |detail|
696
- _path, _position, message = QueryValidationError.split(detail)
697
- detail.transform_keys(&:to_s).merge("message" => message)
698
- end
699
- end
700
- private :validation_errors
701
-
702
- # Which subgraphs a validation error is about, on a federated schema:
703
- # "Field 'weight' doesn't exist on type 'Product'" is much less useful
704
- # than the same line plus "(products)" — whose code to look at, whose
705
- # team to talk to. graphql-ruby reports the coordinate structurally, so
706
- # this is a lookup rather than message parsing. Both halves of the
707
- # coordinate are required: an argument error reports typeName "Field"
708
- # (the AST node kind, not a type), and looking that up would attribute
709
- # confidently and wrongly.
710
- def attribute(table, extensions)
711
- return [] unless extensions
712
-
713
- type_name, field_name = extensions.values_at("typeName", "fieldName")
714
- return [] unless type_name && field_name
715
-
716
- table.responsible(type_name, field_name)
717
- end
718
- private :attribute
719
-
720
688
  # Load the generated modules — one line in an initializer or spec
721
689
  # helper (loading happens only when you call this; skip it and
722
690
  # require files yourself if you'd rather):
@@ -847,6 +815,7 @@ module GraphWeaver
847
815
 
848
816
  used = { inputs: [], enums: [], mapped: [] }
849
817
  used_unions = []
818
+ helpers = []
850
819
  shared = Codegen.load_fragments(fragments)
851
820
 
852
821
  refusals = []
@@ -870,6 +839,7 @@ module GraphWeaver
870
839
  codegen.variable_type_names.each { |kind, names| used[kind] |= names }
871
840
  found.concat(codegen.untyped_scalars).uniq!
872
841
  used_unions |= codegen.used_union_names
842
+ helpers |= codegen.block_helpers
873
843
  [filename, out]
874
844
  rescue GraphWeaver::Error => e
875
845
  # collected, not raised: nothing is written either way, and an adopter
@@ -887,16 +857,52 @@ module GraphWeaver
887
857
  unions: used_unions, fragments: shared,
888
858
  )
889
859
  found.concat(codegen.untyped_scalars).uniq!
860
+ helpers |= codegen.block_helpers
890
861
  # these land in the graph's output like any other file, so they collide
891
862
  # with another graph's the same way
892
863
  types.each_key { |filename| refuse_duplicate_file!(seen, filename, graph, graph.types_module) }
893
864
  plan = types.to_a + plan
894
865
  end
895
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
+
896
872
  plan
897
873
  end
898
874
  private :generation_plan
899
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
+
900
906
  # Every query that refused, in one error. One refusal is re-raised as
901
907
  # itself, so a single bad file reads exactly as it always has — class,
902
908
  # message and all; several become one list, because clearing them a file
@@ -1038,8 +1044,18 @@ module GraphWeaver
1038
1044
  # strict), requires: names files the generated code should require.
1039
1045
  # Generation fails naming any schema value that doesn't resolve —
1040
1046
  # exhaustiveness checked ahead of runtime.
1041
- def register_enum(graphql_name, type, positional_map = nil, map: nil, fallback: nil, requires: nil)
1042
- 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))
1043
1059
  end
1044
1060
 
1045
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.2
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.2/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