schema_reaper 1.0.13 → 1.0.15

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: 920a3c80a9b99271cfb1152f9a4682dd42c03516934fbd6971c56ef0af06882f
4
- data.tar.gz: 63567586bef79cb15f27be238def5882fe3119955bb45bea346b4f979599e8ff
3
+ metadata.gz: 6b327fade324c6f2e9a871da57ae46814c1b077a1aca3aec64ca6681f270ffe6
4
+ data.tar.gz: d63156d86d7dc44e92204475c3d23ffa7e6b87730d0a436dc9aefd041ea8b843
5
5
  SHA512:
6
- metadata.gz: fa80aecb2dacdbf843f03734818d739b7d5d386ba4096fc69163dacbc55ef52fb92a1b93adc1c4f5f4bf14bb5a1dc3a449fab647101d5a4b43a9922fa7088469
7
- data.tar.gz: 410917c5ece25227dbcfeb0b32128b8d0105770631c10a4175da273c3c36744b4bed428857db38b2b621fb4d5ffd03fce89e70d4599bae9c66676371ce42687f
6
+ metadata.gz: cb3fe82c18f9f198ec544089305b183f2a413123093edd6f437a04c8d0b32879c123d1e4d1c338a1e03539a8eb16847406bb21f4eab5932ed6b4aba4fcdf461e
7
+ data.tar.gz: 1c1cf99e43ff66383f5b3bb0c42d5cfec209cbc8dab3bb986e734b6dcf75706c5ed1ecbf09d61f116ce2883957630f0ceeff7a102fb49793fe73cd93cd8283ad
data/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.15] - 2026-09-18
4
+
5
+ ### Fixed
6
+ - **`dead_column` still missed the two gems its own macro-detection feature
7
+ named as motivation.** 1.0.14 added recognition for `has_secure_password`,
8
+ `attr_encrypted`, and Lockbox macro columns, but only the first actually
9
+ worked. `attr_encrypted`'s real default column naming is a *prefix*
10
+ (`encrypted_<attr>`), confirmed against the gem's own
11
+ `attr_encrypted_default_options`; the feature generated a suffix guess
12
+ that never matched it. Lockbox's real macro is `has_encrypted`; the
13
+ feature checked for `lockbox_encrypts`, which is not a method that
14
+ exists in the gem, so it never even fired for genuine Lockbox code.
15
+ Rewritten as a macro-name -> column-template table so each macro's real
16
+ naming shape is expressed explicitly, and dropped an unnecessary
17
+ suffix guess for Rails' native `encrypts` (which stores ciphertext in
18
+ the original column and creates no extra one). Eight new specs cover
19
+ all four macros -- this had no test coverage before. (#15, mitkush)
20
+
21
+ ## [1.0.14] - 2026-09-18
22
+
23
+ Published directly without a version-bump commit or tag; this entry
24
+ reconciles the record after the fact. Confirmed by diffing the
25
+ published gem against `main` at the time -- the only difference was the
26
+ version string.
27
+
28
+ ### Fixed
29
+ - `duplicate_index` respecting uniqueness and partial `WHERE` clauses --
30
+ see 1.0.13's entry for the underlying issue; this release adds the fix.
31
+ (#12, mitkush)
32
+ - `dead_column` missing `has_secure_password`, `encrypts`, and
33
+ `attr_encrypted` macro columns -- a virtual attribute like
34
+ `has_secure_password` never has its generated column name
35
+ (`password_digest`) written anywhere in application code, so it looked
36
+ unreferenced. (aksshatt)
37
+
38
+ ### Known issue
39
+ - The `attr_encrypted`/Lockbox part of the macro-column fix above does
40
+ not work as intended: it derives a suffix-based column name
41
+ (`<attr>_encrypted`) that does not match `attr_encrypted`'s actual
42
+ default naming (`encrypted_<attr>`, a prefix), and it references a
43
+ `lockbox_encrypts` macro name that does not exist in Lockbox -- the
44
+ gem's real method is `has_encrypted`. `has_secure_password` and Rails'
45
+ native `encrypts` are unaffected and work correctly. Tracked for 1.0.15.
46
+
3
47
  ## [1.0.13] - 2026-09-18
4
48
 
5
49
  ### Fixed
@@ -3,7 +3,8 @@
3
3
  module SchemaReaper
4
4
  module Analyzers
5
5
  # An index whose column list is a leading prefix of another index on the
6
- # same table is redundant (the wider index serves both).
6
+ # same table is redundant (the wider index serves both), and two indexes
7
+ # with identical column lists are redundant with each other.
7
8
  class DuplicateIndex < Base
8
9
  Registry.register(self)
9
10
 
@@ -14,28 +15,79 @@ module SchemaReaper
14
15
 
15
16
  private
16
17
 
18
+ # A partial index only exists for rows matching its WHERE clause and is
19
+ # usually there on purpose (a smaller, faster index for one condition),
20
+ # so it is excluded entirely rather than reasoned about: not a
21
+ # candidate for removal, and not a stand-in for a full index either.
22
+ # Comparing WHERE clauses for implication is out of scope here, and a
23
+ # wrong guess in either direction is a real index gone from production.
17
24
  def dupes_in(table)
18
- non_pk = table.indexes.reject(&:primary)
19
- non_pk.filter_map do |ix|
20
- covering = non_pk.find { |o| o != ix && !o.unique && o.covers?(ix) && o.columns != ix.columns }
21
- next unless covering
22
-
23
- finding(
24
- type: :duplicate_index,
25
- table: table.name,
26
- index: ix.name,
27
- column: ix.columns.join(","),
28
- severity: :low,
29
- confidence: 0.8,
30
- bytes_per_row: 0,
31
- evidence: [
32
- "#{ix.name} (#{ix.columns.join(", ")}) is a prefix of " \
33
- "#{covering.name} (#{covering.columns.join(", ")})"
34
- ],
35
- suggested_fix: "remove_index :#{table.name}, name: :#{ix.name}"
36
- )
25
+ non_pk = table.indexes.reject { |index| index.primary || index.partial? }
26
+ by_columns = non_pk.group_by(&:columns)
27
+ non_pk.filter_map { |index| finding_for(table, index, non_pk, by_columns) }
28
+ end
29
+
30
+ def finding_for(table, index, non_pk, by_columns)
31
+ covering = covering_for(index, non_pk, by_columns)
32
+ return unless covering
33
+
34
+ finding(
35
+ type: :duplicate_index,
36
+ table: table.name,
37
+ index: index.name,
38
+ column: index.columns.join(","),
39
+ severity: :low,
40
+ confidence: 0.8,
41
+ bytes_per_row: 0,
42
+ evidence: [evidence_for(index, covering)],
43
+ suggested_fix: "remove_index :#{table.name}, name: :#{index.name}"
44
+ )
45
+ end
46
+
47
+ # Two indexes with the same column list have no natural wider/narrower
48
+ # direction, so comparing them pairwise would have each flag the other --
49
+ # applying both suggested fixes would then drop the column pair
50
+ # entirely. Instead, one designated survivor per exact-column group is
51
+ # chosen once; every other member of the group is redundant against it,
52
+ # and the survivor itself is only checked against a genuinely wider
53
+ # prefix elsewhere on the table.
54
+ def covering_for(index, non_pk, by_columns)
55
+ peers = by_columns[index.columns]
56
+ return prefix_covering_for(index, non_pk) if peers.size == 1
57
+
58
+ survivor = survivor_of(peers)
59
+ return survivor unless survivor == index
60
+
61
+ prefix_covering_for(index, non_pk)
62
+ end
63
+
64
+ # Keep a unique index over a non-unique one -- it enforces a guarantee
65
+ # the others don't -- breaking further ties by name for a stable,
66
+ # order-independent choice.
67
+ def survivor_of(peers)
68
+ unique_peers = peers.select(&:unique)
69
+ (unique_peers.empty? ? peers : unique_peers).min_by(&:name)
70
+ end
71
+
72
+ def prefix_covering_for(index, non_pk)
73
+ non_pk.find do |o|
74
+ o != index && o.columns != index.columns && o.covers?(index) && safe_to_drop?(index, against: o)
37
75
  end
38
76
  end
77
+
78
+ # A wider index does not make a narrower prefix unique: unique (a, b)
79
+ # says nothing about whether a alone is unique. So a unique index is
80
+ # only safe to drop in favour of another index that is itself unique on
81
+ # that exact same column list -- never a merely-wider or non-unique one.
82
+ def safe_to_drop?(index, against:)
83
+ !index.unique || (against.unique && against.columns == index.columns)
84
+ end
85
+
86
+ def evidence_for(index, covering)
87
+ relation = covering.columns == index.columns ? "duplicates" : "is a prefix of"
88
+ "#{index.name} (#{index.columns.join(", ")}) #{relation} " \
89
+ "#{covering.name} (#{covering.columns.join(", ")})"
90
+ end
39
91
  end
40
92
  end
41
93
  end
@@ -100,22 +100,16 @@ module SchemaReaper
100
100
  end
101
101
  end
102
102
 
103
- # Columns must come back in index-key order, not table order: prefix
104
- # comparisons (Index#covers?) are only meaningful on the real key order.
105
- #
106
- # pg_get_indexdef(indexrelid, column_no, pretty) is keyed by column
107
- # *position* (1..indnkeyatts), not by table attnum, so it renders a
108
- # plain column and an expression column uniformly. The previous query
109
- # joined each indkey entry against pg_attribute by attnum; an expression
110
- # column's indkey entry is 0, which matches no real column, so the join
111
- # silently dropped it -- an index on (COALESCE(a, b), c) came back as
112
- # just "c", which then looked like a genuine duplicate of any ordinary
113
- # index on :c. indnkeyatts also excludes INCLUDE columns, which are
114
- # payload only and never participate in Index#covers?'s prefix check.
103
+ # Columns come back in index-key order via pg_get_indexdef(indexrelid,
104
+ # column_no, pretty), keyed by column position rather than table attnum
105
+ # so it renders a plain column and an expression uniformly and never
106
+ # silently drops one. indnkeyatts excludes INCLUDE columns, which are
107
+ # payload only. partial (indpred IS NOT NULL) lets analyzers refuse to
108
+ # treat a conditional index as if it covered every row.
115
109
  def indexes_for(table)
116
110
  exec(<<~SQL, [table]).map do |r|
117
111
  SELECT i.relname AS name, ix.indisunique AS "unique", ix.indisprimary AS "primary",
118
- s.idx_scan AS scans,
112
+ (ix.indpred IS NOT NULL) AS partial, s.idx_scan AS scans,
119
113
  array_to_string(
120
114
  array_agg(pg_get_indexdef(ix.indexrelid, k.ord::int, true) ORDER BY k.ord),
121
115
  '#{COLUMN_SEPARATOR}'
@@ -126,11 +120,11 @@ module SchemaReaper
126
120
  JOIN LATERAL generate_series(1, ix.indnkeyatts) AS k(ord) ON TRUE
127
121
  LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
128
122
  WHERE t.relname = $1
129
- GROUP BY i.relname, ix.indisunique, ix.indisprimary, s.idx_scan
123
+ GROUP BY i.relname, ix.indisunique, ix.indisprimary, ix.indpred, s.idx_scan
130
124
  SQL
131
125
  Index.new(
132
126
  name: r["name"], columns: r["cols"].split(COLUMN_SEPARATOR),
133
- unique: r["unique"] == "t", primary: r["primary"] == "t",
127
+ unique: r["unique"] == "t", primary: r["primary"] == "t", partial: r["partial"] == "t",
134
128
  scans: r["scans"]&.to_i
135
129
  )
136
130
  end
@@ -19,10 +19,18 @@ module SchemaReaper
19
19
  end
20
20
  end
21
21
 
22
- Index = Struct.new(:name, :columns, :unique, :primary, :scans, keyword_init: true) do
22
+ Index = Struct.new(:name, :columns, :unique, :primary, :scans, :partial, keyword_init: true) do
23
23
  def covers?(other)
24
24
  columns.first(other.columns.length) == other.columns
25
25
  end
26
+
27
+ # A partial index only exists for rows matching its WHERE clause, so it
28
+ # cannot stand in for a full index for rows outside that condition.
29
+ # comparing WHERE clauses for implication is out of scope here, so a
30
+ # partial index is simply never treated as covering another.
31
+ def partial?
32
+ !!partial
33
+ end
26
34
  end
27
35
 
28
36
  Table = Struct.new(
@@ -12,6 +12,38 @@ module SchemaReaper
12
12
  RUBY_GLOB = "**/*.rb"
13
13
  WORD_RE = /[a-z_][a-z0-9_]*/i.freeze
14
14
 
15
+ # Macros whose generated column name differs from the virtual attribute
16
+ # declared in the call, so the real column name never appears anywhere
17
+ # in application code and would otherwise look dead. %s is replaced with
18
+ # the declared attribute.
19
+ #
20
+ # Several shapes are listed for some macros on purpose: getting the real
21
+ # default right matters most, but an extra guess that never matches a
22
+ # real column is harmless -- over-collecting is this scanner's stated
23
+ # design, and both attr_encrypted and Lockbox let a call override their
24
+ # naming, which a static scan cannot always evaluate.
25
+ #
26
+ # attr_encrypted's own default is a PREFIX (encrypted_<attr>), not a
27
+ # suffix -- confirmed against the gem's own attr_encrypted_default_options
28
+ # (prefix: "encrypted_", suffix: ""). The suffix form is kept as a
29
+ # fallback for calls that override it.
30
+ #
31
+ # Lockbox's real macro is has_encrypted, not lockbox_encrypts.
32
+ #
33
+ # Rails' own `encrypts` has no entry: it stores ciphertext in the
34
+ # original column, so the bare attribute name is what needs to be
35
+ # "used", and that is already picked up as a symbol literal by
36
+ # tokens_for below -- do not add a suffix pattern for it.
37
+ MACRO_COLUMN_TEMPLATES = {
38
+ "has_secure_password" => ["%s_digest"],
39
+ "attr_encrypted" => ["encrypted_%s", "%s_encrypted", "%s_ciphertext"],
40
+ "has_encrypted" => ["%s_ciphertext", "%s_iv", "%s_tag"]
41
+ }.freeze
42
+
43
+ # The attribute a macro implies when called with no explicit name, e.g.
44
+ # bare `has_secure_password` -> :password.
45
+ MACRO_DEFAULT_ATTR = { "has_secure_password" => "password" }.freeze
46
+
15
47
  # Node classes whose #name (or #unescaped) is a bare identifier we treat
16
48
  # as a possible column/table reference.
17
49
  NAME_NODES = [
@@ -61,6 +93,7 @@ module SchemaReaper
61
93
  return unless node.is_a?(Prism::Node)
62
94
 
63
95
  out.merge(tokens_for(node))
96
+ out.merge(macro_derived_tokens(node)) if node.is_a?(Prism::CallNode)
64
97
  node.compact_child_nodes.each { |c| collect_from_node(c, out) }
65
98
  end
66
99
 
@@ -78,6 +111,24 @@ module SchemaReaper
78
111
  end
79
112
  end
80
113
 
114
+ def macro_derived_tokens(node)
115
+ call_name = node.name&.to_s
116
+ templates = call_name && MACRO_COLUMN_TEMPLATES[call_name]
117
+ return [] unless templates
118
+
119
+ attrs = macro_symbol_args(node)
120
+ attrs = [MACRO_DEFAULT_ATTR[call_name]].compact if attrs.empty?
121
+ attrs.product(templates).map { |attr, template| format(template, attr) }
122
+ end
123
+
124
+ # Leading bare symbol arguments of a call, e.g. `encrypts :a, :b, purpose: :x`
125
+ # => ["a", "b"]. Stops at the first non-symbol (keyword args, etc).
126
+ def macro_symbol_args(node)
127
+ args = node.arguments&.arguments || []
128
+ args.take_while { |a| a.is_a?(Prism::SymbolNode) }
129
+ .map { |a| a.unescaped.to_s.downcase }
130
+ end
131
+
81
132
  def text_tokens(path)
82
133
  File.read(path).scan(WORD_RE).to_set(&:downcase)
83
134
  rescue StandardError
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaReaper
4
- VERSION = "1.0.13"
4
+ VERSION = "1.0.15"
5
5
  end
@@ -9,15 +9,17 @@ Gem::Specification.new do |spec|
9
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
- spec.description =
13
- "schema_reaper finds schema dead-weight in Rails/ActiveRecord + PostgreSQL apps: " \
14
- "dead columns and tables, unused, duplicate and missing foreign-key indexes, and " \
15
- "always-NULL or single-value columns. It cross-references the live schema and pg_stats " \
16
- "against a static scan of your code, with an optional production runtime signal for " \
17
- "higher confidence. Each finding is scored, carries a reclaimable-bytes estimate, and " \
18
- "ships with a staged, reversible migration. Reporters for terminal, JSON, Markdown and " \
19
- "SARIF; a CI baseline gate; a trend log; a Rails railtie. PostgreSQL only for now; " \
20
- "Ruby >= 2.7. See the README for full usage."
12
+ spec.description = <<~DESC.strip
13
+ schema_reaper scans a Rails + PostgreSQL app for schema debt that's easy to accumulate and hard to find by hand.
14
+
15
+ It checks your live database against your codebase and flags three kinds of problems: things nothing references anymore (dead columns and dead tables), index trouble (indexes nobody queries, indexes made redundant by a wider index that already covers them, and foreign-key columns with no index at all), and degenerate data (columns that are always NULL, or hold the exact same value in every row).
16
+
17
+ Every finding comes with a confidence score, an estimate of the disk space removing it would reclaim, and a concrete fix. For a column, that's a two-step migration: stop reading it first, then drop it once you've confirmed nothing broke. An optional runtime tracker can sample real production traffic to raise confidence further, for cases a static code scan alone can't settle.
18
+
19
+ Reports come as a colored terminal summary, JSON, Markdown for a PR comment, or SARIF for GitHub code scanning -- plus a CI baseline gate and a trend log to track progress release over release.
20
+
21
+ PostgreSQL only for now. Requires Ruby 2.7 or later. Full usage is in the README.
22
+ DESC
21
23
  spec.homepage = "https://github.com/aksshatt/schema_reaper"
22
24
  spec.license = "MIT"
23
25
  spec.required_ruby_version = ">= 2.7.0"
metadata CHANGED
@@ -1,14 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schema_reaper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.13
4
+ version: 1.0.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - aksshatt
8
8
  - mitkush
9
+ autorequire:
9
10
  bindir: exe
10
11
  cert_chain: []
11
- date: 1980-01-02 00:00:00.000000000 Z
12
+ date: 2026-09-18 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: prism
@@ -78,14 +79,16 @@ dependencies:
78
79
  - - "~>"
79
80
  - !ruby/object:Gem::Version
80
81
  version: '1.5'
81
- description: 'schema_reaper finds schema dead-weight in Rails/ActiveRecord + PostgreSQL
82
- apps: dead columns and tables, unused, duplicate and missing foreign-key indexes,
83
- and always-NULL or single-value columns. It cross-references the live schema and
84
- pg_stats against a static scan of your code, with an optional production runtime
85
- signal for higher confidence. Each finding is scored, carries a reclaimable-bytes
86
- estimate, and ships with a staged, reversible migration. Reporters for terminal,
87
- JSON, Markdown and SARIF; a CI baseline gate; a trend log; a Rails railtie. PostgreSQL
88
- only for now; Ruby >= 2.7. See the README for full usage.'
82
+ description: |-
83
+ schema_reaper scans a Rails + PostgreSQL app for schema debt that's easy to accumulate and hard to find by hand.
84
+
85
+ It checks your live database against your codebase and flags three kinds of problems: things nothing references anymore (dead columns and dead tables), index trouble (indexes nobody queries, indexes made redundant by a wider index that already covers them, and foreign-key columns with no index at all), and degenerate data (columns that are always NULL, or hold the exact same value in every row).
86
+
87
+ Every finding comes with a confidence score, an estimate of the disk space removing it would reclaim, and a concrete fix. For a column, that's a two-step migration: stop reading it first, then drop it once you've confirmed nothing broke. An optional runtime tracker can sample real production traffic to raise confidence further, for cases a static code scan alone can't settle.
88
+
89
+ Reports come as a colored terminal summary, JSON, Markdown for a PR comment, or SARIF for GitHub code scanning -- plus a CI baseline gate and a trend log to track progress release over release.
90
+
91
+ PostgreSQL only for now. Requires Ruby 2.7 or later. Full usage is in the README.
89
92
  email:
90
93
  - akshatpegwar5@gmail.com
91
94
  - mitanshukushwah@gmail.com
@@ -156,6 +159,7 @@ metadata:
156
159
  wiki_uri: https://github.com/aksshatt/schema_reaper/wiki
157
160
  funding_uri: https://github.com/sponsors/aksshatt
158
161
  rubygems_mfa_required: 'true'
162
+ post_install_message:
159
163
  rdoc_options: []
160
164
  require_paths:
161
165
  - lib
@@ -170,7 +174,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
170
174
  - !ruby/object:Gem::Version
171
175
  version: '0'
172
176
  requirements: []
173
- rubygems_version: 3.6.9
177
+ rubygems_version: 3.3.3
178
+ signing_key:
174
179
  specification_version: 4
175
180
  summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
176
181
  test_files: []