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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +56 -0
  4. data/.schema_reaper.yml.example +43 -0
  5. data/CHANGELOG.md +45 -0
  6. data/CODE_OF_CONDUCT.md +84 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +136 -0
  9. data/Rakefile +12 -0
  10. data/exe/schema_reaper +6 -0
  11. data/lib/schema_reaper/analyzers/always_null_column.rb +43 -0
  12. data/lib/schema_reaper/analyzers/base.rb +54 -0
  13. data/lib/schema_reaper/analyzers/dead_column.rb +72 -0
  14. data/lib/schema_reaper/analyzers/dead_table.rb +60 -0
  15. data/lib/schema_reaper/analyzers/duplicate_index.rb +41 -0
  16. data/lib/schema_reaper/analyzers/missing_fk_index.rb +43 -0
  17. data/lib/schema_reaper/analyzers/registry.rb +20 -0
  18. data/lib/schema_reaper/analyzers/single_value_column.rb +46 -0
  19. data/lib/schema_reaper/analyzers/unused_index.rb +43 -0
  20. data/lib/schema_reaper/baseline.rb +35 -0
  21. data/lib/schema_reaper/cli.rb +68 -0
  22. data/lib/schema_reaper/config.rb +78 -0
  23. data/lib/schema_reaper/finding.rb +28 -0
  24. data/lib/schema_reaper/gem_awareness.rb +80 -0
  25. data/lib/schema_reaper/history.rb +60 -0
  26. data/lib/schema_reaper/introspect/postgres.rb +134 -0
  27. data/lib/schema_reaper/migration_generator.rb +67 -0
  28. data/lib/schema_reaper/railtie.rb +21 -0
  29. data/lib/schema_reaper/reporters/bytes.rb +22 -0
  30. data/lib/schema_reaper/reporters/json.rb +31 -0
  31. data/lib/schema_reaper/reporters/markdown.rb +41 -0
  32. data/lib/schema_reaper/reporters/sarif.rb +67 -0
  33. data/lib/schema_reaper/reporters/table.rb +45 -0
  34. data/lib/schema_reaper/runner.rb +64 -0
  35. data/lib/schema_reaper/runtime.rb +111 -0
  36. data/lib/schema_reaper/schema.rb +34 -0
  37. data/lib/schema_reaper/static/scanner.rb +88 -0
  38. data/lib/schema_reaper/tasks/schema_reaper.rake +26 -0
  39. data/lib/schema_reaper/version.rb +5 -0
  40. data/lib/schema_reaper.rb +44 -0
  41. data/schema_reaper.gemspec +47 -0
  42. data/sig/schema_reaper.rbs +4 -0
  43. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ VERSION = "1.0.0"
5
+ 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