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
|
@@ -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
|
|
@@ -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
|
|
102
|
+
return "add_index #{sym(table.name)}, :#{col}" unless type_col
|
|
101
103
|
|
|
102
|
-
"add_index
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
entries.reject(&:expired?).map(&:id)
|
|
47
|
+
end
|
|
15
48
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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" =>
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
data/lib/schema_reaper/cli.rb
CHANGED
|
@@ -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,
|
|
24
|
-
|
|
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
|
|
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)
|
|
48
|
+
store = Baseline.new(config.baseline_path)
|
|
38
49
|
console.title("baseline")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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",
|
|
53
|
-
|
|
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
|
|
77
|
-
Runner.new(config: config
|
|
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
|
-
|
|
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(
|
data/lib/schema_reaper/config.rb
CHANGED
|
@@ -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
|