rails-ai-context 5.32.0 → 5.32.1
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/CHANGELOG.md +64 -0
- data/docs/COMPATIBILITY.md +21 -0
- data/docs/INTROSPECTORS.md +1 -1
- data/lib/rails_ai_context/database_yml.rb +52 -2
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +30 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/pg_naming.rb +133 -0
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +0 -6
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +188 -51
- data/lib/rails_ai_context/introspectors/schema_reader.rb +98 -12
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +56 -33
- data/lib/rails_ai_context/payload.rb +25 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +2 -0
- data/lib/rails_ai_context/tools/get_schema.rb +17 -14
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- 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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
644
|
+
read_dump(format, path).first
|
|
572
645
|
end
|
|
573
646
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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[:
|
|
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
|
-
|
|
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
|
|
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[:
|
|
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
|
-
|
|
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
|
-
|
|
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: {},
|
|
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
|
-
|
|
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
|