idxfence

Gem Version License: MIT

Flags a Rails ActiveRecord validates :column, uniqueness: true (or uniqueness: { scope: ... }, or validates_uniqueness_of) whose column set has no matching database-level unique index in db/schema.rb.

uniqueness: true only runs a SELECT ... WHERE check at the application layer, in the same request, before the INSERT. Under real concurrency, two requests can both run that SELECT and both see "no existing row" before either one's INSERT commits -- landing a genuine duplicate row despite the validation "working" in every manual test and every single-threaded spec run. The Rails Guides themselves document this exact race and recommend a matching DB-level unique index as the only real fix -- the AR validation is a friendly UX nicety, not a correctness guarantee. See DETAILS.md for the exact race, the table-name/scope-matching algorithm, and why this genuinely needs two passes over two different files.

Quick start

gem install idxfence
idxfence check /path/to/rails-project
idxfence: 1 finding(s)
[IX001] app/models/user.rb:2 User#email -> users has no matching unique index: User validates uniqueness of :email at the application layer only -- db/schema.rb has no unique index on users(email). Two concurrent requests can both pass the validation's SELECT check before either INSERT commits, producing a genuine duplicate row. Add a matching `add_index :users, [:email], unique: true` migration. See DETAILS.md.

Each <rails-project-dir> argument must contain app/models/ and db/schema.rb. Exits 1 if any finding, 0 otherwise -- wire it into CI as a pre-merge gate on the whole project.

As a library

require "idxfence"

findings, warning = Idxfence.check(project_dir: "/path/to/rails-project")
findings.each { |f| puts "[#{f.code}] #{f.model}##{f.column} -> #{f.table} (#{f.file}:#{f.line})" }

What it checks (v0.1)

For every app/models/*.rb model whose superclass is ApplicationRecord or ActiveRecord::Base:

  1. Every validates :column[, :column2, ...], uniqueness: true (or uniqueness: { scope: ..., ... }), and every validates_uniqueness_of :column[, ...][, scope: ...] -- each column named is treated as its own independent validation, matching Rails' own runtime behavior.
  2. The model's table name -- self.table_name = "..." if the model sets it explicitly, otherwise Rails' own default (demodulize.underscore.pluralize).
  3. Cross-referenced against db/schema.rb's create_table block for that table: is there a t.index [...], unique: true whose column set (the validated column, plus any scope: column(s)) matches exactly, in either order?

No matching unique index -> flagged. A scope: validation additionally requires the composite index (a single-column index on just the validated column does not satisfy a scoped validation -- see DETAILS.md).

Not flagged / explicitly out of scope:

  • A uniqueness validation with a matching unique index already in place -- that's the correct, race-safe setup.
  • A model with no uniqueness validations at all -- nothing to check.
  • db/schema.rb missing or unparseable -- a clear warning, not a crash (nothing can be cross-referenced without it).

Class boundaries, create_table blocks, and validates statements are found by parsing with Ruby's own Ripper for structure and pattern matching within it -- see DETAILS.md for exactly how, the full table-name/scope-matching algorithm, and its honestly documented limitations (irregular pluralization, case-sensitivity, functional indexes).

Requirements

Developed and tested against Ruby 3.0.2, matching this workspace's other Ruby packages. No known incompatibility with newer 3.x versions. No Rails installation or database connection is required to run idxfence -- it's a pure static-source check over app/models/*.rb and db/schema.rb.

License

MIT