schema_reaper 1.0.12 → 1.0.14

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: b7e70e2066753282ad6757f3d73175a57b73b7026aa8879314f693dafc8547e7
4
- data.tar.gz: d4b2ac7a4e6934e068d79ac32f694a5bffcd329bed689c23d0a080266b8bdb6b
3
+ metadata.gz: 6f169920afef1838471a418e29ad207b6c9d3193ef57caf6678cf68d675a3405
4
+ data.tar.gz: 3e5d507411e727278004def7a752db662620a01371625ce761af4495fd020f2e
5
5
  SHA512:
6
- metadata.gz: '04943ff8915c5a809a9f81741f14e98712a6453dcf046441b7ae10d726031a994045e9490b5a8a62f948210c0e030825429bde387f35ffc15176a6b419e5f57a'
7
- data.tar.gz: da080808761005f66096458968fc7c68d3c6f3251d2c97aa50a56020a37abb0d673129782244e880473ffb6287df191418f1142e1bc7632e4da05f49def17cc2
6
+ metadata.gz: 739b0cf29def40063c3a182ad5b9d207f7409f9277e134e76bd980bc3db276a333f225a1974bf5c30a3e14af769d0c8128efeb46961d084f1349fc17bddd6eca
7
+ data.tar.gz: 692ed7533e2553ebbc9085001f1d805519149fe988860e23da9902b941de60c292a8918e60f2fe9c2d0e558aaeacd182d31e6376570e7ea9e8551977d5856db8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.13] - 2026-09-18
4
+
5
+ ### Fixed
6
+ - **Expression key columns were silently dropped from an index's column
7
+ list.** `indexes_for` joined `pg_index.indkey` entries against
8
+ `pg_attribute` by `attnum`; an expression column's `indkey` entry is
9
+ `0`, which matches no real column, so the join dropped it instead of
10
+ erroring. An index on `((data ->> 'type')), ((data ->> 'id')), user_id,
11
+ created_at` came back as just `user_id,created_at`, which then made a
12
+ plain index on `:user_id` look like a genuine leading-edge duplicate of
13
+ a key it was not actually a prefix of -- `duplicate_index` recommended
14
+ dropping an index that ordinary `WHERE user_id = ?` lookups depended
15
+ on. Rewritten to use `pg_get_indexdef(indexrelid, column_no, pretty)`,
16
+ which is keyed by column position rather than table attnum and renders
17
+ a plain column and an expression the same way. Also switched the
18
+ internal column separator from `,` to a control byte, since an
19
+ expression can legitimately contain a comma (`COALESCE(a, b)`) that a
20
+ comma-delimited split would have cut in half. (#9, mitkush)
21
+ - **A table a gem creates and owns outright could be reported as dead.**
22
+ `dead_table` never consulted `GemAwareness`, so a table only ever
23
+ referenced through a gem's own internal classes -- never named in app
24
+ code -- looked exactly like real dead weight. Confirmed on a real app:
25
+ all of that app's `dead_table` findings were gem-owned tables
26
+ (`active_admin_comments`, `devise_api_tokens`, `friendly_id_slugs`),
27
+ and `drop_table :devise_api_tokens` would have deleted its mobile
28
+ session storage. `GemAwareness.owned_tables` now feeds `dead_table` the
29
+ same way it already fed the `dead_column` exemption. Also added
30
+ `activeadmin` and `devise-api` to the gem map, verified against each
31
+ gem's own migration template. (#10, mitkush)
32
+
3
33
  ## [1.0.12] - 2026-09-15
4
34
 
5
35
  ### Fixed
@@ -1,14 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module SchemaReaper
4
6
  module Analyzers
5
7
  # Context passed to every analyzer's #call.
6
- # schema - DatabaseSchema
7
- # used_tokens - Set<String> from the static scan
8
- # runtime - Runtime::Report (may be empty)
9
- # gem_columns - Hash{table_name => Set<column_name>} reserved by gems
10
- # config - Config
11
- Context = Struct.new(:schema, :used_tokens, :runtime, :gem_columns, :config, keyword_init: true) do
8
+ # schema - DatabaseSchema
9
+ # used_tokens - Set<String> from the static scan
10
+ # runtime - Runtime::Report (may be empty)
11
+ # gem_columns - Hash{table_name => Set<column_name>} reserved by gems
12
+ # gem_owned_tables - Set<table_name> created and owned outright by a gem
13
+ # config - Config
14
+ Context = Struct.new(:schema, :used_tokens, :runtime, :gem_columns, :gem_owned_tables, :config,
15
+ keyword_init: true) do
12
16
  def runtime
13
17
  self[:runtime] || Runtime::Report.empty
14
18
  end
@@ -16,6 +20,10 @@ module SchemaReaper
16
20
  def gem_columns
17
21
  self[:gem_columns] || {}
18
22
  end
23
+
24
+ def gem_owned_tables
25
+ self[:gem_owned_tables] || Set.new
26
+ end
19
27
  end
20
28
 
21
29
  # Shared plumbing for analyzers: schema access and token lookup helpers.
@@ -57,6 +65,10 @@ module SchemaReaper
57
65
  ctx.gem_columns.fetch(table, []).include?(column)
58
66
  end
59
67
 
68
+ def gem_owned_table?(table)
69
+ ctx.gem_owned_tables.include?(table)
70
+ end
71
+
60
72
  # Builds a Finding, filling in reclaimable_bytes from the row count.
61
73
  # An unknown row count leaves reclaimable_bytes nil: "we cannot say" is
62
74
  # not the same claim as "zero bytes", and reporters need to tell them
@@ -24,7 +24,8 @@ module SchemaReaper
24
24
 
25
25
  def ignored?(table)
26
26
  config.ignore_tables.include?(table.name) ||
27
- table.name.start_with?("active_storage_", "action_text_", "action_mailbox_")
27
+ table.name.start_with?("active_storage_", "action_text_", "action_mailbox_") ||
28
+ gem_owned_table?(table.name)
28
29
  end
29
30
 
30
31
  def dead(table)
@@ -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
@@ -33,9 +33,38 @@ module SchemaReaper
33
33
  "ahoy_matey" => {
34
34
  "ahoy_visits" => %w[visit_token visitor_token],
35
35
  "ahoy_events" => %w[visit_id name properties]
36
+ },
37
+ "activeadmin" => {
38
+ "active_admin_comments" => %w[namespace body resource_type resource_id author_type author_id]
39
+ },
40
+ "devise-api" => {
41
+ "devise_api_tokens" => %w[
42
+ resource_owner_type resource_owner_id access_token refresh_token
43
+ expires_in revoked_at previous_refresh_token
44
+ ]
36
45
  }
37
46
  }.freeze
38
47
 
48
+ # Table names an installed gem creates and owns outright -- as opposed to
49
+ # columns a gem adds onto a table the app itself defines. DeadTable
50
+ # consults this: the static scanner only reads app code, so a table a
51
+ # gem's own internal classes reference (ActiveAdmin::Comment,
52
+ # Devise::Api::Token, FriendlyId::Slug, ...) looks unreferenced and gets
53
+ # suggested for `drop_table`, which for something like devise_api_tokens
54
+ # -- mobile session storage -- is actively destructive.
55
+ #
56
+ # Only exact table_glob entries qualify: a "*" entry means "this gem adds
57
+ # these columns to whatever table has them", not "this gem owns this
58
+ # table", so it says nothing about whole-table ownership.
59
+ def self.owned_tables(installed:)
60
+ installed = installed.to_set
61
+ MAP.each_with_object(Set.new) do |(gem_name, table_map), owned|
62
+ next unless installed.include?(gem_name)
63
+
64
+ table_map.each_key { |glob| owned << glob unless glob == "*" }
65
+ end
66
+ end
67
+
39
68
  # @param installed [Enumerable<String>] gem names present in the bundle
40
69
  # @param tables [Enumerable<#name,#column_names>] or Enumerable<String>
41
70
  # @return [Hash{String => Set<String>}]
@@ -16,6 +16,12 @@ module SchemaReaper
16
16
  "database_url: in .schema_reaper.yml; the DATABASE_URL env var; " \
17
17
  "config/database.yml for RAILS_ENV (default: development, Postgres only)."
18
18
 
19
+ # A record separator that cannot appear inside a plain identifier and is
20
+ # exceedingly unlikely inside an expression, so splitting the aggregated
21
+ # column list back apart in Ruby cannot be fooled by a comma the
22
+ # expression itself contains: COALESCE(a, b), ROUND(x, 2), etc.
23
+ COLUMN_SEPARATOR = 31.chr
24
+
19
25
  def initialize(url)
20
26
  require "pg" # load first so the PG::Error rescue below can resolve
21
27
  raise Error, NO_URL if url.nil? || url.empty?
@@ -94,26 +100,31 @@ module SchemaReaper
94
100
  end
95
101
  end
96
102
 
97
- # Columns must come back in index-key order, not table order: prefix
98
- # comparisons (Index#covers?) are only meaningful on the real key order.
99
- # unnest(indkey) WITH ORDINALITY preserves that; ORDER BY attnum does not.
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.
100
109
  def indexes_for(table)
101
110
  exec(<<~SQL, [table]).map do |r|
102
111
  SELECT i.relname AS name, ix.indisunique AS "unique", ix.indisprimary AS "primary",
103
- s.idx_scan AS scans,
104
- array_to_string(array_agg(a.attname ORDER BY k.ord), ',') AS cols
112
+ (ix.indpred IS NOT NULL) AS partial, s.idx_scan AS scans,
113
+ array_to_string(
114
+ array_agg(pg_get_indexdef(ix.indexrelid, k.ord::int, true) ORDER BY k.ord),
115
+ '#{COLUMN_SEPARATOR}'
116
+ ) AS cols
105
117
  FROM pg_class t
106
118
  JOIN pg_index ix ON t.oid = ix.indrelid
107
119
  JOIN pg_class i ON i.oid = ix.indexrelid
108
- JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord) ON TRUE
109
- JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
120
+ JOIN LATERAL generate_series(1, ix.indnkeyatts) AS k(ord) ON TRUE
110
121
  LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
111
122
  WHERE t.relname = $1
112
- 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
113
124
  SQL
114
125
  Index.new(
115
- name: r["name"], columns: r["cols"].split(","),
116
- unique: r["unique"] == "t", primary: r["primary"] == "t",
126
+ name: r["name"], columns: r["cols"].split(COLUMN_SEPARATOR),
127
+ unique: r["unique"] == "t", primary: r["primary"] == "t", partial: r["partial"] == "t",
117
128
  scans: r["scans"]&.to_i
118
129
  )
119
130
  end
@@ -23,6 +23,7 @@ module SchemaReaper
23
23
  used_tokens: Static::Scanner.new(@config, root: @root).call,
24
24
  runtime: runtime_report,
25
25
  gem_columns: gem_columns(db),
26
+ gem_owned_tables: gem_owned_tables,
26
27
  config: @config
27
28
  )
28
29
 
@@ -89,6 +90,12 @@ module SchemaReaper
89
90
  )
90
91
  end
91
92
 
93
+ def gem_owned_tables
94
+ return Set.new unless @config.gem_awareness?
95
+
96
+ GemAwareness.owned_tables(installed: GemAwareness.installed_gems)
97
+ end
98
+
92
99
  def load_plugins
93
100
  @config.require_paths.each do |path|
94
101
  require(File.expand_path(path, @root))
@@ -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,16 @@ module SchemaReaper
12
12
  RUBY_GLOB = "**/*.rb"
13
13
  WORD_RE = /[a-z_][a-z0-9_]*/i.freeze
14
14
 
15
+ # Macros that generate a real column from a differently-named virtual
16
+ # attribute, so the literal column name never appears in application
17
+ # code. Missing this made dead_column flag has_secure_password's
18
+ # password_digest, and attr_encrypted/Lockbox/KMS ciphertext columns,
19
+ # as unused even though they're the live backing store.
20
+ DIGEST_MACROS = %w[has_secure_password].freeze
21
+ DIGEST_SUFFIXES = %w[_digest].freeze
22
+ ENCRYPTED_MACROS = %w[encrypts attr_encrypted lockbox_encrypts].freeze
23
+ ENCRYPTED_SUFFIXES = %w[_ciphertext _iv _tag _encrypted].freeze
24
+
15
25
  # Node classes whose #name (or #unescaped) is a bare identifier we treat
16
26
  # as a possible column/table reference.
17
27
  NAME_NODES = [
@@ -61,6 +71,7 @@ module SchemaReaper
61
71
  return unless node.is_a?(Prism::Node)
62
72
 
63
73
  out.merge(tokens_for(node))
74
+ out.merge(macro_derived_tokens(node)) if node.is_a?(Prism::CallNode)
64
75
  node.compact_child_nodes.each { |c| collect_from_node(c, out) }
65
76
  end
66
77
 
@@ -78,6 +89,34 @@ module SchemaReaper
78
89
  end
79
90
  end
80
91
 
92
+ # `has_secure_password` / `encrypts :field` / `attr_encrypted :field`
93
+ # never write their generated column name (password_digest,
94
+ # field_ciphertext, ...) anywhere in source -- only the virtual
95
+ # attribute name. Derive the column names a macro call implies so they
96
+ # count as "used" instead of looking dead.
97
+ def macro_derived_tokens(node)
98
+ call_name = node.name&.to_s
99
+ return [] unless call_name
100
+
101
+ if DIGEST_MACROS.include?(call_name)
102
+ attrs = macro_symbol_args(node)
103
+ attrs = ["password"] if attrs.empty?
104
+ attrs.flat_map { |a| DIGEST_SUFFIXES.map { |s| "#{a}#{s}" } }
105
+ elsif ENCRYPTED_MACROS.include?(call_name)
106
+ macro_symbol_args(node).flat_map { |a| ENCRYPTED_SUFFIXES.map { |s| "#{a}#{s}" } }
107
+ else
108
+ []
109
+ end
110
+ end
111
+
112
+ # Leading bare symbol arguments of a call, e.g. `encrypts :a, :b, purpose: :x`
113
+ # => ["a", "b"]. Stops at the first non-symbol (keyword args, etc).
114
+ def macro_symbol_args(node)
115
+ args = node.arguments&.arguments || []
116
+ args.take_while { |a| a.is_a?(Prism::SymbolNode) }
117
+ .map { |a| a.unescaped.to_s.downcase }
118
+ end
119
+
81
120
  def text_tokens(path)
82
121
  File.read(path).scan(WORD_RE).to_set(&:downcase)
83
122
  rescue StandardError
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaReaper
4
- VERSION = "1.0.12"
4
+ VERSION = "1.0.14"
5
5
  end
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.12
4
+ version: 1.0.14
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
@@ -156,6 +157,7 @@ metadata:
156
157
  wiki_uri: https://github.com/aksshatt/schema_reaper/wiki
157
158
  funding_uri: https://github.com/sponsors/aksshatt
158
159
  rubygems_mfa_required: 'true'
160
+ post_install_message:
159
161
  rdoc_options: []
160
162
  require_paths:
161
163
  - lib
@@ -170,7 +172,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
170
172
  - !ruby/object:Gem::Version
171
173
  version: '0'
172
174
  requirements: []
173
- rubygems_version: 3.6.9
175
+ rubygems_version: 3.4.10
176
+ signing_key:
174
177
  specification_version: 4
175
178
  summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
176
179
  test_files: []