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
|
@@ -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
|
data/lib/schema_reaper/runner.rb
CHANGED
|
@@ -7,32 +7,84 @@ module SchemaReaper
|
|
|
7
7
|
class Runner
|
|
8
8
|
SEVERITY_RANK = { high: 0, medium: 1, low: 2 }.freeze
|
|
9
9
|
|
|
10
|
-
|
|
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:
|
|
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 =
|
|
31
|
-
dedupe(findings).
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/schema_reaper/schema.rb
CHANGED
|
@@ -19,7 +19,9 @@ module SchemaReaper
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module SchemaReaper
|
|
8
|
+
module Static
|
|
9
|
+
# Per-file scan results keyed by mtime + size, so a re-scan only parses
|
|
10
|
+
# the files that changed. Purely a speed-up: any problem reading or
|
|
11
|
+
# writing it falls back to a full scan, never a wrong one.
|
|
12
|
+
class Cache
|
|
13
|
+
# Bumped whenever what a file contributes changes, so a cache written by
|
|
14
|
+
# an older scanner is discarded instead of trusted.
|
|
15
|
+
FORMAT = 3
|
|
16
|
+
|
|
17
|
+
# path - where to keep it, or nil to disable
|
|
18
|
+
# key - anything else that changes a file's result (gem version, ...)
|
|
19
|
+
def initialize(path, key:)
|
|
20
|
+
@path = path
|
|
21
|
+
# A digest, not #hash: Ruby seeds #hash per process, so a key built
|
|
22
|
+
# with it never matches the one the previous run saved.
|
|
23
|
+
@key = Digest::SHA256.hexdigest(JSON.generate([FORMAT, key]))
|
|
24
|
+
@files = load
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def lookup(file, kind)
|
|
28
|
+
stamp = stamp_for(file, kind)
|
|
29
|
+
hit = @files[file]
|
|
30
|
+
return hit["entry"].merge("stamp" => stamp) if stamp && hit && hit["stamp"] == stamp
|
|
31
|
+
|
|
32
|
+
yield.merge("stamp" => stamp)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def save(entries)
|
|
36
|
+
return unless @path
|
|
37
|
+
|
|
38
|
+
files = entries.to_h do |file, entry|
|
|
39
|
+
[file, { "stamp" => entry["stamp"], "entry" => entry.reject { |k, _| k == "stamp" } }]
|
|
40
|
+
end
|
|
41
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
42
|
+
File.write(@path, JSON.generate("key" => @key, "files" => files))
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def load
|
|
50
|
+
return {} unless @path && File.exist?(@path)
|
|
51
|
+
|
|
52
|
+
data = JSON.parse(File.read(@path))
|
|
53
|
+
data["key"] == @key ? data["files"] : {}
|
|
54
|
+
rescue StandardError
|
|
55
|
+
{}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def stamp_for(file, kind)
|
|
59
|
+
st = File.stat(file)
|
|
60
|
+
"#{kind}:#{st.mtime.to_f}:#{st.size}"
|
|
61
|
+
rescue SystemCallError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|