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.
gem install idxfence
idxfence check /path/to/rails-projectidxfence: 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.
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})" }For every app/models/*.rb model whose superclass is ApplicationRecord
or ActiveRecord::Base:
- Every
validates :column[, :column2, ...], uniqueness: true(oruniqueness: { scope: ..., ... }), and everyvalidates_uniqueness_of :column[, ...][, scope: ...]-- each column named is treated as its own independent validation, matching Rails' own runtime behavior. - The model's table name --
self.table_name = "..."if the model sets it explicitly, otherwise Rails' own default (demodulize.underscore.pluralize). - Cross-referenced against
db/schema.rb'screate_tableblock for that table: is there at.index [...], unique: truewhose column set (the validated column, plus anyscope: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.rbmissing 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).
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.
MIT