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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d122147d8197ddd301336fd6df71fc21941676c20276e5297ae14e893840e520
4
- data.tar.gz: a7e7979c52db2efb3653d1ccc2d8ea0ac491f3a009fc4d14001a8c22d28c1d54
3
+ metadata.gz: da0f12372e58a903300979aa8be03f53f53e9d5de073709e549a47b46cb8a5eb
4
+ data.tar.gz: cc0b3c5e77840cef702e9e163bd3e2d73f24a56ec324fbdfe3ec5b8c6e58eaf7
5
5
  SHA512:
6
- metadata.gz: 994ce3426dfa3abf64ba88c4807fd3fd0206048a710037acfcd286ccc04b2a7286fcd69104eaeb09e449416a4fbc8a90a64dae7a14b4f170ec7de5defef6a38f
7
- data.tar.gz: 21acba142c645343286465f2b4d615627d6d22983c91d803b8a241f17a0e21cf5d3bf634532fb415c55a883d253f88fd8b253f0da3c36bbe7cbf6bfe63ef48a8
6
+ metadata.gz: 5cc39f9e5208f88eea4ac18c3dac8a9abbe5da282c8a9b8f62594e5f9069483db9ced2f537768a97b864d17921b622ce7c640590e47546292fe3dafb5c7cd65e
7
+ data.tar.gz: 56d23208cbd739927ac9d85af26a39bfab5da0cebd75731791717bd67b83752bc28a096d1307e956ee9de593287b17efc70d91b81fa47c6deec2678beb064fd4
data/CHANGELOG.md CHANGED
@@ -5,6 +5,70 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.32.1] - 2026-10-06
9
+
10
+ ### Fixed
11
+
12
+ - **A structure.sql app names its PostgreSQL extensions the way the booted app
13
+ does.** The static tier named an extension bare only when it lived in
14
+ `public`. Rails names it bare when it lives in the connection's current
15
+ schema, the first entry of `schema_search_path`, so an app with
16
+ `schema_search_path: "app,public"` saw `app.pg_trgm` without a boot and
17
+ `pg_trgm` with one. The static tier now reads the search path from
18
+ config/database.yml, and on Rails before 8.0 names every extension bare, as
19
+ that connection does.
20
+ - **Tables in every schema on the search path show without a boot.** The
21
+ static tier listed only the tables in `public`, while the booted app lists
22
+ every table in a schema on `schema_search_path` by its bare name. Both dump
23
+ formats now read the search path from config/database.yml, or its older
24
+ name `schema_order`, take `"$user"` as the configured username when the dump
25
+ creates that schema, and when two schemas hold the same table name, show the
26
+ one in the earlier schema, as PostgreSQL resolves it. When the search path
27
+ holds more than `public`, the schema answer names it on a `**Search path:**`
28
+ line, and the JSON answer under `search_path`.
29
+ - **A table Rails 8.0 dumps twice lists its columns, foreign keys and check
30
+ constraints once.** Before Rails 8.1, schema.rb writes a table and its
31
+ foreign keys once per search path schema holding its name, and the static
32
+ tier listed each of them twice.
33
+ - **A structure.sql app lists plpgsql, as the booted app does.** Every
34
+ PostgreSQL database starts with plpgsql, so the booted app lists it, but
35
+ pg_dump leaves it out of the dump. The static tier now adds it, named
36
+ `pg_catalog.plpgsql` on Rails 8 and `plpgsql` before, unless the dump drops it.
37
+ - **Extensions are listed in name order, with or without a boot.** The booted
38
+ app listed them in the order the database created them and the static tier
39
+ in the order the dump wrote them, so the same app gave two orders.
40
+ - **Enum types are named the way the booted app names them.** The enum list
41
+ holds the types in a schema on the search path, bare in the current schema and
42
+ schema-qualified otherwise, or every type by its bare name before Rails 7.1. A
43
+ column's enum type is bare when the search path finds that type first, and
44
+ qualified otherwise. A schema.rb from before Rails 8.1 already holds these
45
+ names and is read as written; a Rails 8.1 schema.rb qualifies every name, and
46
+ the static tier renames them. How the dump names things follows its
47
+ `ActiveRecord::Schema[8.1]` stamp, the Rails that wrote it, or none for one
48
+ Rails 6.1 or earlier wrote, so a schema.rb not dumped again since a Rails
49
+ upgrade still reads right; how the connection names them follows Gemfile.lock,
50
+ or the stamp when the lock names none. Both tiers list enum types in name
51
+ order, and a table's Enum types section pairs each column with the type the
52
+ search path finds for it, so a column `mood` whose type is `public.mood` shows
53
+ that entry. The booted app reads its search path with SQL on every Rails
54
+ version.
55
+ - **A table outside the search path stays out of the table list but can still
56
+ be looked up.** Without a boot, a schema.rb table in another schema was
57
+ listed and counted, while the booted app leaves it out. Both tiers now leave
58
+ it out of the list in both dump formats, and both find it by its
59
+ schema-qualified name: `schema --table audit.events`, and the columns of a
60
+ model whose `table_name` is `audit.events`. A listed table is found by its
61
+ qualified name too, so `app.users` answers as `users` does. The booted app
62
+ reads such a table only when asked for it, so an app with a schema per
63
+ tenant pays nothing for it at boot. On Rails 8.1, whose schema.rb holds only
64
+ the search path schemas by default, the not-found answer for another
65
+ schema's table adds that `config.active_record.dump_schemas = :all`
66
+ includes it.
67
+ - **`"$user"` on the search path reads the user from a database URL too.** A
68
+ `DATABASE_URL`, `<NAME>_DATABASE_URL` or the entry's `url:` now supplies the
69
+ username, and a `schema_search_path` in its query, over the entry's own
70
+ keys, as Rails merges a URL over the rest of the entry.
71
+
8
72
  ## [5.32.0] - 2026-10-06
9
73
 
10
74
  ### Added
@@ -234,6 +234,11 @@ Proof sources:
234
234
  Sinatra and a plain gem); the lab regressions on SQLite in all three tiers,
235
235
  PostgreSQL and MySQL; and the unit suite on Ruby 3.1 with Rails 7.0 through
236
236
  Ruby 4.0 with Rails 8.1.
237
+ In the v5.32.1 release QA: a PostgreSQL 17 lab on Rails 8.1 and 8.0 with a
238
+ two-schema search path, answered booted and static from schema.rb and
239
+ structure.sql side by side; the small apps and the same private app
240
+ answering as v5.32.0 did; and the unit suite on Ruby 3.1 with Rails 7.0
241
+ through Ruby 4.0 with Rails 8.1.
237
242
  2. Non-crash coverage for every built-in tool including `get_view` in
238
243
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
239
244
  (ivar cross-check, render-form detection, partial interfaces) verified
@@ -324,6 +329,22 @@ Postgres instance in `spec/e2e/postgres_install_spec.rb`, opt-in via
324
329
  in the static tier, so databases, replicas, sharding and `connects_to`
325
330
  declarations come from the files rather than from the live connection
326
331
  handlers. A database only a runtime `connects_to` names is missed.
332
+ - **Rails 7.0 merges same-name enum types.** On Rails 7.0, two PostgreSQL enum
333
+ types with one name in different schemas come back from the connection as
334
+ one type holding both label lists; the static tier keeps the first type's
335
+ labels.
336
+ - **A schema.rb from before Rails 8.1 does not say which schema holds a
337
+ table.** It names each table bare, whichever search path schema holds it,
338
+ so without a boot a schema-qualified name such as `app.users` finds a listed
339
+ table only when the search path has one schema. A Rails 7.0 schema.rb names
340
+ no schema at all, so the static tier takes every schema the search path
341
+ names as existing, except the one `"$user"` stands for.
342
+ - **A dump made with a `schema_search_path` holds only those schemas.** By
343
+ default Rails passes each search path schema to pg_dump, so structure.sql
344
+ holds no other schema's tables and no extension, and a Rails 8.1 schema.rb
345
+ holds no other schema's tables. Without a boot those tables are not found and
346
+ the extension list is plpgsql alone. `config.active_record.dump_schemas =
347
+ :all` dumps everything.
327
348
  - **Inherited controller actions and filters are resolved by parent name, so
328
349
  some walks end early.** A controller that defines no action of its own takes
329
350
  the actions of the nearest app ancestor the listing holds, walked through the
@@ -214,7 +214,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
214
214
  | DefinitionFilePathsListener | Where a test helper points factory_bot: `FactoryBot.definition_file_paths =` (replaces the defaults) and `<<`/`+=`/`push`/`concat` (adds), each write as `{ replace:, paths: }` in the same forms as FixturePathsListener (`unread: true` and no paths when it cannot read them), and each `FactoryBot.find_definitions`/`FactoryBot.reload` as `{ load: }`, in source order; `::FactoryBot` counts |
215
215
  | ViewPathsListener | View roots `config/application.rb` adds to `config.paths["app/views"]`: `unshift` puts one before app/views, `<<`/`push`/`concat` after it, in the same literal forms as AutoloadPathsListener; a write whose path it cannot read is left out |
216
216
  | NamespacedRootsListener | Roots `config/application.rb` or an initializer hands Zeitwerk under a namespace: `push_dir(path, namespace: Const)`, as phlex:install writes, in the same literal forms as AutoloadPathsListener; a write whose path it cannot read is left out |
217
- | SchemaDslListener | `schema.rb`: `create_table`, `t.string`, `t.column`, `t.index`, `add_foreign_key`, `create_enum`, `create_view`, `create_virtual_table`, and the comment the dumper writes for a table it could not describe |
217
+ | SchemaDslListener | `schema.rb`: `create_table`, `t.string`, `t.column`, `t.index`, `add_foreign_key`, `create_enum`, `create_schema`, `create_view`, `create_virtual_table`, the `ActiveRecord::Schema[x.y]` version stamp (a nil one for the unstamped `Schema.define` of Rails before 7.0), and the comment the dumper writes for a table it could not describe. Names drop `public.` unless `raw_names:` is set, which SchemaReader uses to name them against the search path |
218
218
  | MigrationDslListener | Migration DSL: `create_table`, `add_column`, `add_index`, `add_reference`, and friends |
219
219
  | MigrationReplayListener | What a replay needs beside the DSL: `def down`, `down`/`revert` blocks, and `t.timestamps` |
220
220
  | RoutesDslListener | `config/routes.rb`, resolving namespace/scope/resources nesting into flat routes (a `controller:` with a leading slash is absolute, as in Rails); routing concerns (`concern` definitions replayed at each `concerns:` site), `with_options` defaults merged under each inner call, `match ... via:` (or the `via:` of an enclosing `scope`) as one route answering each verb it names (`GET|POST`, and `ANY` for `via: :all`, as the booted table has it), the controller a `scope(controller:)` or a route's own `controller:` names (with Rails' `a/b` shorthand when no action is given), and the `as:`, `param:`, `module:`, `path:` and `only:`/`except:` options. A block drawn through an app class (`ApiRouteSet::V1.draw(self) do`) takes the path and `as:` prefixes that class's `self.prefix` and `mapper_prefix` return as literals, or a literal prefix argument; a class whose prefix is not a literal, and that class's own `resources`, are counted as unexpanded. Routes drawn into an engine's table (`Spree::Core::Engine.routes.draw`) sit under the engine's namespace and carry `engine:`, which the route introspector files under that engine's mount, apart from the app's count. A file pulled in by `draw` is walked inside the scope its `draw` sits in, once per scope that draws it, so `draw :api` under `namespace :api` and `scope module: :v1` routes to `api/v1/...` at `/api/...` |
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
3
4
  require "yaml"
4
5
 
5
6
  module RailsAiContext
@@ -69,6 +70,41 @@ module RailsAiContext
69
70
  databases(root)[primary_name(root)]
70
71
  end
71
72
 
73
+ # A database's schema_search_path as PostgreSQL reads it: an unquoted name folds to
74
+ # lowercase and "$user" is the configured username. Unset, PostgreSQL's "$user", public.
75
+ # namespace.c recomputeNamespacePath keeps a repeated schema's first place only.
76
+ def schema_search_path(root, name = "primary")
77
+ search_path_entries(root, name).map(&:first).uniq
78
+ end
79
+
80
+ # The schema only the search path's "$user" names, or nil: one the path also names outright is configured.
81
+ def user_schema(root, name = "primary")
82
+ entries = search_path_entries(root, name)
83
+ user = entries.find(&:last)&.first
84
+ user unless entries.any? { |schema, from_user| !from_user && schema == user }
85
+ end
86
+
87
+ # [schema, from_user] per search path entry.
88
+ def search_path_entries(root, name)
89
+ settings = entry(root, name)
90
+ settings = {} unless settings.is_a?(Hash)
91
+ known = adapter(name, settings).first
92
+ return [ [ "public", false ] ] if known && !known.start_with?("postg")
93
+
94
+ settings = settings.merge(url_settings(name, settings["url"]))
95
+ # postgresql_adapter.rb:984 (8.1), :865 (7.0): schema_order is the older name.
96
+ text = (settings["schema_search_path"] || settings["schema_order"]).to_s
97
+ text = '"$user", public' if text.strip.empty?
98
+ user = settings["username"].to_s
99
+ text.split(",").filter_map do |part|
100
+ part = part.strip
101
+ next if part.empty? || computed?(part)
102
+ next ([ user, true ] unless user.empty? || computed?(user)) if part.delete('"') == "$user"
103
+
104
+ [ part.start_with?('"') ? part.delete('"') : part.downcase, false ]
105
+ end
106
+ end
107
+
72
108
  # The named database's settings in the running environment, or nil.
73
109
  def entry(root, name)
74
110
  entry_in(databases(root), name)
@@ -99,13 +135,27 @@ module RailsAiContext
99
135
  # Rails' DatabaseConfigurations: an entry's own url wins over its keys, and an entry
100
136
  # without one takes <NAME>_DATABASE_URL, or DATABASE_URL for the primary.
101
137
  def url_adapter(name, own_url)
102
- url = own_url.nil? ? ENV["#{name.upcase}_DATABASE_URL"] || (ENV["DATABASE_URL"] if name == "primary") : own_url.to_s
103
- return nil if url.to_s.empty? || computed?(url)
138
+ url = url_for(name, own_url) or return nil
104
139
 
105
140
  scheme = url[/\A([a-z][a-z0-9+.-]*):/i, 1]&.tr("-", "_")
106
141
  scheme && URL_SCHEME_ADAPTERS.fetch(scheme, scheme)
107
142
  end
108
143
 
144
+ def url_for(name, own_url)
145
+ url = own_url.nil? ? ENV["#{name.upcase}_DATABASE_URL"] || (ENV["DATABASE_URL"] if name == "primary") : own_url.to_s
146
+ url unless url.to_s.empty? || computed?(url)
147
+ end
148
+
149
+ # The username and schema_search_path a database URL carries, which Rails merges over the entry's keys.
150
+ def url_settings(name, own_url)
151
+ uri = URI.parse(url_for(name, own_url).to_s)
152
+ found = uri.query ? URI.decode_www_form(uri.query).to_h.slice("schema_search_path") : {}
153
+ found["username"] = URI.decode_www_form_component(uri.user) if uri.user
154
+ found
155
+ rescue URI::Error, ArgumentError
156
+ {}
157
+ end
158
+
109
159
  # [adapter, from_default]: the URL's scheme wins, then the entry's adapter. An ERB-computed
110
160
  # value is unknown unless it is one tag carrying its own literal default.
111
161
  def adapter(name, entry)
@@ -52,12 +52,18 @@ module RailsAiContext
52
52
  # The dumper writes a comment, not a create_table, for a table it could not describe.
53
53
  NOT_DUMPED = /\A#\s*Could not dump table "([^"]+)" because of following (\S+)/
54
54
 
55
+ # raw_names: each name as the dump writes it, for a reader that names them after the walk.
56
+ def initialize(raw_names: false)
57
+ super()
58
+ @raw_names = raw_names
59
+ end
60
+
55
61
  def on_program_node_enter(_node)
56
62
  comments = Array(@comments)
57
63
  comments.each_with_index do |comment, i|
58
64
  match = NOT_DUMPED.match(comment.location.slice) or next
59
65
  message = comments[i + 1]&.location&.slice.to_s[/\A#\s+(\S.*)/, 1]
60
- @results << { type: :not_dumped, table: SchemaConventions.local_name(match[1]),
66
+ @results << { type: :not_dumped, table: local_name(match[1]),
61
67
  reason: [ match[2], message ].compact.join(": "), location: comment.location.start_line }
62
68
  end
63
69
  end
@@ -66,6 +72,13 @@ module RailsAiContext
66
72
  note_block(node)
67
73
  if node.receiver.nil?
68
74
  extract_top_level_call(node)
75
+ elsif node.name == :[] && node.receiver.is_a?(Prism::ConstantPathNode) && node.receiver.slice == "ActiveRecord::Schema"
76
+ # ActiveRecord::Schema[8.1].define: the Rails version that wrote the dump.
77
+ stamp = node.arguments&.arguments&.first
78
+ @results << { type: :stamp, version: stamp.slice, location: node.location.start_line } if stamp.is_a?(Prism::FloatNode)
79
+ elsif node.name == :define && node.receiver.is_a?(Prism::ConstantPathNode) && node.receiver.slice == "ActiveRecord::Schema"
80
+ # Rails before 7.0 writes no stamp.
81
+ @results << { type: :stamp, version: nil, location: node.location.start_line }
69
82
  elsif column_call?(node)
70
83
  extract_column(node)
71
84
  elsif index_call?(node)
@@ -87,6 +100,11 @@ module RailsAiContext
87
100
 
88
101
  private
89
102
 
103
+ # Without a search path to read, public's are the bare names.
104
+ def local_name(name)
105
+ @raw_names ? name : name.delete_prefix("public.")
106
+ end
107
+
90
108
  def read_elsewhere?(name)
91
109
  OTHER_TABLE_METHODS.include?(name) || MigrationReplayListener::TABLE_OPS.key?(name)
92
110
  end
@@ -104,6 +122,9 @@ module RailsAiContext
104
122
  extract_foreign_key(node)
105
123
  when :create_enum
106
124
  extract_enum(node)
125
+ when :create_schema
126
+ name = literal_string(node.arguments&.arguments&.first)
127
+ @results << { type: :create_schema, name: name, location: node.location.start_line } if name
107
128
  when :add_index
108
129
  extract_top_level_add_index(node)
109
130
  when :add_check_constraint
@@ -123,7 +144,7 @@ module RailsAiContext
123
144
  name = literal_string(node.arguments&.arguments&.first) or return
124
145
  options = extract_keyword_nodes(node)
125
146
  @results << {
126
- type: :view, name: SchemaConventions.local_name(name), materialized: options[:materialized].is_a?(Prism::TrueNode),
147
+ type: :view, name: local_name(name), materialized: options[:materialized].is_a?(Prism::TrueNode),
127
148
  sql: literal_string(options[:sql_definition])&.strip, location: node.location.start_line
128
149
  }.compact
129
150
  end
@@ -143,7 +164,7 @@ module RailsAiContext
143
164
 
144
165
  @results << {
145
166
  type: :create_table,
146
- table: SchemaConventions.local_name(table_arg.unescaped),
167
+ table: local_name(table_arg.unescaped),
147
168
  # id: false / id: :uuid / primary_key: ... decide whether the
148
169
  # implicit primary-key column exists and what to call it.
149
170
  options: extract_keyword_options(node),
@@ -161,8 +182,8 @@ module RailsAiContext
161
182
 
162
183
  @results << {
163
184
  type: :foreign_key,
164
- from: SchemaConventions.local_name(from_arg.unescaped),
165
- to: SchemaConventions.local_name(to_arg.unescaped),
185
+ from: local_name(from_arg.unescaped),
186
+ to: local_name(to_arg.unescaped),
166
187
  # Absent means the Rails convention holds; naming it here would
167
188
  # make a declared column indistinguishable from a guessed one.
168
189
  column: SchemaConventions.primary_key_value(options[:column]),
@@ -191,7 +212,7 @@ module RailsAiContext
191
212
 
192
213
  @results << {
193
214
  type: :enum,
194
- name: SchemaConventions.local_name(name_arg.unescaped),
215
+ name: local_name(name_arg.unescaped),
195
216
  values: values,
196
217
  location: node.location.start_line
197
218
  }
@@ -207,7 +228,7 @@ module RailsAiContext
207
228
 
208
229
  @results << {
209
230
  type: :add_index,
210
- table: SchemaConventions.local_name(table_arg.unescaped),
231
+ table: local_name(table_arg.unescaped),
211
232
  columns: columns,
212
233
  options: options,
213
234
  location: node.location.start_line
@@ -222,7 +243,7 @@ module RailsAiContext
222
243
 
223
244
  @results << {
224
245
  type: :add_check_constraint,
225
- table: SchemaConventions.local_name(table_arg.unescaped),
246
+ table: local_name(table_arg.unescaped),
226
247
  expression: expr_arg.unescaped,
227
248
  name: literal_string(extract_keyword_nodes(node)[:name]),
228
249
  location: node.location.start_line
@@ -288,7 +309,7 @@ module RailsAiContext
288
309
 
289
310
  options = braced.map { |hash| hash_node_to_hash(hash) }.reduce(extract_keyword_options(node), :merge)
290
311
  virtual = node.name == :virtual
291
- options[:enum_type] = SchemaConventions.local_name(options[:enum_type]) if options[:enum_type].is_a?(String)
312
+ options[:enum_type] = local_name(options[:enum_type]) if options[:enum_type].is_a?(String)
292
313
  # A generated column takes its own type from type: (each adapter's virtual, 7.0 to 8.1).
293
314
  column_type = options[:type].to_s if virtual && (options[:type].is_a?(Symbol) || options[:type].is_a?(String))
294
315
  names.each do |col_name|
@@ -172,7 +172,7 @@ module RailsAiContext
172
172
  # Rails runs no STI on a table without the type column; a table the dump does not hold is not known to lack it.
173
173
  def lacks_column?(table, column)
174
174
  reader = SchemaReader.for(app.root)
175
- reader.tables.key?(table) && column != RailsAiContext::Confidence::INFERRED && !reader.column?(table, column)
175
+ reader.table?(table) && column != RailsAiContext::Confidence::INFERRED && !reader.column?(table, column)
176
176
  end
177
177
 
178
178
  # Rails' default column, the literal a class sets, or nil where `inheritance_column = nil` turns STI off.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # How Rails' PostgreSQL connection and schema.rb dumper name extensions, enum types and
6
+ # relations, by Rails version, so the static tier names them as the booted app does.
7
+ # Each rule cites the activerecord source it mirrors.
8
+ module PgNaming
9
+ DEFAULT_SEARCH_PATH = %w[public].freeze
10
+ # 6.1 schema_dumper.rb:89 writes Schema.define, 7.0 :90 Schema[x.y]; every rule here starts at 7.0 or later.
11
+ UNSTAMPED_DUMP = "6.1"
12
+
13
+ module_function
14
+
15
+ # The running Rails when booted, else the lockfile's; nil when neither says.
16
+ def rails_version(root)
17
+ return Rails.version if defined?(Rails) && Rails.respond_to?(:version) && !RailsAiContext.static_tier?
18
+
19
+ lock = GemLock.for(root)
20
+ lock.version("rails") || lock.version("railties")
21
+ end
22
+
23
+ # An unknown version takes the newest rules.
24
+ def at_least?(version, release)
25
+ version.nil? || Gem::Version.new(version) >= Gem::Version.new(release)
26
+ end
27
+
28
+ # 8.0 postgresql_adapter.rb:506-520 drops the schema only when it is current_schema;
29
+ # 7.0 :453, 7.1 :497 and 7.2 :496 select extname alone.
30
+ def extension_name(name, schema, path, version)
31
+ return name if schema.nil? || !at_least?(version, "8.0") || schema == path.first
32
+
33
+ "#{schema}.#{name}"
34
+ end
35
+
36
+ # 7.1 postgresql_adapter.rb:502-522 (to 8.1 :533-553): the types in current_schemas(false),
37
+ # bare in current_schema. 7.0 :458-469 merges same-name types' labels; the static tier keeps the first set.
38
+ def enum_list(enums, path, version)
39
+ legacy = !at_least?(version, "7.1")
40
+ listed = enums.filter_map do |name, values|
41
+ schema, dot, bare = name.rpartition(".")
42
+ shown = if dot.empty? || legacy then bare
43
+ elsif path.include?(schema) then schema == path.first ? bare : name
44
+ end
45
+ { name: shown, values: values } if shown
46
+ end
47
+ listed.uniq { |enum| enum[:name] }.sort_by { |enum| enum[:name] }
48
+ end
49
+
50
+ # 8.1 schema_dumper.rb:154-162 (relation_name) qualifies every name in a dump of more than one
51
+ # schema. Before, the dumper writes connection.tables, enum_types and column.sql_type
52
+ # (7.2 postgresql/schema_dumper.rb:19-29, :94), the names the booted app reads.
53
+ def dump_qualifies_names?(version)
54
+ at_least?(version, "8.1")
55
+ end
56
+
57
+ # Whether a bare name in the dump says its schema. Before 8.1 the dumper names each of
58
+ # connection.tables bare, whichever search path schema holds it, so only a one-schema path places it.
59
+ def dump_places_names?(version, path)
60
+ dump_qualifies_names?(version) || path.size < 2
61
+ end
62
+
63
+ # 7.1 postgresql/schema_dumper.rb:31-40 writes create_schema for every schema; 7.0 has no schemas().
64
+ def dump_lists_schemas?(version)
65
+ at_least?(version, "7.1")
66
+ end
67
+
68
+ # current_schemas(false) skips a schema that does not exist; pg_dump never creates public.
69
+ def existing_path(path, created)
70
+ path.select { |schema| schema == "public" || created.include?(schema) }
71
+ end
72
+
73
+ # The names a dump's relations and enum types go by, given its qualified names.
74
+ def names(path, relations: [], types: [])
75
+ Names.new(path, shadowed(relations, path), shadowed(types, path))
76
+ end
77
+
78
+ # The qualified names an earlier search path schema hides by holding the same bare name.
79
+ def shadowed(names, path)
80
+ return Set.new if path.size < 2
81
+
82
+ ranked = names.uniq.filter_map do |name|
83
+ schema, dot, bare = name.rpartition(".")
84
+ rank = path.index(schema) unless dot.empty?
85
+ [ rank, bare, name ] if rank
86
+ end
87
+ first = ranked.sort_by(&:first).reverse.to_h { |_, bare, name| [ bare, name ] }
88
+ ranked.filter_map { |_, bare, name| name unless first[bare] == name }.to_set
89
+ end
90
+
91
+ # PostgreSQL resolves a bare name through the search path (data_sources and format_type alike).
92
+ Names = Struct.new(:path, :shadowed_relations, :shadowed_types) do
93
+ def relation(name)
94
+ visible(name, shadowed_relations)
95
+ end
96
+
97
+ def type(name)
98
+ visible(name, shadowed_types)
99
+ end
100
+
101
+ private
102
+
103
+ def visible(name, shadowed)
104
+ schema, dot, bare = name.rpartition(".")
105
+ dot.empty? || !path.include?(schema) || shadowed.include?(name) ? name : bare
106
+ end
107
+ end
108
+
109
+ # The enum types a table's columns use: a bare column type is the first search path schema's
110
+ # holding it, and a bare list name is the current schema's.
111
+ def enums_used(enum_types, column_types, path)
112
+ path = Array(path).presence || DEFAULT_SEARCH_PATH
113
+ listed = Array(enum_types).to_h { |enum| [ enum[:name].to_s.include?(".") ? enum[:name].to_s : "#{path.first}.#{enum[:name]}", enum ] }
114
+ used = column_types.map do |type|
115
+ type.include?(".") ? type : path.map { |schema| "#{schema}.#{type}" }.find { |key| listed.key?(key) }
116
+ end
117
+ listed.select { |key, _| used.include?(key) }.values
118
+ end
119
+
120
+ # 8.1 postgresql/schema_dumper.rb:13-23, :50-68 dumps only ActiveRecord.dump_schemas, the search
121
+ # path by default (active_record.rb:436); 7.1 to 8.0 dump every schema's create_schema and ignore it.
122
+ # A dump naming no schema off the path is the default; one that does has dump_schemas set.
123
+ def missing_from_schema_rb(name, path, created, version, dump)
124
+ schema, dot, = name.to_s.rpartition(".")
125
+ return if dot.empty? || !at_least?(version, "8.1") || path.include?(schema) || created.include?(schema)
126
+ return unless (created - path).empty?
127
+
128
+ "If schema '#{schema}' exists, #{dump} leaves it out: Rails 8.1 dumps only the search path schemas there by default. " \
129
+ "`config.active_record.dump_schemas = :all` includes it."
130
+ end
131
+ end
132
+ end
133
+ end
@@ -189,12 +189,6 @@ module RailsAiContext
189
189
  yml.adapter(db_name, entry).first || (yml.adapter("primary", yml.primary(root)).first unless entry)
190
190
  end
191
191
 
192
- # A dump of more than one schema qualifies every name (relation_name, 8.1);
193
- # the app sees a public table by its bare name under the default search_path.
194
- def local_name(name)
195
- name.delete_prefix("public.")
196
- end
197
-
198
192
  # A primary key as connection.primary_key gives it: the column's name, or
199
193
  # the names in order for a composite key; nil for none.
200
194
  def primary_key_value(key)