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,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