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.
Files changed (47) 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 +144 -0
  5. data/PRO.md +11 -6
  6. data/README.md +421 -71
  7. data/RELEASE_CHECKLIST.md +13 -2
  8. data/lib/generators/schema_reaper/install/install_generator.rb +11 -11
  9. data/lib/generators/schema_reaper/install/templates/initializer.rb.tt +8 -0
  10. data/lib/schema_reaper/alert_config.rb +21 -1
  11. data/lib/schema_reaper/alert_state.rb +35 -0
  12. data/lib/schema_reaper/analyzers/always_null_column.rb +12 -5
  13. data/lib/schema_reaper/analyzers/base.rb +66 -2
  14. data/lib/schema_reaper/analyzers/dead_column.rb +12 -6
  15. data/lib/schema_reaper/analyzers/dead_table.rb +10 -6
  16. data/lib/schema_reaper/analyzers/duplicate_foreign_key.rb +53 -0
  17. data/lib/schema_reaper/analyzers/duplicate_index.rb +1 -1
  18. data/lib/schema_reaper/analyzers/missing_fk_index.rb +10 -4
  19. data/lib/schema_reaper/analyzers/sequence_exhaustion.rb +55 -0
  20. data/lib/schema_reaper/analyzers/single_value_column.rb +17 -5
  21. data/lib/schema_reaper/analyzers/unused_index.rb +11 -6
  22. data/lib/schema_reaper/baseline.rb +95 -10
  23. data/lib/schema_reaper/cli/baseline_support.rb +45 -0
  24. data/lib/schema_reaper/cli.rb +69 -14
  25. data/lib/schema_reaper/config.rb +28 -0
  26. data/lib/schema_reaper/doctor.rb +151 -0
  27. data/lib/schema_reaper/explain.rb +102 -0
  28. data/lib/schema_reaper/gem_awareness.rb +31 -1
  29. data/lib/schema_reaper/inflect.rb +36 -0
  30. data/lib/schema_reaper/introspect/postgres.rb +258 -55
  31. data/lib/schema_reaper/migration_generator.rb +90 -4
  32. data/lib/schema_reaper/notifier.rb +69 -8
  33. data/lib/schema_reaper/railtie.rb +1 -1
  34. data/lib/schema_reaper/reporters/sarif.rb +15 -6
  35. data/lib/schema_reaper/reporters/slack.rb +67 -0
  36. data/lib/schema_reaper/runner.rb +58 -6
  37. data/lib/schema_reaper/runtime.rb +94 -11
  38. data/lib/schema_reaper/scan_job.rb +24 -2
  39. data/lib/schema_reaper/schema.rb +32 -3
  40. data/lib/schema_reaper/schema_locator.rb +75 -0
  41. data/lib/schema_reaper/static/cache.rb +66 -0
  42. data/lib/schema_reaper/static/scanner.rb +174 -6
  43. data/lib/schema_reaper/tasks/schema_reaper.rake +2 -2
  44. data/lib/schema_reaper/version.rb +1 -1
  45. data/lib/schema_reaper.rb +18 -1
  46. data/schema_reaper.gemspec +1 -2
  47. metadata +13 -4
@@ -4,6 +4,8 @@ require "net/http"
4
4
  require "json"
5
5
  require "stringio"
6
6
  require_relative "reporters/markdown"
7
+ require_relative "reporters/slack"
8
+ require_relative "alert_state"
7
9
 
8
10
  module SchemaReaper
9
11
  # Delivers a completed scan's findings through whichever channels are
@@ -22,34 +24,81 @@ module SchemaReaper
22
24
  # or because a caller genuinely wants email delivery skipped) is
23
25
  # respected rather than silently falling back to the auto-detected
24
26
  # SchemaReaper::Mailer via `||`.
25
- def initialize(findings, config: AlertConfig.instance, http: Net::HTTP, mailer: :auto)
27
+ #
28
+ # `mail_delivery:` is :later (enqueue via ActiveJob, the normal case) or
29
+ # :now -- for a caller that is itself running inline in a short-lived
30
+ # process, where an in-process queue would be torn down before the mail
31
+ # job ever ran (see the schema_reaper:alert rake task).
32
+ def initialize(findings, config: AlertConfig.instance, http: Net::HTTP, mailer: :auto, mail_delivery: :later,
33
+ state: AlertState.new)
26
34
  @findings = findings
27
35
  @config = config
28
36
  @http = http
29
37
  @mailer = mailer == :auto ? default_mailer : mailer
38
+ @mail_delivery = mail_delivery
39
+ @state = state
30
40
  end
31
41
 
32
42
  def deliver
33
43
  return unless @config.configured?
44
+ return if @config.changes_only? && unchanged?
34
45
 
35
- deliver_webhook if @config.webhook?
36
- deliver_email if @config.emails?
46
+ sent = [(deliver_webhook if @config.webhook?), (deliver_email if @config.emails?)]
47
+ # Only a report someone received becomes the baseline for "what
48
+ # changed" -- otherwise a failed delivery would make the next run
49
+ # think it was already reported, and alert_on: :changes would stay
50
+ # silent about it.
51
+ @state.remember(@findings.map(&:id)) if sent.any?
52
+ sent.any?
37
53
  end
38
54
 
39
55
  private
40
56
 
57
+ # {new:, resolved:} against the last delivered report, or nil when there
58
+ # is none to compare with.
59
+ def changes
60
+ return @changes if defined?(@changes)
61
+
62
+ prev = @state.previous_ids
63
+ @changes = prev && {
64
+ new: @findings.reject { |f| prev.include?(f.id) },
65
+ resolved: prev - @findings.map(&:id)
66
+ }
67
+ end
68
+
69
+ # A first report (nothing to compare with) always goes out.
70
+ def unchanged?
71
+ !changes.nil? && changes[:new].empty? && changes[:resolved].empty?
72
+ end
73
+
74
+ def webhook_body
75
+ return Reporters::Slack.new(@findings, changes: changes).payload if @config.slack_blocks?
76
+
77
+ { text: report_text }
78
+ end
79
+
80
+ # Mailer is autoloaded (see lib/schema_reaper.rb), and it can only be
81
+ # defined when ActionMailer is present -- check that, not Mailer itself.
41
82
  def default_mailer
42
- defined?(Mailer) ? Mailer : nil
83
+ defined?(::ActionMailer::Base) ? Mailer : nil
43
84
  end
44
85
 
45
86
  def report_text
46
87
  @report_text ||= begin
47
88
  io = StringIO.new
89
+ io.puts(changes_text) if changes
48
90
  Reporters::Markdown.new(@findings, io: io).render
49
91
  io.string
50
92
  end
51
93
  end
52
94
 
95
+ def changes_text
96
+ lines = ["**Since the last report:** #{changes[:new].size} new, #{changes[:resolved].size} resolved"]
97
+ lines += changes[:new].map { |f| "- new: `#{f.id}`" }
98
+ lines += changes[:resolved].map { |id| "- resolved: `#{id}`" }
99
+ "#{lines.join("\n")}\n"
100
+ end
101
+
53
102
  # Slack's incoming-webhook shape ({"text": ...}) is the most common
54
103
  # receiver in practice; Discord's legacy webhook path also accepts a
55
104
  # plain "content" body but that's a different key, and structured-alert
@@ -60,7 +109,7 @@ module SchemaReaper
60
109
  uri = URI.parse(@config.webhook_url)
61
110
  request = Net::HTTP::Post.new(uri)
62
111
  request["Content-Type"] = "application/json"
63
- request.body = JSON.generate(text: report_text)
112
+ request.body = JSON.generate(webhook_body)
64
113
 
65
114
  client = @http.new(uri.host, uri.port)
66
115
  client.use_ssl = uri.scheme == "https"
@@ -69,20 +118,32 @@ module SchemaReaper
69
118
  # Net::HTTP's own (version-dependent) defaults.
70
119
  client.open_timeout = 10
71
120
  client.read_timeout = 10
72
- client.request(request)
121
+ accepted?(client.request(request))
73
122
  rescue StandardError => e
74
123
  log_error("webhook delivery failed", e)
124
+ false
125
+ end
126
+
127
+ # Slack answers a bad payload with 400 invalid_blocks, not an exception.
128
+ def accepted?(response)
129
+ return true unless response.respond_to?(:code) && !response.code.to_s.start_with?("2")
130
+
131
+ log_error("webhook delivery failed", "HTTP #{response.code}: #{response.body.to_s[0, 200]}")
132
+ false
75
133
  end
76
134
 
77
135
  def deliver_email
78
136
  unless @mailer
79
137
  log_error("email delivery skipped", "SchemaReaper::Mailer is not loaded (ActionMailer not present?)")
80
- return
138
+ return false
81
139
  end
82
140
 
83
- @mailer.report_email(to: @config.emails, report: report_text).deliver_later
141
+ message = @mailer.report_email(to: @config.emails, report: report_text)
142
+ @mail_delivery == :now ? message.deliver_now : message.deliver_later
143
+ true
84
144
  rescue StandardError => e
85
145
  log_error("email delivery failed", e)
146
+ false
86
147
  end
87
148
 
88
149
  def log_error(message, error)
@@ -14,7 +14,7 @@ module SchemaReaper
14
14
 
15
15
  begin
16
16
  config = SchemaReaper::Config.load
17
- store = SchemaReaper::Runtime::Store.new(path: config.runtime_log)
17
+ store = SchemaReaper::Runtime::Store.new(path: config.runtime_log, max_bytes: config.runtime_log_max_bytes)
18
18
  rate = (ENV["SCHEMA_REAPER_SAMPLE"] || "0.05").to_f
19
19
  SchemaReaper::Runtime::Tracker.install!(store: store, sample_rate: rate)
20
20
  rescue StandardError => e
@@ -8,9 +8,10 @@ module SchemaReaper
8
8
  class Sarif
9
9
  LEVEL = { high: "error", medium: "warning", low: "note" }.freeze
10
10
 
11
- def initialize(findings, io: $stdout)
11
+ def initialize(findings, io: $stdout, locator: SchemaLocator.new)
12
12
  @findings = findings
13
13
  @io = io
14
+ @locator = locator
14
15
  end
15
16
 
16
17
  def render
@@ -52,15 +53,23 @@ module SchemaReaper
52
53
  "confidence" => f.confidence,
53
54
  "reclaimableBytes" => f.reclaimable_bytes
54
55
  },
55
- "locations" => [{
56
- "physicalLocation" => {
57
- "artifactLocation" => { "uri" => "db/schema.rb" }
58
- }
59
- }]
56
+ "locations" => locations_for(f)
60
57
  }
61
58
  end
62
59
  end
63
60
 
61
+ # GitHub code scanning requires a location on every result, so with no
62
+ # schema file in the repo fall back to the conventional path, unlined.
63
+ def locations_for(finding)
64
+ loc = @locator.locate(finding)
65
+ return [{ "physicalLocation" => { "artifactLocation" => { "uri" => "db/schema.rb" } } }] unless loc
66
+
67
+ [{ "physicalLocation" => {
68
+ "artifactLocation" => { "uri" => loc.path },
69
+ "region" => { "startLine" => loc.line }
70
+ } }]
71
+ end
72
+
64
73
  def camelize(str)
65
74
  str.split("_").map(&:capitalize).join
66
75
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ module Reporters
5
+ # Slack Block Kit payload for the scheduled report. Slack renders neither
6
+ # Markdown tables nor messages past ~40k characters, so this is a summary
7
+ # with the most important findings, not the whole report.
8
+ class Slack
9
+ MAX_LISTED = 10
10
+ SEVERITY_ICON = { high: ":red_circle:", medium: ":large_orange_circle:", low: ":white_circle:" }.freeze
11
+
12
+ # changes - {new: [Finding], resolved: [String id]} or nil on a first report
13
+ def initialize(findings, changes: nil)
14
+ @findings = findings
15
+ @changes = changes
16
+ end
17
+
18
+ def payload
19
+ { "text" => summary, "blocks" => blocks }
20
+ end
21
+
22
+ # Also the notification text, and what a client without Block Kit shows.
23
+ def summary
24
+ line = "schema_reaper: #{@findings.size} finding#{"s" unless @findings.size == 1}"
25
+ return line unless @changes
26
+
27
+ "#{line} (#{@changes[:new].size} new, #{@changes[:resolved].size} resolved since the last report)"
28
+ end
29
+
30
+ private
31
+
32
+ def blocks
33
+ out = [{ "type" => "section", "text" => mrkdwn("*#{summary}*") }]
34
+ listed = @changes ? @changes[:new] : @findings
35
+ out << list_block(@changes ? "New" : "Top findings", listed) if listed.any?
36
+ out << resolved_block if @changes && @changes[:resolved].any?
37
+ out << { "type" => "context",
38
+ "elements" => [mrkdwn("Full report: `bundle exec schema_reaper scan`")] }
39
+ out
40
+ end
41
+
42
+ def list_block(title, findings)
43
+ lines = findings.first(MAX_LISTED).map do |f|
44
+ "#{SEVERITY_ICON.fetch(f.severity, "")} `#{f.id}` -- #{f.suggested_fix}"
45
+ end
46
+ lines << "_...and #{findings.size - MAX_LISTED} more_" if findings.size > MAX_LISTED
47
+ { "type" => "section", "text" => mrkdwn(truncate("*#{title}*\n#{lines.join("\n")}")) }
48
+ end
49
+
50
+ def resolved_block
51
+ ids = @changes[:resolved]
52
+ text = "*Resolved*\n#{ids.first(MAX_LISTED).map { |id| "~#{id}~" }.join("\n")}"
53
+ text += "\n_...and #{ids.size - MAX_LISTED} more_" if ids.size > MAX_LISTED
54
+ { "type" => "section", "text" => mrkdwn(truncate(text)) }
55
+ end
56
+
57
+ # A section's text is capped at 3000 characters by Slack.
58
+ def truncate(text)
59
+ text.length > 3000 ? "#{text[0, 2990]}..." : text
60
+ end
61
+
62
+ def mrkdwn(text)
63
+ { "type" => "mrkdwn", "text" => text }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -7,32 +7,84 @@ module SchemaReaper
7
7
  class Runner
8
8
  SEVERITY_RANK = { high: 0, medium: 1, low: 2 }.freeze
9
9
 
10
- def initialize(config: Config.load, root: Dir.pwd, introspector: nil, runtime: nil)
10
+ # only / except - analyzer type names (strings or symbols) to restrict the
11
+ # run to, or to leave out. Unknown names raise Error.
12
+ # min_confidence - drop findings below this; defaults to the config key.
13
+ def initialize(config: Config.load, root: Dir.pwd, introspector: nil, runtime: nil,
14
+ only: nil, except: nil, min_confidence: nil)
11
15
  @config = config
12
16
  @root = root
13
17
  @introspector = introspector
14
18
  @runtime = runtime
19
+ @only = names(only)
20
+ @except = names(except)
21
+ @min_confidence = min_confidence || config.min_confidence
15
22
  load_plugins
16
23
  end
17
24
 
18
25
  def run
26
+ selected = analyzers
19
27
  db = schema
20
- warn_missing_query_history(db)
28
+ warn_missing_query_history(db) if selected.include?(Analyzers::UnusedIndex)
29
+ scan = Static::Scanner.new(@config, root: @root).scan
21
30
  ctx = Analyzers::Context.new(
22
31
  schema: db,
23
- used_tokens: Static::Scanner.new(@config, root: @root).call,
32
+ used_tokens: scan.tokens,
33
+ scan: scan,
24
34
  runtime: runtime_report,
25
35
  gem_columns: gem_columns(db),
26
36
  gem_owned_tables: gem_owned_tables,
27
37
  config: @config
28
38
  )
29
39
 
30
- findings = Analyzers::Registry.all.flat_map { |klass| klass.new(ctx).call }
31
- dedupe(findings).sort_by { |f| [-f.confidence, f.id] }
40
+ findings = selected.flat_map { |klass| klass.new(ctx).call }
41
+ dedupe(kept_in_code(findings, scan.keeps)).select { |f| f.confidence >= @min_confidence }
42
+ .sort_by do |f|
43
+ [
44
+ -f.confidence, f.id
45
+ ]
46
+ end
47
+ end
48
+
49
+ # Analyzer types this run covers, as strings, or nil when every analyzer
50
+ # runs. Callers that persist results (the baseline) use it to leave the
51
+ # other analyzers' records alone.
52
+ def selected_types
53
+ return nil if @only.nil? && @except.nil?
54
+
55
+ analyzers.map { |k| k.type.to_s }
32
56
  end
33
57
 
34
58
  private
35
59
 
60
+ # `# schema_reaper:keep TARGET` in code silences a finding. TARGET is a
61
+ # column (any table), table.column, a table (every finding on it) or an
62
+ # index name.
63
+ def kept_in_code(findings, keeps)
64
+ return findings if keeps.empty?
65
+
66
+ findings.reject do |f|
67
+ targets = [f.table, f.column, f.index, f.column && "#{f.table}.#{f.column}"].compact.map(&:downcase)
68
+ targets.any? { |t| keeps.key?(t) }
69
+ end
70
+ end
71
+
72
+ def names(list)
73
+ list && Array(list).flat_map { |n| n.to_s.split(",") }.map { |n| n.strip.to_sym }.reject(&:empty?)
74
+ end
75
+
76
+ # Plugins load in #initialize, so the registry is complete by the time
77
+ # the names are checked -- a custom analyzer can be selected like a core one.
78
+ def analyzers
79
+ all = Analyzers::Registry.all
80
+ known = all.map(&:type)
81
+ unknown = ((@only || []) + (@except || [])) - known
82
+ raise Error, "unknown analyzer(s): #{unknown.join(", ")} (known: #{known.join(", ")})" if unknown.any?
83
+
84
+ all.select { |k| @only.nil? || @only.include?(k.type) }
85
+ .reject { |k| @except&.include?(k.type) }
86
+ end
87
+
36
88
  # Silently reporting nothing would look like a clean bill of health, so say
37
89
  # why the unused-index analyzer sat this run out.
38
90
  def warn_missing_query_history(db)
@@ -72,7 +124,7 @@ module SchemaReaper
72
124
  def schema
73
125
  return @introspector.call if @introspector
74
126
 
75
- Introspect::Postgres.new(@config.database_url).call
127
+ Introspect::Postgres.new(@config.database_url, schemas: @config.schemas).call
76
128
  end
77
129
 
78
130
  def runtime_report
@@ -15,19 +15,32 @@ module SchemaReaper
15
15
  new(accessed: Set.new, observed_days: 0)
16
16
  end
17
17
 
18
+ # Reads the rotated generation (path.1) too, so rotation never shortens
19
+ # the observed window. Several processes append to one log, so lines are
20
+ # not in time order -- the span is min..max, not first..last line.
18
21
  def self.load(path)
19
- return empty unless path && File.exist?(path)
22
+ return empty unless path
23
+
24
+ files = [Store.rotated_path(path), path].select { |f| File.exist?(f) }
25
+ return empty if files.empty?
20
26
 
21
27
  seen = Set.new
22
28
  first = last = nil
23
- File.foreach(path) do |line|
24
- row = JSON.parse(line)
25
- seen << row["key"]
26
- ts = row["at"]
27
- first ||= ts
28
- last = ts
29
- rescue JSON::ParserError
30
- next
29
+ files.each do |file|
30
+ File.foreach(file) do |line|
31
+ row = JSON.parse(line)
32
+ seen << row["key"]
33
+ # Store writes UTC ISO8601 ("2026-01-05T00:00:00Z"), which sorts
34
+ # correctly as a string -- far cheaper than parsing every line of
35
+ # a log that can hold millions. Only the extremes are parsed.
36
+ ts = row["at"]
37
+ next unless ts.is_a?(String)
38
+
39
+ first = ts if first.nil? || ts < first
40
+ last = ts if last.nil? || ts > last
41
+ rescue JSON::ParserError
42
+ next
43
+ end
31
44
  end
32
45
  new(accessed: seen, observed_days: day_span(first, last))
33
46
  end
@@ -36,6 +49,14 @@ module SchemaReaper
36
49
  return 0 unless first && last
37
50
 
38
51
  ((Time.parse(last) - Time.parse(first)) / 86_400).ceil
52
+ rescue ArgumentError
53
+ 0
54
+ end
55
+
56
+ # Raw-SQL access recorded by the tracker's SQL subscriber, keyed
57
+ # "table.*": the table was touched, but by no column we can name.
58
+ def table_accessed?(table)
59
+ accessed.any? { |k| k.start_with?("#{table}.") }
39
60
  end
40
61
 
41
62
  def empty?
@@ -52,10 +73,19 @@ module SchemaReaper
52
73
  end
53
74
 
54
75
  # Buffered writer for the usage log. Thread-safe append of JSON lines.
76
+ #
77
+ # Past max_bytes the log is renamed to `<path>.1` (replacing any older
78
+ # generation) and a fresh one started, so disk use stays under roughly
79
+ # 2 x max_bytes. nil disables rotation.
55
80
  class Store
56
- def initialize(path:, flush_every: 200)
81
+ def self.rotated_path(path)
82
+ "#{path}.1"
83
+ end
84
+
85
+ def initialize(path:, flush_every: 200, max_bytes: Config::DEFAULTS["runtime_log_max_bytes"])
57
86
  @path = path
58
87
  @flush_every = flush_every
88
+ @max_bytes = max_bytes
59
89
  @buffer = []
60
90
  @mutex = Mutex.new
61
91
  FileUtils.mkdir_p(File.dirname(path))
@@ -79,6 +109,23 @@ module SchemaReaper
79
109
 
80
110
  File.open(@path, "a") { |f| f.write(@buffer.join) }
81
111
  @buffer.clear
112
+ rotate_if_needed
113
+ end
114
+
115
+ # Several processes (Puma workers) share one log, so the size check and
116
+ # the rename happen under an exclusive flock and the size is checked
117
+ # again once it's held: otherwise two workers that both saw an
118
+ # oversized log would rotate twice, and the second rename would put a
119
+ # nearly empty log over the full generation in <path>.1.
120
+ def rotate_if_needed
121
+ return unless @max_bytes && File.size?(@path).to_i > @max_bytes
122
+
123
+ File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
124
+ lock.flock(File::LOCK_EX)
125
+ File.rename(@path, self.class.rotated_path(@path)) if File.size?(@path).to_i > @max_bytes
126
+ end
127
+ rescue SystemCallError
128
+ nil
82
129
  end
83
130
  end
84
131
 
@@ -88,16 +135,52 @@ module SchemaReaper
88
135
  class << self
89
136
  attr_accessor :store, :sample_rate
90
137
 
91
- def install!(store:, sample_rate: 0.05)
138
+ # track_sql: also sample the tables named in every executed SQL
139
+ # statement, so a table read only through find_by_sql, a raw
140
+ # connection.execute or a view-backed report still counts as used.
141
+ def install!(store:, sample_rate: 0.05, track_sql: true)
92
142
  return if @installed
93
143
 
94
144
  self.store = store
95
145
  self.sample_rate = sample_rate
96
146
  require "active_record"
97
147
  ActiveRecord::Base.prepend(Hook)
148
+ subscribe_sql if track_sql
98
149
  at_exit { store.flush }
99
150
  @installed = true
100
151
  end
152
+
153
+ private
154
+
155
+ def subscribe_sql
156
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
157
+ Tracker.note_sql(payload[:sql], payload[:name])
158
+ end
159
+ end
160
+ end
161
+
162
+ # The relation after FROM / JOIN / UPDATE / INTO, optionally
163
+ # schema-qualified and double-quoted: "billing"."invoices" -> billing.invoices.
164
+ #
165
+ # INSERT INTO targets are left out on purpose: a table that is only ever
166
+ # written (an audit log nobody reads) is exactly what dead_table should
167
+ # be able to find. INSERT ... SELECT still records its FROM tables.
168
+ SQL_TABLE_RE = /\b(?:FROM|JOIN|UPDATE)\s+((?:"?\w+"?\.)?"?\w+"?)/i.freeze
169
+ SKIP_SQL_NAMES = %w[SCHEMA TRANSACTION].freeze
170
+
171
+ def self.note_sql(sql, name = nil)
172
+ return unless store && sql
173
+ return if SKIP_SQL_NAMES.include?(name)
174
+ return unless rand < sample_rate
175
+
176
+ # "public"."users" is the same table the app (and the schema) calls users
177
+ sql.scan(SQL_TABLE_RE).flatten.map { |t| t.delete('"').delete_prefix("public.") }.uniq.each do |table|
178
+ next if table.start_with?("pg_", "information_schema")
179
+
180
+ store.record(table, "*")
181
+ end
182
+ rescue StandardError
183
+ nil
101
184
  end
102
185
 
103
186
  module Hook
@@ -9,9 +9,31 @@ module SchemaReaper
9
9
  class ScanJob < ActiveJob::Base
10
10
  queue_as :default
11
11
 
12
- def perform
12
+ # Rails' :async adapter (the default until Rails 8 unless an app sets up
13
+ # a real backend) runs jobs on a thread pool inside the enqueueing
14
+ # process. Fine in a web server; fatal in a one-shot `rake` process,
15
+ # which exits the moment the task returns and takes the queued job with
16
+ # it -- the scheduled scan would silently never run.
17
+ def self.in_process_queue?
18
+ queue_adapter.instance_of?(::ActiveJob::QueueAdapters::AsyncAdapter)
19
+ end
20
+
21
+ # What the scheduled cron entry (`rake schema_reaper:alert`) calls:
22
+ # enqueue normally, but on an in-process queue run inline instead --
23
+ # email included -- so the scan isn't lost when the process exits.
24
+ def self.run_scheduled
25
+ return perform_later unless in_process_queue?
26
+
27
+ puts "[schema_reaper] ActiveJob adapter is :async -- running the scan inline"
28
+ perform_now(deliver_mail_now: true)
29
+ end
30
+
31
+ # `deliver_mail_now:` is for running inline from a process about to exit
32
+ # (see the schema_reaper:alert rake task) -- a deliver_later there would
33
+ # be lost for the same reason as above.
34
+ def perform(deliver_mail_now: false)
13
35
  findings = Runner.new.run
14
- Notifier.new(findings).deliver
36
+ Notifier.new(findings, mail_delivery: deliver_mail_now ? :now : :later).deliver
15
37
  end
16
38
  end
17
39
  end
@@ -19,7 +19,9 @@ module SchemaReaper
19
19
  end
20
20
  end
21
21
 
22
- Index = Struct.new(:name, :columns, :unique, :primary, :scans, :partial, keyword_init: true) do
22
+ # partitioned: an index on a partitioned parent (relkind 'I'). PostgreSQL
23
+ # cannot DROP one CONCURRENTLY, so removal advice differs.
24
+ Index = Struct.new(:name, :columns, :unique, :primary, :scans, :partial, :partitioned, keyword_init: true) do
23
25
  def covers?(other)
24
26
  columns.first(other.columns.length) == other.columns
25
27
  end
@@ -33,10 +35,33 @@ module SchemaReaper
33
35
  end
34
36
  end
35
37
 
38
+ # A declared FOREIGN KEY constraint. columns is in key order; references
39
+ # is the referenced table's display name.
40
+ # behaviour is an opaque signature of referenced key, ON UPDATE/DELETE,
41
+ # MATCH and deferrability: equal only when the constraints act alike.
42
+ ForeignKey = Struct.new(:name, :columns, :references, :behaviour, :validated, keyword_init: true)
43
+
44
+ # A column-owned sequence (serial / IDENTITY). max_value is the effective
45
+ # ceiling: the lower of the sequence's own limit and the column type's.
46
+ Sequence = Struct.new(:name, :table, :column, :column_type, :last_value, :max_value,
47
+ keyword_init: true) do
48
+ def used_fraction
49
+ return 0.0 unless max_value&.positive?
50
+
51
+ last_value.to_f / max_value
52
+ end
53
+ end
54
+
36
55
  Table = Struct.new(
37
- :name, :columns, :indexes, :primary_key, :foreign_keys, :row_count,
56
+ :name, :columns, :indexes, :primary_key, :foreign_keys, :foreign_key_constraints, :row_count,
38
57
  keyword_init: true
39
58
  ) do
59
+ # Introspectors (and hand-built test schemas) that predate constraint
60
+ # details leave this nil.
61
+ def foreign_key_constraints
62
+ self[:foreign_key_constraints] || []
63
+ end
64
+
40
65
  def column_names
41
66
  columns.map(&:name)
42
67
  end
@@ -61,7 +86,11 @@ module SchemaReaper
61
86
  end
62
87
  end
63
88
 
64
- DatabaseSchema = Struct.new(:tables, :index_scan_total, keyword_init: true) do
89
+ DatabaseSchema = Struct.new(:tables, :index_scan_total, :sequences, keyword_init: true) do
90
+ def sequences
91
+ self[:sequences] || []
92
+ end
93
+
65
94
  def table(name)
66
95
  tables.find { |t| t.name == name }
67
96
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ # Finds the line in db/schema.rb (or db/structure.sql) that defines a
5
+ # finding's table, column or index, so SARIF annotations land on it instead
6
+ # of line 1 of a file that may not even exist.
7
+ class SchemaLocator
8
+ Location = Struct.new(:path, :line, keyword_init: true)
9
+
10
+ CANDIDATES = %w[db/schema.rb db/structure.sql].freeze
11
+
12
+ def initialize(root: Dir.pwd)
13
+ @root = root
14
+ @path = CANDIDATES.find { |p| File.exist?(File.join(root, p)) }
15
+ @lines = @path ? File.readlines(File.join(root, @path)) : []
16
+ end
17
+
18
+ # @return [Location, nil] nil when there is no schema file at all
19
+ def locate(finding)
20
+ return nil unless @path
21
+
22
+ Location.new(path: @path, line: line_for(finding) || 1)
23
+ end
24
+
25
+ private
26
+
27
+ def line_for(finding)
28
+ start = table_line(finding.table)
29
+ return nil unless start
30
+
31
+ block = block_after(start)
32
+ found = (finding.index && (find_in(block, quoted(finding.index)) || find_in(all_lines, quoted(finding.index)))) ||
33
+ (finding.column && find_in(block, column_pattern(finding.column.split(",").first)))
34
+ found || (start + 1)
35
+ end
36
+
37
+ # schema.rb: create_table "users", ... (schema-qualified: "billing.users")
38
+ # structure.sql: CREATE TABLE public.users (
39
+ def table_line(table)
40
+ bare = table.split(".").last
41
+ ns = table.include?(".") ? table.split(".").first : "public"
42
+ rb = /create_table\s+"#{Regexp.escape(table)}"/
43
+ sql = /CREATE TABLE (?:"?#{Regexp.escape(ns)}"?\.)?"?#{Regexp.escape(bare)}"?\s*\(/
44
+ @lines.index { |l| l.match?(rb) || l.match?(sql) }
45
+ end
46
+
47
+ # Lines from the table's definition to the next table, as [text, index]
48
+ # pairs. Indexes in schema.rb sit inside the block; in structure.sql they
49
+ # come later as CREATE INDEX name ON ..., hence the whole-file fallback
50
+ # for an index (index names are unique per schema, so it can't mismatch).
51
+ def block_after(start)
52
+ rest = all_lines.drop(start + 1)
53
+ stop = rest.index { |l, _| l.match?(/\A\s*create_table\s|\ACREATE TABLE /) }
54
+ stop ? rest.first(stop) : rest
55
+ end
56
+
57
+ def all_lines
58
+ @all_lines ||= @lines.each_with_index.to_a
59
+ end
60
+
61
+ def find_in(pairs, pattern)
62
+ hit = pairs.find { |l, _| l.match?(pattern) }
63
+ hit && (hit[1] + 1)
64
+ end
65
+
66
+ def quoted(name)
67
+ /["\s]#{Regexp.escape(name)}["\s]/
68
+ end
69
+
70
+ # schema.rb: t.string "email" structure.sql: email character varying
71
+ def column_pattern(column)
72
+ /\A\s*(?:t\.\w+\s+"#{Regexp.escape(column)}"|"?#{Regexp.escape(column)}"?\s+\w)/
73
+ end
74
+ end
75
+ end