schema_reaper 1.0.3 → 1.0.5
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 +31 -0
- data/lib/schema_reaper/finding.rb +13 -0
- data/lib/schema_reaper/migration_generator.rb +19 -8
- data/lib/schema_reaper/runner.rb +20 -1
- data/lib/schema_reaper/version.rb +1 -1
- data/schema_reaper.gemspec +10 -33
- metadata +10 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 162d87ba1d2536d8db97d20dd2df6af8675529e2d967a1271d5c25ff9f1f5549
|
|
4
|
+
data.tar.gz: 33fa30c1d35e8f1b8cbcbd5af0e4724389462bf77921470152c9403965f4f574
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c4e72d7cd3b050d21261bde44581525aff0e81190610edbff6891a8bed844d40401aa41ae8fb82e4d77781ad68b61dd75c2618d22fba625ef0ebbc0999bd6d9
|
|
7
|
+
data.tar.gz: 610286a554f314bc296fed9ef239a4c78946b4ea5d53ab3e2b9b9eaeb3e8e5d995b33ebd2cffdff587a7e311cbf0fc00e3463f35d776a53e4dcebfc3a49f84ff
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.5] - 2026-09-04
|
|
4
|
+
|
|
5
|
+
Fixes found by running the gem against a live PostgreSQL database.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Duplicate findings for one object.** When several analyzers flagged the
|
|
9
|
+
same physical column (e.g. `dead_column` + `always_null_column`) or index
|
|
10
|
+
(`unused_index` + `duplicate_index`), each was reported separately *and* its
|
|
11
|
+
reclaimable bytes were counted more than once, inflating the run total.
|
|
12
|
+
Findings are now collapsed to the highest-confidence one per target; the
|
|
13
|
+
others are noted as `also flagged by: ...` in its evidence.
|
|
14
|
+
- **`generate-migration` output.** The generated STEP 1 migration contained a
|
|
15
|
+
fragile multi-line string (heredoc line-continuation) that rendered with a
|
|
16
|
+
stray gap. It is now a single clean string. Both files are verified valid
|
|
17
|
+
Ruby.
|
|
18
|
+
- **Migration schema version.** Generated migrations now inherit the host
|
|
19
|
+
app's Rails minor version (`ActiveRecord::Migration[X.Y]`) when ActiveRecord
|
|
20
|
+
is loaded, instead of a hard-coded `7.1`.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Specs covering target collapsing and migration generation, exercised end to
|
|
24
|
+
end through `Runner` with an injected schema.
|
|
25
|
+
|
|
26
|
+
## [1.0.4] - 2026-09-04
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- Rewrote `description` as a single tight paragraph. RubyGems collapses
|
|
30
|
+
description whitespace, so the previous multi-line bulleted text rendered as
|
|
31
|
+
an unreadable blob on the gem page. Full analyzer list and usage stay in the
|
|
32
|
+
README. Link metadata from 1.0.3 unchanged.
|
|
33
|
+
|
|
3
34
|
## [1.0.3] - 2026-09-04
|
|
4
35
|
|
|
5
36
|
### Changed
|
|
@@ -19,6 +19,19 @@ module SchemaReaper
|
|
|
19
19
|
[type, table, column, index].compact.join("/")
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# The physical object this finding is about, ignoring which analyzer raised
|
|
23
|
+
# it. Two findings with the same target_key are the same underlying problem
|
|
24
|
+
# (e.g. dead_column + always_null_column on one column) -- keep the strongest.
|
|
25
|
+
def target_key
|
|
26
|
+
if index
|
|
27
|
+
["index", table, index]
|
|
28
|
+
elsif column
|
|
29
|
+
["column", table, column]
|
|
30
|
+
else
|
|
31
|
+
["table", table]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
22
35
|
def reclaimable_bytes
|
|
23
36
|
self[:reclaimable_bytes] || 0
|
|
24
37
|
end
|
|
@@ -19,35 +19,38 @@ module SchemaReaper
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def stage_one
|
|
22
|
+
note = "Add `self.ignored_columns += %w[#{@column}]` to the #{model} model, " \
|
|
23
|
+
"then deploy STEP 2 after a soak period."
|
|
22
24
|
write "ignore_#{@table}_#{@column}", <<~RUBY
|
|
23
25
|
# frozen_string_literal: true
|
|
24
26
|
|
|
25
27
|
# STEP 1 of 2. Deploy this alone and let it soak. It only tells
|
|
26
28
|
# ActiveRecord to stop selecting the column; nothing is dropped.
|
|
27
|
-
class Ignore#{camel}Column < ActiveRecord::Migration[
|
|
29
|
+
class Ignore#{camel}Column < ActiveRecord::Migration[#{migration_version}]
|
|
28
30
|
def up
|
|
29
|
-
say
|
|
30
|
-
"then deploy STEP 2 after a soak period."
|
|
31
|
+
say #{note.inspect}
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
def down
|
|
34
|
+
def down
|
|
35
|
+
end
|
|
34
36
|
end
|
|
35
37
|
RUBY
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def stage_two
|
|
41
|
+
down_msg = "recreate :#{@column} on :#{@table} manually if you need it back"
|
|
39
42
|
write "drop_#{@table}_#{@column}", <<~RUBY
|
|
40
43
|
# frozen_string_literal: true
|
|
41
44
|
|
|
42
|
-
# STEP 2 of 2. Run only after STEP 1 has been deployed and verified
|
|
43
|
-
|
|
45
|
+
# STEP 2 of 2. Run only after STEP 1 has been deployed and verified in
|
|
46
|
+
# production (nothing reads the column, no errors).
|
|
47
|
+
class Drop#{camel}Column < ActiveRecord::Migration[#{migration_version}]
|
|
44
48
|
def up
|
|
45
49
|
remove_column :#{@table}, :#{@column}
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
def down
|
|
49
|
-
raise ActiveRecord::IrreversibleMigration,
|
|
50
|
-
"recreate :#{@column} on :#{@table} manually if you need it back"
|
|
53
|
+
raise ActiveRecord::IrreversibleMigration, #{down_msg.inspect}
|
|
51
54
|
end
|
|
52
55
|
end
|
|
53
56
|
RUBY
|
|
@@ -61,6 +64,14 @@ module SchemaReaper
|
|
|
61
64
|
path
|
|
62
65
|
end
|
|
63
66
|
|
|
67
|
+
# Match the host app's Rails minor version when ActiveRecord is loaded,
|
|
68
|
+
# otherwise a sane recent default the developer can edit.
|
|
69
|
+
def migration_version
|
|
70
|
+
return "7.1" unless defined?(ActiveRecord::VERSION::STRING)
|
|
71
|
+
|
|
72
|
+
ActiveRecord::VERSION::STRING.split(".").first(2).join(".")
|
|
73
|
+
end
|
|
74
|
+
|
|
64
75
|
def camel
|
|
65
76
|
"#{@table}_#{@column}".split("_").map(&:capitalize).join
|
|
66
77
|
end
|
data/lib/schema_reaper/runner.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "set"
|
|
|
5
5
|
module SchemaReaper
|
|
6
6
|
# Ties introspection + scanning + runtime data + analyzers together.
|
|
7
7
|
class Runner
|
|
8
|
+
SEVERITY_RANK = { high: 0, medium: 1, low: 2 }.freeze
|
|
9
|
+
|
|
8
10
|
def initialize(config: Config.load, root: Dir.pwd, introspector: nil, runtime: nil)
|
|
9
11
|
@config = config
|
|
10
12
|
@root = root
|
|
@@ -29,13 +31,30 @@ module SchemaReaper
|
|
|
29
31
|
|
|
30
32
|
private
|
|
31
33
|
|
|
34
|
+
def dedupe(findings)
|
|
35
|
+
collapse_targets(drop_findings_on_dead_tables(findings))
|
|
36
|
+
end
|
|
37
|
+
|
|
32
38
|
# When a whole table is dead, its per-column and per-index findings are
|
|
33
39
|
# noise -- keep only the table-level finding for that table.
|
|
34
|
-
def
|
|
40
|
+
def drop_findings_on_dead_tables(findings)
|
|
35
41
|
dead_tables = findings.select { |f| f.type == :dead_table }.to_set(&:table)
|
|
36
42
|
findings.reject { |f| f.type != :dead_table && dead_tables.include?(f.table) }
|
|
37
43
|
end
|
|
38
44
|
|
|
45
|
+
# Several analyzers can flag the same physical column or index (e.g.
|
|
46
|
+
# dead_column + always_null_column). Keep the highest-confidence finding per
|
|
47
|
+
# target so it is reported -- and its bytes counted -- exactly once, but note
|
|
48
|
+
# the other analyzers that agreed.
|
|
49
|
+
def collapse_targets(findings)
|
|
50
|
+
findings.group_by(&:target_key).map do |_key, group|
|
|
51
|
+
winner = group.max_by { |f| [f.confidence, -SEVERITY_RANK.fetch(f.severity, 9)] }
|
|
52
|
+
also = group.map(&:type).uniq - [winner.type]
|
|
53
|
+
winner.evidence += ["also flagged by: #{also.join(", ")}"] if also.any?
|
|
54
|
+
winner
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
39
58
|
def schema
|
|
40
59
|
return @introspector.call if @introspector
|
|
41
60
|
|
data/schema_reaper.gemspec
CHANGED
|
@@ -8,39 +8,16 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["aksshatt"]
|
|
9
9
|
spec.email = ["akshatpegwar5@gmail.com"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "Find
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
* dead_column -- column no code path references
|
|
22
|
-
* dead_table -- table with no model/query reference, zero rows
|
|
23
|
-
* unused_index -- non-unique index, idx_scan = 0 in pg_stat
|
|
24
|
-
* duplicate_index -- index that is a prefix of a wider index
|
|
25
|
-
* missing_fk_index -- foreign-key / *_id column with no covering index
|
|
26
|
-
* always_null_column -- pg_stats null_frac = 1.0, carries no data
|
|
27
|
-
* single_value_column -- one distinct value across a large table
|
|
28
|
-
|
|
29
|
-
Every finding carries a reclaimable-bytes estimate (from row counts) and a
|
|
30
|
-
concrete fix. `generate-migration` emits a staged, reversible pair:
|
|
31
|
-
ignored_columns first, remove_column after a soak. Nothing is dropped
|
|
32
|
-
automatically. Columns owned by common gems (devise, paper_trail,
|
|
33
|
-
activestorage, actiontext, friendly_id, audited, pg_search, ahoy_matey,
|
|
34
|
-
paranoia) are whitelisted when the gem is in your bundle.
|
|
35
|
-
|
|
36
|
-
Reporters: table, json, markdown (PR comments), SARIF 2.1.0 (GitHub code
|
|
37
|
-
scanning). `scan --ci` gates only findings absent from a committed baseline.
|
|
38
|
-
`trend` keeps an append-only history log for cleanup burndown. A Rails
|
|
39
|
-
railtie adds rake tasks and an opt-in runtime tracker. Custom analyzers
|
|
40
|
-
load through the `require:` config key.
|
|
41
|
-
|
|
42
|
-
PostgreSQL only for now (MySQL adapter on the roadmap). Ruby >= 2.7.
|
|
43
|
-
DESC
|
|
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."
|
|
44
21
|
spec.homepage = "https://github.com/aksshatt/schema_reaper"
|
|
45
22
|
spec.license = "MIT"
|
|
46
23
|
spec.required_ruby_version = ">= 2.7.0"
|
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.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- aksshatt
|
|
@@ -78,36 +78,14 @@ dependencies:
|
|
|
78
78
|
- - "~>"
|
|
79
79
|
- !ruby/object:Gem::Version
|
|
80
80
|
version: '1.5'
|
|
81
|
-
description:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
* dead_column -- column no code path references
|
|
90
|
-
* dead_table -- table with no model/query reference, zero rows
|
|
91
|
-
* unused_index -- non-unique index, idx_scan = 0 in pg_stat
|
|
92
|
-
* duplicate_index -- index that is a prefix of a wider index
|
|
93
|
-
* missing_fk_index -- foreign-key / *_id column with no covering index
|
|
94
|
-
* always_null_column -- pg_stats null_frac = 1.0, carries no data
|
|
95
|
-
* single_value_column -- one distinct value across a large table
|
|
96
|
-
|
|
97
|
-
Every finding carries a reclaimable-bytes estimate (from row counts) and a
|
|
98
|
-
concrete fix. `generate-migration` emits a staged, reversible pair:
|
|
99
|
-
ignored_columns first, remove_column after a soak. Nothing is dropped
|
|
100
|
-
automatically. Columns owned by common gems (devise, paper_trail,
|
|
101
|
-
activestorage, actiontext, friendly_id, audited, pg_search, ahoy_matey,
|
|
102
|
-
paranoia) are whitelisted when the gem is in your bundle.
|
|
103
|
-
|
|
104
|
-
Reporters: table, json, markdown (PR comments), SARIF 2.1.0 (GitHub code
|
|
105
|
-
scanning). `scan --ci` gates only findings absent from a committed baseline.
|
|
106
|
-
`trend` keeps an append-only history log for cleanup burndown. A Rails
|
|
107
|
-
railtie adds rake tasks and an opt-in runtime tracker. Custom analyzers
|
|
108
|
-
load through the `require:` config key.
|
|
109
|
-
|
|
110
|
-
PostgreSQL only for now (MySQL adapter on the roadmap). Ruby >= 2.7.
|
|
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.'
|
|
111
89
|
email:
|
|
112
90
|
- akshatpegwar5@gmail.com
|
|
113
91
|
executables:
|
|
@@ -188,6 +166,5 @@ requirements: []
|
|
|
188
166
|
rubygems_version: 3.4.10
|
|
189
167
|
signing_key:
|
|
190
168
|
specification_version: 4
|
|
191
|
-
summary: Find
|
|
192
|
-
in Rails/ActiveRecord apps -- then remove it safely.
|
|
169
|
+
summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
|
|
193
170
|
test_files: []
|