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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdbf8aa4b69d34178b8de050d97cd83b440c522c46cb88669282310dd8b5781e
4
- data.tar.gz: 7f82a14e208481dda4b35e2a78f7f95ed943d6ca4d912357a12f513d389ad968
3
+ metadata.gz: e31095f85a3f90825fbd66bf2a7deae9224ac4fe94474c8d60da20c46ed52b47
4
+ data.tar.gz: 190a7d48230b4ae98300b2619a0d2cf7a1ddb8a6e24619a7a54cfa3a6e5b233b
5
5
  SHA512:
6
- metadata.gz: 45dfba73734c8044fff7075b73c6e70280bb305b0a9cd13542055da30dafb7aded28584fddd2897f16dce6aea03e725022aeb9659bc026c1b23ccf7c19930059
7
- data.tar.gz: 1c11ac34c10dc55010c769091dcaff297656f60489f676f0bf4951ebb10dd05238692fd233d0916eeae6eefeea6d3c05f24aee7dd078ed6e1fd9596a4dc72c93
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
- Four modules answer questions every introspector used to answer for itself:
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
@@ -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
- # Collects approximate row counts from PostgreSQL's pg_stat_user_tables.
6
- # Only activates for PostgreSQL adapter; returns { skipped: true } otherwise.
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 /postgresql/
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
- rows = ActiveRecord::Base.connection.select_all(<<~SQL)
36
- SELECT relname AS table_name,
37
- n_live_tup AS approximate_row_count,
38
- n_dead_tup AS dead_rows
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]&.to_s,
76
- primary_key: options[:primary_key]&.to_s,
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&.to_s || "#{to.to_s.singularize}_id",
36
- primary_key: primary_key&.to_s || "id"
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.reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
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
- connection.foreign_keys(table).map do |fk|
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
- def initialize(path, pk_type: nil)
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]}` → `#{fk[:to_table]}.#{fk[:primary_key]}`"
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
 
@@ -573,7 +573,7 @@ module RailsAiContext
573
573
  end
574
574
 
575
575
  def postgresql_adapter?
576
- current_adapter.match?(/postgres/i)
576
+ current_adapter.match?(/postg/i)
577
577
  end
578
578
  end
579
579
  end
@@ -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 /postgresql/
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 /postgresql/
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| -(i[:scans] || 0) }.first(5)
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 /postgresql/
159
- sql = "SELECT relname AS name, pg_total_relation_size(relid) AS bytes FROM pg_stat_user_tables ORDER BY bytes DESC"
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 /postgresql/
208
- sql = "SELECT relname AS table, indexrelname AS index, idx_scan AS scans FROM pg_stat_user_indexes ORDER BY idx_scan ASC"
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
- unless indexed.include?(col)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.30.0"
4
+ VERSION = "5.30.1"
5
5
  end
data/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "5.30.0",
10
+ "version": "5.30.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
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.0
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