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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ module Analyzers
5
+ # Two FOREIGN KEY constraints on the same columns pointing at the same
6
+ # key of the same table, with the same ON UPDATE / ON DELETE, MATCH and
7
+ # deferrability -- only then is either one redundant. (Same columns but a
8
+ # different ON DELETE is not a duplicate: dropping one changes what a
9
+ # parent delete does.) Usually a hand-written add_foreign_key layered on a
10
+ # `t.references ..., foreign_key: true` that already made one. Each copy is
11
+ # checked on every insert/update of the child and every delete of the
12
+ # parent, so the extra one is pure overhead.
13
+ class DuplicateForeignKey < Base
14
+ Registry.register(self)
15
+
16
+ def call
17
+ schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
18
+ .flat_map { |t| duplicates_in(t) }
19
+ end
20
+
21
+ private
22
+
23
+ def duplicates_in(table)
24
+ table.foreign_key_constraints
25
+ .group_by { |fk| [fk.columns, fk.references, fk.behaviour] }
26
+ .values.select { |group| group.size > 1 }
27
+ .flat_map { |group| redundant(table, group) }
28
+ end
29
+
30
+ # Keep a validated one (a NOT VALID copy doesn't check existing rows),
31
+ # then the alphabetically first name -- Rails' fk_rails_<hash> sorts
32
+ # ahead of hand-named ones. Report the rest.
33
+ def redundant(table, group)
34
+ keep, *extra = group.sort_by { |fk| [fk.validated == false ? 1 : 0, fk.name] }
35
+ extra.map do |fk|
36
+ finding(
37
+ type: :duplicate_foreign_key,
38
+ table: table.name,
39
+ column: fk.columns.join(","),
40
+ index: fk.name,
41
+ severity: :low,
42
+ confidence: 0.9,
43
+ bytes_per_row: 0,
44
+ evidence: [
45
+ "#{fk.name} and #{keep.name} both constrain (#{fk.columns.join(", ")}) -> #{fk.references}"
46
+ ],
47
+ suggested_fix: "remove_foreign_key #{sym(table.name)}, name: :#{fk.name}"
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -40,7 +40,7 @@ module SchemaReaper
40
40
  confidence: 0.8,
41
41
  bytes_per_row: 0,
42
42
  evidence: [evidence_for(index, covering)],
43
- suggested_fix: "remove_index :#{table.name}, name: :#{index.name}"
43
+ suggested_fix: remove_index_fix(table, index)
44
44
  )
45
45
  end
46
46
 
@@ -32,6 +32,8 @@ module SchemaReaper
32
32
 
33
33
  def missing_in(table)
34
34
  fk_columns(table).filter_map do |col|
35
+ next if ignored_column?(col)
36
+
35
37
  type_col = polymorphic_type_for(table, col)
36
38
 
37
39
  # A bare index on the id alone doesn't cover a polymorphic pair --
@@ -56,7 +58,7 @@ module SchemaReaper
56
58
  end
57
59
 
58
60
  def finding_for(table, col, type_col, declared:)
59
- referent = declared ? nil : referent_table(col)
61
+ referent = declared ? nil : referent_table(col, table.name)
60
62
  confidence = confidence_for(declared, referent)
61
63
 
62
64
  finding(
@@ -97,9 +99,9 @@ module SchemaReaper
97
99
  end
98
100
 
99
101
  def fix_for(table, col, type_col)
100
- return "add_index :#{table.name}, :#{col}" unless type_col
102
+ return "add_index #{sym(table.name)}, :#{col}" unless type_col
101
103
 
102
- "add_index :#{table.name}, %i[#{type_col} #{col}]"
104
+ "add_index #{sym(table.name)}, %i[#{type_col} #{col}]"
103
105
  end
104
106
 
105
107
  def fk_columns(table)
@@ -109,7 +111,9 @@ module SchemaReaper
109
111
  # A table the column plausibly points at: account_id -> accounts,
110
112
  # company_id -> companies, address_id -> addresses. Absence is the signal
111
113
  # that a *_id name is not really a foreign key.
112
- def referent_table(col)
114
+ # A table outside public looks in its own schema first:
115
+ # billing.invoices.account_id -> billing.accounts, then accounts.
116
+ def referent_table(col, from_table)
113
117
  return nil unless col.end_with?("_id")
114
118
 
115
119
  base = col[0..-4]
@@ -117,6 +121,8 @@ module SchemaReaper
117
121
 
118
122
  candidates = [base, "#{base}s", "#{base}es"]
119
123
  candidates << "#{base[0..-2]}ies" if base.end_with?("y")
124
+ ns = from_table.include?(".") ? from_table.split(".").first : nil
125
+ candidates = candidates.map { |c| "#{ns}.#{c}" } + candidates if ns
120
126
  candidates.find { |name| table_names.include?(name) }
121
127
  end
122
128
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ module Analyzers
5
+ # A serial / IDENTITY sequence that has used up a large share of the values
6
+ # its column can hold. When it runs out, every INSERT into the table fails.
7
+ # The classic case is a pre-Rails-5.1 `integer` primary key, capped at
8
+ # 2,147,483,647 even when its sequence is bigint.
9
+ #
10
+ # Not dead weight -- a health check, like missing_fk_index -- but it is
11
+ # read straight from the schema scan the other analyzers already need.
12
+ class SequenceExhaustion < Base
13
+ Registry.register(self)
14
+
15
+ WARN_AT = 0.5
16
+ HIGH_AT = 0.75
17
+
18
+ def call
19
+ schema.sequences.filter_map do |seq|
20
+ next if config.ignore_tables.include?(seq.table)
21
+ next if seq.used_fraction < WARN_AT
22
+
23
+ finding_for(seq)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def finding_for(seq)
30
+ pct = (seq.used_fraction * 100).round(1)
31
+ finding(
32
+ type: :sequence_exhaustion,
33
+ table: seq.table,
34
+ column: seq.column,
35
+ severity: seq.used_fraction >= HIGH_AT ? :high : :medium,
36
+ confidence: 0.95, # read from the database, not inferred
37
+ bytes_per_row: 0,
38
+ evidence: [
39
+ "#{seq.name} is at #{seq.last_value} of #{seq.max_value} (#{pct}%) " \
40
+ "for #{seq.column_type} column #{seq.column}",
41
+ "INSERTs into #{seq.table} fail once it is exhausted"
42
+ ],
43
+ suggested_fix: fix_for(seq)
44
+ )
45
+ end
46
+
47
+ def fix_for(seq)
48
+ return "the sequence itself is near its MAXVALUE -- raise it with ALTER SEQUENCE" if seq.column_type == "bigint"
49
+
50
+ "change_column #{sym(seq.table)}, :#{seq.column}, :bigint " \
51
+ "(rewrites the table under an exclusive lock -- plan for it on a large table)"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -24,6 +24,7 @@ module SchemaReaper
24
24
  next unless col.single_value?
25
25
  next if table.primary_key?(col.name)
26
26
  next if config.always_keep_columns.include?(col.name)
27
+ next if ignored_column?(col.name)
27
28
  next if gem_reserved?(table.name, col.name)
28
29
 
29
30
  finding(
@@ -31,16 +32,27 @@ module SchemaReaper
31
32
  table: table.name,
32
33
  column: col.name,
33
34
  severity: :low,
34
- confidence: 0.6,
35
+ confidence: sampled_stats?(table) ? 0.5 : 0.6,
35
36
  bytes_per_row: col.bytes,
36
- evidence: [
37
- "pg_stats.n_distinct = 1 across ~#{table.row_count} row(s)",
38
- "column has the same value in every row"
39
- ],
37
+ evidence: evidence_for(table, col),
40
38
  suggested_fix: "confirm the value is not a meaningful default before removing"
41
39
  )
42
40
  end
43
41
  end
42
+
43
+ # n_distinct counts non-null values only, so "one value" and "the same
44
+ # value in every row" are different claims when some rows are NULL.
45
+ def evidence_for(table, col)
46
+ nulls = col.null_fraction.to_f
47
+ ev = ["pg_stats.n_distinct = 1 across ~#{table.row_count} row(s)"]
48
+ ev << if nulls.positive?
49
+ "one distinct non-null value; #{(nulls * 100).round(1)}% of rows are NULL"
50
+ else
51
+ "column has the same value in every row"
52
+ end
53
+ ev << "estimated from a #{STATS_SAMPLE_ROWS}-row ANALYZE sample" if sampled_stats?(table)
54
+ ev
55
+ end
44
56
  end
45
57
  end
46
58
  end
@@ -34,15 +34,20 @@ module SchemaReaper
34
34
  severity: :medium,
35
35
  confidence: 0.55,
36
36
  bytes_per_row: 0,
37
- evidence: [
38
- "pg_stat_user_indexes.idx_scan = 0 for #{ix.name} (#{ix.columns.join(", ")})",
39
- "confirm stats have not been reset recently before dropping"
40
- ],
41
- suggested_fix: "remove_index :#{table.name}, name: :#{ix.name} " \
42
- "(use algorithm: :concurrently in production)"
37
+ evidence: evidence_for(ix),
38
+ suggested_fix: remove_index_fix(table, ix)
43
39
  )
44
40
  end
45
41
  end
42
+
43
+ def evidence_for(index)
44
+ [
45
+ "pg_stat_user_indexes.idx_scan = 0 for #{index.name} (#{index.columns.join(", ")})",
46
+ "confirm stats have not been reset recently before dropping",
47
+ "idx_scan is per server: if read replicas serve queries, check " \
48
+ "pg_stat_user_indexes on each replica too -- an index used only there looks unused here"
49
+ ]
50
+ end
46
51
  end
47
52
  end
48
53
  end
@@ -2,34 +2,119 @@
2
2
 
3
3
  require "json"
4
4
  require "fileutils"
5
+ require "date"
5
6
 
6
7
  module SchemaReaper
7
8
  # Persists known finding ids so CI only fails on *new* dead weight.
9
+ #
10
+ # Each accepted finding can carry a reason and an expiry date. Once an entry
11
+ # expires, `scan --ci` treats the finding as new again, so "we'll deal with
12
+ # it later" comes back around instead of living in the file forever.
13
+ #
14
+ # Reads the 2.0 format ({"ids": [...]}) as entries with no reason or expiry,
15
+ # and keeps writing "ids" alongside "entries" so a 2.0.x gem sharing the
16
+ # file (mixed versions across a team or CI) still sees the accepted set.
17
+ #
18
+ # scope: analyzer types (e.g. %w[dead_column]) a write or prune is limited
19
+ # to -- what `--only` / `--except` selected. Entries of other analyzers
20
+ # were not looked at in this run, so they are left exactly as they are.
8
21
  class Baseline
22
+ Entry = Struct.new(:id, :reason, :expires_on, keyword_init: true) do
23
+ def expired?(today = Date.today)
24
+ !expires_on.nil? && expires_on < today
25
+ end
26
+
27
+ def type
28
+ id.split("/").first
29
+ end
30
+
31
+ def to_h
32
+ { "id" => id, "reason" => reason, "expires_on" => expires_on&.iso8601 }.compact
33
+ end
34
+ end
35
+
9
36
  def initialize(path)
10
37
  @path = path
11
38
  end
12
39
 
40
+ def entries
41
+ @entries ||= read_entries
42
+ end
43
+
44
+ # Ids still accepted today (expired entries excluded).
13
45
  def ids
14
- return [] unless File.exist?(@path)
46
+ entries.reject(&:expired?).map(&:id)
47
+ end
15
48
 
16
- JSON.parse(File.read(@path)).fetch("ids", [])
17
- rescue JSON::ParserError
18
- []
49
+ def expired
50
+ entries.select(&:expired?)
51
+ end
52
+
53
+ # Replaces the (in-scope part of the) baseline with exactly these
54
+ # findings. A reason / expiry passed in applies to new entries and to
55
+ # expired ones being re-accepted -- that is how an expired entry gets
56
+ # renewed; unexpired entries keep what they had.
57
+ def write(findings, reason: nil, expires_on: nil, scope: nil)
58
+ known = entries.to_h { |e| [e.id, e] }
59
+ kept = entries.reject { |e| in_scope?(e, scope) }
60
+ written = findings.map(&:id).uniq.map do |id|
61
+ entry = known[id]
62
+ next Entry.new(id: id, reason: reason, expires_on: expires_on) unless entry
63
+ next renew(entry, reason, expires_on) if entry.expired?
64
+
65
+ entry
66
+ end
67
+ @entries = (kept + written).uniq(&:id).sort_by(&:id)
68
+ save
19
69
  end
20
70
 
21
- def write(findings)
71
+ # Drops entries for findings that no longer occur, without accepting any
72
+ # new ones -- so a fixed finding can't hide a later regression with the
73
+ # same id. @return [Array<String>] dropped ids
74
+ def prune(findings, scope: nil)
75
+ current = findings.map(&:id)
76
+ dropped = entries.select { |e| in_scope?(e, scope) && !current.include?(e.id) }.map(&:id)
77
+ @entries = entries.reject { |e| dropped.include?(e.id) }
78
+ save
79
+ dropped
80
+ end
81
+
82
+ # findings not present in the stored baseline (or whose entry expired)
83
+ def new_among(findings)
84
+ known = ids
85
+ findings.reject { |f| known.include?(f.id) }
86
+ end
87
+
88
+ private
89
+
90
+ def in_scope?(entry, scope)
91
+ scope.nil? || scope.include?(entry.type)
92
+ end
93
+
94
+ def renew(entry, reason, expires_on)
95
+ Entry.new(id: entry.id, reason: reason || entry.reason, expires_on: expires_on)
96
+ end
97
+
98
+ def save
22
99
  FileUtils.mkdir_p(File.dirname(@path))
23
100
  File.write(@path, JSON.pretty_generate(
24
101
  "generated_at" => Time.now.utc.iso8601,
25
- "ids" => findings.map(&:id).sort
102
+ "ids" => ids.sort, # read by 2.0.x; unexpired only
103
+ "entries" => entries.map(&:to_h)
26
104
  ))
27
105
  end
28
106
 
29
- # findings not present in the stored baseline
30
- def new_among(findings)
31
- known = ids
32
- findings.reject { |f| known.include?(f.id) }
107
+ def read_entries
108
+ return [] unless File.exist?(@path)
109
+
110
+ data = JSON.parse(File.read(@path))
111
+ return data.fetch("ids", []).map { |id| Entry.new(id: id) } unless data.key?("entries")
112
+
113
+ data["entries"].map do |e|
114
+ Entry.new(id: e["id"], reason: e["reason"], expires_on: e["expires_on"] && Date.parse(e["expires_on"]))
115
+ end
116
+ rescue JSON::ParserError, ArgumentError
117
+ []
33
118
  end
34
119
  end
35
120
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module SchemaReaper
6
+ class CLI < Thor
7
+ # Output helpers for `baseline` and the `scan --ci` gate.
8
+ module BaselineSupport
9
+ private
10
+
11
+ def report_written(findings, scope)
12
+ console.ok("recorded #{findings.size} finding#{"s" unless findings.size == 1} " \
13
+ "as the accepted baseline")
14
+ console.info("file: #{config.baseline_path}")
15
+ console.info("only #{scope.join(", ")} entries were replaced; other analyzers' are unchanged") if scope
16
+ console.info("`scan --ci` now fails only on findings that appear after this point.")
17
+ end
18
+
19
+ def report_pruned(dropped)
20
+ return console.ok("nothing to prune -- every baseline entry still occurs") if dropped.empty?
21
+
22
+ console.ok("dropped #{dropped.size} resolved entr#{dropped.size == 1 ? "y" : "ies"}")
23
+ dropped.each { |id| console.info(" #{id}") }
24
+ end
25
+
26
+ def parse_date(value)
27
+ return nil unless value
28
+
29
+ Date.iso8601(value)
30
+ rescue ArgumentError
31
+ console.problem("--expires must be a date like 2027-01-31, got #{value.inspect}")
32
+ exit 1
33
+ end
34
+
35
+ def warn_expired(expired, findings)
36
+ current = findings.map(&:id)
37
+ live = expired.select { |e| current.include?(e.id) }
38
+ return if live.empty?
39
+
40
+ console.notice("#{live.size} baseline entr#{live.size == 1 ? "y has" : "ies have"} expired and count as new:")
41
+ live.each { |e| console.info(" #{e.id} (expired #{e.expires_on}#{" -- #{e.reason}" if e.reason})") }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,16 +2,23 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "../schema_reaper"
5
+ require_relative "cli/baseline_support"
5
6
 
6
7
  module SchemaReaper
7
8
  # Command-line entry point. See `exe/schema_reaper`.
8
9
  class CLI < Thor
10
+ include BaselineSupport
11
+
9
12
  def self.exit_on_failure?
10
13
  true
11
14
  end
12
15
 
13
16
  class_option :config, type: :string, default: ".schema_reaper.yml",
14
17
  desc: "path to config file"
18
+ class_option :color, type: :boolean, default: nil,
19
+ desc: "force colour on/off (default: auto)"
20
+ class_option :only, type: :array, desc: "run only these analyzers (e.g. dead_column unused_index)"
21
+ class_option :except, type: :array, desc: "skip these analyzers"
15
22
 
16
23
  desc "scan", "Scan schema + code and report dead weight"
17
24
  option :format, type: :string, default: "table",
@@ -20,11 +27,10 @@ module SchemaReaper
20
27
  desc: "exit non-zero on findings not in the baseline"
21
28
  option :record, type: :boolean, default: false,
22
29
  desc: "append this run to the history log"
23
- option :min_confidence, type: :numeric, default: 0.0
24
- option :color, type: :boolean, default: nil,
25
- desc: "force colour on/off for the table report (default: auto)"
30
+ option :min_confidence, type: :numeric,
31
+ desc: "hide findings below this confidence (default: min_confidence in config, 0.0)"
26
32
  def scan
27
- findings = run.select { |f| f.confidence >= options[:min_confidence] }
33
+ findings = run(min_confidence: options[:min_confidence])
28
34
  render_report(findings)
29
35
 
30
36
  History.new(config.history_log).record(findings) if options[:record]
@@ -32,14 +38,20 @@ module SchemaReaper
32
38
  end
33
39
 
34
40
  desc "baseline", "Write current findings to the baseline file"
41
+ option :reason, type: :string, desc: "why these findings are accepted (set on new and expired entries)"
42
+ option :expires, type: :string, desc: "YYYY-MM-DD after which `scan --ci` fails on them again " \
43
+ "(renews expired entries)"
44
+ option :prune, type: :boolean, default: false,
45
+ desc: "only drop entries for findings that no longer occur; accept nothing new"
35
46
  def baseline
36
47
  findings = run
37
- Baseline.new(config.baseline_path).write(findings)
48
+ store = Baseline.new(config.baseline_path)
38
49
  console.title("baseline")
39
- console.ok("recorded #{findings.size} finding#{"s" unless findings.size == 1} " \
40
- "as the accepted baseline")
41
- console.info("file: #{config.baseline_path}")
42
- console.info("`scan --ci` now fails only on findings that appear after this point.")
50
+ scope = runner.selected_types
51
+ return report_pruned(store.prune(findings, scope: scope)) if options[:prune]
52
+
53
+ store.write(findings, reason: options[:reason], expires_on: parse_date(options[:expires]), scope: scope)
54
+ report_written(findings, scope)
43
55
  end
44
56
 
45
57
  desc "trend", "Append a snapshot and print progress over time"
@@ -49,8 +61,17 @@ module SchemaReaper
49
61
  Reporters::Trend.new(history.trend, color: options[:color]).render
50
62
  end
51
63
 
52
- desc "generate-migration TABLE COLUMN", "Emit a staged removal migration pair"
53
- def generate_migration(table, column)
64
+ desc "generate-migration TABLE [COLUMN]",
65
+ "Emit a staged removal migration pair for a column, or a removal migration for an index (--index) or table"
66
+ option :index, type: :string, desc: "name of an index on TABLE to remove instead of a column"
67
+ def generate_migration(table, column = nil)
68
+ if options[:index] && column
69
+ console.problem("pass either COLUMN or --index, not both")
70
+ exit 1
71
+ end
72
+ return generate_index_migration(table, options[:index]) if options[:index]
73
+ return generate_table_migration(table) unless column
74
+
54
75
  paths = MigrationGenerator.new(table: table, column: column).call
55
76
  console.title("generate-migration", "#{table}.#{column}")
56
77
  console.ok("created two migrations:")
@@ -62,6 +83,16 @@ module SchemaReaper
62
83
  ])
63
84
  end
64
85
 
86
+ desc "explain ID", "Show everything known about one finding and the command to act on it"
87
+ def explain(id)
88
+ exit 1 unless Explain.new(run, config: config, console: console).call(id)
89
+ end
90
+
91
+ desc "doctor", "Check the database, statistics, runtime log, baseline and alert setup"
92
+ def doctor
93
+ exit 1 unless Doctor.new(config: config, console: console).call
94
+ end
95
+
65
96
  desc "version", "Print version"
66
97
  def version
67
98
  say(SchemaReaper::VERSION)
@@ -69,12 +100,34 @@ module SchemaReaper
69
100
 
70
101
  private
71
102
 
103
+ def generate_index_migration(table, index)
104
+ path = MigrationGenerator.new(table: table, index: index).call.first
105
+ console.title("generate-migration", "#{table} · #{index}")
106
+ console.ok("created #{path}")
107
+ console.info("runs with algorithm: :concurrently, so it does not lock writes to #{table}")
108
+ end
109
+
110
+ def generate_table_migration(table)
111
+ path = MigrationGenerator.new(table: table).call.first
112
+ console.title("generate-migration", table)
113
+ console.ok("created #{path}")
114
+ console.list("before running it:", [
115
+ "confirm no external consumer (reports, ETL, other services) reads #{table}",
116
+ "take a backup -- its `down` cannot bring the data back"
117
+ ])
118
+ end
119
+
72
120
  def config
73
121
  @config ||= Config.load(options[:config])
74
122
  end
75
123
 
76
- def run
77
- Runner.new(config: config).run
124
+ def runner(min_confidence: nil)
125
+ @runner ||= Runner.new(config: config, only: options[:only], except: options[:except],
126
+ min_confidence: min_confidence)
127
+ end
128
+
129
+ def run(min_confidence: nil)
130
+ runner(min_confidence: min_confidence).run
78
131
  rescue SchemaReaper::Error => e
79
132
  console.problem(e.message)
80
133
  exit 1
@@ -93,7 +146,9 @@ module SchemaReaper
93
146
  end
94
147
 
95
148
  def enforce_baseline(findings)
96
- new_ones = Baseline.new(config.baseline_path).new_among(findings)
149
+ store = Baseline.new(config.baseline_path)
150
+ warn_expired(store.expired, findings)
151
+ new_ones = store.new_among(findings)
97
152
  return if new_ones.empty?
98
153
 
99
154
  console.problem(
@@ -9,6 +9,7 @@ module SchemaReaper
9
9
  class Config
10
10
  DEFAULTS = {
11
11
  "database_url" => nil, # falls back to ENV["DATABASE_URL"]
12
+ "schemas" => %w[public], # PostgreSQL schemas to introspect
12
13
  "scan_paths" => %w[app lib config],
13
14
  "view_globs" => %w[
14
15
  app/**/*.erb app/**/*.haml app/**/*.slim app/**/*.jbuilder
@@ -20,9 +21,16 @@ module SchemaReaper
20
21
  "always_keep_columns" => %w[id created_at updated_at type],
21
22
  "gem_awareness" => true, # auto-whitelist columns owned by known gems
22
23
  "min_age_days" => 14,
24
+ "min_confidence" => 0.0, # drop findings below this from every command
25
+ "runtime_log_max_bytes" => 50 * 1024 * 1024, # rotate the usage log past this size
23
26
  "runtime_log" => ".schema_reaper/runtime.jsonl",
24
27
  "history_log" => ".schema_reaper/history.jsonl",
25
28
  "baseline" => ".schema_reaper/baseline.json",
29
+ "scan_cache" => ".schema_reaper/scan_cache.json", # per-file scan cache; null disables
30
+ # global: a column counts as used if its name appears anywhere.
31
+ # model: a name in an app/models file only counts for that model's
32
+ # table and the tables it mentions -- stricter, finds more.
33
+ "column_scope" => "global",
26
34
  "database_yml" => "config/database.yml", # Rails fallback for the connection
27
35
  "require" => [] # extra files to load (custom analyzers)
28
36
  }.freeze
@@ -74,6 +82,18 @@ module SchemaReaper
74
82
  @data.dig("ignore", "tables").to_a
75
83
  end
76
84
 
85
+ def schemas
86
+ Array(@data["schemas"]).map(&:to_s)
87
+ end
88
+
89
+ def min_confidence
90
+ @data["min_confidence"].to_f
91
+ end
92
+
93
+ def runtime_log_max_bytes
94
+ @data["runtime_log_max_bytes"]
95
+ end
96
+
77
97
  def min_age_days
78
98
  @data["min_age_days"]
79
99
  end
@@ -90,6 +110,14 @@ module SchemaReaper
90
110
  @data["history_log"]
91
111
  end
92
112
 
113
+ def scan_cache
114
+ @data["scan_cache"]
115
+ end
116
+
117
+ def column_scope
118
+ @data["column_scope"].to_s == "model" ? :model : :global
119
+ end
120
+
93
121
  def gem_awareness?
94
122
  @data["gem_awareness"] != false
95
123
  end