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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/.schema_reaper.yml.example +17 -0
  4. data/CHANGELOG.md +102 -0
  5. data/README.md +64 -11
  6. data/RELEASE_CHECKLIST.md +13 -2
  7. data/lib/generators/schema_reaper/install/templates/initializer.rb.tt +8 -0
  8. data/lib/schema_reaper/alert_config.rb +21 -1
  9. data/lib/schema_reaper/alert_state.rb +35 -0
  10. data/lib/schema_reaper/analyzers/always_null_column.rb +12 -5
  11. data/lib/schema_reaper/analyzers/base.rb +66 -2
  12. data/lib/schema_reaper/analyzers/dead_column.rb +2 -2
  13. data/lib/schema_reaper/analyzers/dead_table.rb +10 -6
  14. data/lib/schema_reaper/analyzers/duplicate_foreign_key.rb +53 -0
  15. data/lib/schema_reaper/analyzers/duplicate_index.rb +1 -1
  16. data/lib/schema_reaper/analyzers/missing_fk_index.rb +10 -4
  17. data/lib/schema_reaper/analyzers/sequence_exhaustion.rb +55 -0
  18. data/lib/schema_reaper/analyzers/single_value_column.rb +17 -5
  19. data/lib/schema_reaper/analyzers/unused_index.rb +11 -6
  20. data/lib/schema_reaper/baseline.rb +95 -10
  21. data/lib/schema_reaper/cli/baseline_support.rb +45 -0
  22. data/lib/schema_reaper/cli.rb +69 -14
  23. data/lib/schema_reaper/config.rb +28 -0
  24. data/lib/schema_reaper/doctor.rb +151 -0
  25. data/lib/schema_reaper/explain.rb +102 -0
  26. data/lib/schema_reaper/gem_awareness.rb +31 -1
  27. data/lib/schema_reaper/inflect.rb +36 -0
  28. data/lib/schema_reaper/introspect/postgres.rb +236 -51
  29. data/lib/schema_reaper/migration_generator.rb +90 -4
  30. data/lib/schema_reaper/notifier.rb +58 -6
  31. data/lib/schema_reaper/railtie.rb +1 -1
  32. data/lib/schema_reaper/reporters/sarif.rb +15 -6
  33. data/lib/schema_reaper/reporters/slack.rb +67 -0
  34. data/lib/schema_reaper/runner.rb +58 -6
  35. data/lib/schema_reaper/runtime.rb +94 -11
  36. data/lib/schema_reaper/schema.rb +32 -3
  37. data/lib/schema_reaper/schema_locator.rb +75 -0
  38. data/lib/schema_reaper/static/cache.rb +66 -0
  39. data/lib/schema_reaper/static/scanner.rb +174 -6
  40. data/lib/schema_reaper/version.rb +1 -1
  41. data/lib/schema_reaper.rb +7 -0
  42. data/schema_reaper.gemspec +0 -1
  43. 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 = Set.new
63
- ruby_files.each { |f| tokens.merge(ruby_tokens(f)) }
64
- view_files.each { |f| tokens.merge(text_tokens(f)) }
65
- tokens
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaReaper
4
- VERSION = "2.0.1"
4
+ VERSION = "2.1.0"
5
5
  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
 
@@ -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.1
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-09-23 00:00:00.000000000 Z
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.3.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.