rails-ai-context 5.35.1 → 5.35.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +198 -0
- data/CONTEXT.md +2 -2
- data/CONTRIBUTING.md +1 -1
- data/docs/COMPATIBILITY.md +5 -0
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +60 -32
- data/docs/TOOLS.md +20 -8
- data/docs/TROUBLESHOOTING.md +33 -6
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +3 -3
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +2 -3
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +30 -15
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +2 -2
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +39 -11
- data/lib/rails_ai_context/database_family.rb +9 -5
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/job_introspector.rb +6 -6
- data/lib/rails_ai_context/introspectors/model_introspector.rb +5 -12
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +0 -1
- data/lib/rails_ai_context/payload.rb +13 -3
- data/lib/rails_ai_context/secret_columns.rb +65 -39
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
- data/lib/rails_ai_context/sql_gate.rb +68 -14
- data/lib/rails_ai_context/tools/get_context.rb +4 -4
- data/lib/rails_ai_context/tools/get_controllers.rb +5 -1
- data/lib/rails_ai_context/tools/onboard.rb +3 -6
- data/lib/rails_ai_context/tools/query.rb +1 -1
- data/lib/rails_ai_context/tools/runtime_info.rb +8 -4
- data/lib/rails_ai_context/tools/safe_call.rb +2 -3
- data/lib/rails_ai_context/tools/session_context.rb +2 -3
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +1 -1
data/docs/TOOLS.md
CHANGED
|
@@ -145,9 +145,12 @@ tables of every database the app's own schema dumps declare, as
|
|
|
145
145
|
Cache and Solid Cable get one line, without their tables. A database whose
|
|
146
146
|
dump was not read is named with "(tables not read)" and left out of the table
|
|
147
147
|
count, and the summary says so: "5 tables in 2 databases, the warehouse
|
|
148
|
-
database not read". It ends with the app's custom
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
database not read". It ends with the app's custom rake tasks (Rakefile,
|
|
149
|
+
`lib/tasks`, `rakelib`), each with its arguments, description and file: the
|
|
150
|
+
first 15 at `standard`, every one at `full`. A known limit: on
|
|
151
|
+
`preset: standard` on the static tier the stack line reads the primary's
|
|
152
|
+
adapter as `unknown`, and a database's heading in the generated files can name
|
|
153
|
+
no adapter.
|
|
151
154
|
Then the app's own generators under `lib/generators` (the `bin/rails generate`
|
|
152
155
|
command and its USAGE line), the `lib/templates` files that replace a built-in
|
|
153
156
|
generator's template, and the Railties under `lib/` with their initializers.
|
|
@@ -178,6 +181,14 @@ its own path, else from the other format's file under its name
|
|
|
178
181
|
(`db/queue_schema.rb` or `db/queue_structure.sql`), whatever `database_tasks`
|
|
179
182
|
or `schema_format` says.
|
|
180
183
|
|
|
184
|
+
A database only another environment configures carries a mark, as in
|
|
185
|
+
`cache (production only)`, and a first database that is not named `primary`
|
|
186
|
+
is listed under its own name where another database is the one named
|
|
187
|
+
`primary`. Both are the same under every `preset`. With the
|
|
188
|
+
`multi_database` introspector off, as on `preset: standard`, `rails_get_schema`,
|
|
189
|
+
`rails_onboard`, `rails_performance_check` and the generated files read them
|
|
190
|
+
from the app databases list.
|
|
191
|
+
|
|
181
192
|
Column hints follow the leftmost-prefix rule: `[indexed]` means some index leads
|
|
182
193
|
with the column, so a lookup on it alone can use one; `[unique]` means a unique
|
|
183
194
|
index covers the column alone; `[unique with x]` names the partners of a
|
|
@@ -471,7 +482,8 @@ database's name is read whatever `database_tasks` or `schema_dump` says. A
|
|
|
471
482
|
database whose tables could not be read is listed under **Not checked** with
|
|
472
483
|
the reason: its dump is not on disk or is over `max_schema_file_size`, or its
|
|
473
484
|
`config/database.yml` entry turns `database_tasks` or `schema_dump` off and no
|
|
474
|
-
dump is on disk
|
|
485
|
+
dump is on disk, or "`schema_dump` in its database.yml entry names no file this
|
|
486
|
+
check reads". A database another environment configures carries its mark:
|
|
475
487
|
`**Not checked:** warehouse (production only, db/warehouse_schema.rb is not on disk)`.
|
|
476
488
|
|
|
477
489
|
| Parameter | Type | Default | Description |
|
|
@@ -735,13 +747,13 @@ Safe read-only SQL with layered security: the SQL gate, a PostgreSQL plan check
|
|
|
735
747
|
|
|
736
748
|
It runs on PostgreSQL, MySQL (with MariaDB) and SQLite, each with its own read-only transaction and statement timeout. On any other adapter it answers with that adapter's name and runs nothing. A statement that runs past `query_timeout` is answered as a timeout and any other database error as an SQL error. Both are error responses: `isError` over MCP and a non-zero exit on the CLI. So is a database no connection reaches, which the answer names with what to do about it. So are a locked SQLite database and the refusal in production.
|
|
737
749
|
|
|
738
|
-
On PostgreSQL, MySQL and MariaDB every statement a call sends has a time limit. The query has `query_timeout` to itself and the reads beside it share a second one, so the statements of a call take about twice `query_timeout` at most. On SQLite the query has `query_timeout`, and the reads beside it run on the app's own connection with no statement limit. The [security model](SECURITY.md#layer-2---database-level-read-only) says what bounds each statement and lists the stated limits.
|
|
750
|
+
On PostgreSQL, MySQL and MariaDB every statement a call sends has a time limit. The query has `query_timeout` to itself and the reads beside it share a second one, so the statements of a call take about twice `query_timeout` at most. On SQLite the query has `query_timeout`, and the reads beside it run on the app's own connection with no statement limit. A SQLite statement that names something the app's connection alone holds, such as a temp table or an attached database, runs on that connection, and its answer says it ran without a time limit. The [security model](SECURITY.md#layer-2---database-level-read-only) says what bounds each statement and lists the stated limits.
|
|
739
751
|
|
|
740
752
|
On MySQL, MariaDB and SQLite a statement that names a view whose definition names a secret column is refused. So is a `*` inside a subquery when the statement names a table that holds a secret column, and on SQLite `IN` before a table's name (`x IN t`). On PostgreSQL a materialized view whose definition names a secret column is refused. On SQLite a virtual table that holds a secret column, a shadow table and the page modules are refused, and only the pragma functions that describe the schema or the database file run.
|
|
741
753
|
|
|
742
754
|
The secret column rule reads the columns of every schema and database a statement reaches: the ones its text names, on PostgreSQL the ones its plan names, and the ones the views it names lead to, three reads deep. So a secret column of a table in another one is refused by name too.
|
|
743
755
|
|
|
744
|
-
On MySQL and MariaDB the text checks read a backslash in a string both ways, and a statement that either reading refuses is refused. On every family the text checks give up with a refusal when they have run longer than `query_timeout`.
|
|
756
|
+
On MySQL and MariaDB the text checks read a backslash in a string both ways, and a statement that either reading refuses is refused. There a `[` outside a string or a quoted name, a user variable assignment (`:=`) and `LAST_INSERT_ID` with an argument are refused too. On every family the text checks give up with a refusal when they have run longer than `query_timeout`.
|
|
745
757
|
|
|
746
758
|
| Parameter | Type | Default | Description |
|
|
747
759
|
|:----------|:-----|:--------|:------------|
|
|
@@ -755,7 +767,7 @@ On MySQL and MariaDB the text checks read a backslash in a string both ways, and
|
|
|
755
767
|
|
|
756
768
|
- A name the app does not have is answered with the closest one and every name it has.
|
|
757
769
|
- A database only another environment configures, a replica and one with `database_tasks` off included, is answered with the environments that do.
|
|
758
|
-
- A top-level entry of `config/database.yml` that code connects to by name (`establish_connection(:reporting)`) is an environment to Rails, so `database` does not take its name: the answer lists the
|
|
770
|
+
- A top-level entry of `config/database.yml` that code connects to by name (`establish_connection(:reporting)`) is an environment to Rails, so `database` does not take its name: the answer lists every database the app has, as in "No database named reporting. This app has: primary, cache, queue".
|
|
759
771
|
- One that only a committed dump names is answered as such.
|
|
760
772
|
- On the static tier the whole tool is unavailable, with or without `database`.
|
|
761
773
|
- A SQLite database whose file is not there is an error, with or without `database`. The message starts "The <name> database does not exist yet" and names `bin/rails db:prepare`, and nothing is created. A `file:` URI is checked at the path inside it.
|
|
@@ -818,7 +830,7 @@ Each diff passes through the redaction every tool's source does, read as the fil
|
|
|
818
830
|
|
|
819
831
|
Live database pool stats, table sizes, pending migrations, cache stats, queue depth.
|
|
820
832
|
|
|
821
|
-
Table sizes come from PostgreSQL, MySQL (base tables only) and SQLite, and index usage from PostgreSQL. On MySQL, MariaDB and SQLite the full detail says "_Index usage is not available for the MySQL database family._", or SQLite. On an adapter with no database family the database section says table sizes and index usage are not available for that adapter.
|
|
833
|
+
Table sizes come from PostgreSQL, MySQL (base tables only) and SQLite, and index usage from PostgreSQL. On MySQL, MariaDB and SQLite the full detail says "_Index usage is not available for the MySQL database family._", or SQLite. On an adapter with no database family the database section says table sizes and index usage are not available for that adapter. A read that failed or took longer than `query_timeout` is said in one line: "_Table sizes were not read: the read failed or took longer than query_timeout._", and at `detail: "full"` "_Index usage was not read: the read failed or took longer than query_timeout._"
|
|
822
834
|
|
|
823
835
|
The pool and the cache are read inside the MCP server process, and the answer says so: the pool's counts are that process's own connections, and a `MemoryStore`'s entries are its own, since each app server process holds its own. A store the processes share (Redis, Memcached, Solid Cache, a file store) is the app's. Another environment that configures a shared store is named, as where the app's cache lives there. Solid Cache's stats are read from its database, so when that is a SQLite file that is not there, the cache section says the database does not exist yet and creates nothing.
|
|
824
836
|
|
data/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -278,6 +278,7 @@ The SQL gate refuses write statements, file and admin functions and secret colum
|
|
|
278
278
|
|
|
279
279
|
What a string literal holds decides some of these:
|
|
280
280
|
- A write keyword or a semicolon inside a string (`WHERE note = 'please insert coin'`, `'a; b'`) is data on every database, and the query runs
|
|
281
|
+
- A write after a `WITH` clause (`WITH ... INSERT INTO ...`) is named as that write: "Blocked: contains INSERT"
|
|
281
282
|
- A secret column's name, `FOR UPDATE` / `FOR SHARE` or `UNION` inside a string is data on PostgreSQL only. On MySQL, MariaDB and SQLite the raw text decides and the query is refused, since no later check there would catch a quote the masking misread
|
|
282
283
|
- `INTO OUTFILE` / `INTO DUMPFILE` is refused wherever it stands, inside a string too, on every database
|
|
283
284
|
- A file or network function (`pg_read_file`, `LOAD_FILE`), a refused `SHOW` form (`SHOW GRANTS`) and a relation that is not read (`pg_stats`, `slow_log`) are refused wherever they stand, inside a string too, each on the database that has it. `slow_log` is refused on MySQL and MariaDB, and `pg_stats` on PostgreSQL
|
|
@@ -292,7 +293,7 @@ MySQL, MariaDB and SQLite trace no result column back to its table column, so th
|
|
|
292
293
|
- A `*` inside a subquery, a CTE body included, when the statement names a table that holds a secret column. The message names that table and the column. The rule tests the two facts together and does not tell which table the wildcard reads, so the statement is refused also when the wildcard is over another table. Name the columns in the subquery: `WITH x AS (SELECT id, email FROM users) SELECT * FROM x`. On MySQL and MariaDB `(TABLE t)` counts as a wildcard: select the columns you need. `EXISTS (SELECT * ...)` and `count(*)` run
|
|
293
294
|
- On SQLite, `IN` before a table's name (`WHERE id IN users`), when the statement names a table that holds a secret column. It is SQLite's shorthand for `IN (SELECT * FROM users)`. Write the subquery with its columns named: `WHERE id IN (SELECT id FROM users)`
|
|
294
295
|
- On SQLite, either of those two in a statement that holds a string equal to such a table's name (`WHERE role = 'users'`). SQLite reads a string as a table's name where a name can stand, and the rule cannot tell that use from plain data. The message says "in a statement that holds the string 'users'". Name the columns in the subquery, or compare with another value
|
|
295
|
-
- A view whose definition names a secret column, or has a wildcard and names a table that holds one. Query the tables the view reads. When the column holds no secret,
|
|
296
|
+
- A view whose definition names a secret column, or has a wildcard and names a table that holds one. Query the tables the view reads. When the column holds no secret, exempt it by name as [Column values show [FILTERED]](#column-values-show-filtered) says
|
|
296
297
|
|
|
297
298
|
On PostgreSQL a plain view runs and its secret columns come back `[FILTERED]`. A materialized view whose definition names a secret column gets the view message, since its stored rows cannot be traced to a table column. Query the tables it was built from.
|
|
298
299
|
|
|
@@ -306,11 +307,11 @@ On SQLite `rails_query` reads no table that holds another table's rows or words
|
|
|
306
307
|
- An `fts5vocab` or `fts4aux` table over such a table lists its words, so it is refused too
|
|
307
308
|
- A virtual table whose definition names a refused view, or a table that is not read, holds their rows or words. The message says "its definition names" and what it names
|
|
308
309
|
|
|
309
|
-
A view over one of these is refused with "its definition
|
|
310
|
+
A view over one of these is refused with "its definition names `<table>`, which is not read". The name is refused inside a string too.
|
|
310
311
|
|
|
311
312
|
### "Blocked: `pragma_foreign_keys` tells of the connection that runs it"
|
|
312
313
|
|
|
313
|
-
On SQLite `rails_query` runs a statement on a connection of its own, so a value that belongs to a connection would not be the app's. The pragma functions that describe the schema or the database file run: `pragma_table_info`, `pragma_index_list`, `pragma_foreign_key_list`, `pragma_integrity_check`, `pragma_page_count` and the others [SECURITY.md](SECURITY.md#layer-1---the-sql-gate-statement-checks) lists. Every other `pragma_*` function is refused, and so are `changes()`, `total_changes()` and `last_insert_rowid()`. Read those on the app's own connection, in `bin/rails runner` or the console.
|
|
314
|
+
On SQLite `rails_query` runs a statement on a connection of its own, so a value that belongs to a connection would not be the app's. The pragma functions that describe the schema or the database file run: `pragma_table_info`, `pragma_index_list`, `pragma_foreign_key_list`, `pragma_integrity_check`, `pragma_page_count` and the others [SECURITY.md](SECURITY.md#layer-1---the-sql-gate-statement-checks) lists. Every other `pragma_*` function is refused, and so are `changes()`, `total_changes()` and `last_insert_rowid()`. Read those on the app's own connection, in `bin/rails runner` or the console. A `pragma_*` name is refused wherever it stands, so a column whose own name starts with `pragma_` is refused too: read it in the console as well.
|
|
314
315
|
|
|
315
316
|
### "Blocked: the statement is ... bytes long"
|
|
316
317
|
|
|
@@ -318,7 +319,7 @@ On SQLite `rails_query` runs a statement on a connection of its own, so a value
|
|
|
318
319
|
|
|
319
320
|
### "Blocked: the checks on the statement's text ran longer than query_timeout"
|
|
320
321
|
|
|
321
|
-
The checks that read a statement's text have `query_timeout` to themselves and give up when they pass it.
|
|
322
|
+
The checks that read a statement's text have `query_timeout` to themselves and give up when they pass it. The statement was not sent to the database. The schema read that comes before the checks was. Send a shorter statement.
|
|
322
323
|
|
|
323
324
|
### "Blocked: ... is not queried: its columns could not be read" and "leads through more databases than rails_query follows"
|
|
324
325
|
|
|
@@ -334,12 +335,32 @@ MySQL's statement timeout does not hold a `SHOW` filter, so it is refused on eve
|
|
|
334
335
|
|
|
335
336
|
### "A name before a dot is read as that schema, so an alias named `sys` needs another name"
|
|
336
337
|
|
|
337
|
-
On MySQL and MariaDB `mysql.` and `sys.` are read as those schemas wherever they stand outside a single-quoted string. An alias cannot be told from a schema, so `FROM users sys ... sys.id` is refused. Give the alias another name. A double-quoted string that holds `mysql.` or `sys.` is refused too, with a sentence of its own: MySQL reads double-quoted text as a name when the session's sql_mode holds `ANSI_QUOTES`. Write it in single quotes.
|
|
338
|
+
On MySQL and MariaDB `mysql.` and `sys.` are read as those schemas wherever they stand outside a single-quoted string. An alias cannot be told from a schema, so `FROM users sys ... sys.id` is refused. Give the alias another name. The sentence ends every refusal over `mysql.` or `sys.` outside double-quoted text, so a real `FROM mysql.user` gets it too, with `mysql` as the name. A double-quoted string that holds `mysql.` or `sys.` is refused too, with a sentence of its own: MySQL reads double-quoted text as a name when the session's sql_mode holds `ANSI_QUOTES`. Write it in single quotes.
|
|
338
339
|
|
|
339
340
|
### "That is how MySQL reads the statement when the session's sql_mode holds NO_BACKSLASH_ESCAPES"
|
|
340
341
|
|
|
341
342
|
On MySQL and MariaDB a backslash in a string is an escape, unless the session's sql_mode holds `NO_BACKSLASH_ESCAPES`. `rails_query` does not read the session's mode, so it checks the statement under both readings and refuses it when either one does. This message ends a refusal that the second reading alone gives: with `\'` read as a backslash and the end of the string, the text after it is code. Write a quote inside a string doubled (`'it''s'`), so that both readings agree.
|
|
342
343
|
|
|
344
|
+
### "Blocked: a `[` outside a string or a quoted name is not run"
|
|
345
|
+
|
|
346
|
+
On MySQL and MariaDB a square bracket outside a string or a quoted name is refused. MariaDB reads `[name]` as a quoted name when the session's sql_mode holds `MSSQL`, and `rails_query` does not read the session's mode, so its checks could read such a statement another way than the server does. Quote a name in backticks: `` `name` ``. A `[` inside a string, such as a JSON path (`'$[0]'`), runs. One case apart: a string that holds a backslash before a quote (`'O\'Brien'`) is read a second way that ends it at that quote, and a `[` which that reading leaves outside a string is refused. Write the quote doubled (`'O''Brien'`).
|
|
347
|
+
|
|
348
|
+
### "Blocked: a user variable assignment (`:=`) is not run"
|
|
349
|
+
|
|
350
|
+
On MySQL and MariaDB `SELECT @n := @n + 1` leaves `@n` set on the app's pooled connection after the query returns, and a read-only transaction does not stop that. To number rows, use a window function, which MySQL 8 and MariaDB 10.2 and later have: `SELECT ROW_NUMBER() OVER (ORDER BY id) AS n, title FROM posts`. A `:=` inside a string or a quoted name runs, unless a backslash before a quote in the statement leaves it outside a string in the second reading of a backslash: write that quote doubled.
|
|
351
|
+
|
|
352
|
+
### "Blocked: LAST_INSERT_ID with an argument is not run"
|
|
353
|
+
|
|
354
|
+
On MySQL and MariaDB `LAST_INSERT_ID(<value>)` sets the value the next `LAST_INSERT_ID()` returns, and it stays on the app's pooled connection after the query returns. `LAST_INSERT_ID()` with no argument runs. The call is refused wherever it stands, inside a string too.
|
|
355
|
+
|
|
356
|
+
### "Blocked: a column-alias list" on a statement that has none
|
|
357
|
+
|
|
358
|
+
One name in parentheses after `GLOB`, `MATCH` or `OVER` that follows a subquery has the shape of a column-alias list, so `WHERE (SELECT title FROM post_titles LIMIT 1) GLOB (title)` and `sum((SELECT 1)) OVER (w)` are refused. Write the name with no parentheses (`GLOB title`), or write the window inline (`OVER (ORDER BY id)`).
|
|
359
|
+
|
|
360
|
+
### "Blocked: UNION is not run" on a statement with no UNION
|
|
361
|
+
|
|
362
|
+
A quoted name that spells the word is refused, as in `SELECT 1 AS "union"`. Give the alias another name. On PostgreSQL the word inside a string is data.
|
|
363
|
+
|
|
343
364
|
### "Blocked: the setting ... is not read", "Blocked: SHOW ALL is not run" and "Blocked: SHOW takes a setting's plain name on PostgreSQL"
|
|
344
365
|
|
|
345
366
|
On PostgreSQL `rails_query` reads one setting at a time, by its plain name: `SHOW work_mem`. `SHOW ALL` and a quoted name (`SHOW "work_mem"`) are refused. Six settings can hold a password or a command line that carries one and are refused: `primary_conninfo`, `ssl_passphrase_command`, `archive_command`, `restore_command`, `archive_cleanup_command` and `recovery_end_command`. So is a setting whose own name reads as a secret, such as `app.jwt_secret`.
|
|
@@ -362,7 +383,11 @@ The secret column rule reads the database's columns before any query runs. When
|
|
|
362
383
|
|
|
363
384
|
### "The database is locked by another connection, so the query did not run"
|
|
364
385
|
|
|
365
|
-
Another connection holds a lock on the SQLite database, such as a write transaction left open in a console. Nothing ran. End that transaction and run the query again. The query waits on a lock for 80% of `query_timeout` before it answers this. A lock met while the connection opens, as on the CLI or on a server's first call, gets the same answer.
|
|
386
|
+
Another connection holds a lock on the SQLite database, such as a write transaction left open in a console. Nothing ran. End that transaction and run the query again. The query waits on a lock for 80% of `query_timeout` before it answers this. A lock met while the connection opens, as on the CLI or on a server's first call, gets the same answer. A query that holds its read lock and then runs past `query_timeout` answers the timeout, also when a writer asks for the lock while it runs.
|
|
387
|
+
|
|
388
|
+
### "This SQLite query ran without a time limit"
|
|
389
|
+
|
|
390
|
+
On SQLite `rails_query` runs a statement in a process of its own, which it ends at `query_timeout`. That process cannot see what the app's own connection alone holds. So the statement runs on the app's connection, with this note, on an in-memory database, on a platform without `fork`, inside an open transaction, and when it needs a table, a function, a module, a collation or an encryption key only that connection has. While the connection holds a temp object or an attachment, it runs there too when it names a temp object, the `temp` schema, `sqlite_temp_master` or an attached database, or calls a pragma function (`pragma_table_info(...)`, `pragma_table_list` and the rest) or `dbstat`. A name is matched anywhere in the statement, in any letter case and inside a string too. A statement that returns rows slowly still ends a few rows after `query_timeout`. One long step cannot be ended there.
|
|
366
391
|
|
|
367
392
|
### "The reads rails_query makes before a statement ... took the whole <N> second timeout"
|
|
368
393
|
|
|
@@ -376,6 +401,8 @@ Columns whose names look secret are redacted, and a query that names one is reje
|
|
|
376
401
|
config.query_allowed_columns = %w[secret]
|
|
377
402
|
```
|
|
378
403
|
|
|
404
|
+
On a standalone install, where no initializer runs, put the name under `query_allowed_columns:` in `.rails-ai-context.yml`.
|
|
405
|
+
|
|
379
406
|
---
|
|
380
407
|
|
|
381
408
|
## Search issues
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Status: accepted
|
|
4
4
|
|
|
5
|
-
`rails_query` promises two guards the database itself enforces: a read-only transaction and a statement timeout. Each database server spells both differently, and some cannot give one at all, so the tool runs a statement only where it knows the spelling. A **database family** is that knowledge: one per kind of database,
|
|
5
|
+
`rails_query` promises two guards the database itself enforces: a read-only transaction and a statement timeout. Each database server spells both differently, and some cannot give one at all, so the tool runs a statement only where it knows the spelling. A **database family** is that knowledge: one per kind of database, told by the adapter's name, and the only place a database's read-only and timeout rules live. `rails_query` reads the name from the database's configuration, before any connection opens.
|
|
6
6
|
|
|
7
7
|
## Decision
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@ Status: accepted
|
|
|
12
12
|
- PostgreSQL: `SET LOCAL statement_timeout` inside the read-only transaction, at one millisecond or more, since zero turns the timeout off.
|
|
13
13
|
- MySQL: the session's `max_execution_time`, read first, set for the statement and restored after. The `MAX_EXECUTION_TIME` hint binds only the outermost SELECT and leaves a statement that starts with WITH or EXPLAIN with no bound, so it is not used.
|
|
14
14
|
- MariaDB: the session's `max_statement_time`, read first, set for the statement and restored after. MariaDB ignores the MySQL hint and has no `max_execution_time`.
|
|
15
|
-
- SQLite: a forked child with a deadline, since sqlite3-ruby holds the interpreter lock while a statement steps and nothing in the process can end it. A statement the child cannot prepare
|
|
15
|
+
- SQLite: a forked child with a deadline, since sqlite3-ruby holds the interpreter lock while a statement steps and nothing in the process can end it. A statement runs on the app's own connection in five cases: an in-memory database, a platform without `fork`, a statement the child cannot prepare, a transaction open on that connection, and, while that connection holds a temp object or an attachment, a statement that names one of them, the temp catalog or schema, or calls a pragma function or `dbstat`. There a watchdog's interrupt ends a statement that returns rows slowly.
|
|
16
16
|
- What bounds each kind of statement a call sends:
|
|
17
17
|
- The query and `explain: true` have `query_timeout` to themselves: `statement_timeout` on PostgreSQL, the session's statement limit with `lock_wait_timeout` on MySQL and MariaDB, the child's deadline on SQLite.
|
|
18
18
|
- The reads beside the query (the schema, the PostgreSQL plan with its `pg_proc` lookup, the column trace) run in a block the family bounds the same way. They share a second `query_timeout`, and each is charged the time it ran. On SQLite they run on the app's own connection with no statement limit, and a wait on another writer ends at its busy timeout.
|
|
@@ -24,7 +24,7 @@ Status: accepted
|
|
|
24
24
|
- MySQL's statement limit holds `SELECT` only, so the gate refuses `SHOW` with a `WHERE` clause on every family.
|
|
25
25
|
- A timeout is recognised by `ActiveRecord::QueryAborted` (QueryCanceled, StatementTimeout) or by the database's native code. MariaDB's 1969 has no Rails class in any version. Message text is never read, so a missing column named "timeout" is reported as an SQL error.
|
|
26
26
|
- The statement checks before execution stay on every family. MySQL's read-only transaction lets `FOR SHARE`, `LOCK IN SHARE MODE`, `INTO @var` and `INTO OUTFILE` through, so it cannot be the only guard.
|
|
27
|
-
- Only PostgreSQL traces a result column to its table column, through the statement's plan, so there a statement runs only with a plan. MySQL, MariaDB and SQLite have no such trace. There a view is checked by the definition the database stores and refused when it names a secret column, and a `*` inside a subquery is refused when the statement names a table that holds one.
|
|
27
|
+
- Only PostgreSQL traces a result column to its table column, through the statement's plan, so there a statement runs only with a plan. MySQL, MariaDB and SQLite have no such trace. There a view is checked by the definition the database stores and refused when it names a secret column, and a `*` inside a subquery is refused when the statement names a table that holds one. A PostgreSQL materialized view stores its rows, so the plan traces none of its columns either, and it is held to the same view rule.
|
|
28
28
|
- A guarded read leaves an open transaction as it found it: a savepoint on PostgreSQL, and a refusal on MySQL and MariaDB, which cannot make an open transaction read-only.
|
|
29
29
|
|
|
30
30
|
## Consequences
|
|
@@ -20,9 +20,8 @@ module RailsAiContext
|
|
|
20
20
|
REPLACED = "the connection was replaced before the statement was sent, and the new session has no " \
|
|
21
21
|
"read-only transaction and no time limit, so the statement was not sent"
|
|
22
22
|
|
|
23
|
-
# The statement limit does not cover SHOW, so lock_wait_timeout (whole seconds) ends a wait on a
|
|
24
|
-
#
|
|
25
|
-
# Yields the id of the session that holds them.
|
|
23
|
+
# The statement limit does not cover SHOW, so lock_wait_timeout (whole seconds) ends a wait on a metadata lock.
|
|
24
|
+
# Both session values are put back after the block, which is given the id of the session that holds them.
|
|
26
25
|
def bounded(conn, timeout)
|
|
27
26
|
limit, value = if conn.mariadb? then [ "max_statement_time", [ timeout.to_f, 0.001 ].max ]
|
|
28
27
|
else [ "max_execution_time", [ (timeout * 1000).to_i, 1 ].max ]
|
|
@@ -16,6 +16,8 @@ module RailsAiContext
|
|
|
16
16
|
SYSTEM_SCHEMA = /\A(?:pg_catalog|information_schema|pg_toast(?:_temp_\d+)?)\z/
|
|
17
17
|
|
|
18
18
|
APPENDS = [ "Append", "Merge Append" ].freeze
|
|
19
|
+
REMOTE_NAME = /"(?:""|[^"])+"|[^\s()."]+/
|
|
20
|
+
REMOTE_RELATION = /\((?:(#{REMOTE_NAME})\.)?(#{REMOTE_NAME})(?: (#{REMOTE_NAME}))?\)/
|
|
19
21
|
|
|
20
22
|
# aliases: alias => the relations it stands for, each as `schema.relation` where the plan gives a schema that is no system one.
|
|
21
23
|
attr_reader :nodes, :aliases
|
|
@@ -25,17 +27,19 @@ module RailsAiContext
|
|
|
25
27
|
@top = top
|
|
26
28
|
@nodes = []
|
|
27
29
|
@parents = {}.compare_by_identity
|
|
30
|
+
@appended = {}.compare_by_identity
|
|
28
31
|
collect(top)
|
|
29
32
|
# The alias defaults to the relation name when the query gave none, which is how `users.*` resolves.
|
|
30
33
|
# Keyed as EXPLAIN prints it: `t` and "T" are two aliases.
|
|
31
34
|
@names = {}
|
|
32
35
|
@aliases = @nodes.each_with_object({}) do |node, map|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
scanned(node).each do |schema, rel, as|
|
|
37
|
+
key = [ (schema unless schema.to_s.match?(SYSTEM_SCHEMA)), rel ].compact.join(".").downcase
|
|
38
|
+
@names[key] = rel.downcase
|
|
39
|
+
# The members of an Append over partitions are `name_1`, `name_2`, and a node over them names their columns by `name`.
|
|
40
|
+
# Anywhere else `name_1` is the plan's own name for a second use of `name`, which stands for another relation.
|
|
41
|
+
[ as, (as[/\A(.+)_\d+\z/m, 1] if @appended[node]) ].compact.each { |name| (map[name] ||= []) << key }
|
|
42
|
+
end
|
|
39
43
|
end
|
|
40
44
|
@aliases.each_value(&:uniq!)
|
|
41
45
|
end
|
|
@@ -48,15 +52,13 @@ module RailsAiContext
|
|
|
48
52
|
# @return [Array<Array(String, String)>] [schema, `schema.relation`] for each relation the plan reads
|
|
49
53
|
# outside the system schemas
|
|
50
54
|
def relations
|
|
51
|
-
@nodes.filter_map do |
|
|
52
|
-
schema
|
|
53
|
-
[ schema, "#{schema}.#{node["Relation Name"]}".downcase ] if schema && node["Relation Name"] && !schema.match?(SYSTEM_SCHEMA)
|
|
55
|
+
@nodes.flat_map { |node| scanned(node) }.filter_map do |schema, rel, _as|
|
|
56
|
+
[ schema, "#{schema}.#{rel}".downcase ] if schema && !schema.match?(SYSTEM_SCHEMA)
|
|
54
57
|
end.uniq
|
|
55
58
|
end
|
|
56
59
|
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
# @return [Array<Array<String>>]
|
|
60
|
+
# @return [Array<Array<String>>] the expressions that can stand at result column i, with any sort or group key
|
|
61
|
+
# past the result width. An Append prints no Output, and each of its members hands up a row of its own.
|
|
60
62
|
def output
|
|
61
63
|
return Array(@top["Output"]).map { |expr| [ expr ] } if @top.key?("Output") || !APPENDS.include?(@top["Node Type"])
|
|
62
64
|
|
|
@@ -67,11 +69,11 @@ module RailsAiContext
|
|
|
67
69
|
members.all? { |member| member["Relation Name"] } && columns.size == 1 ? outputs.transpose : []
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
# @return [Array<Array(String, String)>] [relation, column]
|
|
71
|
-
# one for each relation its alias stands for
|
|
72
|
+
# @return [Array<Array(String, String)>] [relation, column] for a plain Var of a base relation, the column
|
|
73
|
+
# folded as the secret column rule folds a name: one for each relation its alias stands for
|
|
72
74
|
def base_columns(expr)
|
|
73
75
|
m = expr.to_s.strip.match(BASE_VAR) or return []
|
|
74
|
-
column = (m[3] || m[4].gsub('""', '"'))
|
|
76
|
+
column = SecretColumns.fold(m[3] || m[4].gsub('""', '"'))
|
|
75
77
|
Array(aliases[m[1] || m[2].gsub('""', '"')]).map { |rel| [ rel, column ] }
|
|
76
78
|
end
|
|
77
79
|
|
|
@@ -89,11 +91,24 @@ module RailsAiContext
|
|
|
89
91
|
|
|
90
92
|
private
|
|
91
93
|
|
|
94
|
+
# [schema, relation, alias] of each relation the node scans. A join or an aggregate that a foreign server runs has
|
|
95
|
+
# no relation of its own: postgres_fdw names its tables there, each with its alias, as `(schema.table alias)`.
|
|
96
|
+
def scanned(node)
|
|
97
|
+
rel = node["Relation Name"]
|
|
98
|
+
return [ [ node["Schema"], rel.to_s, (node["Alias"] || rel).to_s ] ] if rel
|
|
99
|
+
|
|
100
|
+
node["Relations"].to_s.scan(REMOTE_RELATION).map do |parts|
|
|
101
|
+
schema, name, as = parts.map { |part| part&.delete_prefix('"')&.delete_suffix('"')&.gsub('""', '"') }
|
|
102
|
+
[ schema, name, as || name ]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
92
106
|
def collect(node, parent = nil)
|
|
93
107
|
return unless node.is_a?(Hash)
|
|
94
108
|
|
|
95
109
|
@nodes << node
|
|
96
110
|
@parents[node] = parent
|
|
111
|
+
@appended[node] = true if parent && (@appended[parent] || APPENDS.include?(parent["Node Type"]))
|
|
97
112
|
Array(node["Plans"]).each { |child| collect(child, node) }
|
|
98
113
|
end
|
|
99
114
|
|
|
@@ -116,12 +116,12 @@ module RailsAiContext
|
|
|
116
116
|
schemas.empty? ? "n.nspname = ANY (current_schemas(false))" : "#{SCHEMA} IN (#{schemas.map { |name| conn.quote(name) }.join(", ")})"
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
# Every relation that stores rows: tables, partitioned tables, materialized views
|
|
119
|
+
# Every relation a plan can name that stores rows: tables, partitioned tables, materialized views, foreign tables and sequences.
|
|
120
120
|
# One read that takes no lock on any of them.
|
|
121
121
|
def relation_columns(conn, schemas = [])
|
|
122
122
|
rows = conn.select_rows(
|
|
123
123
|
"SELECT #{SCHEMA}, c.relname, a.attname FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid " \
|
|
124
|
-
"JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p', 'm', 'f') AND a.attnum > 0 " \
|
|
124
|
+
"JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p', 'm', 'f', 'S') AND a.attnum > 0 " \
|
|
125
125
|
"AND NOT a.attisdropped AND #{in_schemas(conn, schemas)} ORDER BY c.relname, a.attnum"
|
|
126
126
|
)
|
|
127
127
|
rows.each_with_object({}) do |(schema, relation, column), found|
|
|
@@ -16,7 +16,7 @@ module RailsAiContext
|
|
|
16
16
|
|
|
17
17
|
# What the child writes where a result's length would stand, the moment it meets a lock.
|
|
18
18
|
MET_LOCK = [ (1 << 64) - 1 ].pack("Q>").freeze
|
|
19
|
-
# What it writes once
|
|
19
|
+
# What it writes once it holds its read lock: no lock can stop the statement from there on.
|
|
20
20
|
PAST_LOCK = [ (1 << 64) - 2 ].pack("Q>").freeze
|
|
21
21
|
WAITS_ON_LOCK = { MET_LOCK => true, PAST_LOCK => false }.freeze
|
|
22
22
|
|
|
@@ -33,7 +33,7 @@ module RailsAiContext
|
|
|
33
33
|
def read_only(conn, sql, timeout)
|
|
34
34
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
35
35
|
# A child opens the file itself and would answer without the rows of a transaction open here.
|
|
36
|
-
if !conn.transaction_open? && (path = fork_path(conn))
|
|
36
|
+
if !conn.transaction_open? && (path = fork_path(conn)) && !DatabaseFamily.bounding { held_here?(conn, sql) }
|
|
37
37
|
result = in_child(conn, path, sql, deadline)
|
|
38
38
|
return Rows.new(result.columns, result.rows, false) if result
|
|
39
39
|
end
|
|
@@ -42,6 +42,21 @@ module RailsAiContext
|
|
|
42
42
|
Rows.new(result.columns, result.rows, true)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# The temp catalog, the temp schema, and a pragma function or dbstat: each reads any schema, by a name that can be put
|
|
46
|
+
# together as the statement runs.
|
|
47
|
+
CONNECTION_CATALOG = /(?<![\w$])(?:sqlite_temp_(?:master|schema)|temp["`'\]]?\s*\.|pragma_\w|dbstat(?![\w$]))/i
|
|
48
|
+
|
|
49
|
+
# Whether the statement names what the connection holds and a child would answer wrongly: a temp object, the temp schema or
|
|
50
|
+
# an attached database, in a string too. A table only an attached database holds needs no match: no child can prepare it.
|
|
51
|
+
def held_here?(conn, sql)
|
|
52
|
+
# Neither read waits on another connection's lock, which the pragma function for the same list does.
|
|
53
|
+
names = conn.select_rows("PRAGMA database_list").filter_map { |_seq, name, _file| name unless OWN_SCHEMAS.include?(name) } +
|
|
54
|
+
conn.select_values("SELECT name FROM sqlite_temp_master")
|
|
55
|
+
return false if names.empty?
|
|
56
|
+
|
|
57
|
+
sql.match?(CONNECTION_CATALOG) || SecretColumns.fold(sql).match?(Regexp.union(names.map { |name| SecretColumns.identifier(name) }))
|
|
58
|
+
end
|
|
59
|
+
|
|
45
60
|
# The watchdog's interrupt lands when the driver is back in Ruby between two rows, so it ends a
|
|
46
61
|
# statement that returns rows slowly and cannot end one long step: the run counts as unbounded.
|
|
47
62
|
def on_app_connection(conn, sql, deadline)
|
|
@@ -184,7 +199,7 @@ module RailsAiContext
|
|
|
184
199
|
|
|
185
200
|
# pragma_table_list, which types a shadow table, came with this version.
|
|
186
201
|
TYPED_TABLES = "3.37"
|
|
187
|
-
VOCABULARY = /\
|
|
202
|
+
VOCABULARY = /\A\s*USING\s+["'`\[]?(fts5vocab|fts4aux)\b["'`\]]?\s*\((.*)\)/mi
|
|
188
203
|
|
|
189
204
|
# Modules that return the database file's pages or cells, whatever table they belong to.
|
|
190
205
|
PAGE_MODULES = %w[sqlite_dbpage sqlite_dbdata sqlite_dbptr].freeze
|
|
@@ -193,7 +208,8 @@ module RailsAiContext
|
|
|
193
208
|
def index_tables(conn, schemas = [])
|
|
194
209
|
tables = ->(kept) { across(schemas) { |schema, _| "SELECT name, sql FROM #{schema}.sqlite_master WHERE type = 'table' AND rootpage #{kept}" } }
|
|
195
210
|
virtual = conn.select_rows(tables.call("= 0")).to_h
|
|
196
|
-
stored = if conn.database_version >= TYPED_TABLES
|
|
211
|
+
stored = if conn.database_version >= TYPED_TABLES
|
|
212
|
+
conn.select_values(across(schemas) { |_, name| "SELECT name FROM pragma_table_list WHERE type = 'shadow' AND schema = #{name} COLLATE NOCASE" })
|
|
197
213
|
else conn.select_rows(tables.call("> 0")).map(&:first)
|
|
198
214
|
end
|
|
199
215
|
found = stored.each_with_object(PAGE_MODULES.to_h { |name| [ name, [ :pages, nil ] ] }) do |name, tables|
|
|
@@ -201,14 +217,24 @@ module RailsAiContext
|
|
|
201
217
|
tables[name] = [ :shadow, owner ] if owner
|
|
202
218
|
end
|
|
203
219
|
virtual.each do |name, sql|
|
|
204
|
-
|
|
220
|
+
clause = module_clause(SqlGate.comment_free(sql.to_s, SQLITE))
|
|
221
|
+
mod, args = clause.match(VOCABULARY)&.captures
|
|
205
222
|
# fts5vocab([schema,] table, type) and fts4aux([schema,] table).
|
|
206
|
-
names = args.to_s.
|
|
207
|
-
found[name] = mod ? [ :vocabulary, mod.casecmp?("fts4aux") ? names.last : names[-2],
|
|
223
|
+
names = args.to_s.scan(SecretColumns::MODULE_ARGUMENT).map { |arg| arg.strip.delete(%q('"`[])) }
|
|
224
|
+
found[name] = mod ? [ :vocabulary, mod.casecmp?("fts4aux") ? names.last : names[-2], clause ] : [ :virtual, name, clause ]
|
|
208
225
|
end
|
|
209
226
|
found
|
|
210
227
|
end
|
|
211
228
|
|
|
229
|
+
# What a virtual table's definition says after the table's own name: `USING module(arguments)`.
|
|
230
|
+
# SQLite stores it from the name on, and a quoted name can hold the word USING.
|
|
231
|
+
def module_clause(sql)
|
|
232
|
+
scanner = SqlGate::Scanner.new(sql)
|
|
233
|
+
scanner.skip(/\s*CREATE\s+VIRTUAL\s+TABLE\s+/i)
|
|
234
|
+
SqlGate.quoted(scanner, SQLITE) || scanner.skip(/[^\s"'`\[]+/)
|
|
235
|
+
scanner.rest
|
|
236
|
+
end
|
|
237
|
+
|
|
212
238
|
def table_sizes(conn)
|
|
213
239
|
# dbstat lists every b-tree, indexes and SQLite internals too.
|
|
214
240
|
app_tables = conn.select_values("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").to_set
|
|
@@ -277,8 +303,11 @@ module RailsAiContext
|
|
|
277
303
|
payload = begin
|
|
278
304
|
db = SQLite3::Database.new(path, readonly: true, extensions: extensions)
|
|
279
305
|
wait_on_lock(db, writer, timeout * 0.8)
|
|
280
|
-
|
|
306
|
+
# A read inside a transaction keeps its lock, so the statement itself never waits on one.
|
|
307
|
+
db.execute("BEGIN")
|
|
308
|
+
db.execute("SELECT 1 FROM sqlite_master LIMIT 1")
|
|
281
309
|
writer.write(PAST_LOCK)
|
|
310
|
+
stmt = db.prepare(sql)
|
|
282
311
|
[ :ok, stmt.columns, stmt.to_a ]
|
|
283
312
|
rescue => e
|
|
284
313
|
# Which step failed is the child's own fact; the message can hold text from the statement.
|
|
@@ -293,9 +322,8 @@ module RailsAiContext
|
|
|
293
322
|
exit!(0)
|
|
294
323
|
end
|
|
295
324
|
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
# is still waiting when the deadline passes.
|
|
325
|
+
# Gives up under the deadline, so the child answers a database that stays locked. The parent is told at
|
|
326
|
+
# the first wait, for a child that started late and is still waiting at the deadline.
|
|
299
327
|
def wait_on_lock(db, writer, seconds)
|
|
300
328
|
met = nil
|
|
301
329
|
db.busy_handler do |count|
|
|
@@ -56,6 +56,11 @@ module RailsAiContext
|
|
|
56
56
|
postgresql?
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# A block comment inside a block comment needs a closer of its own.
|
|
60
|
+
def nested_comments?
|
|
61
|
+
postgresql?
|
|
62
|
+
end
|
|
63
|
+
|
|
59
64
|
# `/*! ... */` holds text the database runs.
|
|
60
65
|
def executable_comments?
|
|
61
66
|
mysql?
|
|
@@ -100,8 +105,8 @@ module RailsAiContext
|
|
|
100
105
|
runtime.column_sources(connection, sql, plan)
|
|
101
106
|
end
|
|
102
107
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
108
|
+
# A schema read that is given no schemas reads at least what a statement reaches with no schema named:
|
|
109
|
+
# the PostgreSQL search path, the MySQL connection's database, SQLite's main and temp.
|
|
105
110
|
|
|
106
111
|
# @return [Array<String>] the schemas a statement reaches with no schema named, where the reads' own
|
|
107
112
|
# default does not cover them: SQLite's attached databases
|
|
@@ -137,9 +142,8 @@ module RailsAiContext
|
|
|
137
142
|
runtime.generated_columns(connection, schemas)
|
|
138
143
|
end
|
|
139
144
|
|
|
140
|
-
# @return [Hash{String => Array
|
|
141
|
-
#
|
|
142
|
-
# vocabulary also carry their definition.
|
|
145
|
+
# @return [Hash{String => Array}] what holds another table's rows or words under other names: table =>
|
|
146
|
+
# [:pages, :shadow, :vocabulary or :virtual, its virtual table, and for the last two kinds their definition]
|
|
143
147
|
def index_tables(connection, schemas = [])
|
|
144
148
|
runtime.index_tables(connection, schemas)
|
|
145
149
|
end
|
|
@@ -105,17 +105,29 @@ module RailsAiContext
|
|
|
105
105
|
one.delete("_").casecmp?(other.delete("_"))
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
-
# A loaded class that answers a name no constant carries belongs to no
|
|
109
|
-
#
|
|
110
|
-
# has_and_belongs_to_many through a singleton `name=`, so it answers
|
|
111
|
-
# "HABTM_Tags" while it lives at "Account::HABTM_Tags". Keyed by that
|
|
112
|
-
# name it would overwrite the real entry, or collapse two owners of one
|
|
113
|
-
# association name onto one.
|
|
108
|
+
# A loaded class that answers a name no constant carries belongs to no file: the join class Rails names
|
|
109
|
+
# "HABTM_Tags" through a singleton `name=`, or a class whose constant was removed and is not yet collected.
|
|
114
110
|
#
|
|
115
111
|
# @param klass [Class] a loaded class
|
|
116
112
|
# @return [Boolean]
|
|
117
113
|
def renamed?(klass)
|
|
118
|
-
|
|
114
|
+
scope = Object
|
|
115
|
+
klass.name.to_s.split("::").each do |part|
|
|
116
|
+
return true unless scope.const_defined?(part, false)
|
|
117
|
+
# Asking loads no file: a constant still waiting to autoload is taken to name the class.
|
|
118
|
+
return false if scope.autoload?(part, false)
|
|
119
|
+
|
|
120
|
+
scope = scope.const_get(part, false)
|
|
121
|
+
end
|
|
122
|
+
!scope.equal?(klass)
|
|
123
|
+
rescue NameError
|
|
124
|
+
# A name that is no constant path, or a path through a value that is no module.
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The loaded classes among these that their own constants still name.
|
|
129
|
+
def carried(classes)
|
|
130
|
+
classes.reject { |klass| renamed?(klass) }
|
|
119
131
|
end
|
|
120
132
|
|
|
121
133
|
# @return [Boolean] whether the source declares a class at all. A file
|
|
@@ -79,10 +79,10 @@ module RailsAiContext
|
|
|
79
79
|
JOB_DIRS.each { |kind| EagerLoad.dir(app.root, kind: kind) }
|
|
80
80
|
|
|
81
81
|
ActiveJob::Base.descendants.filter_map do |job|
|
|
82
|
-
next if job.name.nil? || job.name == "ApplicationJob" ||
|
|
82
|
+
next if job.name.nil? || job.name == "ApplicationJob" || DeclaredConstant.renamed?(job) ||
|
|
83
83
|
job.name.start_with?("ActionMailer", "ActiveStorage::", "ActionMailbox::", "Turbo::", "Sentry::")
|
|
84
84
|
next unless app_defined?(job)
|
|
85
|
-
if SuperclassChain.abstract_base?(job.name, inherited: job.descendants.any?)
|
|
85
|
+
if SuperclassChain.abstract_base?(job.name, inherited: DeclaredConstant.carried(job.descendants).any?)
|
|
86
86
|
reflected_bases << { name: job.name, file: source_file_for(job), active_job: true }
|
|
87
87
|
next
|
|
88
88
|
end
|
|
@@ -743,7 +743,7 @@ module RailsAiContext
|
|
|
743
743
|
def booted_mailer_base(mailer)
|
|
744
744
|
file = source_file_for(mailer)
|
|
745
745
|
source = file && SafeFile.read(File.join(app.root.to_s, file))
|
|
746
|
-
heirs = mailer.descendants.
|
|
746
|
+
heirs = DeclaredConstant.carried(mailer.descendants).map(&:name)
|
|
747
747
|
return { name: mailer.name, file: file, inherited_by: heirs.sort.presence }.compact unless source
|
|
748
748
|
|
|
749
749
|
walked = SourceIntrospector.walk_source(source, {
|
|
@@ -765,7 +765,7 @@ module RailsAiContext
|
|
|
765
765
|
ActionMailer::Base.descendants.filter_map do |mailer|
|
|
766
766
|
# `descendants` is every mailer in the process, and a gem's is not
|
|
767
767
|
# the app's however it is named.
|
|
768
|
-
next if mailer.name.nil? || !app_defined?(mailer)
|
|
768
|
+
next if mailer.name.nil? || DeclaredConstant.renamed?(mailer) || !app_defined?(mailer)
|
|
769
769
|
|
|
770
770
|
# Reflection with the app-base subtraction: `action_methods` stops
|
|
771
771
|
# subtracting at the framework base, so a public helper on a
|
|
@@ -776,7 +776,7 @@ module RailsAiContext
|
|
|
776
776
|
)
|
|
777
777
|
# A base other mailers inherit from is where the helpers they share
|
|
778
778
|
# live. Rails dispatches on those, and no reader sends one.
|
|
779
|
-
if SuperclassChain.abstract_base?(mailer.name, inherited: mailer.descendants.any?)
|
|
779
|
+
if SuperclassChain.abstract_base?(mailer.name, inherited: DeclaredConstant.carried(mailer.descendants).any?)
|
|
780
780
|
bases << booted_mailer_base(mailer)
|
|
781
781
|
next
|
|
782
782
|
end
|
|
@@ -1279,7 +1279,7 @@ module RailsAiContext
|
|
|
1279
1279
|
EagerLoad.dir(app.root, kind: "app/channels")
|
|
1280
1280
|
|
|
1281
1281
|
ActionCable::Channel::Base.descendants.filter_map do |channel|
|
|
1282
|
-
next if channel.name.nil? || channel.name == "ApplicationCable::Channel"
|
|
1282
|
+
next if channel.name.nil? || channel.name == "ApplicationCable::Channel" || DeclaredConstant.renamed?(channel)
|
|
1283
1283
|
|
|
1284
1284
|
file = source_file_for(channel)
|
|
1285
1285
|
macros = channel_sources.find { |klass| klass.file == file }&.macros ||
|
|
@@ -629,10 +629,9 @@ module RailsAiContext
|
|
|
629
629
|
next unless klass < ActiveRecord::Base && !klass.abstract_class?
|
|
630
630
|
models << klass
|
|
631
631
|
known << class_name
|
|
632
|
-
rescue
|
|
633
|
-
# A
|
|
634
|
-
#
|
|
635
|
-
# lacks is not the same answer as no such model.
|
|
632
|
+
rescue StandardError, ScriptError => e
|
|
633
|
+
# A file that raises as it loads costs itself alone, and its name is recorded:
|
|
634
|
+
# a file that exists for a class reflection lacks is no missing model.
|
|
636
635
|
@unloadable[class_name] = e.message.to_s.lines.first.to_s.strip
|
|
637
636
|
@outside_model_dirs << class_name unless model_dir_file?(record.path)
|
|
638
637
|
end
|
|
@@ -987,7 +986,7 @@ module RailsAiContext
|
|
|
987
986
|
bases = abstract_bases(model)
|
|
988
987
|
return bases unless model.superclass == ActiveRecord::Base
|
|
989
988
|
|
|
990
|
-
bases | ActiveRecord::Base.subclasses.select
|
|
989
|
+
bases | DeclaredConstant.carried(ActiveRecord::Base.subclasses).select(&:abstract_class?)
|
|
991
990
|
end
|
|
992
991
|
|
|
993
992
|
def every_model_modules
|
|
@@ -1137,13 +1136,7 @@ module RailsAiContext
|
|
|
1137
1136
|
|
|
1138
1137
|
return nil unless has_type_column
|
|
1139
1138
|
|
|
1140
|
-
children =
|
|
1141
|
-
model.descendants.map(&:name).compact.sort
|
|
1142
|
-
elsif model.respond_to?(:subclasses)
|
|
1143
|
-
model.subclasses.map(&:name).compact.sort
|
|
1144
|
-
else
|
|
1145
|
-
[]
|
|
1146
|
-
end
|
|
1139
|
+
children = DeclaredConstant.carried(model.descendants).map(&:name).sort
|
|
1147
1140
|
|
|
1148
1141
|
parent = model.superclass
|
|
1149
1142
|
sti_parent = if parent && parent != ActiveRecord::Base &&
|