schema_reaper 1.0.11 → 1.0.13
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 +47 -0
- data/lib/schema_reaper/analyzers/base.rb +18 -6
- data/lib/schema_reaper/analyzers/dead_table.rb +2 -1
- data/lib/schema_reaper/gem_awareness.rb +29 -0
- data/lib/schema_reaper/introspect/postgres.rb +22 -5
- data/lib/schema_reaper/reporters/markdown.rb +13 -8
- data/lib/schema_reaper/reporters/reclaim.rb +47 -0
- data/lib/schema_reaper/reporters/table.rb +2 -11
- data/lib/schema_reaper/runner.rb +7 -0
- data/lib/schema_reaper/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 920a3c80a9b99271cfb1152f9a4682dd42c03516934fbd6971c56ef0af06882f
|
|
4
|
+
data.tar.gz: 63567586bef79cb15f27be238def5882fe3119955bb45bea346b4f979599e8ff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fa80aecb2dacdbf843f03734818d739b7d5d386ba4096fc69163dacbc55ef52fb92a1b93adc1c4f5f4bf14bb5a1dc3a449fab647101d5a4b43a9922fa7088469
|
|
7
|
+
data.tar.gz: 410917c5ece25227dbcfeb0b32128b8d0105770631c10a4175da273c3c36744b4bed428857db38b2b621fb4d5ffd03fce89e70d4599bae9c66676371ce42687f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
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
|
+
|
|
33
|
+
## [1.0.12] - 2026-09-15
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- **`--format markdown` claimed `0 B reclaimable` when the row count was
|
|
37
|
+
unknown.** `Table` learned in 1.0.11 that `reclaimable_bytes` is `nil`
|
|
38
|
+
both when the true value is zero and when the row count has never been
|
|
39
|
+
measured, and that a report should say which. `Markdown` summed the raw
|
|
40
|
+
bytes directly and always printed a total, so on an unanalysed database
|
|
41
|
+
it stated there was nothing to reclaim — the exact claim the 1.0.11 fix
|
|
42
|
+
was written to stop, just in the one format meant for an unattended PR
|
|
43
|
+
comment or CI job summary. The per-row `Reclaims` column had the same
|
|
44
|
+
bug: an unmeasured finding showed `0.0 B`, indistinguishable from one
|
|
45
|
+
that genuinely frees nothing. The summary/unmeasured logic now lives in
|
|
46
|
+
one shared `Reporters::Reclaim` module used by both reporters, so the
|
|
47
|
+
two formats cannot answer the same question differently again. (#7,
|
|
48
|
+
mitkush)
|
|
49
|
+
|
|
3
50
|
## [1.0.11] - 2026-09-15
|
|
4
51
|
|
|
5
52
|
Everything below landed after 1.0.10 was cut, so 1.0.10 on RubyGems contains
|
|
@@ -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
|
|
7
|
-
# used_tokens
|
|
8
|
-
# runtime
|
|
9
|
-
# gem_columns
|
|
10
|
-
#
|
|
11
|
-
|
|
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)
|
|
@@ -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?
|
|
@@ -96,23 +102,34 @@ module SchemaReaper
|
|
|
96
102
|
|
|
97
103
|
# Columns must come back in index-key order, not table order: prefix
|
|
98
104
|
# comparisons (Index#covers?) are only meaningful on the real key order.
|
|
99
|
-
#
|
|
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.
|
|
100
115
|
def indexes_for(table)
|
|
101
116
|
exec(<<~SQL, [table]).map do |r|
|
|
102
117
|
SELECT i.relname AS name, ix.indisunique AS "unique", ix.indisprimary AS "primary",
|
|
103
118
|
s.idx_scan AS scans,
|
|
104
|
-
array_to_string(
|
|
119
|
+
array_to_string(
|
|
120
|
+
array_agg(pg_get_indexdef(ix.indexrelid, k.ord::int, true) ORDER BY k.ord),
|
|
121
|
+
'#{COLUMN_SEPARATOR}'
|
|
122
|
+
) AS cols
|
|
105
123
|
FROM pg_class t
|
|
106
124
|
JOIN pg_index ix ON t.oid = ix.indrelid
|
|
107
125
|
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
108
|
-
JOIN LATERAL
|
|
109
|
-
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
126
|
+
JOIN LATERAL generate_series(1, ix.indnkeyatts) AS k(ord) ON TRUE
|
|
110
127
|
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
|
|
111
128
|
WHERE t.relname = $1
|
|
112
129
|
GROUP BY i.relname, ix.indisunique, ix.indisprimary, s.idx_scan
|
|
113
130
|
SQL
|
|
114
131
|
Index.new(
|
|
115
|
-
name: r["name"], columns: r["cols"].split(
|
|
132
|
+
name: r["name"], columns: r["cols"].split(COLUMN_SEPARATOR),
|
|
116
133
|
unique: r["unique"] == "t", primary: r["primary"] == "t",
|
|
117
134
|
scans: r["scans"]&.to_i
|
|
118
135
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "reclaim"
|
|
4
4
|
|
|
5
5
|
module SchemaReaper
|
|
6
6
|
module Reporters
|
|
@@ -18,8 +18,7 @@ module SchemaReaper
|
|
|
18
18
|
return
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
@io.puts "\n#{
|
|
22
|
-
"~**#{Bytes.human(total)}** reclaimable.\n\n"
|
|
21
|
+
@io.puts "\n#{summary_line}\n\n"
|
|
23
22
|
@io.puts "| Severity | Confidence | Type | Target | Reclaims | Fix |"
|
|
24
23
|
@io.puts "|---|---|---|---|---|---|"
|
|
25
24
|
rows.each { |r| @io.puts r }
|
|
@@ -27,17 +26,23 @@ module SchemaReaper
|
|
|
27
26
|
|
|
28
27
|
private
|
|
29
28
|
|
|
29
|
+
# "54 finding(s). **~93.8 KB reclaimable.**" -- or, when the row count is
|
|
30
|
+
# unknown or the estimate is genuinely zero, no reclaim clause at all,
|
|
31
|
+
# matching the terminal report rather than claiming a number it does not
|
|
32
|
+
# have.
|
|
33
|
+
def summary_line
|
|
34
|
+
text = Reclaim.summary(@findings)
|
|
35
|
+
line = "#{@findings.size} finding(s)."
|
|
36
|
+
line + (text ? " **#{text}.**" : "")
|
|
37
|
+
end
|
|
38
|
+
|
|
30
39
|
def rows
|
|
31
40
|
@findings.sort_by { |f| -f.confidence }.map do |f|
|
|
32
41
|
target = [f.table, f.column, f.index].compact.join("`.`")
|
|
33
42
|
"| #{f.severity} | #{(f.confidence * 100).round}% | `#{f.type}` | " \
|
|
34
|
-
"`#{target}` | #{
|
|
43
|
+
"`#{target}` | #{Reclaim.cell(f)} | #{f.suggested_fix} |"
|
|
35
44
|
end
|
|
36
45
|
end
|
|
37
|
-
|
|
38
|
-
def total
|
|
39
|
-
@findings.sum(&:reclaimable_bytes)
|
|
40
|
-
end
|
|
41
46
|
end
|
|
42
47
|
end
|
|
43
48
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bytes"
|
|
4
|
+
|
|
5
|
+
module SchemaReaper
|
|
6
|
+
module Reporters
|
|
7
|
+
# Turns a set of findings' reclaimable-bytes into a human sentence, and a
|
|
8
|
+
# single finding's into a table cell. Shared by every reporter that states
|
|
9
|
+
# a reclaim total in prose, so the answer cannot drift between formats --
|
|
10
|
+
# which is how the terminal report learned to distinguish "no bytes" from
|
|
11
|
+
# "no idea" while the Markdown report kept treating them as the same 0.
|
|
12
|
+
module Reclaim
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# A row count is required for a byte estimate, and pg reports
|
|
16
|
+
# reltuples = -1 for a table that has never been analysed. Printing 0.0 B
|
|
17
|
+
# for an unknown reads as "nothing to gain here", a different claim.
|
|
18
|
+
def summary(findings)
|
|
19
|
+
total = findings.sum(&:reclaimable_bytes)
|
|
20
|
+
return "~#{Bytes.human(total)} reclaimable" if total.positive?
|
|
21
|
+
return nil unless unmeasured?(findings)
|
|
22
|
+
|
|
23
|
+
"reclaim estimate unavailable — run ANALYZE to populate table statistics"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# True when some finding's byte estimate is unknown rather than zero.
|
|
27
|
+
# bytes_per_row.zero? findings (missing_fk_index, duplicate_index -- they
|
|
28
|
+
# add or drop an index, not data) always reclaim 0 regardless of row
|
|
29
|
+
# count, so an unknown row count does not make their answer unmeasured.
|
|
30
|
+
def unmeasured?(findings)
|
|
31
|
+
findings.any? { |f| f.bytes_per_row.to_i.positive? && !f.reclaim_known? }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Per-finding cell for a table row: the human size, or a note that it is
|
|
35
|
+
# not known, using the same bytes_per_row-zero exception as summary.
|
|
36
|
+
def cell(finding)
|
|
37
|
+
return Bytes.human(finding.reclaimable_bytes) if measured?(finding)
|
|
38
|
+
|
|
39
|
+
"unknown"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def measured?(finding)
|
|
43
|
+
finding.bytes_per_row.to_i.zero? || finding.reclaim_known?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "bytes"
|
|
4
4
|
require_relative "ansi"
|
|
5
5
|
require_relative "rollup"
|
|
6
|
+
require_relative "reclaim"
|
|
6
7
|
|
|
7
8
|
module SchemaReaper
|
|
8
9
|
module Reporters
|
|
@@ -57,18 +58,8 @@ module SchemaReaper
|
|
|
57
58
|
.map { |t, n| "#{t} #{n}" }.join(" · ")
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
# A reclaim estimate needs a row count, and pg reports reltuples = -1 for
|
|
61
|
-
# a table it has never analysed. Printing 0.0 B for an unknown reads as
|
|
62
|
-
# "nothing to gain here", which is a different claim entirely.
|
|
63
61
|
def reclaim_text
|
|
64
|
-
|
|
65
|
-
return nil unless unmeasured?
|
|
66
|
-
|
|
67
|
-
"reclaim estimate unavailable — run ANALYZE to populate table statistics"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def unmeasured?
|
|
71
|
-
@findings.any? { |f| f.bytes_per_row.to_i.positive? && !f.reclaim_known? }
|
|
62
|
+
Reclaim.summary(@findings)
|
|
72
63
|
end
|
|
73
64
|
|
|
74
65
|
def rolled_up
|
data/lib/schema_reaper/runner.rb
CHANGED
|
@@ -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))
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.13
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- aksshatt
|
|
@@ -130,6 +130,7 @@ files:
|
|
|
130
130
|
- lib/schema_reaper/reporters/console.rb
|
|
131
131
|
- lib/schema_reaper/reporters/json.rb
|
|
132
132
|
- lib/schema_reaper/reporters/markdown.rb
|
|
133
|
+
- lib/schema_reaper/reporters/reclaim.rb
|
|
133
134
|
- lib/schema_reaper/reporters/rollup.rb
|
|
134
135
|
- lib/schema_reaper/reporters/sarif.rb
|
|
135
136
|
- lib/schema_reaper/reporters/table.rb
|