rails-ai-context 5.30.0 → 5.30.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 +37 -0
- data/docs/ARCHITECTURE.md +5 -1
- data/docs/COMPATIBILITY.md +5 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +10 -15
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +73 -0
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +9 -2
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +6 -3
- data/lib/rails_ai_context/introspectors/schema_reader.rb +26 -2
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +3 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/tools/get_schema.rb +2 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +1 -1
- data/lib/rails_ai_context/tools/query.rb +5 -2
- data/lib/rails_ai_context/tools/runtime_info.rb +8 -7
- data/lib/rails_ai_context/tools/validate_semantics.rb +7 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e31095f85a3f90825fbd66bf2a7deae9224ac4fe94474c8d60da20c46ed52b47
|
|
4
|
+
data.tar.gz: 190a7d48230b4ae98300b2619a0d2cf7a1ddb8a6e24619a7a54cfa3a6e5b233b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f91198dc48ae6f259c0f4d170b0fbae5d2174fa142a64530ef4ac46f2e6c51c9e22e54057e7f64712f06fc76a085e45994da2a9dc592edfe9ee3f0366608d642
|
|
7
|
+
data.tar.gz: cee2a7a2d636322fddeedcffa151812d0ae8c52cde17d99394fd7dded5e30e5bddb8db288bc9b31b0fe1281a9a33270739c000735957fab6d64cbafdfec8a149
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ 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.30.1] - 2026-09-28
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A PostgreSQL partitioned table is one table wherever its partitions can be
|
|
13
|
+
told apart.** The booted schema listed each partition as a table of its own,
|
|
14
|
+
repeating the parent's columns, indexes and keys, and one app went from 98
|
|
15
|
+
tables to 211. The schema.rb and structure.sql readers did the same with the
|
|
16
|
+
partitions Rails and pg_dump write out, and schema.rb's copies of the parent's
|
|
17
|
+
foreign keys and check constraints came with them. A foreign key to a
|
|
18
|
+
partitioned table, which PostgreSQL clones once per partition, is listed once.
|
|
19
|
+
A schema.rb from before Rails 8 does not mark partitions, so the booted schema
|
|
20
|
+
asks the primary database which tables are partitions. Everything else that
|
|
21
|
+
reads such a file, the static tier and a secondary database's dump included,
|
|
22
|
+
still lists them. A table that inherits another through `INHERITS` is not a
|
|
23
|
+
partition and keeps its own entry. (#250)
|
|
24
|
+
- **Database stats and `rails_runtime_info` count a partition as part of its
|
|
25
|
+
parent.** Row counts and table sizes, which listed the parent at zero or left
|
|
26
|
+
it out, add each partition's rows and bytes to it, and index usage, which
|
|
27
|
+
listed an index once per partition, names it once. All three break ties by
|
|
28
|
+
name. On PostgreSQL 10 and 11, which lack `pg_partition_root`, they still list
|
|
29
|
+
each partition. (#250)
|
|
30
|
+
- **A foreign key over more than one column reads as a column list.**
|
|
31
|
+
`rails_get_schema` and `rails-schema.md` printed a Ruby array literal for its
|
|
32
|
+
columns, `["event_id", "event_day"]`, and now write `(event_id, event_day)` →
|
|
33
|
+
`events.(id, day)`. `rails_validate` warned that such a key had no index even
|
|
34
|
+
when one led with its columns, with a generator command Rails cannot run, and
|
|
35
|
+
now suggests one `add_index` over the columns. A foreign key to a partitioned
|
|
36
|
+
table spans more than one column whenever the partition column is not its key.
|
|
37
|
+
- **A PostGIS app gets PostgreSQL's answers.** `activerecord-postgis-adapter`
|
|
38
|
+
reports `PostGIS` for what is a PostgreSQL connection, and matching only
|
|
39
|
+
`postgresql` sent `rails_query` down the unguarded path with no READ ONLY
|
|
40
|
+
transaction and no statement timeout, skipped database stats and
|
|
41
|
+
`rails_runtime_info`'s table sizes and index usage, and dropped
|
|
42
|
+
`rails_migration_advisor`'s Strong Migrations warning for an `add_index`
|
|
43
|
+
without `algorithm: :concurrently`.
|
|
44
|
+
|
|
8
45
|
## [5.30.0] - 2026-09-28
|
|
9
46
|
|
|
10
47
|
A pass over the whole tree for code that did not need to exist, with one base
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -111,7 +111,7 @@ flowchart LR
|
|
|
111
111
|
|
|
112
112
|
The `Introspector` orchestrator runs configured introspectors and merges results.
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
Five modules answer questions every introspector used to answer for itself:
|
|
115
115
|
|
|
116
116
|
- **PathResolver** - where a kind of app code lives: `app/<kind>`, packs, and the in-repo code roots read from the tree (a directory holding its own `app/` plus a gemspec, `plugin.rb` or `lib/**/engine.rb`), memoized per root
|
|
117
117
|
- **SourceScan** - one walk over a kind of app source, across every directory `PathResolver` resolves: `paths` stats, `each` reads, `classes` names by the declared constant
|
|
@@ -119,6 +119,10 @@ Four modules answer questions every introspector used to answer for itself:
|
|
|
119
119
|
- **GemLock** - which gems the app resolved and at what version, read once per lockfile and matched by exact name across GEM, GIT and PATH
|
|
120
120
|
- **PackageJson** - which npm packages the app depends on, read once per `package.json`: present means named in `dependencies` or `devDependencies`, so an `overrides` pin is not a dependency, and `@tailwindcss/vite` counts as tailwindcss
|
|
121
121
|
|
|
122
|
+
One more reads PostgreSQL's catalogs for the schema, the database stats and `rails_runtime_info`:
|
|
123
|
+
|
|
124
|
+
- **PgPartitions** - which tables are partitions, and each table's rows, bytes and index scans from `pg_stat_user_tables` and `pg_stat_user_indexes`, counting a partition toward its partitioned parent, so every surface names a partitioned table once
|
|
125
|
+
|
|
122
126
|
Two more answer a question a tool asks:
|
|
123
127
|
|
|
124
128
|
- **PendingMigrations** - which migration files are not in the applied set, from one derivation both the schema and migrations sections read
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -211,7 +211,11 @@ Proof sources:
|
|
|
211
211
|
In the v5.30.0 release QA: Mastodon, Discourse, OpenProject, Canvas, Forem,
|
|
212
212
|
Whitehall, Consul, OpenFoodNetwork, Huginn, Errbit, Diaspora and Plots2 in
|
|
213
213
|
the static and failed-boot tiers, and the same private app in every tier
|
|
214
|
-
and both install paths.
|
|
214
|
+
and both install paths. In the v5.30.1 release QA: the same private app on
|
|
215
|
+
PostgreSQL 17 with a partitioned table added, booted and static from
|
|
216
|
+
schema.rb and structure.sql, a Rails 7.2 app whose schema.rb carries no
|
|
217
|
+
partition marker, and Discourse, Mastodon, OpenFoodNetwork and Forem
|
|
218
|
+
answering as before.
|
|
215
219
|
2. Non-crash coverage for every built-in tool including `get_view` in
|
|
216
220
|
`spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
|
|
217
221
|
(ivar cross-check, render-form detection, partial interfaces) verified
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsAiContext
|
|
4
4
|
module Introspectors
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# Approximate row counts per table on PostgreSQL, MySQL and SQLite; any
|
|
6
|
+
# other adapter returns { skipped: true }.
|
|
7
7
|
class DatabaseStatsIntrospector < Base
|
|
8
8
|
extend StaticTier
|
|
9
9
|
static_tier :runtime_only
|
|
@@ -13,12 +13,15 @@ module RailsAiContext
|
|
|
13
13
|
# stats collection for every Trilogy app.
|
|
14
14
|
MYSQL_ADAPTER = /mysql|trilogy/i
|
|
15
15
|
|
|
16
|
+
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
17
|
+
POSTGRES_ADAPTER = /postg/i
|
|
18
|
+
|
|
16
19
|
def call
|
|
17
20
|
return { skipped: true, reason: "ActiveRecord not available" } unless defined?(ActiveRecord::Base)
|
|
18
21
|
|
|
19
22
|
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
20
23
|
case adapter
|
|
21
|
-
when
|
|
24
|
+
when POSTGRES_ADAPTER
|
|
22
25
|
collect_postgresql_stats
|
|
23
26
|
when MYSQL_ADAPTER
|
|
24
27
|
collect_mysql_stats
|
|
@@ -32,18 +35,10 @@ module RailsAiContext
|
|
|
32
35
|
private
|
|
33
36
|
|
|
34
37
|
def collect_postgresql_stats
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
FROM pg_stat_user_tables
|
|
40
|
-
ORDER BY n_live_tup DESC
|
|
41
|
-
SQL
|
|
42
|
-
|
|
43
|
-
tables = rows.map do |row|
|
|
44
|
-
entry = { table: row["table_name"], approximate_rows: row["approximate_row_count"].to_i }
|
|
45
|
-
dead = row["dead_rows"].to_i
|
|
46
|
-
entry[:dead_rows] = dead if dead > 0
|
|
38
|
+
stats = PgPartitions.table_rows(ActiveRecord::Base.connection).sort_by { |t| [ -t[:rows], t[:name] ] }
|
|
39
|
+
tables = stats.map do |t|
|
|
40
|
+
entry = { table: t[:name], approximate_rows: t[:rows] }
|
|
41
|
+
entry[:dead_rows] = t[:dead_rows] if t[:dead_rows] > 0
|
|
47
42
|
entry
|
|
48
43
|
end
|
|
49
44
|
|
|
@@ -72,8 +72,8 @@ module RailsAiContext
|
|
|
72
72
|
to: to_arg.unescaped,
|
|
73
73
|
# Absent means the Rails convention holds; naming it here would
|
|
74
74
|
# make a declared column indistinguishable from a guessed one.
|
|
75
|
-
column: options[:column]
|
|
76
|
-
primary_key: options[:primary_key]
|
|
75
|
+
column: SchemaConventions.primary_key_value(options[:column]),
|
|
76
|
+
primary_key: SchemaConventions.primary_key_value(options[:primary_key]),
|
|
77
77
|
location: node.location.start_line
|
|
78
78
|
}.compact
|
|
79
79
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# A partition repeats its parent's columns, indexes and keys, so it is listed
|
|
6
|
+
# as its partitioned parent, and its rows, bytes and index scans count there.
|
|
7
|
+
module PgPartitions
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
11
|
+
POSTGRES_ADAPTER = /postg/i
|
|
12
|
+
|
|
13
|
+
# Scoped the way connection.tables is.
|
|
14
|
+
NAMES_SQL = <<~SQL.squish.freeze
|
|
15
|
+
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
16
|
+
WHERE c.relispartition AND c.relkind IN ('r', 'p') AND n.nspname = ANY (current_schemas(false))
|
|
17
|
+
SQL
|
|
18
|
+
|
|
19
|
+
def names(conn)
|
|
20
|
+
# relispartition arrived with partitions in 10; querying it before then
|
|
21
|
+
# fails, and a failed query aborts any open transaction.
|
|
22
|
+
return [] unless conn.adapter_name.match?(POSTGRES_ADAPTER) && conn.database_version >= 10_00_00
|
|
23
|
+
|
|
24
|
+
conn.select_values(NAMES_SQL)
|
|
25
|
+
rescue => e
|
|
26
|
+
RailsAiContext.debug_fail(e, [], label: "PgPartitions.names")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<Hash>] { name:, rows:, dead_rows: } per table, from the statistics alone
|
|
30
|
+
def table_rows(conn)
|
|
31
|
+
per_table(conn, "SUM(s.n_live_tup) AS live_rows, SUM(s.n_dead_tup) AS dead_rows").map do |r|
|
|
32
|
+
{ name: r["name"], rows: r["live_rows"].to_i, dead_rows: r["dead_rows"].to_i }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Array<Hash>] { name:, bytes: } per table. Sizing a relation waits on its lock.
|
|
37
|
+
def table_bytes(conn)
|
|
38
|
+
per_table(conn, "SUM(pg_total_relation_size(s.relid)) AS bytes").map do |r|
|
|
39
|
+
{ name: r["name"], bytes: r["bytes"].to_i }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<Hash>] { table:, index:, scans: } per index, from pg_stat_user_indexes
|
|
44
|
+
def index_stats(conn)
|
|
45
|
+
conn.select_all(<<~SQL).map do |r|
|
|
46
|
+
SELECT t.relname AS table_name, i.relname AS index_name, SUM(s.idx_scan) AS scans
|
|
47
|
+
FROM pg_stat_user_indexes s
|
|
48
|
+
JOIN pg_class t ON t.oid = #{root_of(conn, "s.relid")}
|
|
49
|
+
JOIN pg_class i ON i.oid = #{root_of(conn, "s.indexrelid")}
|
|
50
|
+
GROUP BY t.oid, t.relname, i.oid, i.relname
|
|
51
|
+
SQL
|
|
52
|
+
{ table: r["table_name"], index: r["index_name"], scans: r["scans"].to_i }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def per_table(conn, sums)
|
|
57
|
+
conn.select_all(<<~SQL)
|
|
58
|
+
SELECT c.relname AS name, #{sums}
|
|
59
|
+
FROM pg_stat_user_tables s
|
|
60
|
+
JOIN pg_class c ON c.oid = #{root_of(conn, "s.relid")}
|
|
61
|
+
GROUP BY c.oid, c.relname
|
|
62
|
+
SQL
|
|
63
|
+
end
|
|
64
|
+
private_class_method :per_table
|
|
65
|
+
|
|
66
|
+
def root_of(conn, relid)
|
|
67
|
+
# ponytail: pg_partition_root needs PostgreSQL 12, so on 10 and 11 each partition stays its own row.
|
|
68
|
+
conn.database_version >= 12_00_00 ? "COALESCE(pg_partition_root(#{relid}), #{relid})" : relid
|
|
69
|
+
end
|
|
70
|
+
private_class_method :root_of
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -32,8 +32,8 @@ module RailsAiContext
|
|
|
32
32
|
def foreign_key_entry(from, to, column, primary_key)
|
|
33
33
|
{
|
|
34
34
|
from_table: from, to_table: to,
|
|
35
|
-
column: column
|
|
36
|
-
primary_key: primary_key
|
|
35
|
+
column: primary_key_value(column) || "#{to.to_s.singularize}_id",
|
|
36
|
+
primary_key: primary_key_value(primary_key) || "id"
|
|
37
37
|
}
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -63,6 +63,13 @@ module RailsAiContext
|
|
|
63
63
|
(key ? leading << key.to_s : leading).to_set
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# A key as a reader writes it beside another key: `id`, or `(id, day)`, so
|
|
67
|
+
# the two sides of a foreign key stay apart.
|
|
68
|
+
def key_text(value)
|
|
69
|
+
columns = Array(value).map(&:to_s)
|
|
70
|
+
columns.size > 1 ? "(#{columns.join(", ")})" : columns.first.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
66
73
|
# A partial index's condition as the dump wrote it, for the end of a line.
|
|
67
74
|
def where_clause(where)
|
|
68
75
|
where ? " where #{where}" : ""
|
|
@@ -12,6 +12,7 @@ module RailsAiContext
|
|
|
12
12
|
def call
|
|
13
13
|
return attach_secondary_databases(static_schema_parse) unless active_record_connected?
|
|
14
14
|
|
|
15
|
+
@partitions = PgPartitions.names(connection)
|
|
15
16
|
if table_names.empty?
|
|
16
17
|
# Connected but not migrated: the files answer, and the notes say so.
|
|
17
18
|
@connection_state = "connected, no tables yet"
|
|
@@ -77,7 +78,8 @@ module RailsAiContext
|
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
def table_names
|
|
80
|
-
@table_names ||= connection.tables
|
|
81
|
+
@table_names ||= (connection.tables - @partitions.to_a)
|
|
82
|
+
.reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
def extract_tables
|
|
@@ -135,7 +137,8 @@ module RailsAiContext
|
|
|
135
137
|
end
|
|
136
138
|
|
|
137
139
|
def extract_foreign_keys(table)
|
|
138
|
-
|
|
140
|
+
# PostgreSQL clones a key that references a partitioned table once per partition.
|
|
141
|
+
connection.foreign_keys(table).reject { |fk| @partitions.to_a.include?(fk.to_table) }.map do |fk|
|
|
139
142
|
{
|
|
140
143
|
from_table: fk.from_table,
|
|
141
144
|
to_table: fk.to_table,
|
|
@@ -158,7 +161,7 @@ module RailsAiContext
|
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
def schema_reader
|
|
161
|
-
@schema_reader ||= SchemaReader.new(schema_file_path)
|
|
164
|
+
@schema_reader ||= SchemaReader.new(schema_file_path, partitions: @partitions.to_a)
|
|
162
165
|
end
|
|
163
166
|
|
|
164
167
|
# Constraints and enum types are declared in the dump, not reported by
|
|
@@ -59,9 +59,12 @@ module RailsAiContext
|
|
|
59
59
|
|
|
60
60
|
attr_reader :source
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
# partitions: the tables the database says are partitions, which a
|
|
63
|
+
# schema.rb from before Rails 8 dumps as plain tables.
|
|
64
|
+
def initialize(path, pk_type: nil, partitions: [])
|
|
63
65
|
@path = path
|
|
64
66
|
@pk_type = pk_type
|
|
67
|
+
@partitions = partitions
|
|
65
68
|
@source = File.exist?(path.to_s) ? :schema_rb : :none
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -147,11 +150,32 @@ module RailsAiContext
|
|
|
147
150
|
current = absorb(event, schema, current)
|
|
148
151
|
end
|
|
149
152
|
|
|
150
|
-
schema
|
|
153
|
+
drop_partitions(schema)
|
|
151
154
|
rescue => e
|
|
152
155
|
RailsAiContext.debug_fail(e, empty_schema, label: "SchemaReader")
|
|
153
156
|
end
|
|
154
157
|
|
|
158
|
+
# Rails dumps a partition as a table inheriting its parent, and PostgreSQL
|
|
159
|
+
# lets only a partition inherit a partitioned table.
|
|
160
|
+
def drop_partitions(schema)
|
|
161
|
+
table_options = schema[:tables].transform_values { |t| t.dig(:options, :options).to_s }
|
|
162
|
+
partitioned = table_options.select { |_, o| o.start_with?("PARTITION BY") }.keys
|
|
163
|
+
dumped_partitions = []
|
|
164
|
+
loop do
|
|
165
|
+
found = table_options.select { |_, o| partitioned.include?(o[/\AINHERITS \(([^,]+)\)\z/, 1]) }.keys - dumped_partitions
|
|
166
|
+
break if found.empty?
|
|
167
|
+
|
|
168
|
+
dumped_partitions.concat(found)
|
|
169
|
+
partitioned.concat(found)
|
|
170
|
+
end
|
|
171
|
+
partitions = dumped_partitions | @partitions
|
|
172
|
+
|
|
173
|
+
partitions.each { |name| schema[:tables].delete(name) }
|
|
174
|
+
schema[:foreign_keys].reject! { |fk| partitions.include?(fk[:from]) || partitions.include?(fk[:to]) }
|
|
175
|
+
schema[:check_constraints].reject! { |c| partitions.include?(c[:table]) }
|
|
176
|
+
schema
|
|
177
|
+
end
|
|
178
|
+
|
|
155
179
|
# AstCache caps parses below the configured schema limit, so a dump
|
|
156
180
|
# between the two has to be parsed directly. Everything under the cap
|
|
157
181
|
# goes through the cache, which the other introspectors share.
|
|
@@ -58,6 +58,9 @@ module RailsAiContext
|
|
|
58
58
|
tables[from]&.dig(:foreign_keys)&.push({ from_table: from, to_table: to, column: col, primary_key: pk })
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# pg_dump writes each partition as a table, then attaches it in exactly this form.
|
|
62
|
+
content.scan(/^ALTER TABLE ONLY .+? ATTACH PARTITION (?:public\.)?(?:"([^"]+)"|(\w+)) /) { |quoted, bare| tables.delete(quoted || bare) }
|
|
63
|
+
|
|
61
64
|
{ dialect: detect_sql_dialect(content), tables: tables }
|
|
62
65
|
end
|
|
63
66
|
|
|
@@ -104,7 +104,7 @@ module RailsAiContext
|
|
|
104
104
|
col_str = col_sample.any? ? " - #{col_sample.join(', ')}" : ""
|
|
105
105
|
|
|
106
106
|
# Foreign keys
|
|
107
|
-
fks = (data[:foreign_keys] || []).map { |f| "#{f[:column]}→#{f[:to_table]}" }
|
|
107
|
+
fks = (data[:foreign_keys] || []).map { |f| "#{Introspectors::SchemaConventions.key_text(f[:column])}→#{f[:to_table]}" }
|
|
108
108
|
fk_str = fks.any? ? " | FK: #{fks.join(', ')}" : ""
|
|
109
109
|
|
|
110
110
|
# Key indexes (unique or composite)
|
|
@@ -469,7 +469,8 @@ module RailsAiContext
|
|
|
469
469
|
if data[:foreign_keys]&.any?
|
|
470
470
|
lines << "" << "### Foreign keys"
|
|
471
471
|
data[:foreign_keys].each do |fk|
|
|
472
|
-
lines << "- `#{fk[:column]}` →
|
|
472
|
+
lines << "- `#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:column])}` → " \
|
|
473
|
+
"`#{fk[:to_table]}.#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:primary_key])}`"
|
|
473
474
|
end
|
|
474
475
|
end
|
|
475
476
|
|
|
@@ -124,6 +124,9 @@ module RailsAiContext
|
|
|
124
124
|
# with no READ ONLY transaction and no statement timeout.
|
|
125
125
|
MYSQL_ADAPTER = /mysql|trilogy/i
|
|
126
126
|
|
|
127
|
+
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
128
|
+
POSTGRES_ADAPTER = /postg/i
|
|
129
|
+
|
|
127
130
|
# SHOW/DESCRIBE/EXPLAIN return schema metadata (a table's column list,
|
|
128
131
|
# an EXPLAIN plan), never application data rows. Two consequences:
|
|
129
132
|
# 1. They're inherently bounded - appending `LIMIT n` to them is
|
|
@@ -335,7 +338,7 @@ module RailsAiContext
|
|
|
335
338
|
# the plan, so `explain: true` must not hold a connection past query_timeout.
|
|
336
339
|
private_class_method def self.run_guarded(conn, adapter, sql, timeout)
|
|
337
340
|
case adapter
|
|
338
|
-
when
|
|
341
|
+
when POSTGRES_ADAPTER
|
|
339
342
|
execute_postgresql(conn, sql, timeout)
|
|
340
343
|
when MYSQL_ADAPTER
|
|
341
344
|
execute_mysql(conn, sql, timeout)
|
|
@@ -418,7 +421,7 @@ module RailsAiContext
|
|
|
418
421
|
adapter = conn.adapter_name.downcase
|
|
419
422
|
|
|
420
423
|
explain_sql, parser = case adapter
|
|
421
|
-
when
|
|
424
|
+
when POSTGRES_ADAPTER
|
|
422
425
|
[ "EXPLAIN (FORMAT JSON, ANALYZE) #{sql}", :parse_pg_explain ]
|
|
423
426
|
when MYSQL_ADAPTER
|
|
424
427
|
# MySQL 8.3+ and 9.x default explain_format to TREE, which
|
|
@@ -130,7 +130,7 @@ module RailsAiContext
|
|
|
130
130
|
lines << "**Unused indexes (0 scans):**"
|
|
131
131
|
unused.first(10).each { |i| lines << "- `#{i[:index]}` on `#{i[:table]}`" }
|
|
132
132
|
end
|
|
133
|
-
hot = index_usage.sort_by { |i| -
|
|
133
|
+
hot = index_usage.sort_by { |i| [ -i[:scans].to_i, i[:table].to_s, i[:index].to_s ] }.first(5)
|
|
134
134
|
lines << "" << "**Most used indexes:**"
|
|
135
135
|
hot.each { |i| lines << "- `#{i[:index]}` on `#{i[:table]}` - #{count_phrase(i[:scans], "scan")}" }
|
|
136
136
|
end
|
|
@@ -152,12 +152,14 @@ module RailsAiContext
|
|
|
152
152
|
# table sizes for every Trilogy app.
|
|
153
153
|
MYSQL_ADAPTER = /mysql|trilogy/i
|
|
154
154
|
|
|
155
|
+
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
156
|
+
POSTGRES_ADAPTER = /postg/i
|
|
157
|
+
|
|
155
158
|
def gather_table_sizes(conn, adapter)
|
|
156
159
|
rows =
|
|
157
160
|
case adapter
|
|
158
|
-
when
|
|
159
|
-
|
|
160
|
-
conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
|
|
161
|
+
when POSTGRES_ADAPTER
|
|
162
|
+
Introspectors::PgPartitions.table_bytes(conn).sort_by { |r| [ -r[:bytes], r[:name] ] }
|
|
161
163
|
when MYSQL_ADAPTER
|
|
162
164
|
sql = "SELECT table_name AS name, (data_length + index_length) AS bytes FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = DATABASE() ORDER BY bytes DESC"
|
|
163
165
|
conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
|
|
@@ -204,9 +206,8 @@ module RailsAiContext
|
|
|
204
206
|
|
|
205
207
|
def gather_index_usage(conn, adapter)
|
|
206
208
|
case adapter
|
|
207
|
-
when
|
|
208
|
-
|
|
209
|
-
conn.select_all(sql).map { |r| { table: r["table"], index: r["index"], scans: r["scans"].to_i } }
|
|
209
|
+
when POSTGRES_ADAPTER
|
|
210
|
+
Introspectors::PgPartitions.index_stats(conn).sort_by { |i| [ i[:scans], i[:table], i[:index] ] }
|
|
210
211
|
else
|
|
211
212
|
nil
|
|
212
213
|
end
|
|
@@ -684,7 +684,13 @@ module RailsAiContext
|
|
|
684
684
|
indexed = Introspectors::SchemaConventions.lookup_indexed_columns(table_data)
|
|
685
685
|
|
|
686
686
|
fk_columns.each do |col|
|
|
687
|
-
|
|
687
|
+
if col.is_a?(Array)
|
|
688
|
+
primary_key = { columns: Array(table_data[:primary_key] || table_data.dig(:options, :primary_key)) }
|
|
689
|
+
next if Introspectors::SchemaConventions.leading_index?(Array(table_data[:indexes]) + [ primary_key ], col)
|
|
690
|
+
|
|
691
|
+
warnings << "#{Introspectors::SchemaConventions.key_text(col)} in #{table_name} - foreign key without an index leading with its columns (slow queries). " \
|
|
692
|
+
"Fix: `add_index :#{table_name}, [#{col.map { |c| ":#{c}" }.join(", ")}]`"
|
|
693
|
+
elsif !indexed.include?(col)
|
|
688
694
|
warnings << "#{col} in #{table_name} - foreign key without index (slow queries). Fix: `rails g migration AddIndexTo#{table_name.camelize} #{col}:index`"
|
|
689
695
|
end
|
|
690
696
|
end
|
data/server.json
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-ai-context
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.30.
|
|
4
|
+
version: 5.30.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- crisnahine
|
|
@@ -398,6 +398,7 @@ files:
|
|
|
398
398
|
- lib/rails_ai_context/introspectors/node_source.rb
|
|
399
399
|
- lib/rails_ai_context/introspectors/observability_introspector.rb
|
|
400
400
|
- lib/rails_ai_context/introspectors/performance_introspector.rb
|
|
401
|
+
- lib/rails_ai_context/introspectors/pg_partitions.rb
|
|
401
402
|
- lib/rails_ai_context/introspectors/rake_task_introspector.rb
|
|
402
403
|
- lib/rails_ai_context/introspectors/retry_policy.rb
|
|
403
404
|
- lib/rails_ai_context/introspectors/route_introspector.rb
|