schema_reaper 1.0.5 → 1.0.7

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: 162d87ba1d2536d8db97d20dd2df6af8675529e2d967a1271d5c25ff9f1f5549
4
- data.tar.gz: 33fa30c1d35e8f1b8cbcbd5af0e4724389462bf77921470152c9403965f4f574
3
+ metadata.gz: 2fc585e50326fe0b90f5ac697eb1211988ebc71e691f8a3253d427d611954adc
4
+ data.tar.gz: 84474703f7100d0b0055a3918ec611aa2032cf731ba86f27cb4ab7503a308178
5
5
  SHA512:
6
- metadata.gz: 7c4e72d7cd3b050d21261bde44581525aff0e81190610edbff6891a8bed844d40401aa41ae8fb82e4d77781ad68b61dd75c2618d22fba625ef0ebbc0999bd6d9
7
- data.tar.gz: 610286a554f314bc296fed9ef239a4c78946b4ea5d53ab3e2b9b9eaeb3e8e5d995b33ebd2cffdff587a7e311cbf0fc00e3463f35d776a53e4dcebfc3a49f84ff
6
+ metadata.gz: 2a54a044eb8d9f910ebef39cd172854101e23ba9e3f67dc91533fa197727bca0de60486cedc35a67e3f92940eabf000bd351f3c7ef27ebd76e4dd0e7689677a0
7
+ data.tar.gz: 85e194f2c74030c5df99a703dd523381e40bbfcf6507d37bf4249f42cd48abd0696db0473b6f36f007f61e4894c900b40ffd7c7185e44214589ce3e46f3ea175
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.7] - 2026-09-04
4
+
5
+ Correctness fixes to introspection and analyzers, all contributed by @mitkush
6
+ and verified against a live PostgreSQL database.
7
+
8
+ ### Fixed
9
+ - **Index-key order** (#1). Index columns are now read in real key order via
10
+ `unnest(indkey) WITH ORDINALITY` instead of `attnum` order, so
11
+ `duplicate_index`'s prefix comparison is correct for composite indexes.
12
+ - **`unused_index` false positives on quiet databases** (#2). If the whole
13
+ cluster has fewer recorded index scans than it has indexes, there is no
14
+ query history to judge by -- the analyzer now skips with an explanation
15
+ instead of flagging every index as unused.
16
+ - **Polymorphic associations in `missing_fk_index`** (#3). A `*_id` column
17
+ paired with a `*_type` column is matched against the composite
18
+ `(type, id)` index Rails actually uses; a bare `*_id` index is no longer
19
+ demanded, and when the pair is unindexed the suggested fix is the composite
20
+ `add_index :t, %i[thing_type thing_id]`.
21
+ - **`dead_table` plurals and route helpers** (#5). Tables whose model name
22
+ needs `-y -> -ies` (`categories` -> `Category`) and names referenced only
23
+ through route helpers are recognised as used.
24
+ - **Composite primary keys** (#6). Introspection returns every primary-key
25
+ column in key order; `Table#primary_key?` / `#primary_key_columns` let
26
+ analyzers treat each part of a composite key as a key column.
27
+
28
+ ### Changed
29
+ - `DatabaseSchema` carries `index_scan_total`; `Table#primary_key` may now be
30
+ an array. Test helpers updated accordingly.
31
+
32
+ ## [1.0.6] - 2026-09-04
33
+
34
+ ### Changed
35
+ - Added `mitkush` (mitanshukushwah@gmail.com) as a gemspec author.
36
+
3
37
  ## [1.0.5] - 2026-09-04
4
38
 
5
39
  Fixes found by running the gem against a live PostgreSQL database.
@@ -41,7 +41,7 @@ module SchemaReaper
41
41
  def keep?(table, col)
42
42
  config.always_keep_columns.include?(col.name) ||
43
43
  config.ignored_column?(col.name) ||
44
- col.name == table.primary_key ||
44
+ table.primary_key?(col.name) ||
45
45
  table.foreign_keys.include?(col.name) ||
46
46
  col.name.end_with?("_id", "_type") ||
47
47
  gem_reserved?(table.name, col.name)
@@ -7,6 +7,15 @@ module SchemaReaper
7
7
  class DeadTable < Base
8
8
  Registry.register(self)
9
9
 
10
+ # Enough of Rails' inflector for table names. A wrong guess only costs a
11
+ # redundant lookup -- every form is checked and any hit counts -- so the
12
+ # rules stay small rather than trying to be complete.
13
+ SINGULAR_RULES = [
14
+ [/ies\z/, "y"], # activities -> activity
15
+ [/(ss|sh|ch|x|z)es\z/, '\1'], # addresses -> address, boxes -> box
16
+ [/s\z/, ""] # employees -> employee
17
+ ].freeze
18
+
10
19
  def call
11
20
  schema.tables.reject { |t| ignored?(t) }.filter_map { |t| dead(t) }
12
21
  end
@@ -36,9 +45,26 @@ module SchemaReaper
36
45
 
37
46
  # Match the table name and its singular/camelized model form.
38
47
  def referenced?(table)
39
- singular = table.name.sub(/s\z/, "")
40
- [table.name, singular, camelize(table.name), camelize(singular)]
41
- .any? { |form| used?(form) }
48
+ singular = singularize(table.name)
49
+ forms = [table.name, singular, camelize(table.name), camelize(singular)]
50
+ return true if forms.any? { |form| used?(form) }
51
+
52
+ embedded_in_identifier?(table.name)
53
+ end
54
+
55
+ def singularize(name)
56
+ rule = SINGULAR_RULES.find { |pattern, _| name.match?(pattern) }
57
+ rule ? name.sub(rule[0], rule[1]) : name
58
+ end
59
+
60
+ # Route helpers, i18n keys and CSS selectors bury a table name inside a
61
+ # longer identifier (admin_crm_activities_path), which the scanner
62
+ # tokenises as a single word. Count the name as referenced when it appears
63
+ # as a whole underscore-delimited run inside some token -- `logs` must not
64
+ # match `catalogs`, but must match `audit_logs_path`.
65
+ def embedded_in_identifier?(name)
66
+ pattern = /(?:\A|_)#{Regexp.escape(name)}(?:_|\z)/
67
+ ctx.used_tokens.any? { |token| token.include?(name) && pattern.match?(token) }
42
68
  end
43
69
 
44
70
  def confidence_for(table)
@@ -4,6 +4,11 @@ module SchemaReaper
4
4
  module Analyzers
5
5
  # A foreign-key column with no index: every parent delete/update scans the
6
6
  # child table. This is a repo-health nag, not dead weight.
7
+ #
8
+ # A `*_id` column paired with a `*_type` column is a polymorphic
9
+ # association. Rails never queries the id without the type, so the index
10
+ # that matters is the composite `(type, id)` -- a bare index on the id
11
+ # alone would go unused.
7
12
  class MissingFkIndex < Base
8
13
  Registry.register(self)
9
14
 
@@ -18,19 +23,38 @@ module SchemaReaper
18
23
  fk_columns(table).filter_map do |col|
19
24
  next if indexed?(table, col)
20
25
 
21
- finding(
22
- type: :missing_fk_index,
23
- table: table.name,
24
- column: col,
25
- severity: :medium,
26
- confidence: 0.9,
27
- bytes_per_row: 0,
28
- evidence: ["#{col} is a foreign key with no covering index"],
29
- suggested_fix: "add_index :#{table.name}, :#{col}"
30
- )
26
+ type_col = polymorphic_type_for(table, col)
27
+ next if type_col && polymorphic_indexed?(table, type_col, col)
28
+
29
+ finding_for(table, col, type_col)
31
30
  end
32
31
  end
33
32
 
33
+ def finding_for(table, col, type_col)
34
+ finding(
35
+ type: :missing_fk_index,
36
+ table: table.name,
37
+ column: col,
38
+ severity: :medium,
39
+ confidence: 0.9,
40
+ bytes_per_row: 0,
41
+ evidence: [evidence_for(col, type_col)],
42
+ suggested_fix: fix_for(table, col, type_col)
43
+ )
44
+ end
45
+
46
+ def evidence_for(col, type_col)
47
+ return "#{col} is a foreign key with no covering index" unless type_col
48
+
49
+ "#{col} is a polymorphic association with #{type_col} and has no (#{type_col}, #{col}) index"
50
+ end
51
+
52
+ def fix_for(table, col, type_col)
53
+ return "add_index :#{table.name}, :#{col}" unless type_col
54
+
55
+ "add_index :#{table.name}, %i[#{type_col} #{col}]"
56
+ end
57
+
34
58
  def fk_columns(table)
35
59
  (table.foreign_keys + table.column_names.grep(/_id\z/)).uniq
36
60
  end
@@ -38,6 +62,20 @@ module SchemaReaper
38
62
  def indexed?(table, col)
39
63
  table.indexes.any? { |ix| ix.columns.first == col }
40
64
  end
65
+
66
+ # The `*_type` column paired with a polymorphic `*_id`, when present.
67
+ def polymorphic_type_for(table, col)
68
+ return nil unless col.end_with?("_id")
69
+
70
+ type_col = "#{col[0..-4]}_type"
71
+ table.column_names.include?(type_col) ? type_col : nil
72
+ end
73
+
74
+ # Rails writes these as add_index :table, %i[thing_type thing_id], so the
75
+ # pair leads the index; trailing columns (version, name, ...) are fine.
76
+ def polymorphic_indexed?(table, type_col, id_col)
77
+ table.indexes.any? { |ix| ix.columns.first(2) == [type_col, id_col] }
78
+ end
41
79
  end
42
80
  end
43
81
  end
@@ -22,7 +22,7 @@ module SchemaReaper
22
22
 
23
23
  table.columns.filter_map do |col|
24
24
  next unless col.single_value?
25
- next if col.name == table.primary_key
25
+ next if table.primary_key?(col.name)
26
26
  next if config.always_keep_columns.include?(col.name)
27
27
  next if gem_reserved?(table.name, col.name)
28
28
 
@@ -9,6 +9,11 @@ module SchemaReaper
9
9
  Registry.register(self)
10
10
 
11
11
  def call
12
+ # Without query history every idx_scan is 0, so this analyzer would
13
+ # report every non-unique index in the database. Report nothing rather
14
+ # than burying the other analyzers' findings under that noise.
15
+ return [] unless schema.query_history?
16
+
12
17
  schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
13
18
  .flat_map { |t| unused_in(t) }
14
19
  end
@@ -20,11 +20,22 @@ module SchemaReaper
20
20
  end
21
21
 
22
22
  def call
23
- DatabaseSchema.new(tables: table_names.map { |n| build_table(n) })
23
+ DatabaseSchema.new(
24
+ tables: table_names.map { |n| build_table(n) },
25
+ index_scan_total: index_scan_total
26
+ )
24
27
  end
25
28
 
26
29
  private
27
30
 
31
+ # Cluster-wide cumulative index scans. Lets the unused-index analyzer tell
32
+ # "this index is never used" apart from "this database has no query
33
+ # history", which look identical at the level of a single idx_scan = 0.
34
+ def index_scan_total
35
+ exec("SELECT COALESCE(sum(idx_scan), 0) AS total FROM pg_stat_user_indexes")
36
+ .first&.fetch("total")&.to_i
37
+ end
38
+
28
39
  def table_names
29
40
  exec(<<~SQL).map { |r| r["tablename"] }
30
41
  SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename
@@ -77,15 +88,19 @@ module SchemaReaper
77
88
  end
78
89
  end
79
90
 
91
+ # Columns must come back in index-key order, not table order: prefix
92
+ # comparisons (Index#covers?) are only meaningful on the real key order.
93
+ # unnest(indkey) WITH ORDINALITY preserves that; ORDER BY attnum does not.
80
94
  def indexes_for(table)
81
95
  exec(<<~SQL, [table]).map do |r|
82
96
  SELECT i.relname AS name, ix.indisunique AS "unique", ix.indisprimary AS "primary",
83
97
  s.idx_scan AS scans,
84
- array_to_string(array_agg(a.attname ORDER BY a.attnum), ',') AS cols
98
+ array_to_string(array_agg(a.attname ORDER BY k.ord), ',') AS cols
85
99
  FROM pg_class t
86
100
  JOIN pg_index ix ON t.oid = ix.indrelid
87
101
  JOIN pg_class i ON i.oid = ix.indexrelid
88
- JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
102
+ JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord) ON TRUE
103
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
89
104
  LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
90
105
  WHERE t.relname = $1
91
106
  GROUP BY i.relname, ix.indisunique, ix.indisprimary, s.idx_scan
@@ -98,12 +113,17 @@ module SchemaReaper
98
113
  end
99
114
  end
100
115
 
116
+ # Every primary-key column, in key order. A composite key needs the whole
117
+ # list: returning one arbitrary column leaves the rest looking like
118
+ # ordinary columns to the analyzers.
101
119
  def primary_key_for(table)
102
- exec(<<~SQL, [table]).map { |r| r["attname"] }.first
120
+ exec(<<~SQL, [table]).map { |r| r["attname"] }
103
121
  SELECT a.attname
104
122
  FROM pg_index i
105
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
123
+ JOIN LATERAL unnest(i.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord) ON TRUE
124
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
106
125
  WHERE i.indrelid = $1::regclass AND i.indisprimary
126
+ ORDER BY k.ord
107
127
  SQL
108
128
  rescue PG::Error
109
129
  nil
@@ -17,6 +17,7 @@ module SchemaReaper
17
17
 
18
18
  def run
19
19
  db = schema
20
+ warn_missing_query_history(db)
20
21
  ctx = Analyzers::Context.new(
21
22
  schema: db,
22
23
  used_tokens: Static::Scanner.new(@config, root: @root).call,
@@ -31,6 +32,16 @@ module SchemaReaper
31
32
 
32
33
  private
33
34
 
35
+ # Silently reporting nothing would look like a clean bill of health, so say
36
+ # why the unused-index analyzer sat this run out.
37
+ def warn_missing_query_history(db)
38
+ return if db.query_history?
39
+
40
+ warn "schema_reaper: skipping unused_index -- only #{db.index_scan_total} index scan(s) " \
41
+ "recorded across #{db.index_count} index(es). Scan a database that has served " \
42
+ "production traffic, or check whether statistics were recently reset."
43
+ end
44
+
34
45
  def dedupe(findings)
35
46
  collapse_targets(drop_findings_on_dead_tables(findings))
36
47
  end
@@ -40,11 +40,40 @@ module SchemaReaper
40
40
  def index_on(cols)
41
41
  indexes.find { |i| i.columns == Array(cols) }
42
42
  end
43
+
44
+ # primary_key may be a single name or, for a composite key, the ordered
45
+ # list. Array() accepts both so introspectors that still return one name
46
+ # keep working.
47
+ def primary_key_columns
48
+ Array(primary_key)
49
+ end
50
+
51
+ def primary_key?(column_name)
52
+ primary_key_columns.include?(column_name)
53
+ end
43
54
  end
44
55
 
45
- DatabaseSchema = Struct.new(:tables, keyword_init: true) do
56
+ DatabaseSchema = Struct.new(:tables, :index_scan_total, keyword_init: true) do
46
57
  def table(name)
47
58
  tables.find { |t| t.name == name }
48
59
  end
60
+
61
+ def index_count
62
+ tables.sum { |t| t.indexes.size }
63
+ end
64
+
65
+ # pg_stat counters are cumulative since the last reset, so idx_scan = 0 only
66
+ # means "unused" if the database has served enough traffic for a used index
67
+ # to have registered. A database that has taken real queries scans its
68
+ # indexes far more than once each; below that we are looking at a fresh,
69
+ # restored, or just-reset cluster where every zero is an artifact.
70
+ #
71
+ # nil means the introspector did not report a total -- assume stats are
72
+ # usable rather than silently dropping findings.
73
+ def query_history?
74
+ return true if index_scan_total.nil?
75
+
76
+ index_scan_total >= index_count
77
+ end
49
78
  end
50
79
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaReaper
4
- VERSION = "1.0.5"
4
+ VERSION = "1.0.7"
5
5
  end
@@ -5,8 +5,8 @@ require_relative "lib/schema_reaper/version"
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "schema_reaper"
7
7
  spec.version = SchemaReaper::VERSION
8
- spec.authors = ["aksshatt"]
9
- spec.email = ["akshatpegwar5@gmail.com"]
8
+ spec.authors = %w[aksshatt mitkush]
9
+ spec.email = ["akshatpegwar5@gmail.com", "mitanshukushwah@gmail.com"]
10
10
 
11
11
  spec.summary = "Find and safely remove schema dead-weight in Rails + PostgreSQL apps."
12
12
  spec.description =
metadata CHANGED
@@ -1,10 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schema_reaper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.5
4
+ version: 1.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - aksshatt
8
+ - mitkush
8
9
  autorequire:
9
10
  bindir: exe
10
11
  cert_chain: []
@@ -88,6 +89,7 @@ description: 'schema_reaper finds schema dead-weight in Rails/ActiveRecord + Pos
88
89
  only for now; Ruby >= 2.7. See the README for full usage.'
89
90
  email:
90
91
  - akshatpegwar5@gmail.com
92
+ - mitanshukushwah@gmail.com
91
93
  executables:
92
94
  - schema_reaper
93
95
  extensions: []