schema_reaper 2.0.0 → 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 +144 -0
- data/PRO.md +11 -6
- data/README.md +421 -71
- data/RELEASE_CHECKLIST.md +13 -2
- data/lib/generators/schema_reaper/install/install_generator.rb +11 -11
- 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 +12 -6
- 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 +258 -55
- data/lib/schema_reaper/migration_generator.rb +90 -4
- data/lib/schema_reaper/notifier.rb +69 -8
- 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/scan_job.rb +24 -2
- 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/tasks/schema_reaper.rake +2 -2
- data/lib/schema_reaper/version.rb +1 -1
- data/lib/schema_reaper.rb +18 -1
- data/schema_reaper.gemspec +1 -2
- metadata +13 -4
|
@@ -48,7 +48,7 @@ module SchemaReaper
|
|
|
48
48
|
|
|
49
49
|
say_status :warning,
|
|
50
50
|
"schema_reaper is Gemfile-scoped to group: :development. Most production deploy " \
|
|
51
|
-
"pipelines strip dev/test groups (`
|
|
51
|
+
"pipelines strip dev/test groups (`BUNDLE_WITHOUT=development:test`), so " \
|
|
52
52
|
"the scheduled scan will not run in production until you remove that restriction.",
|
|
53
53
|
:red
|
|
54
54
|
end
|
|
@@ -57,7 +57,8 @@ module SchemaReaper
|
|
|
57
57
|
return if mailer_from_configured?
|
|
58
58
|
|
|
59
59
|
say_status :warning,
|
|
60
|
-
"no ApplicationMailer default `from:` detected
|
|
60
|
+
"no ApplicationMailer default `from:` detected (nor config.action_mailer.default_options). " \
|
|
61
|
+
"SchemaReaper::Mailer falls back to a " \
|
|
61
62
|
"placeholder sender in that case, which most real SMTP relays reject or spam-flag -- " \
|
|
62
63
|
"email reports would silently fail to arrive. If you plan to use the email channel " \
|
|
63
64
|
"(config.emails in the initializer this generator just wrote), set " \
|
|
@@ -152,7 +153,7 @@ module SchemaReaper
|
|
|
152
153
|
# `gem "schema_reaper", group: :development` (or the equivalent
|
|
153
154
|
# `group :development do ... end` block form) in the app's own Gemfile
|
|
154
155
|
# -- see README's current install snippet. A dev-scoped gem is absent
|
|
155
|
-
# from `
|
|
156
|
+
# from `BUNDLE_WITHOUT=development:test` installs, which most
|
|
156
157
|
# production deploy pipelines run, so the scheduled scan silently
|
|
157
158
|
# never runs.
|
|
158
159
|
#
|
|
@@ -171,15 +172,14 @@ module SchemaReaper
|
|
|
171
172
|
false # a Gemfile we can't parse shouldn't block the rest of the generator
|
|
172
173
|
end
|
|
173
174
|
|
|
174
|
-
# Checked against
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
175
|
+
# Checked against the class SchemaReaper::Mailer will inherit from --
|
|
176
|
+
# ApplicationMailer when the app has one, ActionMailer::Base otherwise
|
|
177
|
+
# (where `config.action_mailer.default_options = { from: ... }` lands).
|
|
178
|
+
# At generate time the app is already booted, so this reads the same
|
|
179
|
+
# `default_params[:from]` chain Mailer itself will see.
|
|
179
180
|
def mailer_from_configured?
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
::ApplicationMailer.default_params[:from].present?
|
|
181
|
+
parent = defined?(::ApplicationMailer) ? ::ApplicationMailer : ::ActionMailer::Base
|
|
182
|
+
parent.default_params[:from].present?
|
|
183
183
|
rescue StandardError
|
|
184
184
|
true # can't determine -- don't nag over something we're unsure about
|
|
185
185
|
end
|
|
@@ -15,4 +15,12 @@
|
|
|
15
15
|
SchemaReaper::AlertConfig.configure do |config|
|
|
16
16
|
# config.emails = %w[dev1@example.com dev2@example.com]
|
|
17
17
|
# config.webhook_url = "https://hooks.slack.com/services/T000/B000/XXXX"
|
|
18
|
+
|
|
19
|
+
# :changes sends a report only when findings appeared or were resolved
|
|
20
|
+
# since the last one (needs Rails.cache); :always sends every run.
|
|
21
|
+
# config.alert_on = :changes
|
|
22
|
+
|
|
23
|
+
# :auto uses Slack Block Kit for hooks.slack.com URLs and a plain
|
|
24
|
+
# {"text": <markdown>} body for anything else. Force with :slack or :text.
|
|
25
|
+
# config.webhook_format = :auto
|
|
18
26
|
end
|
|
@@ -30,11 +30,31 @@ module SchemaReaper
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
# alert_on: :always -- send every scheduled report (the 2.0 behaviour)
|
|
34
|
+
# :changes -- send only when findings appeared or were
|
|
35
|
+
# resolved since the last report
|
|
36
|
+
# webhook_format: :auto -- Slack Block Kit for hooks.slack.com, plain
|
|
37
|
+
# {"text": <markdown>} for anything else
|
|
38
|
+
# :slack / :text to force one
|
|
39
|
+
attr_accessor :emails, :webhook_url, :alert_on, :webhook_format
|
|
34
40
|
|
|
35
41
|
def initialize
|
|
36
42
|
@emails = []
|
|
37
43
|
@webhook_url = nil
|
|
44
|
+
@alert_on = :always
|
|
45
|
+
@webhook_format = :auto
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def changes_only?
|
|
49
|
+
alert_on.to_s == "changes"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def slack_blocks?
|
|
53
|
+
case webhook_format.to_s
|
|
54
|
+
when "slack" then true
|
|
55
|
+
when "text" then false
|
|
56
|
+
else webhook_url.to_s.include?("hooks.slack.com")
|
|
57
|
+
end
|
|
38
58
|
end
|
|
39
59
|
|
|
40
60
|
def emails?
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
# Remembers which finding ids the last delivered report contained, so the
|
|
5
|
+
# next one can say what changed -- and, with alert_on: :changes, stay quiet
|
|
6
|
+
# when nothing did. Kept in Rails.cache (the manual trigger's cooldown
|
|
7
|
+
# already relies on it) because production filesystems are often
|
|
8
|
+
# ephemeral. Without a cache every report is treated as the first.
|
|
9
|
+
class AlertState
|
|
10
|
+
KEY = "schema_reaper:last_alert_ids"
|
|
11
|
+
|
|
12
|
+
def initialize(cache: default_cache)
|
|
13
|
+
@cache = cache
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<String>, nil] nil when no earlier report is known
|
|
17
|
+
def previous_ids
|
|
18
|
+
@cache&.read(KEY)
|
|
19
|
+
rescue StandardError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def remember(ids)
|
|
24
|
+
@cache&.write(KEY, ids.sort)
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def default_cache
|
|
32
|
+
defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : nil
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -20,6 +20,7 @@ module SchemaReaper
|
|
|
20
20
|
table.columns.filter_map do |col|
|
|
21
21
|
next unless col.always_null?
|
|
22
22
|
next if config.always_keep_columns.include?(col.name)
|
|
23
|
+
next if ignored_column?(col.name)
|
|
23
24
|
next if gem_reserved?(table.name, col.name)
|
|
24
25
|
|
|
25
26
|
finding(
|
|
@@ -27,17 +28,23 @@ module SchemaReaper
|
|
|
27
28
|
table: table.name,
|
|
28
29
|
column: col.name,
|
|
29
30
|
severity: :high,
|
|
30
|
-
confidence: 0.85,
|
|
31
|
+
confidence: sampled_stats?(table) ? 0.7 : 0.85,
|
|
31
32
|
bytes_per_row: col.bytes,
|
|
32
|
-
evidence:
|
|
33
|
-
"pg_stats.null_frac = 1.0 across ~#{table.row_count} row(s)",
|
|
34
|
-
"column carries no data"
|
|
35
|
-
],
|
|
33
|
+
evidence: evidence_for(table),
|
|
36
34
|
suggested_fix: "verify with `SELECT count(#{col.name}) FROM #{table.name}` " \
|
|
37
35
|
"then stage a removal"
|
|
38
36
|
)
|
|
39
37
|
end
|
|
40
38
|
end
|
|
39
|
+
|
|
40
|
+
def evidence_for(table)
|
|
41
|
+
ev = ["pg_stats.null_frac = 1.0 across ~#{table.row_count} row(s)"]
|
|
42
|
+
ev << if sampled_stats?(table)
|
|
43
|
+
"estimated from a #{STATS_SAMPLE_ROWS}-row ANALYZE sample -- a few non-null rows could be missed"
|
|
44
|
+
else
|
|
45
|
+
"column carries no data"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
41
48
|
end
|
|
42
49
|
end
|
|
43
50
|
end
|
|
@@ -11,8 +11,14 @@ module SchemaReaper
|
|
|
11
11
|
# gem_columns - Hash{table_name => Set<column_name>} reserved by gems
|
|
12
12
|
# gem_owned_tables - Set<table_name> created and owned outright by a gem
|
|
13
13
|
# config - Config
|
|
14
|
-
|
|
14
|
+
# scan - Static::Result (optional; per-model detail
|
|
15
|
+
# for column_scope: model)
|
|
16
|
+
Context = Struct.new(:schema, :used_tokens, :runtime, :gem_columns, :gem_owned_tables, :config, :scan,
|
|
15
17
|
keyword_init: true) do
|
|
18
|
+
def scan
|
|
19
|
+
self[:scan] || Static::Result.from_tokens(used_tokens || [])
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
def runtime
|
|
17
23
|
self[:runtime] || Runtime::Report.empty
|
|
18
24
|
end
|
|
@@ -28,6 +34,11 @@ module SchemaReaper
|
|
|
28
34
|
|
|
29
35
|
# Shared plumbing for analyzers: schema access and token lookup helpers.
|
|
30
36
|
class Base
|
|
37
|
+
# ANALYZE reads a sample of 300 x default_statistics_target rows (30,000
|
|
38
|
+
# at the default of 100). Above that, pg_stats' null_frac and n_distinct
|
|
39
|
+
# are estimates: a value held by a handful of rows can be missed.
|
|
40
|
+
STATS_SAMPLE_ROWS = 30_000
|
|
41
|
+
|
|
31
42
|
def self.type
|
|
32
43
|
name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
|
|
33
44
|
end
|
|
@@ -61,8 +72,61 @@ module SchemaReaper
|
|
|
61
72
|
ctx.used_tokens.include?(token.to_s.downcase)
|
|
62
73
|
end
|
|
63
74
|
|
|
75
|
+
# Whether code references `column` on `table`. With column_scope: model,
|
|
76
|
+
# a name seen only in model files counts just for the tables those
|
|
77
|
+
# models are, or mention (an association, a class name), so Order's
|
|
78
|
+
# `status` no longer hides a dead `status` on users.
|
|
79
|
+
def column_used?(table, column)
|
|
80
|
+
return used?(column) unless config.column_scope == :model
|
|
81
|
+
|
|
82
|
+
name = column.to_s.downcase
|
|
83
|
+
scan = ctx.scan
|
|
84
|
+
return true if scan.shared.include?(name)
|
|
85
|
+
|
|
86
|
+
forms = table_forms(table)
|
|
87
|
+
scan.models.any? do |model|
|
|
88
|
+
model.tokens.include?(name) && (model.tables.include?(table) || forms.any? { |f| model.tokens.include?(f) })
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def table_forms(table)
|
|
93
|
+
bare = table.to_s.split(".").last
|
|
94
|
+
singular = Inflect.singularize(bare)
|
|
95
|
+
[bare, singular, singular.delete("_")].uniq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Every column of a table a gem owns outright is the gem's business.
|
|
64
99
|
def gem_reserved?(table, column)
|
|
65
|
-
ctx.gem_columns.fetch(table, []).include?(column)
|
|
100
|
+
gem_owned_table?(table) || ctx.gem_columns.fetch(table, []).include?(column)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sampled_stats?(table)
|
|
104
|
+
table.row_count.to_i > STATS_SAMPLE_ROWS
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The `ignore.columns` list (names or /regex/) applies to every
|
|
108
|
+
# column-level analyzer, not only dead_column.
|
|
109
|
+
def ignored_column?(name)
|
|
110
|
+
config.ignored_column?(name)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The removal command for an index. DROP INDEX CONCURRENTLY is refused
|
|
114
|
+
# on a partitioned index ("cannot drop partitioned index ...
|
|
115
|
+
# concurrently"), and its per-partition indexes can't be dropped on
|
|
116
|
+
# their own while attached, so the only path is a plain drop, which
|
|
117
|
+
# takes an ACCESS EXCLUSIVE lock on the parent and every partition.
|
|
118
|
+
def remove_index_fix(table, index)
|
|
119
|
+
base = "remove_index #{sym(table.name)}, name: :#{index.name}"
|
|
120
|
+
return "#{base} (use algorithm: :concurrently in production)" unless index.partitioned
|
|
121
|
+
|
|
122
|
+
"#{base} (partitioned index: CONCURRENTLY is not possible -- this locks the table " \
|
|
123
|
+
"and all partitions while it runs, so schedule it for a quiet window)"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A table name as a Ruby symbol literal for suggested fixes: :users, or
|
|
127
|
+
# :"billing.invoices" for a table outside the public schema.
|
|
128
|
+
def sym(name)
|
|
129
|
+
name.to_sym.inspect
|
|
66
130
|
end
|
|
67
131
|
|
|
68
132
|
def gem_owned_table?(table)
|
|
@@ -4,12 +4,12 @@ module SchemaReaper
|
|
|
4
4
|
module Analyzers
|
|
5
5
|
# Flags columns present in the schema but never referenced in code. When a
|
|
6
6
|
# runtime usage log is supplied, its signal is fused in: a column unseen in
|
|
7
|
-
# BOTH code and
|
|
7
|
+
# BOTH code and at least `min_age_days` (default 14) observed days of
|
|
8
|
+
# runtime reaches high confidence.
|
|
8
9
|
class DeadColumn < Base
|
|
9
10
|
Registry.register(self)
|
|
10
11
|
|
|
11
|
-
STATIC_ONLY_CAP
|
|
12
|
-
RUNTIME_MIN_DAYS = 14
|
|
12
|
+
STATIC_ONLY_CAP = 0.6
|
|
13
13
|
|
|
14
14
|
def call
|
|
15
15
|
schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
|
|
@@ -21,7 +21,7 @@ module SchemaReaper
|
|
|
21
21
|
def dead_in(table)
|
|
22
22
|
table.columns.filter_map do |col|
|
|
23
23
|
next if keep?(table, col)
|
|
24
|
-
next if
|
|
24
|
+
next if column_used?(table.name, col.name)
|
|
25
25
|
next if runtime.read?(table.name, col.name)
|
|
26
26
|
|
|
27
27
|
finding(
|
|
@@ -33,7 +33,7 @@ module SchemaReaper
|
|
|
33
33
|
bytes_per_row: col.bytes,
|
|
34
34
|
evidence: evidence_for(table, col),
|
|
35
35
|
suggested_fix: "Stage removal: `self.ignored_columns += %w[#{col.name}]` on the " \
|
|
36
|
-
"model, deploy, then `remove_column
|
|
36
|
+
"model, deploy, then `remove_column #{sym(table.name)}, :#{col.name}`."
|
|
37
37
|
)
|
|
38
38
|
end
|
|
39
39
|
end
|
|
@@ -48,7 +48,7 @@ module SchemaReaper
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def confidence_for(col)
|
|
51
|
-
if runtime.present? && runtime.observed_days >=
|
|
51
|
+
if runtime.present? && runtime.observed_days >= min_runtime_days
|
|
52
52
|
col.null ? 0.9 : 0.8
|
|
53
53
|
else
|
|
54
54
|
base = col.null ? 0.5 : 0.4
|
|
@@ -56,6 +56,12 @@ module SchemaReaper
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# How many days of runtime data it takes before "never read at runtime"
|
|
60
|
+
# is trusted. A config built without the key (nil) keeps the default.
|
|
61
|
+
def min_runtime_days
|
|
62
|
+
config.min_age_days || Config::DEFAULTS["min_age_days"]
|
|
63
|
+
end
|
|
64
|
+
|
|
59
65
|
def evidence_for(table, col)
|
|
60
66
|
ev = ["no `#{col.name}` reference found in scanned code"]
|
|
61
67
|
ev << "column is nullable" if col.null
|
|
@@ -24,13 +24,13 @@ module SchemaReaper
|
|
|
24
24
|
|
|
25
25
|
def ignored?(table)
|
|
26
26
|
config.ignore_tables.include?(table.name) ||
|
|
27
|
-
table.name.start_with?("active_storage_", "action_text_", "action_mailbox_") ||
|
|
27
|
+
table.name.split(".").last.start_with?("active_storage_", "action_text_", "action_mailbox_") ||
|
|
28
28
|
gem_owned_table?(table.name)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def dead(table)
|
|
32
32
|
return if referenced?(table)
|
|
33
|
-
return if runtime.
|
|
33
|
+
return if runtime.table_accessed?(table.name)
|
|
34
34
|
|
|
35
35
|
finding(
|
|
36
36
|
type: :dead_table,
|
|
@@ -40,17 +40,21 @@ module SchemaReaper
|
|
|
40
40
|
confidence: confidence_for(table),
|
|
41
41
|
bytes_per_row: table.columns.sum(&:bytes),
|
|
42
42
|
evidence: evidence_for(table),
|
|
43
|
-
suggested_fix: "confirm no external consumer, then `drop_table
|
|
43
|
+
suggested_fix: "confirm no external consumer, then `drop_table #{sym(table.name)}`"
|
|
44
44
|
)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
# Match the table name and its singular/camelized model form.
|
|
48
|
+
# A table outside public ("billing.invoices") is still referenced by its
|
|
49
|
+
# bare name in code -- `self.table_name = "billing.invoices"` tokenises
|
|
50
|
+
# to billing + invoices, and the model is Invoice.
|
|
48
51
|
def referenced?(table)
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
bare = table.name.split(".").last
|
|
53
|
+
singular = singularize(bare)
|
|
54
|
+
forms = [bare, singular, camelize(bare), camelize(singular)]
|
|
51
55
|
return true if forms.any? { |form| used?(form) }
|
|
52
56
|
|
|
53
|
-
embedded_in_identifier?(
|
|
57
|
+
embedded_in_identifier?(bare)
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def singularize(name)
|
|
@@ -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
|