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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module SchemaReaper
6
+ # `schema_reaper doctor`: checks everything a scan's accuracy depends on and
7
+ # says what to fix, so a quiet report can be trusted to mean "nothing dead"
8
+ # rather than "nothing measured".
9
+ class Doctor
10
+ Check = Struct.new(:status, :label, :detail, keyword_init: true) # status: :ok | :warn | :fail
11
+
12
+ STATS_FRESH_DAYS = 7
13
+ ICON = { ok: "✓", warn: "!", fail: "✗" }.freeze
14
+
15
+ def initialize(config:, root: Dir.pwd, introspector: nil, console: Reporters::Console.new)
16
+ @config = config
17
+ @root = root
18
+ @introspector = introspector
19
+ @console = console
20
+ @checks = []
21
+ end
22
+
23
+ # @return [Boolean] true unless a check failed outright
24
+ def call
25
+ check_scan_paths
26
+ check_database
27
+ check_runtime_log
28
+ check_baseline
29
+ check_schema_file
30
+ check_alerts
31
+ render
32
+ @checks.none? { |c| c.status == :fail }
33
+ end
34
+
35
+ private
36
+
37
+ def ok(label, detail = nil)
38
+ @checks << Check.new(status: :ok, label: label, detail: detail)
39
+ end
40
+
41
+ def warn_check(label, detail)
42
+ @checks << Check.new(status: :warn, label: label, detail: detail)
43
+ end
44
+
45
+ def fail_check(label, detail)
46
+ @checks << Check.new(status: :fail, label: label, detail: detail)
47
+ end
48
+
49
+ def check_scan_paths
50
+ missing = @config.scan_paths.reject { |p| Dir.exist?(File.join(@root, p)) }
51
+ return ok("scan paths", @config.scan_paths.join(", ")) if missing.empty?
52
+
53
+ warn_check("scan paths", "not found: #{missing.join(", ")} -- code there can't mark anything as used")
54
+ end
55
+
56
+ def check_database
57
+ intro = @introspector || Introspect::Postgres.new(@config.database_url, schemas: @config.schemas)
58
+ diag = intro.diagnostics
59
+ ok("database", "connected, PostgreSQL #{diag[:server_version]}")
60
+ check_stats_reset(diag[:stats_reset])
61
+ check_analyze(diag[:never_analyzed])
62
+ check_query_history(intro.call)
63
+ if diag[:in_recovery]
64
+ warn_check("replica", "this is a read replica -- index usage here doesn't cover the primary")
65
+ end
66
+ rescue Error => e
67
+ fail_check("database", e.message)
68
+ end
69
+
70
+ def check_stats_reset(reset_at)
71
+ return ok("statistics", "never reset") if reset_at.nil?
72
+
73
+ days = ((Time.now - Time.parse(reset_at.to_s)) / 86_400).floor
74
+ return ok("statistics", "collected for #{days} day(s)") if days >= STATS_FRESH_DAYS
75
+
76
+ warn_check("statistics", "reset #{days} day(s) ago -- unused_index needs a longer window to be trusted")
77
+ end
78
+
79
+ def check_analyze(tables)
80
+ return ok("ANALYZE", "every table has planner statistics") if tables.empty?
81
+
82
+ sample = tables.first(5).join(", ") + (tables.size > 5 ? ", ..." : "")
83
+ warn_check("ANALYZE", "#{tables.size} table(s) never analysed (#{sample}) -- " \
84
+ "row counts and null/distinct checks are blind there; run ANALYZE")
85
+ end
86
+
87
+ def check_query_history(schema)
88
+ return ok("query history", "#{schema.index_scan_total} index scans recorded") if schema.query_history?
89
+
90
+ warn_check("query history", "only #{schema.index_scan_total} index scan(s) -- unused_index will sit out; " \
91
+ "scan a database that serves real traffic")
92
+ end
93
+
94
+ def check_runtime_log
95
+ report = Runtime::Report.load(@config.runtime_log)
96
+ need = @config.min_age_days || Config::DEFAULTS["min_age_days"]
97
+ if report.empty?
98
+ warn_check("runtime log", "none at #{@config.runtime_log} -- dead_column confidence stays capped at " \
99
+ "#{Analyzers::DeadColumn::STATIC_ONLY_CAP}; see README > Runtime signal")
100
+ elsif report.observed_days < need
101
+ warn_check("runtime log", "#{report.observed_days} of #{need} day(s) observed -- not trusted yet")
102
+ else
103
+ ok("runtime log", "#{report.observed_days} day(s), #{report.accessed.size} column(s) seen")
104
+ end
105
+ end
106
+
107
+ def check_baseline
108
+ base = Baseline.new(@config.baseline_path)
109
+ return ok("baseline", "none (scan --ci would fail on every finding)") if base.entries.empty?
110
+
111
+ expired = base.expired
112
+ return ok("baseline", "#{base.entries.size} accepted finding(s)") if expired.empty?
113
+
114
+ warn_check("baseline", "#{expired.size} entr#{expired.size == 1 ? "y has" : "ies have"} expired: " \
115
+ "#{expired.map(&:id).first(3).join(", ")}")
116
+ end
117
+
118
+ def check_schema_file
119
+ path = SchemaLocator::CANDIDATES.find { |p| File.exist?(File.join(@root, p)) }
120
+ return ok("schema file", path) if path
121
+
122
+ warn_check("schema file", "no db/schema.rb or db/structure.sql -- SARIF results can't point at a line")
123
+ end
124
+
125
+ # Only meaningful inside a Rails app with the install generator's
126
+ # initializer loaded.
127
+ def check_alerts
128
+ return unless defined?(AlertConfig) && defined?(Rails)
129
+
130
+ cfg = AlertConfig.instance
131
+ unless cfg.configured?
132
+ return warn_check("alerts", "no emails or webhook_url configured -- scheduled scans report nowhere")
133
+ end
134
+
135
+ channels = [("email (#{cfg.emails.size})" if cfg.emails?), ("webhook" if cfg.webhook?)].compact
136
+ ok("alerts", "#{channels.join(" + ")}, alert_on: #{cfg.alert_on}")
137
+ end
138
+
139
+ def render
140
+ @console.title("doctor")
141
+ @checks.each do |c|
142
+ line = format("%<icon>s %<label>-14s %<detail>s", icon: ICON[c.status], label: c.label, detail: c.detail)
143
+ @console.info(line)
144
+ end
145
+ @console.blank
146
+ counts = @checks.group_by(&:status).transform_values(&:size)
147
+ @console.info("#{counts.fetch(:ok, 0)} ok, #{counts.fetch(:warn, 0)} warning(s), " \
148
+ "#{counts.fetch(:fail, 0)} failed")
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ # `schema_reaper explain ID`: everything known about one finding, and the
5
+ # exact command to act on it.
6
+ class Explain
7
+ def initialize(findings, config:, root: Dir.pwd, console: Reporters::Console.new)
8
+ @findings = findings
9
+ @config = config
10
+ @root = root
11
+ @console = console
12
+ end
13
+
14
+ # @return [Boolean] false when no finding matches
15
+ def call(query)
16
+ matches = match(query)
17
+ if matches.size == 1
18
+ render(matches.first)
19
+ elsif matches.empty?
20
+ report_not_found(query)
21
+ else
22
+ report_ambiguous(query, matches)
23
+ end
24
+ matches.size == 1
25
+ end
26
+
27
+ private
28
+
29
+ # An exact id, else every finding whose id contains the query
30
+ # ("users/legacy_token" finds dead_column/users/legacy_token).
31
+ def match(query)
32
+ exact = @findings.select { |f| f.id == query }
33
+ exact.any? ? exact : @findings.select { |f| f.id.include?(query) }
34
+ end
35
+
36
+ def report_not_found(query)
37
+ @console.problem("no current finding matches #{query.inspect}")
38
+ table_finding = @findings.find { |f| f.type == :dead_table && query.split("/").include?(f.table) }
39
+ if table_finding
40
+ @console.info("its table is reported whole as #{table_finding.id} -- per-column and per-index " \
41
+ "findings on a dead table are folded into that one")
42
+ return
43
+ end
44
+
45
+ @console.info("list them with `schema_reaper scan --format json` -- ids look like dead_column/users/legacy_token")
46
+ end
47
+
48
+ def report_ambiguous(query, matches)
49
+ @console.problem("#{matches.size} findings match #{query.inspect} -- be more specific:",
50
+ items: matches.map(&:id))
51
+ end
52
+
53
+ def render(finding)
54
+ @console.title("explain", finding.id)
55
+ @console.row("type", finding.type)
56
+ @console.row("severity", finding.severity)
57
+ @console.row("confidence", format("%.2f", finding.confidence))
58
+ @console.row("reclaimable", reclaim(finding))
59
+ @console.row("defined at", location(finding)) if location(finding)
60
+ @console.row("baseline", baseline_status(finding))
61
+ @console.blank
62
+ @console.list("evidence", finding.evidence, colour: :reset)
63
+ @console.blank
64
+ @console.list("fix", [finding.suggested_fix], colour: :reset)
65
+ next_step = next_command(finding)
66
+ @console.list("next", [next_step], colour: :reset) if next_step
67
+ end
68
+
69
+ def reclaim(finding)
70
+ return "unknown (row count unavailable -- run ANALYZE)" unless finding.reclaim_known?
71
+
72
+ Reporters::Bytes.human(finding.reclaimable_bytes)
73
+ end
74
+
75
+ def location(finding)
76
+ @location ||= {}
77
+ @location[finding.id] ||= begin
78
+ loc = SchemaLocator.new(root: @root).locate(finding)
79
+ loc && "#{loc.path}:#{loc.line}"
80
+ end
81
+ end
82
+
83
+ def baseline_status(finding)
84
+ entry = Baseline.new(@config.baseline_path).entries.find { |e| e.id == finding.id }
85
+ return "not accepted (fails `scan --ci`)" unless entry
86
+ return "accepted, but expired #{entry.expires_on}" if entry.expired?
87
+
88
+ ["accepted", entry.reason, entry.expires_on && "until #{entry.expires_on}"].compact.join(" -- ")
89
+ end
90
+
91
+ def next_command(finding)
92
+ case finding.type
93
+ when :dead_column, :always_null_column, :single_value_column
94
+ "bundle exec schema_reaper generate-migration #{finding.table} #{finding.column}"
95
+ when :unused_index, :duplicate_index
96
+ "bundle exec schema_reaper generate-migration #{finding.table} --index #{finding.index}"
97
+ when :dead_table
98
+ "bundle exec schema_reaper generate-migration #{finding.table}"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -9,6 +9,18 @@ module SchemaReaper
9
9
  module GemAwareness
10
10
  # gem name => { table_glob => [column names] }. "*" table_glob matches any
11
11
  # table; an exact name matches only that table.
12
+ def self.owned(tables)
13
+ tables.to_h { |t| [t, []] }
14
+ end
15
+ private_class_method :owned
16
+
17
+ SOLID_QUEUE_TABLES = %w[
18
+ solid_queue_jobs solid_queue_ready_executions solid_queue_scheduled_executions
19
+ solid_queue_claimed_executions solid_queue_blocked_executions solid_queue_failed_executions
20
+ solid_queue_recurring_executions solid_queue_recurring_tasks solid_queue_pauses
21
+ solid_queue_processes solid_queue_semaphores
22
+ ].freeze
23
+
12
24
  MAP = {
13
25
  "devise" => { "*" => %w[
14
26
  encrypted_password reset_password_token reset_password_sent_at
@@ -42,7 +54,25 @@ module SchemaReaper
42
54
  resource_owner_type resource_owner_id access_token refresh_token
43
55
  expires_in revoked_at previous_refresh_token
44
56
  ]
45
- }
57
+ },
58
+ # Gems below own their tables outright and touch every column from
59
+ # their own code, so the tables are listed with no columns: ownership
60
+ # alone keeps every analyzer off them (see Base#gem_reserved?).
61
+ "solid_queue" => owned(SOLID_QUEUE_TABLES),
62
+ "solid_cache" => owned(%w[solid_cache_entries]),
63
+ "solid_cable" => owned(%w[solid_cable_messages]),
64
+ "good_job" => owned(%w[good_jobs good_job_executions good_job_processes good_job_settings
65
+ good_job_batches]),
66
+ "delayed_job_active_record" => owned(%w[delayed_jobs]),
67
+ "flipper-active_record" => owned(%w[flipper_features flipper_gates]),
68
+ "acts-as-taggable-on" => owned(%w[tags taggings]),
69
+ "doorkeeper" => owned(%w[oauth_applications oauth_access_grants oauth_access_tokens]),
70
+ "noticed" => owned(%w[noticed_events noticed_notifications]),
71
+ "activerecord-session_store" => owned(%w[sessions]),
72
+ # Columns these gems add to app tables; the first is the anchor.
73
+ "discard" => { "*" => %w[discarded_at] },
74
+ "acts_as_list" => { "*" => %w[position] },
75
+ "ancestry" => { "*" => %w[ancestry ancestry_depth children_count] }
46
76
  }.freeze
47
77
 
48
78
  # Table names an installed gem creates and owns outright -- as opposed to
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ # Just enough of ActiveSupport's inflector to map model class names to
5
+ # table names without depending on it. A wrong guess only weakens a
6
+ # heuristic -- it never makes a finding more confident.
7
+ module Inflect
8
+ module_function
9
+
10
+ def underscore(str)
11
+ str.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\\1_\\2').gsub(/([a-z\d])([A-Z])/, '\\1_\\2').downcase
12
+ end
13
+
14
+ def pluralize(word)
15
+ case word
16
+ when /[^aeiou]y\z/ then word.sub(/y\z/, "ies")
17
+ when /(s|x|z|ch|sh)\z/ then "#{word}es"
18
+ else "#{word}s"
19
+ end
20
+ end
21
+
22
+ def singularize(word)
23
+ case word
24
+ when /ies\z/ then word.sub(/ies\z/, "y")
25
+ when /(ss|sh|ch|x|z)es\z/ then word.sub(/es\z/, "")
26
+ when /s\z/ then word.sub(/s\z/, "")
27
+ else word
28
+ end
29
+ end
30
+
31
+ # User -> users, LineItem -> line_items, Address -> addresses
32
+ def tableize(class_name)
33
+ pluralize(underscore(class_name))
34
+ end
35
+ end
36
+ end