schema_reaper 2.0.1 → 2.1.0
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/.rubocop.yml +3 -0
- data/.schema_reaper.yml.example +17 -0
- data/CHANGELOG.md +102 -0
- data/README.md +64 -11
- data/RELEASE_CHECKLIST.md +13 -2
- data/lib/generators/schema_reaper/install/templates/initializer.rb.tt +8 -0
- data/lib/schema_reaper/alert_config.rb +21 -1
- data/lib/schema_reaper/alert_state.rb +35 -0
- data/lib/schema_reaper/analyzers/always_null_column.rb +12 -5
- data/lib/schema_reaper/analyzers/base.rb +66 -2
- data/lib/schema_reaper/analyzers/dead_column.rb +2 -2
- data/lib/schema_reaper/analyzers/dead_table.rb +10 -6
- data/lib/schema_reaper/analyzers/duplicate_foreign_key.rb +53 -0
- data/lib/schema_reaper/analyzers/duplicate_index.rb +1 -1
- data/lib/schema_reaper/analyzers/missing_fk_index.rb +10 -4
- data/lib/schema_reaper/analyzers/sequence_exhaustion.rb +55 -0
- data/lib/schema_reaper/analyzers/single_value_column.rb +17 -5
- data/lib/schema_reaper/analyzers/unused_index.rb +11 -6
- data/lib/schema_reaper/baseline.rb +95 -10
- data/lib/schema_reaper/cli/baseline_support.rb +45 -0
- data/lib/schema_reaper/cli.rb +69 -14
- data/lib/schema_reaper/config.rb +28 -0
- data/lib/schema_reaper/doctor.rb +151 -0
- data/lib/schema_reaper/explain.rb +102 -0
- data/lib/schema_reaper/gem_awareness.rb +31 -1
- data/lib/schema_reaper/inflect.rb +36 -0
- data/lib/schema_reaper/introspect/postgres.rb +236 -51
- data/lib/schema_reaper/migration_generator.rb +90 -4
- data/lib/schema_reaper/notifier.rb +58 -6
- data/lib/schema_reaper/railtie.rb +1 -1
- data/lib/schema_reaper/reporters/sarif.rb +15 -6
- data/lib/schema_reaper/reporters/slack.rb +67 -0
- data/lib/schema_reaper/runner.rb +58 -6
- data/lib/schema_reaper/runtime.rb +94 -11
- data/lib/schema_reaper/schema.rb +32 -3
- data/lib/schema_reaper/schema_locator.rb +75 -0
- data/lib/schema_reaper/static/cache.rb +66 -0
- data/lib/schema_reaper/static/scanner.rb +174 -6
- data/lib/schema_reaper/version.rb +1 -1
- data/lib/schema_reaper.rb +7 -0
- data/schema_reaper.gemspec +0 -1
- metadata +13 -4
|
@@ -5,10 +5,30 @@ require "set"
|
|
|
5
5
|
|
|
6
6
|
module SchemaReaper
|
|
7
7
|
module Static
|
|
8
|
+
# Everything a scan learned.
|
|
9
|
+
# tokens - Set<String>, every token in every scanned file
|
|
10
|
+
# shared - Set<String>, tokens from files that are not models
|
|
11
|
+
# models - Array<ModelFile>, one per ActiveRecord model file
|
|
12
|
+
# keeps - Hash{String => String}, `# schema_reaper:keep` targets
|
|
13
|
+
# => the reason given ("" when none)
|
|
14
|
+
Result = Struct.new(:tokens, :shared, :models, :keeps, keyword_init: true) do
|
|
15
|
+
def self.from_tokens(tokens)
|
|
16
|
+
new(tokens: tokens.to_set, shared: tokens.to_set, models: [], keeps: {})
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# tables: every table this model file may be about -- one normally, two
|
|
21
|
+
# for a namespaced class (Admin::Report -> reports, or admin_reports with
|
|
22
|
+
# a table_name_prefix), the root model's for an STI subclass.
|
|
23
|
+
ModelFile = Struct.new(:path, :tables, :tokens, keyword_init: true)
|
|
24
|
+
|
|
8
25
|
# Walks the project and collects every identifier/symbol/string token that
|
|
9
26
|
# could name a database column. Over-collects on purpose: a false "used" is
|
|
10
27
|
# safe, a false "dead" is not.
|
|
11
28
|
class Scanner
|
|
29
|
+
# `# schema_reaper:keep legacy_token, users.api_key -- read by the billing ETL`
|
|
30
|
+
KEEP_RE = /#\s*schema_reaper:keep\s+(.+?)\s*(?:--\s*(.*?))?\s*$/.freeze
|
|
31
|
+
|
|
12
32
|
RUBY_GLOB = "**/*.rb"
|
|
13
33
|
WORD_RE = /[a-z_][a-z0-9_]*/i.freeze
|
|
14
34
|
|
|
@@ -59,14 +79,69 @@ module SchemaReaper
|
|
|
59
79
|
|
|
60
80
|
# @return [Set<String>] lowercased tokens seen anywhere in the codebase
|
|
61
81
|
def call
|
|
62
|
-
tokens
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
scan.tokens
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Result]
|
|
86
|
+
def scan
|
|
87
|
+
cache = Cache.new(@config.scan_cache && File.expand_path(@config.scan_cache, @root),
|
|
88
|
+
key: [SchemaReaper::VERSION, MACRO_COLUMN_TEMPLATES.keys])
|
|
89
|
+
entries = ruby_files.map { |f| [f, cache.lookup(f, :ruby) { ruby_entry(f) }] } +
|
|
90
|
+
view_files.map { |f| [f, cache.lookup(f, :view) { view_entry(f) }] }
|
|
91
|
+
cache.save(entries)
|
|
92
|
+
build_result(entries)
|
|
66
93
|
end
|
|
67
94
|
|
|
68
95
|
private
|
|
69
96
|
|
|
97
|
+
def build_result(entries)
|
|
98
|
+
tokens = Set.new
|
|
99
|
+
shared = Set.new
|
|
100
|
+
models = []
|
|
101
|
+
keeps = {}
|
|
102
|
+
tables = ModelResolver.new(entries.filter_map { |_, e| e["model"] }).tables
|
|
103
|
+
entries.each do |path, entry|
|
|
104
|
+
file_tokens = entry["tokens"].to_set
|
|
105
|
+
tokens.merge(file_tokens)
|
|
106
|
+
keeps.merge!(entry["keeps"])
|
|
107
|
+
model_tables = entry["model"] && tables[entry["model"]["path"]]
|
|
108
|
+
if model_tables
|
|
109
|
+
models << ModelFile.new(path: path, tables: model_tables, tokens: file_tokens)
|
|
110
|
+
else
|
|
111
|
+
shared.merge(file_tokens) # not a model we could place: its names count everywhere
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
Result.new(tokens: tokens, shared: shared, models: models, keeps: keeps)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def ruby_entry(path)
|
|
118
|
+
src = File.read(path)
|
|
119
|
+
{
|
|
120
|
+
"tokens" => ruby_tokens(path, src).to_a,
|
|
121
|
+
"model" => model_file?(path) ? ModelResolver.facts(src) : nil,
|
|
122
|
+
"keeps" => keeps_in(src)
|
|
123
|
+
}
|
|
124
|
+
rescue StandardError
|
|
125
|
+
view_entry(path)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def view_entry(path)
|
|
129
|
+
src = File.read(path)
|
|
130
|
+
{ "tokens" => text_tokens(path).to_a, "model" => nil, "keeps" => keeps_in(src) }
|
|
131
|
+
rescue StandardError
|
|
132
|
+
{ "tokens" => [], "model" => nil, "keeps" => {} }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def keeps_in(src)
|
|
136
|
+
src.scan(KEEP_RE).each_with_object({}) do |(targets, reason), out|
|
|
137
|
+
targets.split(/[\s,]+/).reject(&:empty?).each { |t| out[t.downcase] = reason.to_s }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def model_file?(path)
|
|
142
|
+
path.include?("/app/models/")
|
|
143
|
+
end
|
|
144
|
+
|
|
70
145
|
def ruby_files
|
|
71
146
|
@config.scan_paths.flat_map do |p|
|
|
72
147
|
Dir.glob(File.join(@root, p, RUBY_GLOB))
|
|
@@ -77,8 +152,7 @@ module SchemaReaper
|
|
|
77
152
|
@config.view_globs.flat_map { |g| Dir.glob(File.join(@root, g)) }.uniq
|
|
78
153
|
end
|
|
79
154
|
|
|
80
|
-
def ruby_tokens(path)
|
|
81
|
-
src = File.read(path)
|
|
155
|
+
def ruby_tokens(path, src = File.read(path))
|
|
82
156
|
out = Set.new
|
|
83
157
|
result = Prism.parse(src)
|
|
84
158
|
collect_from_node(result.value, out)
|
|
@@ -135,5 +209,99 @@ module SchemaReaper
|
|
|
135
209
|
Set.new
|
|
136
210
|
end
|
|
137
211
|
end
|
|
212
|
+
|
|
213
|
+
# Maps model classes to the tables they read, from the facts the scanner
|
|
214
|
+
# collected. A class counts as a model only if it inherits from
|
|
215
|
+
# ApplicationRecord / ActiveRecord::Base / another abstract base, or from
|
|
216
|
+
# another model (STI); anything else in app/models -- a PORO, a Struct,
|
|
217
|
+
# a form object -- is not placed, so its names stay shared. Getting this
|
|
218
|
+
# wrong in the narrow direction would hide real use, so every doubt
|
|
219
|
+
# resolves to "shared".
|
|
220
|
+
class ModelResolver
|
|
221
|
+
AR_ROOTS = %w[ApplicationRecord ActiveRecord::Base].freeze
|
|
222
|
+
|
|
223
|
+
# What an app/models file says about the class it defines; ModelResolver
|
|
224
|
+
# turns these into tables once every file has been read (an STI
|
|
225
|
+
# subclass needs its parent's facts). A file with no `class X < Y` --
|
|
226
|
+
# a concern, a module -- has none and stays shared code.
|
|
227
|
+
def self.facts(src)
|
|
228
|
+
match = src.match(/^\s*class\s+((?:\w+::)*\w+)\s*<\s*((?:::)?[\w:]+)/)
|
|
229
|
+
return nil unless match
|
|
230
|
+
|
|
231
|
+
# `module Admin` wrapping the class namespaces it as much as `Admin::`
|
|
232
|
+
modules = src[0...match.begin(0)].scan(/^\s*module\s+([\w:]+)/).flatten.flat_map { |m| m.split("::") }
|
|
233
|
+
parts = modules + match[1].split("::")
|
|
234
|
+
{
|
|
235
|
+
"path" => parts.join("::"), # Admin::User -- the identity
|
|
236
|
+
"class" => parts.last,
|
|
237
|
+
"namespace" => parts[0..-2].join("_"),
|
|
238
|
+
"superclass" => match[2], # as written; resolved like Ruby would
|
|
239
|
+
"table" => src[/self\.table_name\s*=\s*["':]([\w.]+)/, 1],
|
|
240
|
+
"abstract" => src.match?(/self\.abstract_class\s*=\s*true/)
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Keyed by the full class path: User and Admin::User are different
|
|
245
|
+
# models, and keying by the bare name let one overwrite the other.
|
|
246
|
+
# A class reopened in a second file merges its facts.
|
|
247
|
+
def initialize(facts)
|
|
248
|
+
@by_path = facts.each_with_object({}) do |f, h|
|
|
249
|
+
h[f["path"]] = h.key?(f["path"]) ? h[f["path"]].merge(f) { |_, old, new| old || new } : f
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# @return [Hash{String => Array<String>}] class path => tables; abstract
|
|
254
|
+
# and unplaceable classes are absent
|
|
255
|
+
def tables
|
|
256
|
+
@by_path.each_key.with_object({}) do |path, out|
|
|
257
|
+
resolved = tables_for(path, Set.new)
|
|
258
|
+
out[path] = resolved if resolved&.any?
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
def tables_for(path, seen)
|
|
265
|
+
facts = @by_path[path]
|
|
266
|
+
return nil if facts.nil? || facts["abstract"] || seen.include?(path)
|
|
267
|
+
|
|
268
|
+
seen << path
|
|
269
|
+
return [facts["table"]] if facts["table"]
|
|
270
|
+
return own_tables(facts) if root?(facts)
|
|
271
|
+
|
|
272
|
+
parent = resolve(facts["superclass"], facts["path"])
|
|
273
|
+
tables_for(parent, seen) if parent # STI: the parent's table
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def root?(facts)
|
|
277
|
+
sup = facts["superclass"].sub(/\A::/, "")
|
|
278
|
+
return true if AR_ROOTS.include?(sup) || AR_ROOTS.include?(sup.split("::").last)
|
|
279
|
+
|
|
280
|
+
base = @by_path[resolve(facts["superclass"], facts["path"])]
|
|
281
|
+
base && base["abstract"] && !base["table"]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Ruby's constant lookup, approximately: `class Admin::Report < User`
|
|
285
|
+
# means Admin::User if that exists, else ::User. A leading :: skips
|
|
286
|
+
# straight to the top level. nil when no scanned model matches.
|
|
287
|
+
def resolve(name, from_path)
|
|
288
|
+
return @by_path.key?(name[2..]) ? name[2..] : nil if name.start_with?("::")
|
|
289
|
+
|
|
290
|
+
scope = from_path.split("::")[0..-2]
|
|
291
|
+
until scope.empty?
|
|
292
|
+
candidate = (scope + [name]).join("::")
|
|
293
|
+
return candidate if @by_path.key?(candidate)
|
|
294
|
+
|
|
295
|
+
scope = scope[0..-2]
|
|
296
|
+
end
|
|
297
|
+
@by_path.key?(name) ? name : nil
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def own_tables(facts)
|
|
301
|
+
table = Inflect.tableize(facts["class"])
|
|
302
|
+
ns = facts["namespace"]
|
|
303
|
+
ns.empty? ? [table] : [table, "#{Inflect.underscore(ns)}_#{table}"]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
138
306
|
end
|
|
139
307
|
end
|
data/lib/schema_reaper.rb
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "schema_reaper/version"
|
|
4
4
|
require_relative "schema_reaper/config"
|
|
5
|
+
require_relative "schema_reaper/inflect"
|
|
5
6
|
require_relative "schema_reaper/finding"
|
|
6
7
|
require_relative "schema_reaper/schema"
|
|
8
|
+
require_relative "schema_reaper/schema_locator"
|
|
7
9
|
require_relative "schema_reaper/runtime"
|
|
8
10
|
require_relative "schema_reaper/gem_awareness"
|
|
9
11
|
require_relative "schema_reaper/introspect/postgres"
|
|
12
|
+
require_relative "schema_reaper/static/cache"
|
|
10
13
|
require_relative "schema_reaper/static/scanner"
|
|
11
14
|
require_relative "schema_reaper/analyzers/base"
|
|
12
15
|
require_relative "schema_reaper/analyzers/registry"
|
|
@@ -17,6 +20,8 @@ require_relative "schema_reaper/analyzers/duplicate_index"
|
|
|
17
20
|
require_relative "schema_reaper/analyzers/missing_fk_index"
|
|
18
21
|
require_relative "schema_reaper/analyzers/always_null_column"
|
|
19
22
|
require_relative "schema_reaper/analyzers/single_value_column"
|
|
23
|
+
require_relative "schema_reaper/analyzers/sequence_exhaustion"
|
|
24
|
+
require_relative "schema_reaper/analyzers/duplicate_foreign_key"
|
|
20
25
|
require_relative "schema_reaper/reporters/bytes"
|
|
21
26
|
require_relative "schema_reaper/reporters/ansi"
|
|
22
27
|
require_relative "schema_reaper/reporters/console"
|
|
@@ -31,6 +36,8 @@ require_relative "schema_reaper/migration_generator"
|
|
|
31
36
|
require_relative "schema_reaper/runner"
|
|
32
37
|
require_relative "schema_reaper/alert_config"
|
|
33
38
|
require_relative "schema_reaper/notifier"
|
|
39
|
+
require_relative "schema_reaper/explain"
|
|
40
|
+
require_relative "schema_reaper/doctor"
|
|
34
41
|
require_relative "schema_reaper/scan_job" if defined?(ActiveJob::Base)
|
|
35
42
|
require_relative "schema_reaper/railtie" if defined?(Rails::Railtie)
|
|
36
43
|
|
data/schema_reaper.gemspec
CHANGED
|
@@ -26,7 +26,6 @@ Gem::Specification.new do |spec|
|
|
|
26
26
|
|
|
27
27
|
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
28
28
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
29
|
-
spec.metadata["source_code_uri"] = spec.homepage
|
|
30
29
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
31
30
|
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
32
31
|
spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
|
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: 2.0
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- aksshatt
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-
|
|
12
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: prism
|
|
@@ -187,22 +187,29 @@ files:
|
|
|
187
187
|
- lib/generators/schema_reaper/install/templates/schema_reaper_controller.rb.tt
|
|
188
188
|
- lib/schema_reaper.rb
|
|
189
189
|
- lib/schema_reaper/alert_config.rb
|
|
190
|
+
- lib/schema_reaper/alert_state.rb
|
|
190
191
|
- lib/schema_reaper/analyzers/always_null_column.rb
|
|
191
192
|
- lib/schema_reaper/analyzers/base.rb
|
|
192
193
|
- lib/schema_reaper/analyzers/dead_column.rb
|
|
193
194
|
- lib/schema_reaper/analyzers/dead_table.rb
|
|
195
|
+
- lib/schema_reaper/analyzers/duplicate_foreign_key.rb
|
|
194
196
|
- lib/schema_reaper/analyzers/duplicate_index.rb
|
|
195
197
|
- lib/schema_reaper/analyzers/missing_fk_index.rb
|
|
196
198
|
- lib/schema_reaper/analyzers/registry.rb
|
|
199
|
+
- lib/schema_reaper/analyzers/sequence_exhaustion.rb
|
|
197
200
|
- lib/schema_reaper/analyzers/single_value_column.rb
|
|
198
201
|
- lib/schema_reaper/analyzers/unused_index.rb
|
|
199
202
|
- lib/schema_reaper/baseline.rb
|
|
200
203
|
- lib/schema_reaper/cli.rb
|
|
204
|
+
- lib/schema_reaper/cli/baseline_support.rb
|
|
201
205
|
- lib/schema_reaper/config.rb
|
|
202
206
|
- lib/schema_reaper/database_url.rb
|
|
207
|
+
- lib/schema_reaper/doctor.rb
|
|
208
|
+
- lib/schema_reaper/explain.rb
|
|
203
209
|
- lib/schema_reaper/finding.rb
|
|
204
210
|
- lib/schema_reaper/gem_awareness.rb
|
|
205
211
|
- lib/schema_reaper/history.rb
|
|
212
|
+
- lib/schema_reaper/inflect.rb
|
|
206
213
|
- lib/schema_reaper/introspect/postgres.rb
|
|
207
214
|
- lib/schema_reaper/mailer.rb
|
|
208
215
|
- lib/schema_reaper/migration_generator.rb
|
|
@@ -216,12 +223,15 @@ files:
|
|
|
216
223
|
- lib/schema_reaper/reporters/reclaim.rb
|
|
217
224
|
- lib/schema_reaper/reporters/rollup.rb
|
|
218
225
|
- lib/schema_reaper/reporters/sarif.rb
|
|
226
|
+
- lib/schema_reaper/reporters/slack.rb
|
|
219
227
|
- lib/schema_reaper/reporters/table.rb
|
|
220
228
|
- lib/schema_reaper/reporters/trend.rb
|
|
221
229
|
- lib/schema_reaper/runner.rb
|
|
222
230
|
- lib/schema_reaper/runtime.rb
|
|
223
231
|
- lib/schema_reaper/scan_job.rb
|
|
224
232
|
- lib/schema_reaper/schema.rb
|
|
233
|
+
- lib/schema_reaper/schema_locator.rb
|
|
234
|
+
- lib/schema_reaper/static/cache.rb
|
|
225
235
|
- lib/schema_reaper/static/scanner.rb
|
|
226
236
|
- lib/schema_reaper/tasks/schema_reaper.rake
|
|
227
237
|
- lib/schema_reaper/version.rb
|
|
@@ -233,7 +243,6 @@ licenses:
|
|
|
233
243
|
metadata:
|
|
234
244
|
allowed_push_host: https://rubygems.org
|
|
235
245
|
homepage_uri: https://github.com/aksshatt/schema_reaper
|
|
236
|
-
source_code_uri: https://github.com/aksshatt/schema_reaper
|
|
237
246
|
changelog_uri: https://github.com/aksshatt/schema_reaper/blob/main/CHANGELOG.md
|
|
238
247
|
bug_tracker_uri: https://github.com/aksshatt/schema_reaper/issues
|
|
239
248
|
documentation_uri: https://github.com/aksshatt/schema_reaper#readme
|
|
@@ -255,7 +264,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
255
264
|
- !ruby/object:Gem::Version
|
|
256
265
|
version: '0'
|
|
257
266
|
requirements: []
|
|
258
|
-
rubygems_version: 3.
|
|
267
|
+
rubygems_version: 3.5.22
|
|
259
268
|
signing_key:
|
|
260
269
|
specification_version: 4
|
|
261
270
|
summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
|