schema_reaper 1.0.14 → 1.0.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f169920afef1838471a418e29ad207b6c9d3193ef57caf6678cf68d675a3405
4
- data.tar.gz: 3e5d507411e727278004def7a752db662620a01371625ce761af4495fd020f2e
3
+ metadata.gz: df6a3127bc5e379e183a6f525870c5726bbf016223b6c8531b1be8cb97da8ca5
4
+ data.tar.gz: 2810a69e8b382c10ecf52bc35717efd53fc9b826b8aae580b15773f4ffb8e246
5
5
  SHA512:
6
- metadata.gz: 739b0cf29def40063c3a182ad5b9d207f7409f9277e134e76bd980bc3db276a333f225a1974bf5c30a3e14af769d0c8128efeb46961d084f1349fc17bddd6eca
7
- data.tar.gz: 692ed7533e2553ebbc9085001f1d805519149fe988860e23da9902b941de60c292a8918e60f2fe9c2d0e558aaeacd182d31e6376570e7ea9e8551977d5856db8
6
+ metadata.gz: f4dc2789fcdd897e213a4b050ef98a05d94f6c213e145bdc6ac6e8a5168d355b7fc86a2b18aa0799b76ff1183255e64b5d6bbc2789c9d5a063dac91c9fcabdf9
7
+ data.tar.gz: f8026dcfb27f4a9a828f519bef9a78f8e626522f8cadb5082ee39b393652d5ff0224a669fc8a4b7d109f20f5b6bc910bcea336971de0603a85af4d1c4ad2280a
data/.rubocop.yml CHANGED
@@ -44,7 +44,7 @@ Metrics/MethodLength:
44
44
  Max: 20
45
45
 
46
46
  Metrics/ClassLength:
47
- Max: 130
47
+ Max: 135
48
48
 
49
49
  Metrics/CyclomaticComplexity:
50
50
  Max: 12
data/CHANGELOG.md CHANGED
@@ -1,5 +1,89 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.16] - 2026-09-22
4
+
5
+ ### Fixed
6
+ - **A bare index on a polymorphic `*_id` column alone silently suppressed the
7
+ `missing_fk_index` finding it should have raised.** The check tested the
8
+ generic "is this column indexed at all" before checking whether the column
9
+ was part of a polymorphic pair, so an index on `commentable_id` by itself
10
+ — never sufficient, since Rails always queries the pair together — read as
11
+ "covered" and the analyzer's own doc comment went unenforced. Now gates on
12
+ the composite `(type, id)` check whenever a `*_type` column is present.
13
+ (#17, mitkush)
14
+ - **`Postgres#indexes_for` was the one introspection query missing the
15
+ `public`-schema filter** every sibling query (`columns_for`,
16
+ `foreign_keys_for`, `table_names`) already has. In a database with more
17
+ than one schema containing a same-named table, it could pull in and union
18
+ indexes from the wrong table. Verified live against a two-schema database
19
+ with a colliding table name and a planted bogus index. (#17, mitkush)
20
+ - **The `SCHEMA_REAPER_TRACK=1` runtime-tracker initializer could crash a
21
+ host app's entire boot**, not just disable the optional feature, on a
22
+ malformed `.schema_reaper.yml` or an unwritable log directory — nothing
23
+ in the initializer was rescued. Now rescues and logs a warning instead.
24
+ (#18, mitkush)
25
+ - **`schema_reaper scan --format json` could not distinguish a genuinely
26
+ zero-byte reclaim estimate from an unmeasured one.** `Finding#to_h` read
27
+ the zero-defaulted accessor instead of the raw value, silently flattening
28
+ "unknown" into `0` for every CI/tooling consumer of the JSON output, even
29
+ though every other reporter already preserves that distinction. The
30
+ payload now carries the raw value plus an explicit `reclaim_known` flag.
31
+ Verified live: an unmeasured finding now serializes `reclaimable_bytes`
32
+ as `null` with `reclaim_known: false`. (#18, mitkush)
33
+ - **A query-time Postgres error (anything past the initial connection)
34
+ propagated as a raw `PG::Error`** instead of the wrapped `SchemaReaper::Error`
35
+ every other failure path produces, leaking a Ruby backtrace instead of a
36
+ clean CLI error message. `primary_key_for`'s existing fallback-to-nil
37
+ rescue is updated to match. (#18, mitkush)
38
+ - Cosmetic: `MigrationGenerator#model` mangled irregular plural table names
39
+ (`addresses` → "Addresse") in the generated migration's comment text;
40
+ `Reporters::Trend#bytes` printed `-0.0 B` instead of `+0.0 B` for a zero
41
+ delta. (#17, mitkush)
42
+
43
+ ## [1.0.15] - 2026-09-18
44
+
45
+ ### Fixed
46
+ - **`dead_column` still missed the two gems its own macro-detection feature
47
+ named as motivation.** 1.0.14 added recognition for `has_secure_password`,
48
+ `attr_encrypted`, and Lockbox macro columns, but only the first actually
49
+ worked. `attr_encrypted`'s real default column naming is a *prefix*
50
+ (`encrypted_<attr>`), confirmed against the gem's own
51
+ `attr_encrypted_default_options`; the feature generated a suffix guess
52
+ that never matched it. Lockbox's real macro is `has_encrypted`; the
53
+ feature checked for `lockbox_encrypts`, which is not a method that
54
+ exists in the gem, so it never even fired for genuine Lockbox code.
55
+ Rewritten as a macro-name -> column-template table so each macro's real
56
+ naming shape is expressed explicitly, and dropped an unnecessary
57
+ suffix guess for Rails' native `encrypts` (which stores ciphertext in
58
+ the original column and creates no extra one). Eight new specs cover
59
+ all four macros -- this had no test coverage before. (#15, mitkush)
60
+
61
+ ## [1.0.14] - 2026-09-18
62
+
63
+ Published directly without a version-bump commit or tag; this entry
64
+ reconciles the record after the fact. Confirmed by diffing the
65
+ published gem against `main` at the time -- the only difference was the
66
+ version string.
67
+
68
+ ### Fixed
69
+ - `duplicate_index` respecting uniqueness and partial `WHERE` clauses --
70
+ see 1.0.13's entry for the underlying issue; this release adds the fix.
71
+ (#12, mitkush)
72
+ - `dead_column` missing `has_secure_password`, `encrypts`, and
73
+ `attr_encrypted` macro columns -- a virtual attribute like
74
+ `has_secure_password` never has its generated column name
75
+ (`password_digest`) written anywhere in application code, so it looked
76
+ unreferenced. (aksshatt)
77
+
78
+ ### Known issue
79
+ - The `attr_encrypted`/Lockbox part of the macro-column fix above does
80
+ not work as intended: it derives a suffix-based column name
81
+ (`<attr>_encrypted`) that does not match `attr_encrypted`'s actual
82
+ default naming (`encrypted_<attr>`, a prefix), and it references a
83
+ `lockbox_encrypts` macro name that does not exist in Lockbox -- the
84
+ gem's real method is `has_encrypted`. `has_secure_password` and Rails'
85
+ native `encrypts` are unaffected and work correctly. Tracked for 1.0.15.
86
+
3
87
  ## [1.0.13] - 2026-09-18
4
88
 
5
89
  ### Fixed
@@ -32,11 +32,15 @@ module SchemaReaper
32
32
 
33
33
  def missing_in(table)
34
34
  fk_columns(table).filter_map do |col|
35
- next if indexed?(table, col)
36
- next if empty_column?(table, col) # never advise indexing a column with no data
37
-
38
35
  type_col = polymorphic_type_for(table, col)
39
- next if type_col && polymorphic_indexed?(table, type_col, col)
36
+
37
+ # A bare index on the id alone doesn't cover a polymorphic pair --
38
+ # only the composite (type, id) index satisfies it. Check that
39
+ # instead of the generic `indexed?`, or a bare id-only index would
40
+ # wrongly suppress this finding.
41
+ covered = type_col ? polymorphic_indexed?(table, type_col, col) : indexed?(table, col)
42
+ next if covered
43
+ next if empty_column?(table, col) # never advise indexing a column with no data
40
44
 
41
45
  finding_for(table, col, type_col, declared: table.foreign_keys.include?(col))
42
46
  end
@@ -50,8 +50,12 @@ module SchemaReaper
50
50
  parts.empty? ? nil : parts.join(" · ")
51
51
  end
52
52
 
53
+ # Keeps the raw (possibly nil) reclaimable_bytes from Struct#to_h rather
54
+ # than the zero-defaulted accessor above, so JSON/SARIF consumers can tell
55
+ # "genuinely zero" apart from "unknown" the same way every other reporter
56
+ # does -- reclaim_known? makes that distinction explicit in the payload.
53
57
  def to_h
54
- super.merge(id: id, reclaimable_bytes: reclaimable_bytes)
58
+ super.merge(id: id, reclaim_known: reclaim_known?)
55
59
  end
56
60
  end
57
61
  end
@@ -8,13 +8,12 @@ module SchemaReaper
8
8
  AVG_TYPE_BYTES = {
9
9
  "boolean" => 1, "smallint" => 2, "integer" => 4, "bigint" => 8,
10
10
  "real" => 4, "double precision" => 8, "numeric" => 8,
11
- "date" => 4, "timestamp without time zone" => 8,
12
- "timestamp with time zone" => 8, "uuid" => 16
11
+ "date" => 4, "timestamp without time zone" => 8, "timestamp with time zone" => 8, "uuid" => 16
13
12
  }.freeze
14
13
 
15
14
  NO_URL = "no database connection found. schema_reaper looks, in order, for: " \
16
- "database_url: in .schema_reaper.yml; the DATABASE_URL env var; " \
17
- "config/database.yml for RAILS_ENV (default: development, Postgres only)."
15
+ "database_url: in .schema_reaper.yml; the DATABASE_URL env var; config/database.yml " \
16
+ "for RAILS_ENV (default: development, Postgres only)."
18
17
 
19
18
  # A record separator that cannot appear inside a plain identifier and is
20
19
  # exceedingly unlikely inside an expression, so splitting the aggregated
@@ -119,7 +118,7 @@ module SchemaReaper
119
118
  JOIN pg_class i ON i.oid = ix.indexrelid
120
119
  JOIN LATERAL generate_series(1, ix.indnkeyatts) AS k(ord) ON TRUE
121
120
  LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
122
- WHERE t.relname = $1
121
+ WHERE t.relname = $1 AND t.relnamespace = 'public'::regnamespace
123
122
  GROUP BY i.relname, ix.indisunique, ix.indisprimary, ix.indpred, s.idx_scan
124
123
  SQL
125
124
  Index.new(
@@ -142,7 +141,7 @@ module SchemaReaper
142
141
  WHERE i.indrelid = $1::regclass AND i.indisprimary
143
142
  ORDER BY k.ord
144
143
  SQL
145
- rescue PG::Error
144
+ rescue Error, PG::Error
146
145
  nil
147
146
  end
148
147
 
@@ -172,6 +171,8 @@ module SchemaReaper
172
171
 
173
172
  def exec(sql, params = nil)
174
173
  (params ? @conn.exec_params(sql, params) : @conn.exec(sql)).to_a
174
+ rescue PG::Error => e
175
+ raise Error, "query against the database failed: #{e.message.strip}"
175
176
  end
176
177
  end
177
178
  end
@@ -5,6 +5,14 @@ require "fileutils"
5
5
  module SchemaReaper
6
6
  # Emits a two-step, reversible migration pair for a dead column.
7
7
  class MigrationGenerator
8
+ # Enough of Rails' inflector for table names in this comment text -- a
9
+ # wrong guess doesn't break anything functional, just reads oddly.
10
+ SINGULAR_RULES = [
11
+ [/ies\z/, "y"], # activities -> activity
12
+ [/(ss|sh|ch|x|z)es\z/, '\1'], # addresses -> address, boxes -> box
13
+ [/s\z/, ""] # employees -> employee
14
+ ].freeze
15
+
8
16
  def initialize(table:, column:, dir: "db/migrate")
9
17
  @table = table
10
18
  @column = column
@@ -77,7 +85,12 @@ module SchemaReaper
77
85
  end
78
86
 
79
87
  def model
80
- @table.split("_").map(&:capitalize).join.sub(/s$/, "")
88
+ singular.split("_").map(&:capitalize).join
89
+ end
90
+
91
+ def singular
92
+ rule = SINGULAR_RULES.find { |pattern, _| @table.match?(pattern) }
93
+ rule ? @table.sub(rule[0], rule[1]) : @table
81
94
  end
82
95
  end
83
96
  end
@@ -12,10 +12,17 @@ module SchemaReaper
12
12
  initializer "schema_reaper.runtime_tracker" do
13
13
  next unless ENV["SCHEMA_REAPER_TRACK"] == "1"
14
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)
15
+ begin
16
+ config = SchemaReaper::Config.load
17
+ store = SchemaReaper::Runtime::Store.new(path: config.runtime_log)
18
+ rate = (ENV["SCHEMA_REAPER_SAMPLE"] || "0.05").to_f
19
+ SchemaReaper::Runtime::Tracker.install!(store: store, sample_rate: rate)
20
+ rescue StandardError => e
21
+ # Optional instrumentation must never take the host app down with it --
22
+ # a bad config file or an unwritable log directory disables tracking,
23
+ # it does not abort boot.
24
+ Rails.logger&.warn("[schema_reaper] runtime tracker disabled: #{e.class}: #{e.message}")
25
+ end
19
26
  end
20
27
  end
21
28
  end
@@ -47,7 +47,7 @@ module SchemaReaper
47
47
 
48
48
  def bytes
49
49
  delta = @d[:bytes_change_total].to_i
50
- sign = delta.positive? ? "+" : "-"
50
+ sign = delta.negative? ? "-" : "+"
51
51
  @c.row("reclaimable", Bytes.human(@d.fetch(:latest_bytes, 0)),
52
52
  "#{sign}#{Bytes.human(delta.abs)} since first run")
53
53
  end
@@ -12,15 +12,37 @@ module SchemaReaper
12
12
  RUBY_GLOB = "**/*.rb"
13
13
  WORD_RE = /[a-z_][a-z0-9_]*/i.freeze
14
14
 
15
- # Macros that generate a real column from a differently-named virtual
16
- # attribute, so the literal column name never appears in application
17
- # code. Missing this made dead_column flag has_secure_password's
18
- # password_digest, and attr_encrypted/Lockbox/KMS ciphertext columns,
19
- # as unused even though they're the live backing store.
20
- DIGEST_MACROS = %w[has_secure_password].freeze
21
- DIGEST_SUFFIXES = %w[_digest].freeze
22
- ENCRYPTED_MACROS = %w[encrypts attr_encrypted lockbox_encrypts].freeze
23
- ENCRYPTED_SUFFIXES = %w[_ciphertext _iv _tag _encrypted].freeze
15
+ # Macros whose generated column name differs from the virtual attribute
16
+ # declared in the call, so the real column name never appears anywhere
17
+ # in application code and would otherwise look dead. %s is replaced with
18
+ # the declared attribute.
19
+ #
20
+ # Several shapes are listed for some macros on purpose: getting the real
21
+ # default right matters most, but an extra guess that never matches a
22
+ # real column is harmless -- over-collecting is this scanner's stated
23
+ # design, and both attr_encrypted and Lockbox let a call override their
24
+ # naming, which a static scan cannot always evaluate.
25
+ #
26
+ # attr_encrypted's own default is a PREFIX (encrypted_<attr>), not a
27
+ # suffix -- confirmed against the gem's own attr_encrypted_default_options
28
+ # (prefix: "encrypted_", suffix: ""). The suffix form is kept as a
29
+ # fallback for calls that override it.
30
+ #
31
+ # Lockbox's real macro is has_encrypted, not lockbox_encrypts.
32
+ #
33
+ # Rails' own `encrypts` has no entry: it stores ciphertext in the
34
+ # original column, so the bare attribute name is what needs to be
35
+ # "used", and that is already picked up as a symbol literal by
36
+ # tokens_for below -- do not add a suffix pattern for it.
37
+ MACRO_COLUMN_TEMPLATES = {
38
+ "has_secure_password" => ["%s_digest"],
39
+ "attr_encrypted" => ["encrypted_%s", "%s_encrypted", "%s_ciphertext"],
40
+ "has_encrypted" => ["%s_ciphertext", "%s_iv", "%s_tag"]
41
+ }.freeze
42
+
43
+ # The attribute a macro implies when called with no explicit name, e.g.
44
+ # bare `has_secure_password` -> :password.
45
+ MACRO_DEFAULT_ATTR = { "has_secure_password" => "password" }.freeze
24
46
 
25
47
  # Node classes whose #name (or #unescaped) is a bare identifier we treat
26
48
  # as a possible column/table reference.
@@ -89,24 +111,14 @@ module SchemaReaper
89
111
  end
90
112
  end
91
113
 
92
- # `has_secure_password` / `encrypts :field` / `attr_encrypted :field`
93
- # never write their generated column name (password_digest,
94
- # field_ciphertext, ...) anywhere in source -- only the virtual
95
- # attribute name. Derive the column names a macro call implies so they
96
- # count as "used" instead of looking dead.
97
114
  def macro_derived_tokens(node)
98
115
  call_name = node.name&.to_s
99
- return [] unless call_name
100
-
101
- if DIGEST_MACROS.include?(call_name)
102
- attrs = macro_symbol_args(node)
103
- attrs = ["password"] if attrs.empty?
104
- attrs.flat_map { |a| DIGEST_SUFFIXES.map { |s| "#{a}#{s}" } }
105
- elsif ENCRYPTED_MACROS.include?(call_name)
106
- macro_symbol_args(node).flat_map { |a| ENCRYPTED_SUFFIXES.map { |s| "#{a}#{s}" } }
107
- else
108
- []
109
- end
116
+ templates = call_name && MACRO_COLUMN_TEMPLATES[call_name]
117
+ return [] unless templates
118
+
119
+ attrs = macro_symbol_args(node)
120
+ attrs = [MACRO_DEFAULT_ATTR[call_name]].compact if attrs.empty?
121
+ attrs.product(templates).map { |attr, template| format(template, attr) }
110
122
  end
111
123
 
112
124
  # Leading bare symbol arguments of a call, e.g. `encrypts :a, :b, purpose: :x`
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaReaper
4
- VERSION = "1.0.14"
4
+ VERSION = "1.0.16"
5
5
  end
@@ -9,15 +9,17 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ["akshatpegwar5@gmail.com", "mitanshukushwah@gmail.com"]
10
10
 
11
11
  spec.summary = "Find and safely remove schema dead-weight in Rails + PostgreSQL apps."
12
- spec.description =
13
- "schema_reaper finds schema dead-weight in Rails/ActiveRecord + PostgreSQL apps: " \
14
- "dead columns and tables, unused, duplicate and missing foreign-key indexes, and " \
15
- "always-NULL or single-value columns. It cross-references the live schema and pg_stats " \
16
- "against a static scan of your code, with an optional production runtime signal for " \
17
- "higher confidence. Each finding is scored, carries a reclaimable-bytes estimate, and " \
18
- "ships with a staged, reversible migration. Reporters for terminal, JSON, Markdown and " \
19
- "SARIF; a CI baseline gate; a trend log; a Rails railtie. PostgreSQL only for now; " \
20
- "Ruby >= 2.7. See the README for full usage."
12
+ spec.description = <<~DESC.strip
13
+ schema_reaper scans a Rails + PostgreSQL app for schema debt that's easy to accumulate and hard to find by hand.
14
+
15
+ It checks your live database against your codebase and flags three kinds of problems: things nothing references anymore (dead columns and dead tables), index trouble (indexes nobody queries, indexes made redundant by a wider index that already covers them, and foreign-key columns with no index at all), and degenerate data (columns that are always NULL, or hold the exact same value in every row).
16
+
17
+ Every finding comes with a confidence score, an estimate of the disk space removing it would reclaim, and a concrete fix. For a column, that's a two-step migration: stop reading it first, then drop it once you've confirmed nothing broke. An optional runtime tracker can sample real production traffic to raise confidence further, for cases a static code scan alone can't settle.
18
+
19
+ Reports come as a colored terminal summary, JSON, Markdown for a PR comment, or SARIF for GitHub code scanning -- plus a CI baseline gate and a trend log to track progress release over release.
20
+
21
+ PostgreSQL only for now. Requires Ruby 2.7 or later. Full usage is in the README.
22
+ DESC
21
23
  spec.homepage = "https://github.com/aksshatt/schema_reaper"
22
24
  spec.license = "MIT"
23
25
  spec.required_ruby_version = ">= 2.7.0"
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schema_reaper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.14
4
+ version: 1.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - aksshatt
8
8
  - mitkush
9
- autorequire:
9
+ autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-09-18 00:00:00.000000000 Z
12
+ date: 2026-09-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: prism
@@ -79,14 +79,16 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '1.5'
82
- description: 'schema_reaper finds schema dead-weight in Rails/ActiveRecord + PostgreSQL
83
- apps: dead columns and tables, unused, duplicate and missing foreign-key indexes,
84
- and always-NULL or single-value columns. It cross-references the live schema and
85
- pg_stats against a static scan of your code, with an optional production runtime
86
- signal for higher confidence. Each finding is scored, carries a reclaimable-bytes
87
- estimate, and ships with a staged, reversible migration. Reporters for terminal,
88
- JSON, Markdown and SARIF; a CI baseline gate; a trend log; a Rails railtie. PostgreSQL
89
- only for now; Ruby >= 2.7. See the README for full usage.'
82
+ description: |-
83
+ schema_reaper scans a Rails + PostgreSQL app for schema debt that's easy to accumulate and hard to find by hand.
84
+
85
+ It checks your live database against your codebase and flags three kinds of problems: things nothing references anymore (dead columns and dead tables), index trouble (indexes nobody queries, indexes made redundant by a wider index that already covers them, and foreign-key columns with no index at all), and degenerate data (columns that are always NULL, or hold the exact same value in every row).
86
+
87
+ Every finding comes with a confidence score, an estimate of the disk space removing it would reclaim, and a concrete fix. For a column, that's a two-step migration: stop reading it first, then drop it once you've confirmed nothing broke. An optional runtime tracker can sample real production traffic to raise confidence further, for cases a static code scan alone can't settle.
88
+
89
+ Reports come as a colored terminal summary, JSON, Markdown for a PR comment, or SARIF for GitHub code scanning -- plus a CI baseline gate and a trend log to track progress release over release.
90
+
91
+ PostgreSQL only for now. Requires Ruby 2.7 or later. Full usage is in the README.
90
92
  email:
91
93
  - akshatpegwar5@gmail.com
92
94
  - mitanshukushwah@gmail.com
@@ -157,7 +159,7 @@ metadata:
157
159
  wiki_uri: https://github.com/aksshatt/schema_reaper/wiki
158
160
  funding_uri: https://github.com/sponsors/aksshatt
159
161
  rubygems_mfa_required: 'true'
160
- post_install_message:
162
+ post_install_message:
161
163
  rdoc_options: []
162
164
  require_paths:
163
165
  - lib
@@ -172,8 +174,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
172
174
  - !ruby/object:Gem::Version
173
175
  version: '0'
174
176
  requirements: []
175
- rubygems_version: 3.4.10
176
- signing_key:
177
+ rubygems_version: 3.3.3
178
+ signing_key:
177
179
  specification_version: 4
178
180
  summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
179
181
  test_files: []