rails-ai-context 5.32.0 → 5.32.2

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -0
  3. data/CONTEXT.md +10 -8
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +14 -11
  6. data/SECURITY.md +4 -4
  7. data/docs/ARCHITECTURE.md +11 -10
  8. data/docs/CLI.md +33 -7
  9. data/docs/COMPATIBILITY.md +69 -32
  10. data/docs/CONFIGURATION.md +10 -8
  11. data/docs/CUSTOM_TOOLS.md +8 -8
  12. data/docs/FAQ.md +6 -5
  13. data/docs/GUIDE.md +154 -80
  14. data/docs/INTROSPECTORS.md +25 -17
  15. data/docs/QUICKSTART.md +14 -3
  16. data/docs/RECIPES.md +50 -40
  17. data/docs/SECURITY.md +23 -13
  18. data/docs/SETUP.md +23 -26
  19. data/docs/STANDALONE.md +7 -3
  20. data/docs/TOOLS.md +79 -45
  21. data/docs/TROUBLESHOOTING.md +12 -11
  22. data/exe/rails-ai-context +8 -4
  23. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
  24. data/lib/rails_ai_context/configuration.rb +2 -2
  25. data/lib/rails_ai_context/database_yml.rb +52 -2
  26. data/lib/rails_ai_context/install/ai_tool.rb +11 -2
  27. data/lib/rails_ai_context/install/cleanup.rb +69 -23
  28. data/lib/rails_ai_context/install/program.rb +4 -1
  29. data/lib/rails_ai_context/install/selection_record.rb +20 -2
  30. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +30 -9
  31. data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
  32. data/lib/rails_ai_context/introspectors/pg_naming.rb +133 -0
  33. data/lib/rails_ai_context/introspectors/schema_conventions.rb +0 -6
  34. data/lib/rails_ai_context/introspectors/schema_introspector.rb +188 -51
  35. data/lib/rails_ai_context/introspectors/schema_reader.rb +98 -12
  36. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +56 -33
  37. data/lib/rails_ai_context/payload.rb +25 -2
  38. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -2
  39. data/lib/rails_ai_context/server.rb +2 -2
  40. data/lib/rails_ai_context/tasks/rails_ai_context.rake +12 -4
  41. data/lib/rails_ai_context/test_helper.rb +2 -0
  42. data/lib/rails_ai_context/tools/base_tool.rb +3 -1
  43. data/lib/rails_ai_context/tools/dependency_graph.rb +2 -2
  44. data/lib/rails_ai_context/tools/get_concern.rb +1 -1
  45. data/lib/rails_ai_context/tools/get_env.rb +1 -1
  46. data/lib/rails_ai_context/tools/get_gems.rb +2 -2
  47. data/lib/rails_ai_context/tools/get_model_details.rb +2 -0
  48. data/lib/rails_ai_context/tools/get_schema.rb +17 -14
  49. data/lib/rails_ai_context/tools/query.rb +107 -39
  50. data/lib/rails_ai_context/tools/search_code.rb +20 -16
  51. data/lib/rails_ai_context/version.rb +1 -1
  52. data/server.json +1 -1
  53. metadata +2 -1
@@ -8,6 +8,10 @@ module RailsAiContext
8
8
  extend StaticTier
9
9
  static_tier :alternate_source
10
10
 
11
+ # dump path => [what the dump was read under, its DumpLookup], kept across runs for a later lookup.
12
+ DUMP_LOOKUPS = Concurrent::Map.new
13
+ private_constant :DUMP_LOOKUPS
14
+
11
15
  # @return [Hash] database schema context
12
16
  def call
13
17
  return attach_secondary_databases(static_schema_parse) unless active_record_connected?
@@ -23,6 +27,7 @@ module RailsAiContext
23
27
  attach_secondary_databases({
24
28
  adapter: adapter_name,
25
29
  tables: tables,
30
+ search_path: live_search_path,
26
31
  total_tables: SchemaConventions.table_count(tables),
27
32
  schema_version: current_schema_version,
28
33
  # The version stamp is read off db/schema.rb and the tables off the
@@ -45,6 +50,36 @@ module RailsAiContext
45
50
  attach_secondary_databases(static_schema_parse)
46
51
  end
47
52
 
53
+ # A table asked for by a schema-qualified name the listing does not hold. Read only when
54
+ # asked, since an app can hold a schema per tenant.
55
+ def self.qualified_table(name, database: nil, live: false)
56
+ RailsAiContext::RunCache.fetch([ :qualified_table, name, database, live ]) do
57
+ new(RailsAiContext.default_app).qualified_table(name, database: database, live: live)
58
+ end
59
+ end
60
+
61
+ def self.qualified_table_note(name)
62
+ new(RailsAiContext.default_app).qualified_table_note(name)
63
+ end
64
+
65
+ # [the name the listing shows it under, the table] from the connection or the dump, whichever tier answers.
66
+ def qualified_table(name, database: nil, live: false)
67
+ return unless name.to_s.include?(".")
68
+
69
+ if live
70
+ data = live_qualified_table(name) or return
71
+ return [ live_listed_name(name) || name, data ]
72
+ end
73
+
74
+ found = dump_lookup(database)&.table(name)
75
+ found if found&.last
76
+ end
77
+
78
+ # Why the primary's schema.rb leaves out a schema-qualified table, or nil.
79
+ def qualified_table_note(name)
80
+ dump_lookup(nil)&.missing&.call(name)
81
+ end
82
+
48
83
  private
49
84
 
50
85
  def active_record_connected?
@@ -100,19 +135,50 @@ module RailsAiContext
100
135
  end
101
136
 
102
137
  def extract_tables
103
- tables = table_names.each_with_object({}) do |table, hash|
104
- hash[table] = {
105
- columns: extract_columns(table),
106
- indexes: extract_indexes(table),
107
- foreign_keys: extract_foreign_keys(table),
108
- primary_key: SchemaConventions.primary_key_value(connection.primary_key(table)),
109
- comment: table_comment(table),
110
- unique_constraints: unique_constraints(table),
111
- check_constraints: table_check_constraints(table)
112
- }.compact
113
- SchemaConventions.mark_primary_key(hash[table])
114
- end
115
- add_live_relations(tables)
138
+ add_live_relations(table_names.to_h { |table| [ table, table_entry(table) ] })
139
+ end
140
+
141
+ def table_entry(table)
142
+ entry = {
143
+ columns: extract_columns(table),
144
+ indexes: extract_indexes(table),
145
+ foreign_keys: extract_foreign_keys(table),
146
+ primary_key: SchemaConventions.primary_key_value(connection.primary_key(table)),
147
+ comment: table_comment(table),
148
+ unique_constraints: unique_constraints(table),
149
+ check_constraints: table_check_constraints(table)
150
+ }.compact
151
+ SchemaConventions.mark_primary_key(entry)
152
+ entry
153
+ end
154
+
155
+ # The schemas the connection searches, which name its enum types; public alone is the default.
156
+ # SQL, as 8.1's connection.current_schemas runs it (schema_statements.rb:242-246), which 7.0 to 8.0 lack.
157
+ def live_search_path
158
+ return unless postgres?
159
+
160
+ found = connection.select_value("SELECT current_schemas(false)")
161
+ found = found.to_s.delete_prefix("{").delete_suffix("}").split(",").map { |schema| schema.delete('"') } unless found.is_a?(Array)
162
+ found unless found == PgNaming::DEFAULT_SEARCH_PATH
163
+ rescue => e
164
+ RailsAiContext.debug_fail(e, nil, label: "search_path")
165
+ end
166
+
167
+ # The bare name, when the search path resolves it to this same table, as connection.tables lists it.
168
+ def live_listed_name(name)
169
+ bare = name.split(".").last
170
+ same = connection.select_value("SELECT to_regclass(#{connection.quote(connection.quote_table_name(bare))}) = " \
171
+ "to_regclass(#{connection.quote(connection.quote_table_name(name))})")
172
+ bare if same
173
+ rescue => e
174
+ RailsAiContext.debug_fail(e, nil, label: "listed_name")
175
+ end
176
+
177
+ # quoted_scope reads a qualified name's own schema (schema_statements.rb:1179-1191).
178
+ def live_qualified_table(name)
179
+ table_entry(name) if postgres? && connection.data_source_exists?(name)
180
+ rescue => e
181
+ RailsAiContext.debug_fail(e, nil, label: "qualified_table")
116
182
  end
117
183
 
118
184
  # connection.tables leaves out views and SQLite's virtual tables; a view's SQL is the dump's.
@@ -190,7 +256,7 @@ module RailsAiContext
190
256
 
191
257
  def extensions
192
258
  found = connection.extensions if connection.respond_to?(:extensions)
193
- Array(found).map(&:to_s) if found.present?
259
+ Array(found).map(&:to_s).sort if found.present?
194
260
  rescue => e
195
261
  RailsAiContext.debug_fail(e, nil, label: "extensions")
196
262
  end
@@ -297,7 +363,9 @@ module RailsAiContext
297
363
  end
298
364
 
299
365
  def schema_reader
300
- @schema_reader ||= SchemaReader.new(schema_file_path, partitions: @partitions.to_a)
366
+ @schema_reader ||= SchemaReader.new(schema_file_path, partitions: @partitions.to_a, search_path: RailsAiContext::DatabaseYml.schema_search_path(app.root),
367
+ user_schema: RailsAiContext::DatabaseYml.user_schema(app.root),
368
+ rails_version: rails_version)
301
369
  end
302
370
 
303
371
  # The configured dump, whatever its format: a view's SQL and a MySQL generated expression are not on the connection.
@@ -326,7 +394,7 @@ module RailsAiContext
326
394
 
327
395
  connection.enum_types.map do |name, values|
328
396
  { name: name.to_s, values: values.is_a?(String) ? values.delete("{}").split(",") : Array(values).map(&:to_s) }
329
- end
397
+ end.sort_by { |enum| enum[:name] }
330
398
  rescue => e
331
399
  RailsAiContext.debug_fail(e, schema_reader.enums, label: "enum_types")
332
400
  end
@@ -476,7 +544,8 @@ module RailsAiContext
476
544
  @secondary_database_dumps ||= read_secondary_database_dumps
477
545
  end
478
546
 
479
- def read_secondary_database_dumps
547
+ # [name, format, path] for each secondary database's dump on disk, the configured first.
548
+ def secondary_dump_files
480
549
  primary = dump_candidates.map(&:last)
481
550
  configured = SchemaDumpPath.secondaries(app.root).map { |name, (format, path)| [ name, format, path ] }
482
551
  taken = primary + configured.map(&:last)
@@ -486,8 +555,12 @@ module RailsAiContext
486
555
  .reject { |path| taken.include?(path) }
487
556
  .map { |path| [ File.basename(path, ".#{ext}").delete_suffix("_#{kind}"), format, path ] }
488
557
  end
489
- dumps = (configured + globbed).each_with_object({}) do |(name, format, path), found|
490
- next if found.key?(name) || primary.include?(path) || !File.exist?(path)
558
+ (configured + globbed).reject { |_, _, path| primary.include?(path) || !File.exist?(path) }
559
+ end
560
+
561
+ def read_secondary_database_dumps
562
+ dumps = secondary_dump_files.each_with_object({}) do |(name, format, path), found|
563
+ next if found.key?(name)
491
564
 
492
565
  parsed = parse_dump(format, path)
493
566
  next if parsed[:tables].blank?
@@ -568,48 +641,73 @@ module RailsAiContext
568
641
  end
569
642
 
570
643
  def parse_dump(format, path)
571
- format == :ruby ? parse_schema_rb(path) : parse_structure_sql(path)
644
+ read_dump(format, path).first
572
645
  end
573
646
 
574
- def parse_schema_rb(path)
575
- content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
576
- return { error: "#{relative_dump_path(path)} too large (#{File.size(path)} bytes, over max_schema_file_size)" } unless content
647
+ # [the schema answer, a DumpLookup over the same parse]
648
+ def read_dump(format, path)
649
+ read_at = Time.now
650
+ stamp = lookup_stamp(path)
651
+ found = format == :ruby ? read_schema_rb(path) : read_structure_sql(path)
652
+ # A same-size rewrite inside one mtime tick keeps the whole stat, as AstCache knows.
653
+ DUMP_LOOKUPS[path] = [ stamp, found.last ] if found.last && stamp.first < read_at - RailsAiContext::AstCache::RACY_WINDOW
654
+ found
655
+ end
577
656
 
578
- schema = SchemaReader.new(path, pk_type: SchemaConventions.implicit_pk_type(app.root.to_s, secondary_dump(path)))
657
+ # Every input the reader takes besides the file; pk_type is bigint for every PostgreSQL dump.
658
+ def lookup_stamp(path)
659
+ stat = File.stat(path)
660
+ [ stat.mtime, stat.size, stat.ino, search_path_for(path), rails_version,
661
+ RailsAiContext::DatabaseYml.user_schema(app.root, database_name_for(path)) ]
662
+ end
579
663
 
580
- tables = {}
581
- schema.tables.each do |table_name, declared|
582
- next if table_name.start_with?("ar_internal_metadata", "schema_migrations")
664
+ # The tables of one dump by the names a lookup may ask for.
665
+ DumpLookup = Struct.new(:listed, :qualified, :names, :missing, :placed) do
666
+ def table(name)
667
+ shown = names.relation(name)
668
+ return [ name, qualified[shown] ] if shown.include?(".")
583
669
 
584
- comment = declared.dig(:options, :comment)
585
- unique = Array(declared[:unique_constraints]).map do |constraint|
586
- SchemaConventions.unique_constraint_entry(constraint.dig(:options, :name), constraint[:columns], constraint.dig(:options, :deferrable))
587
- end
588
- tables[table_name] = {
589
- columns: declared[:columns].map { |c| static_column(c) },
590
- indexes: declared[:indexes].filter_map { |i| static_index(i) },
591
- foreign_keys: [],
592
- comment: (comment if comment.is_a?(String)),
593
- unique_constraints: (unique if unique.any?),
594
- unread_calls: declared[:unread_calls]
595
- }.compact
596
- key = declared.dig(:options, :primary_key)
597
- tables[table_name][:primary_key] = SchemaConventions.primary_key_value(key) if key
598
- SchemaConventions.mark_primary_key(tables[table_name])
670
+ [ shown, listed[shown] ] if placed
599
671
  end
672
+ end
673
+
674
+ def dump_lookup(database)
675
+ adapter = SchemaConventions.database_adapter_for(app.root.to_s, database || "primary")
676
+ return if adapter && !adapter.start_with?("postg")
677
+
678
+ _, format, path = database ? secondary_dump_files.find { |name, _, _| name == database } : [ nil, *present_dump ]
679
+ return unless path && File.exist?(path)
680
+
681
+ cached = DUMP_LOOKUPS[path]
682
+ cached && cached.first == lookup_stamp(path) ? cached.last : read_dump(format, path).last
683
+ end
684
+
685
+ def read_schema_rb(path)
686
+ content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
687
+ return [ { error: "#{relative_dump_path(path)} too large (#{File.size(path)} bytes, over max_schema_file_size)" }, nil ] unless content
688
+
689
+ search_path = search_path_for(path)
690
+ schema = SchemaReader.new(path, pk_type: SchemaConventions.implicit_pk_type(app.root.to_s, secondary_dump(path)), search_path: search_path,
691
+ user_schema: RailsAiContext::DatabaseYml.user_schema(app.root, database_name_for(path)), rails_version: rails_version)
692
+
693
+ tables = static_tables(schema.tables)
694
+ qualified = static_tables(schema.qualified_tables)
695
+ every = tables.merge(qualified)
600
696
 
601
697
  schema.foreign_keys.each do |fk|
602
- tables[fk[:from]]&.dig(:foreign_keys)&.push(
698
+ every[fk[:from]]&.dig(:foreign_keys)&.push(
603
699
  SchemaConventions.foreign_key_entry(fk[:from], fk[:to], fk[:column], fk[:primary_key], **fk.slice(*SchemaConventions::FOREIGN_KEY_OPTIONS))
604
700
  )
605
701
  end
606
702
 
607
703
  schema.check_constraints.each do |constraint|
608
- table = tables[constraint[:table]] or next
704
+ table = every[constraint[:table]] or next
609
705
  (table[:check_constraints] ||= []) << constraint.slice(:name, :expression)
610
706
  end
611
707
  views = schema.views.transform_values { |view| view.merge(indexes: Array(view[:indexes]).filter_map { |i| static_index(i) }) }
612
708
  SchemaConventions.add_relations(tables, views: views, virtual_tables: schema.virtual_tables, not_dumped: schema.not_dumped)
709
+ # A view outside the search path is findable only by its qualified name, as a table is.
710
+ tables.keys.select { |name| name.include?(".") }.each { |name| qualified[name] = tables.delete(name) }
613
711
 
614
712
  version = schema_version_for(path)
615
713
 
@@ -623,7 +721,8 @@ module RailsAiContext
623
721
  generated_columns: SchemaConventions.generated_columns_of(tables),
624
722
  note: "Parsed from #{relative_dump_path(path)} (#{connection_state})"
625
723
  }
626
- result[:extensions] = schema.extensions if schema.extensions.any?
724
+ result[:search_path] = schema.search_path unless schema.search_path == PgNaming::DEFAULT_SEARCH_PATH
725
+ result[:extensions] = schema.extensions.sort if schema.extensions.any?
627
726
  # schema.rb records only the max applied version, so pending here
628
727
  # means "migration files newer than the schema version" - exact for
629
728
  # linear histories, best-effort for out-of-order merges. With no
@@ -632,17 +731,42 @@ module RailsAiContext
632
731
  migrate_dir = migrate_dir_for_dump(path)
633
732
  result[:pending_migrations] = RailsAiContext::PendingMigrations.for(migrate_dir: migrate_dir, applied: version, root: app.root)
634
733
  end
635
- result
734
+ missing = ->(name) { PgNaming.missing_from_schema_rb(name, search_path, schema.schemas, schema.dump_version, relative_dump_path(path)) }
735
+ placed = PgNaming.dump_places_names?(schema.dump_version, schema.search_path)
736
+ [ result, DumpLookup.new(tables, qualified, schema.names, missing, placed) ]
636
737
  end
637
738
 
638
- def parse_structure_sql(path)
739
+ def static_tables(declared_tables)
740
+ declared_tables.each_with_object({}) do |(table_name, declared), tables|
741
+ next if table_name.start_with?("ar_internal_metadata", "schema_migrations")
742
+
743
+ comment = declared.dig(:options, :comment)
744
+ unique = Array(declared[:unique_constraints]).map do |constraint|
745
+ SchemaConventions.unique_constraint_entry(constraint.dig(:options, :name), constraint[:columns], constraint.dig(:options, :deferrable))
746
+ end
747
+ tables[table_name] = {
748
+ columns: declared[:columns].map { |c| static_column(c) },
749
+ indexes: declared[:indexes].filter_map { |i| static_index(i) },
750
+ foreign_keys: [],
751
+ comment: (comment if comment.is_a?(String)),
752
+ unique_constraints: (unique if unique.any?),
753
+ unread_calls: declared[:unread_calls]
754
+ }.compact
755
+ key = declared.dig(:options, :primary_key)
756
+ tables[table_name][:primary_key] = SchemaConventions.primary_key_value(key) if key
757
+ SchemaConventions.mark_primary_key(tables[table_name])
758
+ end
759
+ end
760
+
761
+ def read_structure_sql(path)
639
762
  content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
640
- return { error: "#{relative_dump_path(path)} too large (#{File.size(path)} bytes, over max_schema_file_size)" } unless content
763
+ return [ { error: "#{relative_dump_path(path)} too large (#{File.size(path)} bytes, over max_schema_file_size)" }, nil ] unless content
641
764
 
642
- parsed = StructureSqlReader.parse(content)
765
+ parsed = StructureSqlReader.parse(content, search_path: search_path_for(path), rails_version: rails_version)
643
766
  dialect = parsed[:dialect]
644
767
  tables = parsed[:tables]
645
768
  tables.each_value { |table| SchemaConventions.mark_primary_key(table) }
769
+ parsed[:qualified_tables].each_value { |table| SchemaConventions.mark_primary_key(table) }
646
770
  SchemaConventions.add_relations(tables, views: parsed[:views], virtual_tables: parsed[:virtual_tables])
647
771
 
648
772
  applied = RailsAiContext::SchemaVersion.applied_versions(content)
@@ -657,13 +781,26 @@ module RailsAiContext
657
781
  generated_columns: SchemaConventions.generated_columns_of(tables),
658
782
  note: "Parsed from #{relative_dump_path(path)} (#{connection_state})"
659
783
  }
660
- result[:extensions] = parsed[:extensions] if parsed[:extensions].any?
784
+ result[:search_path] = parsed[:names].path unless parsed[:names].path == PgNaming::DEFAULT_SEARCH_PATH
785
+ result[:extensions] = parsed[:extensions].sort if parsed[:extensions].any?
661
786
  if applied.any?
662
787
  result[:schema_version] = applied.map(&:to_i).max.to_s
663
788
  migrate_dir = migrate_dir_for_dump(path)
664
789
  result[:pending_migrations] = RailsAiContext::PendingMigrations.for(migrate_dir: migrate_dir, applied: applied, root: app.root)
665
790
  end
666
- result
791
+ [ result, DumpLookup.new(tables, parsed[:qualified_tables], parsed[:names], ->(_) { }, true) ]
792
+ end
793
+
794
+ def rails_version
795
+ @rails_version ||= PgNaming.rails_version(app.root)
796
+ end
797
+
798
+ def search_path_for(dump_path)
799
+ RailsAiContext::DatabaseYml.schema_search_path(app.root, database_name_for(dump_path))
800
+ end
801
+
802
+ def database_name_for(dump_path)
803
+ SchemaDumpPath.database_name(app.root.to_s, secondary_dump(dump_path))
667
804
  end
668
805
 
669
806
  def connection_state
@@ -28,6 +28,9 @@ module RailsAiContext
28
28
  end
29
29
 
30
30
  def self.choose(root)
31
+ search_path = RailsAiContext::DatabaseYml.schema_search_path(root)
32
+ user_schema = RailsAiContext::DatabaseYml.user_schema(root)
33
+ version = PgNaming.rails_version(root)
31
34
  return from_tables({}, source: :none, path: nil) if RailsAiContext::AppKind.sequel_schema(root)
32
35
 
33
36
  candidates = SchemaDumpPath.candidates(root)
@@ -35,12 +38,15 @@ module RailsAiContext
35
38
  next unless File.exist?(path)
36
39
 
37
40
  if format == :ruby
38
- reader = new(path, pk_type: SchemaConventions.implicit_pk_type(root))
41
+ reader = new(path, pk_type: SchemaConventions.implicit_pk_type(root), search_path: search_path, user_schema: user_schema,
42
+ rails_version: version)
39
43
  return reader.with_source(:schema_rb) if reader.tables.any?
40
44
  else
41
45
  content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
42
- parsed = content && StructureSqlReader.parse(content)
43
- return from_tables(parsed[:tables], source: :structure_sql, path: path, views: parsed[:views]) if parsed && parsed[:tables].any?
46
+ parsed = content && StructureSqlReader.parse(content, search_path: search_path, rails_version: version)
47
+ if parsed && parsed[:tables].any?
48
+ return from_tables(parsed[:tables], source: :structure_sql, path: path, views: parsed[:views], qualified_tables: parsed[:qualified_tables])
49
+ end
44
50
  end
45
51
  end
46
52
 
@@ -56,9 +62,9 @@ module RailsAiContext
56
62
 
57
63
  # A reader over an already-parsed tables hash, for the sources that do
58
64
  # not go through the schema.rb event fold.
59
- def self.from_tables(tables, source:, path:, views: {})
65
+ def self.from_tables(tables, source:, path:, views: {}, qualified_tables: {})
60
66
  reader = allocate
61
- reader.send(:initialize_from_tables, tables, source, path, views)
67
+ reader.send(:initialize_from_tables, tables, source, path, views, qualified_tables)
62
68
  reader
63
69
  end
64
70
 
@@ -66,8 +72,13 @@ module RailsAiContext
66
72
 
67
73
  # partitions: the tables the database says are partitions, which a
68
74
  # schema.rb from before Rails 8 dumps as plain tables.
69
- def initialize(path, pk_type: nil, partitions: [])
75
+ # search_path: the database's configured schema_search_path; rails_version: the app's, else the
76
+ # dump's stamp. Together they decide the names the booted app reads. user_schema: the one "$user" names.
77
+ def initialize(path, pk_type: nil, partitions: [], search_path: PgNaming::DEFAULT_SEARCH_PATH, user_schema: nil, rails_version: nil)
70
78
  @path = path
79
+ @search_path = search_path
80
+ @user_schema = user_schema
81
+ @rails_version = rails_version
71
82
  @pk_type = pk_type
72
83
  @partitions = partitions
73
84
  @source = File.exist?(path.to_s) ? :schema_rb : :none
@@ -83,6 +94,40 @@ module RailsAiContext
83
94
  parse[:tables]
84
95
  end
85
96
 
97
+ # @return [Hash] the tables the search path does not show bare, by schema-qualified name
98
+ def qualified_tables
99
+ parse[:qualified_tables]
100
+ end
101
+
102
+ # The configured search path less the schemas the dump never creates.
103
+ def search_path
104
+ names.path
105
+ end
106
+
107
+ # How the booted app names this dump's relations and types.
108
+ def names
109
+ parse[:names]
110
+ end
111
+
112
+ # @return [Array<String>] the schemas the dump creates
113
+ def schemas
114
+ parse[:schemas]
115
+ end
116
+
117
+ # The app's Rails version, else the one the dump is stamped with: the connection's naming.
118
+ def rails_version
119
+ parse[:rails_version]
120
+ end
121
+
122
+ # The Rails version that wrote the dump, from its stamp, else the app's: the dump's naming.
123
+ def dump_version
124
+ parse[:dump_version]
125
+ end
126
+
127
+ def table?(name)
128
+ tables.key?(name) || qualified_tables.key?(name)
129
+ end
130
+
86
131
  # @return [Array<Hash>] { from:, to:, column:, primary_key: } per declared
87
132
  # foreign key, whichever source the schema was read from
88
133
  def foreign_keys
@@ -139,7 +184,7 @@ module RailsAiContext
139
184
 
140
185
  attr_reader :path
141
186
 
142
- def initialize_from_tables(tables, source, path, views)
187
+ def initialize_from_tables(tables, source, path, views, qualified_tables)
143
188
  @path = path
144
189
  @pk_type = nil
145
190
  @source = source
@@ -155,13 +200,16 @@ module RailsAiContext
155
200
  check_constraints: [],
156
201
  extensions: [],
157
202
  views: views,
203
+ qualified_tables: qualified_tables,
158
204
  virtual_tables: {},
159
- not_dumped: {}
205
+ not_dumped: {},
206
+ schemas: [],
207
+ names: PgNaming.names(PgNaming::DEFAULT_SEARCH_PATH)
160
208
  }
161
209
  end
162
210
 
163
211
  def columns_for(table)
164
- tables.dig(table, :columns) || []
212
+ tables.dig(table, :columns) || qualified_tables.dig(table, :columns) || []
165
213
  end
166
214
 
167
215
  def parse
@@ -169,7 +217,8 @@ module RailsAiContext
169
217
  end
170
218
 
171
219
  def empty_schema
172
- { tables: {}, foreign_keys: [], enums: [], check_constraints: [], extensions: [], views: {}, virtual_tables: {}, not_dumped: {} }
220
+ { tables: {}, qualified_tables: {}, foreign_keys: [], enums: [], check_constraints: [], extensions: [], views: {}, virtual_tables: {},
221
+ not_dumped: {}, schemas: [], names: PgNaming.names(@search_path || PgNaming::DEFAULT_SEARCH_PATH), rails_version: @rails_version }
173
222
  end
174
223
 
175
224
  def build
@@ -179,6 +228,11 @@ module RailsAiContext
179
228
  events.sort_by { |e| e[:location] }.each do |event|
180
229
  current = absorb(event, schema, current)
181
230
  end
231
+ # The same loop writes that table's foreign keys twice (8.0 schema_dumper.rb:134-156).
232
+ schema[:foreign_keys].uniq!
233
+ name_relations(schema)
234
+ # The connection lists a table only when the search path shows it bare.
235
+ schema[:tables].keys.select { |name| name.include?(".") }.each { |name| schema[:qualified_tables][name] = schema[:tables].delete(name) }
182
236
 
183
237
  drop_partitions(schema)
184
238
  rescue => e
@@ -215,7 +269,7 @@ module RailsAiContext
215
269
  size = File.size(path)
216
270
  return [] if size > RailsAiContext.configuration.max_schema_file_size
217
271
 
218
- listeners = { schema: -> { Listeners::SchemaDslListener.new } }
272
+ listeners = { schema: -> { Listeners::SchemaDslListener.new(raw_names: true) } }
219
273
  results = if size <= RailsAiContext::AstCache::MAX_PARSE_SIZE
220
274
  SourceIntrospector.walk(path, listeners)
221
275
  else
@@ -226,6 +280,32 @@ module RailsAiContext
226
280
  results[:schema] || []
227
281
  end
228
282
 
283
+ # The booted app's names: a dump before 8.1 already holds them, a later one qualifies them.
284
+ def name_relations(schema)
285
+ # ActiveRecord::Schema[x.y] is Migration.current_version when dumped (schema_dumper.rb:90).
286
+ stamp = schema.delete(:stamp)
287
+ schema[:rails_version] ||= stamp
288
+ version = schema[:dump_version] = stamp || schema[:rails_version]
289
+ # A 7.0 dump names no schema, so only the "$user" one, which rarely exists, is taken as missing.
290
+ created = PgNaming.dump_lists_schemas?(version) ? schema[:schemas] : @search_path - [ @user_schema ]
291
+ path = PgNaming.existing_path(@search_path, created)
292
+ schema[:names] = PgNaming.names(path)
293
+ return unless PgNaming.dump_qualifies_names?(version)
294
+
295
+ names = schema[:names] = PgNaming.names(path, relations: schema[:tables].keys + schema[:views].keys + schema[:virtual_tables].keys,
296
+ types: schema[:enums].map { |enum| enum[:name] })
297
+ %i[tables views virtual_tables not_dumped].each { |key| schema[key] = schema[key].transform_keys { |name| names.relation(name) } }
298
+ schema[:foreign_keys].each { |fk| fk.merge!(from: names.relation(fk[:from]), to: names.relation(fk[:to])) }
299
+ schema[:check_constraints].each { |constraint| constraint[:table] = names.relation(constraint[:table]) if constraint[:table] }
300
+ schema[:enums] = PgNaming.enum_list(schema[:enums].to_h { |enum| [ enum[:name], enum[:values] ] }, path, schema[:rails_version])
301
+ schema[:tables].each_value do |table|
302
+ table[:columns].each do |column|
303
+ type = column.dig(:options, :enum_type)
304
+ column[:options] = column[:options].merge(enum_type: names.type(type)) if type.is_a?(String)
305
+ end
306
+ end
307
+ end
308
+
229
309
  # Returns the table each subsequent column and index belongs to.
230
310
  def absorb(event, schema, current)
231
311
  case event[:type]
@@ -234,7 +314,9 @@ module RailsAiContext
234
314
  # With a pk_type, the implied id is a column like any other - the
235
315
  # convention lives in SchemaConventions, shared with the replay.
236
316
  implied = @pk_type ? SchemaConventions.implicit_primary_key(options, @pk_type) : []
237
- schema[:tables][event[:table]] ||= { options: options, columns: implied, indexes: [] }
317
+ # Before 8.1 the dumper writes a name once per search path schema holding it; loading, the last stands.
318
+ schema[:check_constraints].reject! { |constraint| constraint[:table] == event[:table] } if schema[:tables].key?(event[:table])
319
+ schema[:tables][event[:table]] = { options: options, columns: implied, indexes: [] }
238
320
  return event[:table]
239
321
  when :column
240
322
  schema[:tables][current]&.dig(:columns)&.push(column_entry(event)) if current
@@ -254,6 +336,10 @@ module RailsAiContext
254
336
  schema[:extensions] << event[:name]
255
337
  when :enum
256
338
  schema[:enums] << { name: event[:name], values: event[:values] }
339
+ when :create_schema
340
+ schema[:schemas] << event[:name]
341
+ when :stamp
342
+ schema[:stamp] = event[:version] || PgNaming::UNSTAMPED_DUMP
257
343
  when :check_constraint
258
344
  schema[:check_constraints] << { table: current, **event.slice(:name, :expression) } if current
259
345
  when :add_check_constraint