schema_reaper 1.0.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +56 -0
- data/.schema_reaper.yml.example +43 -0
- data/CHANGELOG.md +45 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +136 -0
- data/Rakefile +12 -0
- data/exe/schema_reaper +6 -0
- data/lib/schema_reaper/analyzers/always_null_column.rb +43 -0
- data/lib/schema_reaper/analyzers/base.rb +54 -0
- data/lib/schema_reaper/analyzers/dead_column.rb +72 -0
- data/lib/schema_reaper/analyzers/dead_table.rb +60 -0
- data/lib/schema_reaper/analyzers/duplicate_index.rb +41 -0
- data/lib/schema_reaper/analyzers/missing_fk_index.rb +43 -0
- data/lib/schema_reaper/analyzers/registry.rb +20 -0
- data/lib/schema_reaper/analyzers/single_value_column.rb +46 -0
- data/lib/schema_reaper/analyzers/unused_index.rb +43 -0
- data/lib/schema_reaper/baseline.rb +35 -0
- data/lib/schema_reaper/cli.rb +68 -0
- data/lib/schema_reaper/config.rb +78 -0
- data/lib/schema_reaper/finding.rb +28 -0
- data/lib/schema_reaper/gem_awareness.rb +80 -0
- data/lib/schema_reaper/history.rb +60 -0
- data/lib/schema_reaper/introspect/postgres.rb +134 -0
- data/lib/schema_reaper/migration_generator.rb +67 -0
- data/lib/schema_reaper/railtie.rb +21 -0
- data/lib/schema_reaper/reporters/bytes.rb +22 -0
- data/lib/schema_reaper/reporters/json.rb +31 -0
- data/lib/schema_reaper/reporters/markdown.rb +41 -0
- data/lib/schema_reaper/reporters/sarif.rb +67 -0
- data/lib/schema_reaper/reporters/table.rb +45 -0
- data/lib/schema_reaper/runner.rb +64 -0
- data/lib/schema_reaper/runtime.rb +111 -0
- data/lib/schema_reaper/schema.rb +34 -0
- data/lib/schema_reaper/static/scanner.rb +88 -0
- data/lib/schema_reaper/tasks/schema_reaper.rake +26 -0
- data/lib/schema_reaper/version.rb +5 -0
- data/lib/schema_reaper.rb +44 -0
- data/schema_reaper.gemspec +47 -0
- data/sig/schema_reaper.rbs +4 -0
- metadata +167 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module SchemaReaper
|
|
6
|
+
# Emits a two-step, reversible migration pair for a dead column.
|
|
7
|
+
class MigrationGenerator
|
|
8
|
+
def initialize(table:, column:, dir: "db/migrate")
|
|
9
|
+
@table = table
|
|
10
|
+
@column = column
|
|
11
|
+
@dir = dir
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
FileUtils.mkdir_p(@dir)
|
|
16
|
+
[stage_one, stage_two]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def stage_one
|
|
22
|
+
write "ignore_#{@table}_#{@column}", <<~RUBY
|
|
23
|
+
# frozen_string_literal: true
|
|
24
|
+
|
|
25
|
+
# STEP 1 of 2. Deploy this alone and let it soak. It only tells
|
|
26
|
+
# ActiveRecord to stop selecting the column; nothing is dropped.
|
|
27
|
+
class Ignore#{camel}Column < ActiveRecord::Migration[7.1]
|
|
28
|
+
def up
|
|
29
|
+
say "Add `self.ignored_columns += %w[#{@column}]` to the #{model} model, " \
|
|
30
|
+
"then deploy STEP 2 after a soak period."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def down; end
|
|
34
|
+
end
|
|
35
|
+
RUBY
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stage_two
|
|
39
|
+
write "drop_#{@table}_#{@column}", <<~RUBY
|
|
40
|
+
# frozen_string_literal: true
|
|
41
|
+
|
|
42
|
+
# STEP 2 of 2. Run only after STEP 1 has been deployed and verified.
|
|
43
|
+
class Drop#{camel}Column < ActiveRecord::Migration[7.1]
|
|
44
|
+
def up
|
|
45
|
+
remove_column :#{@table}, :#{@column}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def down
|
|
49
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
50
|
+
"recreate :#{@column} on :#{@table} manually if you need it back"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
RUBY
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def write(slug, body)
|
|
57
|
+
@seq = (@seq || -1) + 1
|
|
58
|
+
ts = (Time.now + @seq).strftime("%Y%m%d%H%M%S")
|
|
59
|
+
path = File.join(@dir, "#{ts}_#{slug}.rb")
|
|
60
|
+
File.write(path, body)
|
|
61
|
+
path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def camel = "#{@table}_#{@column}".split("_").map(&:capitalize).join
|
|
65
|
+
def model = @table.split("_").map(&:capitalize).join.sub(/s$/, "")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module SchemaReaper
|
|
6
|
+
# Loads rake tasks and, when enabled, installs the runtime tracker.
|
|
7
|
+
class Railtie < Rails::Railtie
|
|
8
|
+
rake_tasks do
|
|
9
|
+
load File.expand_path("tasks/schema_reaper.rake", __dir__)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "schema_reaper.runtime_tracker" do
|
|
13
|
+
next unless ENV["SCHEMA_REAPER_TRACK"] == "1"
|
|
14
|
+
|
|
15
|
+
config = SchemaReaper::Config.load
|
|
16
|
+
store = SchemaReaper::Runtime::Store.new(path: config.runtime_log)
|
|
17
|
+
rate = (ENV["SCHEMA_REAPER_SAMPLE"] || "0.05").to_f
|
|
18
|
+
SchemaReaper::Runtime::Tracker.install!(store: store, sample_rate: rate)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
module Reporters
|
|
5
|
+
# Formats a byte count as a human-readable size (e.g. "3.4 MB").
|
|
6
|
+
module Bytes
|
|
7
|
+
UNITS = %w[B KB MB GB TB].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def human(n)
|
|
12
|
+
n = n.to_f
|
|
13
|
+
idx = 0
|
|
14
|
+
while n >= 1024 && idx < UNITS.length - 1
|
|
15
|
+
n /= 1024
|
|
16
|
+
idx += 1
|
|
17
|
+
end
|
|
18
|
+
format("%.1f %s", n, UNITS[idx])
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module SchemaReaper
|
|
7
|
+
module Reporters
|
|
8
|
+
# Machine-readable output for CI and downstream tooling.
|
|
9
|
+
class Json
|
|
10
|
+
def initialize(findings, io: $stdout)
|
|
11
|
+
@findings = findings
|
|
12
|
+
@io = io
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
@io.puts JSON.pretty_generate(payload)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Also used by History to snapshot a run.
|
|
20
|
+
def payload
|
|
21
|
+
{
|
|
22
|
+
version: SchemaReaper::VERSION,
|
|
23
|
+
generated_at: Time.now.utc.iso8601,
|
|
24
|
+
count: @findings.size,
|
|
25
|
+
reclaimable_bytes: @findings.sum(&:reclaimable_bytes),
|
|
26
|
+
findings: @findings.map(&:to_h)
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bytes"
|
|
4
|
+
|
|
5
|
+
module SchemaReaper
|
|
6
|
+
module Reporters
|
|
7
|
+
# GitHub-flavoured Markdown, suitable for a PR comment or job summary.
|
|
8
|
+
class Markdown
|
|
9
|
+
def initialize(findings, io: $stdout)
|
|
10
|
+
@findings = findings
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render
|
|
15
|
+
@io.puts "## schema_reaper"
|
|
16
|
+
if @findings.empty?
|
|
17
|
+
@io.puts "\nNo findings. Schema is lean. :sparkles:"
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@io.puts "\n#{@findings.size} finding(s), " \
|
|
22
|
+
"~**#{Bytes.human(total)}** reclaimable.\n\n"
|
|
23
|
+
@io.puts "| Severity | Confidence | Type | Target | Reclaims | Fix |"
|
|
24
|
+
@io.puts "|---|---|---|---|---|---|"
|
|
25
|
+
rows.each { |r| @io.puts r }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def rows
|
|
31
|
+
@findings.sort_by { |f| -f.confidence }.map do |f|
|
|
32
|
+
target = [f.table, f.column, f.index].compact.join("`.`")
|
|
33
|
+
"| #{f.severity} | #{(f.confidence * 100).round}% | `#{f.type}` | " \
|
|
34
|
+
"`#{target}` | #{Bytes.human(f.reclaimable_bytes)} | #{f.suggested_fix} |"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def total = @findings.sum(&:reclaimable_bytes)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SchemaReaper
|
|
6
|
+
module Reporters
|
|
7
|
+
# SARIF 2.1.0 so findings show up in GitHub code scanning.
|
|
8
|
+
class Sarif
|
|
9
|
+
LEVEL = { high: "error", medium: "warning", low: "note" }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(findings, io: $stdout)
|
|
12
|
+
@findings = findings
|
|
13
|
+
@io = io
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
@io.puts JSON.pretty_generate(document)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def document
|
|
23
|
+
{
|
|
24
|
+
"$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
|
|
25
|
+
"version" => "2.1.0",
|
|
26
|
+
"runs" => [{
|
|
27
|
+
"tool" => { "driver" => {
|
|
28
|
+
"name" => "schema_reaper",
|
|
29
|
+
"version" => SchemaReaper::VERSION,
|
|
30
|
+
"informationUri" => "https://github.com/aksshatt/schema_reaper",
|
|
31
|
+
"rules" => rules
|
|
32
|
+
} },
|
|
33
|
+
"results" => results
|
|
34
|
+
}]
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rules
|
|
39
|
+
@findings.map(&:type).uniq.map do |type|
|
|
40
|
+
{ "id" => type.to_s, "name" => camelize(type.to_s),
|
|
41
|
+
"shortDescription" => { "text" => "schema dead-weight: #{type}" } }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def results
|
|
46
|
+
@findings.map do |f|
|
|
47
|
+
{
|
|
48
|
+
"ruleId" => f.type.to_s,
|
|
49
|
+
"level" => LEVEL.fetch(f.severity, "note"),
|
|
50
|
+
"message" => { "text" => "#{f.id}: #{f.evidence.join("; ")}. Fix: #{f.suggested_fix}" },
|
|
51
|
+
"properties" => {
|
|
52
|
+
"confidence" => f.confidence,
|
|
53
|
+
"reclaimableBytes" => f.reclaimable_bytes
|
|
54
|
+
},
|
|
55
|
+
"locations" => [{
|
|
56
|
+
"physicalLocation" => {
|
|
57
|
+
"artifactLocation" => { "uri" => "db/schema.rb" }
|
|
58
|
+
}
|
|
59
|
+
}]
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def camelize(str) = str.split("_").map(&:capitalize).join
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bytes"
|
|
4
|
+
|
|
5
|
+
module SchemaReaper
|
|
6
|
+
module Reporters
|
|
7
|
+
# Human-readable terminal output, sorted by confidence then severity.
|
|
8
|
+
class Table
|
|
9
|
+
SEV_ORDER = { high: 0, medium: 1, low: 2 }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(findings, io: $stdout)
|
|
12
|
+
@findings = findings
|
|
13
|
+
@io = io
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
if @findings.empty?
|
|
18
|
+
@io.puts "schema_reaper: no findings. Schema is lean."
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sorted.each { |f| render_finding(f) }
|
|
23
|
+
@io.puts
|
|
24
|
+
@io.puts "#{@findings.size} finding(s). " \
|
|
25
|
+
"~#{Bytes.human(total_reclaimable)} reclaimable."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def render_finding(f)
|
|
31
|
+
target = [f.table, f.column, f.index].compact.join(".")
|
|
32
|
+
@io.puts format("[%-6s %3d%%] %-14s %s",
|
|
33
|
+
f.severity, (f.confidence * 100).round, f.type, target)
|
|
34
|
+
f.evidence.each { |e| @io.puts " - #{e}" }
|
|
35
|
+
@io.puts " fix: #{f.suggested_fix}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sorted
|
|
39
|
+
@findings.sort_by { |f| [-f.confidence, SEV_ORDER.fetch(f.severity, 9)] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def total_reclaimable = @findings.sum(&:reclaimable_bytes)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
# Ties introspection + scanning + runtime data + analyzers together.
|
|
5
|
+
class Runner
|
|
6
|
+
def initialize(config: Config.load, root: Dir.pwd, introspector: nil, runtime: nil)
|
|
7
|
+
@config = config
|
|
8
|
+
@root = root
|
|
9
|
+
@introspector = introspector
|
|
10
|
+
@runtime = runtime
|
|
11
|
+
load_plugins
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
db = schema
|
|
16
|
+
ctx = Analyzers::Context.new(
|
|
17
|
+
schema: db,
|
|
18
|
+
used_tokens: Static::Scanner.new(@config, root: @root).call,
|
|
19
|
+
runtime: runtime_report,
|
|
20
|
+
gem_columns: gem_columns(db),
|
|
21
|
+
config: @config
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
findings = Analyzers::Registry.all.flat_map { |klass| klass.new(ctx).call }
|
|
25
|
+
dedupe(findings).sort_by { |f| [-f.confidence, f.id] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# When a whole table is dead, its per-column and per-index findings are
|
|
31
|
+
# noise -- keep only the table-level finding for that table.
|
|
32
|
+
def dedupe(findings)
|
|
33
|
+
dead_tables = findings.select { |f| f.type == :dead_table }.to_set(&:table)
|
|
34
|
+
findings.reject { |f| f.type != :dead_table && dead_tables.include?(f.table) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def schema
|
|
38
|
+
return @introspector.call if @introspector
|
|
39
|
+
|
|
40
|
+
Introspect::Postgres.new(@config.database_url).call
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def runtime_report
|
|
44
|
+
return @runtime if @runtime
|
|
45
|
+
|
|
46
|
+
Runtime::Report.load(@config.runtime_log)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def gem_columns(db)
|
|
50
|
+
return {} unless @config.gem_awareness?
|
|
51
|
+
|
|
52
|
+
GemAwareness.reserved_columns(
|
|
53
|
+
installed: GemAwareness.installed_gems,
|
|
54
|
+
tables: db.tables
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def load_plugins
|
|
59
|
+
@config.require_paths.each do |path|
|
|
60
|
+
require(File.expand_path(path, @root))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module SchemaReaper
|
|
9
|
+
# Optional production signal: which columns are actually read/written at
|
|
10
|
+
# runtime. Fused with the static scan to raise (or confirm) confidence.
|
|
11
|
+
module Runtime
|
|
12
|
+
# Aggregated view of a usage log, consumed by analyzers.
|
|
13
|
+
Report = Struct.new(:accessed, :observed_days, keyword_init: true) do
|
|
14
|
+
def self.empty = new(accessed: Set.new, observed_days: 0)
|
|
15
|
+
|
|
16
|
+
def self.load(path)
|
|
17
|
+
return empty unless path && File.exist?(path)
|
|
18
|
+
|
|
19
|
+
seen = Set.new
|
|
20
|
+
first = last = nil
|
|
21
|
+
File.foreach(path) do |line|
|
|
22
|
+
row = JSON.parse(line)
|
|
23
|
+
seen << row["key"]
|
|
24
|
+
ts = row["at"]
|
|
25
|
+
first ||= ts
|
|
26
|
+
last = ts
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
new(accessed: seen, observed_days: day_span(first, last))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.day_span(first, last)
|
|
34
|
+
return 0 unless first && last
|
|
35
|
+
|
|
36
|
+
((Time.parse(last) - Time.parse(first)) / 86_400).ceil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def empty? = accessed.empty?
|
|
40
|
+
def present? = !empty?
|
|
41
|
+
def read?(table, column) = accessed.include?("#{table}.#{column}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Buffered writer for the usage log. Thread-safe append of JSON lines.
|
|
45
|
+
class Store
|
|
46
|
+
def initialize(path:, flush_every: 200)
|
|
47
|
+
@path = path
|
|
48
|
+
@flush_every = flush_every
|
|
49
|
+
@buffer = []
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def record(table, column)
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@buffer << %({"key":"#{table}.#{column}","at":"#{Time.now.utc.iso8601}"}\n)
|
|
57
|
+
flush_locked if @buffer.size >= @flush_every
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def flush
|
|
62
|
+
@mutex.synchronize { flush_locked }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def flush_locked
|
|
68
|
+
return if @buffer.empty?
|
|
69
|
+
|
|
70
|
+
File.open(@path, "a") { |f| f.write(@buffer.join) }
|
|
71
|
+
@buffer.clear
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Patches ActiveRecord attribute access to feed a Store. Sampling keeps
|
|
76
|
+
# production overhead negligible.
|
|
77
|
+
module Tracker
|
|
78
|
+
class << self
|
|
79
|
+
attr_accessor :store, :sample_rate
|
|
80
|
+
|
|
81
|
+
def install!(store:, sample_rate: 0.05)
|
|
82
|
+
return if @installed
|
|
83
|
+
|
|
84
|
+
self.store = store
|
|
85
|
+
self.sample_rate = sample_rate
|
|
86
|
+
require "active_record"
|
|
87
|
+
ActiveRecord::Base.prepend(Hook)
|
|
88
|
+
at_exit { store.flush }
|
|
89
|
+
@installed = true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
module Hook
|
|
94
|
+
def _read_attribute(name, *)
|
|
95
|
+
SchemaReaper::Runtime::Tracker.note(self.class, name)
|
|
96
|
+
super
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.note(klass, name)
|
|
101
|
+
return unless store
|
|
102
|
+
return unless rand < sample_rate
|
|
103
|
+
return unless klass.respond_to?(:table_name) && klass.table_name
|
|
104
|
+
|
|
105
|
+
store.record(klass.table_name, name.to_s)
|
|
106
|
+
rescue StandardError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
# Plain-data representation of the database, produced by an introspector.
|
|
5
|
+
# No ActiveRecord objects leak past this boundary.
|
|
6
|
+
Column = Struct.new(
|
|
7
|
+
:name, :sql_type, :null, :default, :bytes,
|
|
8
|
+
:distinct_values, :null_fraction,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
) do
|
|
11
|
+
# true when every row holds NULL (pg_stats null_frac == 1)
|
|
12
|
+
def always_null? = null_fraction && null_fraction >= 1.0
|
|
13
|
+
|
|
14
|
+
# true when the column holds exactly one distinct non-null value
|
|
15
|
+
def single_value? = distinct_values == 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Index = Struct.new(:name, :columns, :unique, :primary, :scans, keyword_init: true) do
|
|
19
|
+
def covers?(other) = columns.first(other.columns.length) == other.columns
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Table = Struct.new(
|
|
23
|
+
:name, :columns, :indexes, :primary_key, :foreign_keys, :row_count,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
) do
|
|
26
|
+
def column_names = columns.map(&:name)
|
|
27
|
+
def column(name) = columns.find { |c| c.name == name }
|
|
28
|
+
def index_on(cols) = indexes.find { |i| i.columns == Array(cols) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
DatabaseSchema = Struct.new(:tables, keyword_init: true) do
|
|
32
|
+
def table(name) = tables.find { |t| t.name == name }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module SchemaReaper
|
|
7
|
+
module Static
|
|
8
|
+
# Walks the project and collects every identifier/symbol/string token that
|
|
9
|
+
# could name a database column. Over-collects on purpose: a false "used" is
|
|
10
|
+
# safe, a false "dead" is not.
|
|
11
|
+
class Scanner
|
|
12
|
+
RUBY_GLOB = "**/*.rb"
|
|
13
|
+
WORD_RE = /[a-z_][a-z0-9_]*/i
|
|
14
|
+
|
|
15
|
+
# Node classes whose #name (or #unescaped) is a bare identifier we treat
|
|
16
|
+
# as a possible column/table reference.
|
|
17
|
+
NAME_NODES = [
|
|
18
|
+
Prism::CallNode, Prism::DefNode, Prism::ConstantReadNode,
|
|
19
|
+
Prism::ConstantPathNode, Prism::ClassNode, Prism::ModuleNode,
|
|
20
|
+
Prism::LocalVariableReadNode, Prism::CallTargetNode
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(config, root: Dir.pwd)
|
|
24
|
+
@config = config
|
|
25
|
+
@root = root
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Set<String>] lowercased tokens seen anywhere in the codebase
|
|
29
|
+
def call
|
|
30
|
+
tokens = Set.new
|
|
31
|
+
ruby_files.each { |f| tokens.merge(ruby_tokens(f)) }
|
|
32
|
+
view_files.each { |f| tokens.merge(text_tokens(f)) }
|
|
33
|
+
tokens
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def ruby_files
|
|
39
|
+
@config.scan_paths.flat_map do |p|
|
|
40
|
+
Dir.glob(File.join(@root, p, RUBY_GLOB))
|
|
41
|
+
end.uniq
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def view_files
|
|
45
|
+
@config.view_globs.flat_map { |g| Dir.glob(File.join(@root, g)) }.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ruby_tokens(path)
|
|
49
|
+
src = File.read(path)
|
|
50
|
+
out = Set.new
|
|
51
|
+
result = Prism.parse(src)
|
|
52
|
+
collect_from_node(result.value, out)
|
|
53
|
+
# SQL string literals: pull bare words out of any string in the file.
|
|
54
|
+
src.scan(/["'`]([^"'`]{0,4000})["'`]/) { |(s)| out.merge(s.scan(WORD_RE).map(&:downcase)) }
|
|
55
|
+
out
|
|
56
|
+
rescue StandardError
|
|
57
|
+
text_tokens(path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def collect_from_node(node, out)
|
|
61
|
+
return unless node.is_a?(Prism::Node)
|
|
62
|
+
|
|
63
|
+
out.merge(tokens_for(node))
|
|
64
|
+
node.compact_child_nodes.each { |c| collect_from_node(c, out) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tokens_for(node)
|
|
68
|
+
case node
|
|
69
|
+
when Prism::SymbolNode
|
|
70
|
+
[node.unescaped&.to_s&.downcase].compact
|
|
71
|
+
when Prism::StringNode
|
|
72
|
+
node.unescaped.to_s.scan(WORD_RE).map(&:downcase)
|
|
73
|
+
when *NAME_NODES
|
|
74
|
+
name = node.respond_to?(:name) ? node.name : nil
|
|
75
|
+
[name&.to_s&.downcase].compact
|
|
76
|
+
else
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def text_tokens(path)
|
|
82
|
+
File.read(path).scan(WORD_RE).to_set(&:downcase)
|
|
83
|
+
rescue StandardError
|
|
84
|
+
Set.new
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "schema_reaper"
|
|
4
|
+
|
|
5
|
+
namespace :schema_reaper do
|
|
6
|
+
desc "Scan schema + code for dead weight (FORMAT=table|json|markdown|sarif)"
|
|
7
|
+
task scan: :environment do
|
|
8
|
+
findings = SchemaReaper::Runner.new.run
|
|
9
|
+
SchemaReaper.reporter(ENV.fetch("FORMAT", "table")).new(findings).render
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
desc "Record current findings to the baseline file"
|
|
13
|
+
task baseline: :environment do
|
|
14
|
+
findings = SchemaReaper::Runner.new.run
|
|
15
|
+
SchemaReaper::Baseline.new(SchemaReaper::Config.load.baseline_path).write(findings)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Append a snapshot to the history log and print the trend"
|
|
19
|
+
task trend: :environment do
|
|
20
|
+
config = SchemaReaper::Config.load
|
|
21
|
+
findings = SchemaReaper::Runner.new(config: config).run
|
|
22
|
+
history = SchemaReaper::History.new(config.history_log)
|
|
23
|
+
history.record(findings)
|
|
24
|
+
pp history.trend
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "schema_reaper/version"
|
|
4
|
+
require_relative "schema_reaper/config"
|
|
5
|
+
require_relative "schema_reaper/finding"
|
|
6
|
+
require_relative "schema_reaper/schema"
|
|
7
|
+
require_relative "schema_reaper/runtime"
|
|
8
|
+
require_relative "schema_reaper/gem_awareness"
|
|
9
|
+
require_relative "schema_reaper/introspect/postgres"
|
|
10
|
+
require_relative "schema_reaper/static/scanner"
|
|
11
|
+
require_relative "schema_reaper/analyzers/base"
|
|
12
|
+
require_relative "schema_reaper/analyzers/registry"
|
|
13
|
+
require_relative "schema_reaper/analyzers/dead_column"
|
|
14
|
+
require_relative "schema_reaper/analyzers/dead_table"
|
|
15
|
+
require_relative "schema_reaper/analyzers/unused_index"
|
|
16
|
+
require_relative "schema_reaper/analyzers/duplicate_index"
|
|
17
|
+
require_relative "schema_reaper/analyzers/missing_fk_index"
|
|
18
|
+
require_relative "schema_reaper/analyzers/always_null_column"
|
|
19
|
+
require_relative "schema_reaper/analyzers/single_value_column"
|
|
20
|
+
require_relative "schema_reaper/reporters/bytes"
|
|
21
|
+
require_relative "schema_reaper/reporters/table"
|
|
22
|
+
require_relative "schema_reaper/reporters/json"
|
|
23
|
+
require_relative "schema_reaper/reporters/markdown"
|
|
24
|
+
require_relative "schema_reaper/reporters/sarif"
|
|
25
|
+
require_relative "schema_reaper/baseline"
|
|
26
|
+
require_relative "schema_reaper/history"
|
|
27
|
+
require_relative "schema_reaper/migration_generator"
|
|
28
|
+
require_relative "schema_reaper/runner"
|
|
29
|
+
require_relative "schema_reaper/railtie" if defined?(Rails::Railtie)
|
|
30
|
+
|
|
31
|
+
# Finds columns, indexes and tables that a Rails/ActiveRecord app no longer
|
|
32
|
+
# uses, then helps remove them safely. See {Runner} and the CLI.
|
|
33
|
+
module SchemaReaper
|
|
34
|
+
class Error < StandardError; end
|
|
35
|
+
|
|
36
|
+
REPORTERS = {
|
|
37
|
+
"table" => Reporters::Table,
|
|
38
|
+
"json" => Reporters::Json,
|
|
39
|
+
"markdown" => Reporters::Markdown,
|
|
40
|
+
"sarif" => Reporters::Sarif
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
def self.reporter(name) = REPORTERS.fetch(name, Reporters::Table)
|
|
44
|
+
end
|