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 +4 -4
- data/CHANGELOG.md +34 -0
- data/lib/schema_reaper/analyzers/dead_column.rb +1 -1
- data/lib/schema_reaper/analyzers/dead_table.rb +29 -3
- data/lib/schema_reaper/analyzers/missing_fk_index.rb +48 -10
- data/lib/schema_reaper/analyzers/single_value_column.rb +1 -1
- data/lib/schema_reaper/analyzers/unused_index.rb +5 -0
- data/lib/schema_reaper/introspect/postgres.rb +25 -5
- data/lib/schema_reaper/runner.rb +11 -0
- data/lib/schema_reaper/schema.rb +30 -1
- data/lib/schema_reaper/version.rb +1 -1
- data/schema_reaper.gemspec +2 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fc585e50326fe0b90f5ac697eb1211988ebc71e691f8a3253d427d611954adc
|
|
4
|
+
data.tar.gz: 84474703f7100d0b0055a3918ec611aa2032cf731ba86f27cb4ab7503a308178
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
40
|
-
[table.name, singular, camelize(table.name), camelize(singular)]
|
|
41
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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"] }
|
|
120
|
+
exec(<<~SQL, [table]).map { |r| r["attname"] }
|
|
103
121
|
SELECT a.attname
|
|
104
122
|
FROM pg_index i
|
|
105
|
-
JOIN
|
|
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
|
data/lib/schema_reaper/runner.rb
CHANGED
|
@@ -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
|
data/lib/schema_reaper/schema.rb
CHANGED
|
@@ -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
|
data/schema_reaper.gemspec
CHANGED
|
@@ -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 = [
|
|
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.
|
|
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: []
|