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,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
module Analyzers
|
|
5
|
+
# An index whose column list is a leading prefix of another index on the
|
|
6
|
+
# same table is redundant (the wider index serves both).
|
|
7
|
+
class DuplicateIndex < Base
|
|
8
|
+
Registry.register(self)
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
|
|
12
|
+
.flat_map { |t| dupes_in(t) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def dupes_in(table)
|
|
18
|
+
non_pk = table.indexes.reject(&:primary)
|
|
19
|
+
non_pk.filter_map do |ix|
|
|
20
|
+
covering = non_pk.find { |o| o != ix && !o.unique && o.covers?(ix) && o.columns != ix.columns }
|
|
21
|
+
next unless covering
|
|
22
|
+
|
|
23
|
+
finding(
|
|
24
|
+
type: :duplicate_index,
|
|
25
|
+
table: table.name,
|
|
26
|
+
index: ix.name,
|
|
27
|
+
column: ix.columns.join(","),
|
|
28
|
+
severity: :low,
|
|
29
|
+
confidence: 0.8,
|
|
30
|
+
bytes_per_row: 0,
|
|
31
|
+
evidence: [
|
|
32
|
+
"#{ix.name} (#{ix.columns.join(", ")}) is a prefix of " \
|
|
33
|
+
"#{covering.name} (#{covering.columns.join(", ")})"
|
|
34
|
+
],
|
|
35
|
+
suggested_fix: "remove_index :#{table.name}, name: :#{ix.name}"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
module Analyzers
|
|
5
|
+
# A foreign-key column with no index: every parent delete/update scans the
|
|
6
|
+
# child table. This is a repo-health nag, not dead weight.
|
|
7
|
+
class MissingFkIndex < Base
|
|
8
|
+
Registry.register(self)
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
|
|
12
|
+
.flat_map { |t| missing_in(t) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def missing_in(table)
|
|
18
|
+
fk_columns(table).filter_map do |col|
|
|
19
|
+
next if indexed?(table, col)
|
|
20
|
+
|
|
21
|
+
finding(
|
|
22
|
+
type: :missing_fk_index,
|
|
23
|
+
table: table.name,
|
|
24
|
+
column: col,
|
|
25
|
+
severity: :medium,
|
|
26
|
+
confidence: 0.9,
|
|
27
|
+
bytes_per_row: 0,
|
|
28
|
+
evidence: ["#{col} is a foreign key with no covering index"],
|
|
29
|
+
suggested_fix: "add_index :#{table.name}, :#{col}"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fk_columns(table)
|
|
35
|
+
(table.foreign_keys + table.column_names.grep(/_id\z/)).uniq
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def indexed?(table, col)
|
|
39
|
+
table.indexes.any? { |ix| ix.columns.first == col }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Analyzers register themselves here so the runner can iterate them.
|
|
6
|
+
module Registry
|
|
7
|
+
@classes = []
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :classes
|
|
11
|
+
|
|
12
|
+
def register(klass)
|
|
13
|
+
@classes << klass unless @classes.include?(klass)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def all = @classes
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
module Analyzers
|
|
5
|
+
# A column holding exactly one distinct value across a non-trivial number of
|
|
6
|
+
# rows carries no information — usually a flag that was never toggled or a
|
|
7
|
+
# backfill that set everyone the same.
|
|
8
|
+
class SingleValueColumn < Base
|
|
9
|
+
Registry.register(self)
|
|
10
|
+
|
|
11
|
+
MIN_ROWS = 500
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
|
|
15
|
+
.flat_map { |t| flat_in(t) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def flat_in(table)
|
|
21
|
+
return [] unless table.row_count.to_i >= MIN_ROWS
|
|
22
|
+
|
|
23
|
+
table.columns.filter_map do |col|
|
|
24
|
+
next unless col.single_value?
|
|
25
|
+
next if col.name == table.primary_key
|
|
26
|
+
next if config.always_keep_columns.include?(col.name)
|
|
27
|
+
next if gem_reserved?(table.name, col.name)
|
|
28
|
+
|
|
29
|
+
finding(
|
|
30
|
+
type: :single_value_column,
|
|
31
|
+
table: table.name,
|
|
32
|
+
column: col.name,
|
|
33
|
+
severity: :low,
|
|
34
|
+
confidence: 0.6,
|
|
35
|
+
bytes_per_row: col.bytes,
|
|
36
|
+
evidence: [
|
|
37
|
+
"pg_stats.n_distinct = 1 across ~#{table.row_count} row(s)",
|
|
38
|
+
"column has the same value in every row"
|
|
39
|
+
],
|
|
40
|
+
suggested_fix: "confirm the value is not a meaningful default before removing"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Indexes with zero scans in pg_stat_user_indexes. Requires stats to be
|
|
6
|
+
# meaningful (a freshly reset stat table would false-positive), so findings
|
|
7
|
+
# stay medium confidence and note the caveat.
|
|
8
|
+
class UnusedIndex < Base
|
|
9
|
+
Registry.register(self)
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
|
|
13
|
+
.flat_map { |t| unused_in(t) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def unused_in(table)
|
|
19
|
+
table.indexes.filter_map do |ix|
|
|
20
|
+
next if ix.primary || ix.unique # keep constraint-backing indexes
|
|
21
|
+
next if ix.scans.nil? # no stats available
|
|
22
|
+
next unless ix.scans.zero?
|
|
23
|
+
|
|
24
|
+
finding(
|
|
25
|
+
type: :unused_index,
|
|
26
|
+
table: table.name,
|
|
27
|
+
index: ix.name,
|
|
28
|
+
column: ix.columns.join(","),
|
|
29
|
+
severity: :medium,
|
|
30
|
+
confidence: 0.55,
|
|
31
|
+
bytes_per_row: 0,
|
|
32
|
+
evidence: [
|
|
33
|
+
"pg_stat_user_indexes.idx_scan = 0 for #{ix.name} (#{ix.columns.join(", ")})",
|
|
34
|
+
"confirm stats have not been reset recently before dropping"
|
|
35
|
+
],
|
|
36
|
+
suggested_fix: "remove_index :#{table.name}, name: :#{ix.name} " \
|
|
37
|
+
"(use algorithm: :concurrently in production)"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module SchemaReaper
|
|
7
|
+
# Persists known finding ids so CI only fails on *new* dead weight.
|
|
8
|
+
class Baseline
|
|
9
|
+
def initialize(path)
|
|
10
|
+
@path = path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def ids
|
|
14
|
+
return [] unless File.exist?(@path)
|
|
15
|
+
|
|
16
|
+
JSON.parse(File.read(@path)).fetch("ids", [])
|
|
17
|
+
rescue JSON::ParserError
|
|
18
|
+
[]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write(findings)
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
23
|
+
File.write(@path, JSON.pretty_generate(
|
|
24
|
+
"generated_at" => Time.now.utc.iso8601,
|
|
25
|
+
"ids" => findings.map(&:id).sort
|
|
26
|
+
))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# findings not present in the stored baseline
|
|
30
|
+
def new_among(findings)
|
|
31
|
+
known = ids
|
|
32
|
+
findings.reject { |f| known.include?(f.id) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "../schema_reaper"
|
|
5
|
+
|
|
6
|
+
module SchemaReaper
|
|
7
|
+
# Command-line entry point. See `exe/schema_reaper`.
|
|
8
|
+
class CLI < Thor
|
|
9
|
+
def self.exit_on_failure? = true
|
|
10
|
+
|
|
11
|
+
class_option :config, type: :string, default: ".schema_reaper.yml",
|
|
12
|
+
desc: "path to config file"
|
|
13
|
+
|
|
14
|
+
desc "scan", "Scan schema + code and report dead weight"
|
|
15
|
+
option :format, type: :string, default: "table",
|
|
16
|
+
enum: %w[table json markdown sarif]
|
|
17
|
+
option :ci, type: :boolean, default: false,
|
|
18
|
+
desc: "exit non-zero on findings not in the baseline"
|
|
19
|
+
option :record, type: :boolean, default: false,
|
|
20
|
+
desc: "append this run to the history log"
|
|
21
|
+
option :min_confidence, type: :numeric, default: 0.0
|
|
22
|
+
def scan
|
|
23
|
+
findings = run.select { |f| f.confidence >= options[:min_confidence] }
|
|
24
|
+
SchemaReaper.reporter(options[:format]).new(findings).render
|
|
25
|
+
|
|
26
|
+
History.new(config.history_log).record(findings) if options[:record]
|
|
27
|
+
enforce_baseline(findings) if options[:ci]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "baseline", "Write current findings to the baseline file"
|
|
31
|
+
def baseline
|
|
32
|
+
findings = run
|
|
33
|
+
Baseline.new(config.baseline_path).write(findings)
|
|
34
|
+
say "wrote #{findings.size} finding(s) to #{config.baseline_path}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc "trend", "Append a snapshot and print progress over time"
|
|
38
|
+
def trend
|
|
39
|
+
History.new(config.history_log).record(run)
|
|
40
|
+
require "pp"
|
|
41
|
+
pp History.new(config.history_log).trend
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc "generate-migration TABLE COLUMN", "Emit a staged removal migration pair"
|
|
45
|
+
def generate_migration(table, column)
|
|
46
|
+
MigrationGenerator.new(table: table, column: column).call
|
|
47
|
+
.each { |p| say "created #{p}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
desc "version", "Print version"
|
|
51
|
+
def version = say(SchemaReaper::VERSION)
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def config = @config ||= Config.load(options[:config])
|
|
56
|
+
|
|
57
|
+
def run = Runner.new(config: config).run
|
|
58
|
+
|
|
59
|
+
def enforce_baseline(findings)
|
|
60
|
+
new_ones = Baseline.new(config.baseline_path).new_among(findings)
|
|
61
|
+
return if new_ones.empty?
|
|
62
|
+
|
|
63
|
+
warn "schema_reaper: #{new_ones.size} new finding(s) since baseline"
|
|
64
|
+
new_ones.each { |f| warn " - #{f.id}" }
|
|
65
|
+
exit 1
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module SchemaReaper
|
|
6
|
+
# Loaded from .schema_reaper.yml at the project root. Every key has a default
|
|
7
|
+
# so a missing file still yields a usable config.
|
|
8
|
+
class Config
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
"database_url" => nil, # falls back to ENV["DATABASE_URL"]
|
|
11
|
+
"scan_paths" => %w[app lib config],
|
|
12
|
+
"view_globs" => %w[
|
|
13
|
+
app/**/*.erb app/**/*.haml app/**/*.slim app/**/*.jbuilder
|
|
14
|
+
],
|
|
15
|
+
"ignore" => {
|
|
16
|
+
"tables" => %w[schema_migrations ar_internal_metadata],
|
|
17
|
+
"columns" => [] # strings, or "/regex/" for a pattern
|
|
18
|
+
},
|
|
19
|
+
"always_keep_columns" => %w[id created_at updated_at type],
|
|
20
|
+
"gem_awareness" => true, # auto-whitelist columns owned by known gems
|
|
21
|
+
"min_age_days" => 14,
|
|
22
|
+
"runtime_log" => ".schema_reaper/runtime.jsonl",
|
|
23
|
+
"history_log" => ".schema_reaper/history.jsonl",
|
|
24
|
+
"baseline" => ".schema_reaper/baseline.json",
|
|
25
|
+
"require" => [] # extra files to load (custom analyzers)
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def self.load(path = ".schema_reaper.yml")
|
|
29
|
+
raw = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
30
|
+
new(deep_merge(DEFAULTS, raw))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.deep_merge(base, override)
|
|
34
|
+
base.merge(override) do |_key, a, b|
|
|
35
|
+
a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(data)
|
|
40
|
+
@data = data
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def database_url
|
|
44
|
+
@data["database_url"] || ENV.fetch("DATABASE_URL", nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def scan_paths = @data["scan_paths"]
|
|
48
|
+
def view_globs = @data["view_globs"]
|
|
49
|
+
def ignore_tables = @data.dig("ignore", "tables").to_a
|
|
50
|
+
def min_age_days = @data["min_age_days"]
|
|
51
|
+
def baseline_path = @data["baseline"]
|
|
52
|
+
def runtime_log = @data["runtime_log"]
|
|
53
|
+
def history_log = @data["history_log"]
|
|
54
|
+
def gem_awareness? = @data["gem_awareness"] != false
|
|
55
|
+
def require_paths = @data["require"].to_a
|
|
56
|
+
|
|
57
|
+
def always_keep_columns
|
|
58
|
+
@data["always_keep_columns"].to_a
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns true when a column name should be ignored outright.
|
|
62
|
+
def ignored_column?(name)
|
|
63
|
+
matchers.any? { |m| m.is_a?(Regexp) ? m.match?(name) : m == name }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def matchers
|
|
69
|
+
@matchers ||= @data.dig("ignore", "columns").to_a.map do |entry|
|
|
70
|
+
if entry.is_a?(String) && entry.start_with?("/") && entry.end_with?("/")
|
|
71
|
+
Regexp.new(entry[1..-2])
|
|
72
|
+
else
|
|
73
|
+
entry
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
# A single issue reported by an analyzer.
|
|
5
|
+
Finding = Struct.new(
|
|
6
|
+
:type, # Symbol, e.g. :dead_column
|
|
7
|
+
:table, # String
|
|
8
|
+
:column, # String or nil
|
|
9
|
+
:index, # String or nil (index name, for index findings)
|
|
10
|
+
:severity, # :low | :medium | :high
|
|
11
|
+
:confidence, # 0.0..1.0
|
|
12
|
+
:bytes_per_row, # Integer estimate, 0 if unknown
|
|
13
|
+
:reclaimable_bytes, # Integer, bytes_per_row * row_count
|
|
14
|
+
:evidence, # Array<String> human-readable reasons
|
|
15
|
+
:suggested_fix, # String
|
|
16
|
+
keyword_init: true
|
|
17
|
+
) do
|
|
18
|
+
def id
|
|
19
|
+
[type, table, column, index].compact.join("/")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def reclaimable_bytes = self[:reclaimable_bytes] || 0
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
super.merge(id: id, reclaimable_bytes: reclaimable_bytes)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
# Columns and tables that popular gems own and reference indirectly (through
|
|
5
|
+
# metaprogramming the static scanner cannot see). Detected gems get their
|
|
6
|
+
# columns whitelisted automatically.
|
|
7
|
+
module GemAwareness
|
|
8
|
+
# gem name => { table_glob => [column names] }. "*" table_glob matches any
|
|
9
|
+
# table; an exact name matches only that table.
|
|
10
|
+
MAP = {
|
|
11
|
+
"devise" => { "*" => %w[
|
|
12
|
+
encrypted_password reset_password_token reset_password_sent_at
|
|
13
|
+
remember_created_at sign_in_count current_sign_in_at last_sign_in_at
|
|
14
|
+
current_sign_in_ip last_sign_in_ip confirmation_token confirmed_at
|
|
15
|
+
confirmation_sent_at unconfirmed_email failed_attempts unlock_token
|
|
16
|
+
locked_at provider uid
|
|
17
|
+
] },
|
|
18
|
+
"paper_trail" => { "versions" => %w[item_type item_id event whodunnit object object_changes] },
|
|
19
|
+
"audited" => { "audits" => %w[auditable_type auditable_id user_type user_id action audited_changes version] },
|
|
20
|
+
"friendly_id" => { "friendly_id_slugs" => %w[slug sluggable_id sluggable_type scope] },
|
|
21
|
+
"acts_as_paranoid" => { "*" => %w[deleted_at] },
|
|
22
|
+
"paranoia" => { "*" => %w[deleted_at] },
|
|
23
|
+
"counter_culture" => { "*" => %w[] }, # dynamic *_count columns handled by regex in config
|
|
24
|
+
"activestorage" => {
|
|
25
|
+
"active_storage_blobs" => %w[key filename content_type metadata service_name byte_size checksum],
|
|
26
|
+
"active_storage_attachments" => %w[name record_type record_id blob_id],
|
|
27
|
+
"active_storage_variant_records" => %w[blob_id variation_digest]
|
|
28
|
+
},
|
|
29
|
+
"actiontext" => { "action_text_rich_texts" => %w[body record_type record_id name] },
|
|
30
|
+
"pg_search" => { "pg_search_documents" => %w[content searchable_type searchable_id] },
|
|
31
|
+
"ahoy_matey" => {
|
|
32
|
+
"ahoy_visits" => %w[visit_token visitor_token],
|
|
33
|
+
"ahoy_events" => %w[visit_id name properties]
|
|
34
|
+
}
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
# @param installed [Enumerable<String>] gem names present in the bundle
|
|
38
|
+
# @param tables [Enumerable<#name,#column_names>] or Enumerable<String>
|
|
39
|
+
# @return [Hash{String => Set<String>}]
|
|
40
|
+
#
|
|
41
|
+
# For an exact table_glob the columns are reserved on that table. For "*"
|
|
42
|
+
# (columns a gem can add to any model table) they are reserved only on
|
|
43
|
+
# tables that already contain the first ("anchor") column of the list, so an
|
|
44
|
+
# unrelated `provider`/`uid` column elsewhere is still reportable.
|
|
45
|
+
def self.reserved_columns(installed:, tables:)
|
|
46
|
+
installed = installed.to_set
|
|
47
|
+
index = tables.to_h { |t| t.respond_to?(:name) ? [t.name, t.column_names] : [t, nil] }
|
|
48
|
+
result = Hash.new { |h, k| h[k] = Set.new }
|
|
49
|
+
|
|
50
|
+
MAP.each do |gem_name, table_map|
|
|
51
|
+
next unless installed.include?(gem_name)
|
|
52
|
+
|
|
53
|
+
table_map.each do |table_glob, columns|
|
|
54
|
+
next if columns.empty?
|
|
55
|
+
|
|
56
|
+
if table_glob == "*"
|
|
57
|
+
anchor = columns.first
|
|
58
|
+
index.each do |name, cols|
|
|
59
|
+
result[name].merge(columns) if cols&.include?(anchor)
|
|
60
|
+
end
|
|
61
|
+
elsif index.key?(table_glob)
|
|
62
|
+
result[table_glob].merge(columns)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Best-effort list of gems in the current bundle.
|
|
70
|
+
def self.installed_gems
|
|
71
|
+
if defined?(Bundler)
|
|
72
|
+
Bundler.load.specs.map(&:name)
|
|
73
|
+
else
|
|
74
|
+
Gem::Specification.map(&:name)
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module SchemaReaper
|
|
8
|
+
# Append-only log of scan snapshots, so cleanup progress is visible over time.
|
|
9
|
+
class History
|
|
10
|
+
Snapshot = Struct.new(:at, :count, :reclaimable_bytes, :ids, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
def initialize(path = ".schema_reaper/history.jsonl")
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def record(findings)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
18
|
+
row = {
|
|
19
|
+
"at" => Time.now.utc.iso8601,
|
|
20
|
+
"count" => findings.size,
|
|
21
|
+
"reclaimable_bytes" => findings.sum(&:reclaimable_bytes),
|
|
22
|
+
"ids" => findings.map(&:id).sort
|
|
23
|
+
}
|
|
24
|
+
File.open(@path, "a") { |f| f.puts JSON.generate(row) }
|
|
25
|
+
row
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def snapshots
|
|
29
|
+
return [] unless File.exist?(@path)
|
|
30
|
+
|
|
31
|
+
File.foreach(@path).filter_map do |line|
|
|
32
|
+
r = JSON.parse(line)
|
|
33
|
+
Snapshot.new(at: r["at"], count: r["count"],
|
|
34
|
+
reclaimable_bytes: r["reclaimable_bytes"], ids: r["ids"])
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash] delta between the first and last snapshot plus current-vs-previous
|
|
41
|
+
def trend
|
|
42
|
+
snaps = snapshots
|
|
43
|
+
return { snapshots: 0 } if snaps.empty?
|
|
44
|
+
|
|
45
|
+
first = snaps.first
|
|
46
|
+
last = snaps.last
|
|
47
|
+
prev = snaps[-2] || first
|
|
48
|
+
{
|
|
49
|
+
snapshots: snaps.size,
|
|
50
|
+
first_at: first.at,
|
|
51
|
+
last_at: last.at,
|
|
52
|
+
count_change_total: last.count - first.count,
|
|
53
|
+
count_change_last: last.count - prev.count,
|
|
54
|
+
newly_introduced: (last.ids - prev.ids),
|
|
55
|
+
resolved_since_prev: (prev.ids - last.ids),
|
|
56
|
+
bytes_change_total: last.reclaimable_bytes - first.reclaimable_bytes
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaReaper
|
|
4
|
+
module Introspect
|
|
5
|
+
# Reads live schema + planner statistics from PostgreSQL using the `pg` gem
|
|
6
|
+
# directly, so the host app does not need to boot Rails.
|
|
7
|
+
class Postgres
|
|
8
|
+
AVG_TYPE_BYTES = {
|
|
9
|
+
"boolean" => 1, "smallint" => 2, "integer" => 4, "bigint" => 8,
|
|
10
|
+
"real" => 4, "double precision" => 8, "numeric" => 8,
|
|
11
|
+
"date" => 4, "timestamp without time zone" => 8,
|
|
12
|
+
"timestamp with time zone" => 8, "uuid" => 16
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(url)
|
|
16
|
+
raise Error, "no database_url configured" if url.nil? || url.empty?
|
|
17
|
+
|
|
18
|
+
require "pg"
|
|
19
|
+
@conn = PG.connect(url)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
DatabaseSchema.new(tables: table_names.map { |n| build_table(n) })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def table_names
|
|
29
|
+
exec(<<~SQL).map { |r| r["tablename"] }
|
|
30
|
+
SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename
|
|
31
|
+
SQL
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_table(name)
|
|
35
|
+
Table.new(
|
|
36
|
+
name: name,
|
|
37
|
+
columns: columns_for(name),
|
|
38
|
+
indexes: indexes_for(name),
|
|
39
|
+
primary_key: primary_key_for(name),
|
|
40
|
+
foreign_keys: foreign_keys_for(name),
|
|
41
|
+
row_count: row_count_for(name)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def columns_for(table)
|
|
46
|
+
stats = column_stats_for(table)
|
|
47
|
+
exec(<<~SQL, [table]).map do |r|
|
|
48
|
+
SELECT column_name, data_type, is_nullable, column_default
|
|
49
|
+
FROM information_schema.columns
|
|
50
|
+
WHERE table_schema = 'public' AND table_name = $1
|
|
51
|
+
ORDER BY ordinal_position
|
|
52
|
+
SQL
|
|
53
|
+
s = stats[r["column_name"]] || {}
|
|
54
|
+
Column.new(
|
|
55
|
+
name: r["column_name"],
|
|
56
|
+
sql_type: r["data_type"],
|
|
57
|
+
null: r["is_nullable"] == "YES",
|
|
58
|
+
default: r["column_default"],
|
|
59
|
+
bytes: AVG_TYPE_BYTES.fetch(r["data_type"], 16),
|
|
60
|
+
null_fraction: s[:null_frac],
|
|
61
|
+
distinct_values: s[:n_distinct]
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# pg_stats.n_distinct: >= 0 is an absolute count, < 0 is a ratio of rows.
|
|
67
|
+
def column_stats_for(table)
|
|
68
|
+
exec(<<~SQL, [table]).each_with_object({}) do |r, h|
|
|
69
|
+
SELECT attname, null_frac, n_distinct
|
|
70
|
+
FROM pg_stats WHERE schemaname = 'public' AND tablename = $1
|
|
71
|
+
SQL
|
|
72
|
+
nd = r["n_distinct"].to_f
|
|
73
|
+
h[r["attname"]] = {
|
|
74
|
+
null_frac: r["null_frac"].to_f,
|
|
75
|
+
n_distinct: nd >= 0 ? nd.round : nil
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def indexes_for(table)
|
|
81
|
+
exec(<<~SQL, [table]).map do |r|
|
|
82
|
+
SELECT i.relname AS name, ix.indisunique AS "unique", ix.indisprimary AS "primary",
|
|
83
|
+
s.idx_scan AS scans,
|
|
84
|
+
array_to_string(array_agg(a.attname ORDER BY a.attnum), ',') AS cols
|
|
85
|
+
FROM pg_class t
|
|
86
|
+
JOIN pg_index ix ON t.oid = ix.indrelid
|
|
87
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
88
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
89
|
+
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
|
|
90
|
+
WHERE t.relname = $1
|
|
91
|
+
GROUP BY i.relname, ix.indisunique, ix.indisprimary, s.idx_scan
|
|
92
|
+
SQL
|
|
93
|
+
Index.new(
|
|
94
|
+
name: r["name"], columns: r["cols"].split(","),
|
|
95
|
+
unique: r["unique"] == "t", primary: r["primary"] == "t",
|
|
96
|
+
scans: r["scans"]&.to_i
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def primary_key_for(table)
|
|
102
|
+
exec(<<~SQL, [table]).map { |r| r["attname"] }.first
|
|
103
|
+
SELECT a.attname
|
|
104
|
+
FROM pg_index i
|
|
105
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
106
|
+
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
|
107
|
+
SQL
|
|
108
|
+
rescue PG::Error
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def foreign_keys_for(table)
|
|
113
|
+
exec(<<~SQL, [table]).map { |r| r["column_name"] }
|
|
114
|
+
SELECT kcu.column_name
|
|
115
|
+
FROM information_schema.table_constraints tc
|
|
116
|
+
JOIN information_schema.key_column_usage kcu
|
|
117
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
118
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
119
|
+
AND tc.table_schema = 'public' AND tc.table_name = $1
|
|
120
|
+
SQL
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def row_count_for(table)
|
|
124
|
+
exec(<<~SQL, [table]).first&.fetch("reltuples")&.to_f&.round
|
|
125
|
+
SELECT reltuples FROM pg_class WHERE relname = $1
|
|
126
|
+
SQL
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def exec(sql, params = nil)
|
|
130
|
+
(params ? @conn.exec_params(sql, params) : @conn.exec(sql)).to_a
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|