idxfence 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 794cbdb21b56bd42bd76746144881c689cca74c95f90e943cc338c19970f4b6d
4
+ data.tar.gz: fe88f00617bb381545c72a74e44c7ee18198d7abc8e43880e5d9f2989bce0cd4
5
+ SHA512:
6
+ metadata.gz: b7bb81eadf4b7503b41bd7c7865286df53b8eef4225e551f0945330996d0660430dfc3e7d15df1d84bc77675e6a8a365c32e2c793a989b2123159e101f9e23c8
7
+ data.tar.gz: 03d2ca96d275480e65eb6610d05be5c3ef9c1bf259097e757066ddf916e052b6d91e7add5d9e1da94c1125aeafbf07579f417b0e4886f67a5c561d5b068b22a3
data/DETAILS.md ADDED
@@ -0,0 +1,293 @@
1
+ # idxfence — Design Details
2
+
3
+ ## Why this exists
4
+
5
+ `ActiveRecord::Validations::Uniqueness` gives every model a friendly,
6
+ one-line way to reject a duplicate before it ever touches the database:
7
+
8
+ ```ruby
9
+ class User < ApplicationRecord
10
+ validates :email, uniqueness: true
11
+ end
12
+ ```
13
+
14
+ It looks like a correctness guarantee. It is not one. `uniqueness: true`
15
+ compiles down to a `SELECT 1 FROM users WHERE email = ? LIMIT 1` run
16
+ **before** the `INSERT`, in the same request, on the same connection. If
17
+ that `SELECT` finds nothing, Rails lets the `INSERT` proceed. Nothing
18
+ about this sequence is atomic:
19
+
20
+ ```
21
+ Request A: SELECT ... WHERE email = 'a@b.com' -> 0 rows, OK to insert
22
+ Request B: SELECT ... WHERE email = 'a@b.com' -> 0 rows, OK to insert
23
+ Request A: INSERT INTO users (email) VALUES ('a@b.com') -> commits
24
+ Request B: INSERT INTO users (email) VALUES ('a@b.com') -> commits too
25
+ ```
26
+
27
+ Two requests, both individually "validated," landing a genuine duplicate
28
+ row. This isn't a contrived edge case -- it's two form submissions a few
29
+ milliseconds apart (a double-click, a flaky client retry, two browser
30
+ tabs), a background job re-processing the same webhook payload that
31
+ raced a live request, or any modest amount of real concurrent traffic
32
+ hitting a signup/registration endpoint. The Rails Guides' own Active
33
+ Record Validations guide documents this exact race in its section on
34
+ `uniqueness`, and recommends a database-level unique index as the only
35
+ real fix -- the AR validation is explicitly framed there as a UX nicety
36
+ (a friendly in-app error message before ever reaching the DB), not a
37
+ correctness guarantee. Plenty of real, shipped Rails apps have only the
38
+ AR-level check, because it "works" in every manual test and in every
39
+ single-threaded spec run, and the gap simply never surfaces until
40
+ production traffic finds it.
41
+
42
+ ## Why a DB-level unique index is the actual fix
43
+
44
+ A unique index makes the *second* `INSERT` fail, atomically, at the
45
+ database's own constraint-enforcement layer -- no race window exists
46
+ because uniqueness is now checked as part of the same operation that
47
+ writes the row, not as a separate prior query. The fix is not to remove
48
+ the AR validation (it's still valuable for a fast, friendly in-request
49
+ error message on the common case) -- it's to **also** add the matching
50
+ index and rescue `ActiveRecord::RecordNotUnique` on the rare occasions
51
+ the race actually happens:
52
+
53
+ ```ruby
54
+ add_index :users, :email, unique: true
55
+ ```
56
+
57
+ This is why idxfence doesn't flag "uniqueness: true with no index" as
58
+ "remove the validation" -- it flags it as "add the index," which is
59
+ exactly what Rails' own guide recommends.
60
+
61
+ ## Why this needs two passes over two different files
62
+
63
+ A single `app/models/user.rb` file only tells you *what the application
64
+ layer checks*. Whether that check is actually backed by a database
65
+ constraint lives in a completely different file, generated by Rails
66
+ itself from actual migrations: `db/schema.rb`. There is no way to answer
67
+ "is this uniqueness validation race-safe?" from the model file alone --
68
+ the same way there's no way to know if a lock is held without also
69
+ looking at what's guarding the resource. idxfence therefore always
70
+ performs two independent passes:
71
+
72
+ 1. **`Idxfence::ModelParser`** parses one `app/models/*.rb` file at a
73
+ time and extracts every `validates ..., uniqueness: true` /
74
+ `uniqueness: { ... }` / `validates_uniqueness_of ...` declaration,
75
+ plus an explicit `self.table_name = "..."` override if present. It
76
+ knows nothing about the database.
77
+ 2. **`Idxfence::SchemaParser`** parses `db/schema.rb` once and extracts,
78
+ for every `create_table` block, the column set of every *unique*
79
+ `t.index` inside it. It knows nothing about any model.
80
+ 3. **`Idxfence::Checker`** is the only place these two are joined: for
81
+ each validation, it resolves the table name (see below), looks up
82
+ that table's unique indexes from the schema pass, and reports a
83
+ finding only if none of them cover exactly that validation's column
84
+ set.
85
+
86
+ This is a deliberately different shape from most of this workspace's
87
+ other Ruby checkers (`mailstall`, `jobclash`, `lockstall`), which all
88
+ answer their question from a single file. idxfence cannot.
89
+
90
+ ## Table-name resolution algorithm
91
+
92
+ Rails' own default: `Model.name.demodulize.underscore.pluralize`. Given
93
+ a model class found in `app/models/user.rb`:
94
+
95
+ 1. If the class body contains `self.table_name = "some_table"` (a plain
96
+ string/symbol literal assignment, found by regex over the class
97
+ body's own source text -- not full Ruby evaluation), that exact
98
+ string is used, unconditionally, no matter what the class is named.
99
+ This is checked **first** and short-circuits inference entirely --
100
+ matching Rails' own behavior, where an explicit `table_name=` always
101
+ wins.
102
+ 2. Otherwise, `Idxfence::Inflector.table_name_for(class_name)`:
103
+ - **Demodulize**: only the last `::`-separated segment of the class
104
+ name is used (`Admin::UserAccount` -> `UserAccount`). A project
105
+ that sets a custom, non-default `table_name_prefix` on a namespace
106
+ isn't modeled -- see limitations.
107
+ - **Underscore**: `UserAccount` -> `user_account` (standard
108
+ CamelCase-to-snake_case).
109
+ - **Pluralize**: a small, deliberately non-exhaustive rule set --
110
+ `-y` preceded by a consonant -> `-ies`; `-ch`/`-sh`/`-ss`/`-x`/`-z`
111
+ -> `+es`; `-fe`/`-f` -> `-ves`; already ending in `-s` -> unchanged;
112
+ a short table of common irregulars (`person` -> `people`, `child`
113
+ -> `children`, etc.); otherwise `+s`. This is **not**
114
+ ActiveSupport::Inflector -- see limitations for exactly what it
115
+ doesn't handle.
116
+
117
+ ## Scope-column-set matching algorithm
118
+
119
+ A `validates :email, uniqueness: { scope: :account_id }` validation is
120
+ only actually enforced by an index covering **both** columns together --
121
+ a unique index on `email` alone does *not* prevent two different
122
+ `account_id`s from each having their own duplicate `email`... but it
123
+ also does not prevent the specific race this validation exists to catch
124
+ (two requests for the *same* `account_id` both inserting the same
125
+ `email`), because a single-column unique index on `email` alone would
126
+ incorrectly forbid that email existing under any other account too,
127
+ which isn't even what the validation says it's protecting -- so a
128
+ single-column index is treated as **not a match** for a scoped
129
+ validation, full stop.
130
+
131
+ Concretely: for a validation on column `C` with scope columns `S`
132
+ (`S` is empty for an unscoped validation), idxfence computes
133
+ `required = ({C} ∪ S).sort` and considers it satisfied only if
134
+ `db/schema.rb` has a unique index on that table whose own column list,
135
+ sorted, equals `required` exactly:
136
+
137
+ - Same size, same columns -- order doesn't matter (`t.index
138
+ ["account_id", "email"], unique: true` and `t.index ["email",
139
+ "account_id"], unique: true` both satisfy `scope: :account_id` on
140
+ `:email`).
141
+ - A **subset** doesn't count (a unique index on `email` alone does not
142
+ satisfy `scope: :account_id` on `:email` -- see above).
143
+ - A **superset** doesn't count either (a unique index on
144
+ `[account_id, email, region]` does not satisfy a validation scoped
145
+ only to `account_id` -- Rails' own uniqueness validation with `scope:
146
+ :account_id` only queries `WHERE account_id = ? AND email = ?`, so a
147
+ three-column unique index enforces a stricter, different invariant
148
+ than what the validation actually checks; a duplicate `(account_id,
149
+ email)` pair with two different `region`s would still pass that
150
+ three-column index but is exactly the race the two-column validation
151
+ is supposed to prevent).
152
+
153
+ `scope:` is recognized in all three forms Rails accepts: a bare symbol
154
+ (`scope: :account_id`), an array literal (`scope: [:account_id,
155
+ :region]`), and a `%i[]` literal (`scope: %i[account_id region]`).
156
+
157
+ `validates :email, :username, uniqueness: true` (multiple columns in one
158
+ statement) is treated as **two independent, single-column** uniqueness
159
+ validations -- this matches Rails' own actual runtime behavior, which
160
+ registers one separate `UniquenessValidator` per attribute listed, each
161
+ checked on its own.
162
+
163
+ ## How it parses (Ripper for structural boundaries, regex within them)
164
+
165
+ Same hybrid technique this workspace's `mailstall`/`jobclash`/`lockstall`
166
+ packages use: structural boundaries -- class bodies, `create_table`
167
+ blocks -- come from `Ripper.sexp`, never from naive `do`/`end` line
168
+ counting. A narrower regex pass then runs only over the *text* of a
169
+ boundary already located structurally.
170
+
171
+ - **`ModelParser`**: walks the s-expression for every `class` node,
172
+ filters to ones whose superclass constant is exactly
173
+ `ApplicationRecord`/`ActiveRecord::Base` (the same conservative,
174
+ single-file superclass check `mailstall` uses for controllers -- a
175
+ model inheriting from a project-specific `ApplicationRecord` subclass
176
+ chain isn't traced). Within that class's own source-text span, a
177
+ `validates`/`validates_uniqueness_of` statement is buffered
178
+ line-by-line, tracking paren/brace/bracket depth, until it balances --
179
+ so a `uniqueness: { ... }` options hash wrapped across several lines
180
+ is still read as one statement, not truncated at the first line break.
181
+ One known Ripper quirk this had to work around: a class node's own
182
+ detected line span, built purely from the positions Ripper attaches to
183
+ *content-bearing* tokens (identifiers, literals, keywords), does not
184
+ extend to a line containing only closing punctuation (a lone `}` or
185
+ `end`) with nothing else on it -- so a still-open statement buffer at
186
+ the end of that span is completed by reading straight from the
187
+ underlying file past the detected span, until its brackets balance or
188
+ the file runs out, rather than being silently dropped.
189
+ - **`SchemaParser`**: walks the s-expression for every
190
+ `:method_add_block` node whose call target is the identifier
191
+ `create_table`, locates that block's own text span the same way, then
192
+ regex-extracts the table name (`create_table "table_name"`) and every
193
+ `t.index [...], ..., unique: true` line inside it (a non-unique
194
+ `t.index` is read but discarded -- it can't enforce anything at the
195
+ database level).
196
+
197
+ ## Explicitly out of scope / known limitations (v0.1)
198
+
199
+ - **Not a full English pluralizer.** `Idxfence::Inflector` implements
200
+ the common regular-plural rules plus a short irregular table, not the
201
+ complete rule set `ActiveSupport::Inflector` ships (custom
202
+ `inflections.rb` overrides, uncommon irregulars, uncountable words
203
+ beyond a small built-in list). A model whose default-inferred table
204
+ name is wrong for this reason will produce a false negative or false
205
+ positive against the wrong table -- set `self.table_name` explicitly
206
+ (already fully honored, and checked first) to sidestep this entirely,
207
+ or treat an unexpected result as a signal to verify the actual table
208
+ name by hand.
209
+ - **Case-sensitive column/table matching.** Column names are compared
210
+ as exact strings; a schema and model that disagree only in case
211
+ (unusual, but not forbidden by Postgres/MySQL) won't match. This
212
+ mirrors real-world convention (Rails/DB column and table names are
213
+ conventionally lowercase snake_case) rather than a deliberate design
214
+ choice to ignore case.
215
+ - **Does not model `uniqueness: { case_sensitive: false }` at the SQL
216
+ level.** A `LOWER(email)` functional/expression unique index (a valid,
217
+ common way to actually enforce case-insensitive uniqueness at the DB
218
+ layer) is not recognized as satisfying a case-insensitive validation
219
+ -- idxfence only looks for a plain `t.index [...], unique: true` on
220
+ the literal column name. A validation declaring `case_sensitive:
221
+ false` is checked the same as any other uniqueness validation (does a
222
+ plain unique index exist on this column set), which is a real but
223
+ separate gap from the one this tool targets -- documented here rather
224
+ than silently mismatched.
225
+ - **No custom `table_name_prefix`/`table_name_suffix` resolution.** Only
226
+ `self.table_name = "..."` (an explicit, literal override) and the
227
+ default demodulized/underscored/pluralized class name are considered;
228
+ a namespace-wide prefix/suffix convention set elsewhere (e.g. in an
229
+ initializer or a shared base class) isn't traced across files.
230
+ - **No STI (single-table inheritance) awareness.** A subclass of another
231
+ AR model (not `ApplicationRecord`/`ActiveRecord::Base` directly) isn't
232
+ recognized as a model at all by the current superclass check -- an STI
233
+ subclass's own `validates` declarations (if any) are not scanned. This
234
+ is the same single-file, no-cross-file-inheritance-graph trade-off
235
+ `mailstall` documents for controllers.
236
+ - **No autofix, no runtime enforcement.** idxfence only reports; it
237
+ never writes a migration, and it never runs inside the request path.
238
+
239
+ ## Prior art (checked before building, 2026-09)
240
+
241
+ **No existing static analyzer checks this specific cross-file
242
+ invariant.** RuboCop-Rails has cops that check validation *syntax*
243
+ (`Rails/UniqueValidationWithoutIndex` was proposed community-side in the
244
+ past but is not present in the current, actively maintained cop list as
245
+ of this writing -- searched 2026-09) but nothing shipped and maintained
246
+ in the current RuboCop-Rails release cross-references `db/schema.rb`
247
+ against `app/models/`. Brakeman is a security scanner focused on
248
+ injection/XSS/mass-assignment classes of bugs, not schema-vs-validation
249
+ consistency. This exact race condition is extremely well-documented
250
+ folklore -- it's in the Rails Guides themselves, in "Rails Antipatterns"
251
+ and multiple widely-read blog posts going back over a decade -- but,
252
+ searched as of this writing, no maintained open-source tool actually
253
+ reads both files and tells you, before you ship, which of your
254
+ `uniqueness: true` validations are silently unguarded. That's the gap
255
+ idxfence fills.
256
+
257
+ ## Package layout
258
+
259
+ ```
260
+ idxfence/
261
+ lib/idxfence.rb # top-level require, Idxfence.check entry point
262
+ lib/idxfence/ # finding, model_parser, schema_parser, checker, cli, render, inflector, version
263
+ exe/idxfence # CLI executable
264
+ spec/ # RSpec suite + fixture Rails-project directories
265
+ examples/ # one project with the genuine gap, fake app/models + db/schema.rb
266
+ docs/USAGE.md # full API/CLI reference
267
+ ```
268
+
269
+ ## Testing
270
+
271
+ Fixture-based, zero network/Rails/database dependency: each fixture is a
272
+ tiny fake Rails project directory (`app/models/*.rb` + `db/schema.rb`)
273
+ under `spec/fixtures/projects/`. Covers: an unscoped `uniqueness: true`
274
+ with a matching unique index (not flagged), the same with a non-unique
275
+ index and with no index at all (both flagged), a `scope:` validation
276
+ with a matching composite unique index declared in either column order
277
+ (not flagged), the same scope with only a single-column index or no
278
+ index at all (both flagged -- a single-column index doesn't enforce
279
+ scoped uniqueness), `self.table_name` correctly resolved against its own
280
+ `create_table` block rather than the pluralized default (both a matching
281
+ and a missing-index variant, the latter proving the *custom* table's own
282
+ index state is what's checked, not the default table's), a model with no
283
+ uniqueness validations at all (not flagged, nothing to check),
284
+ `db/schema.rb` missing or failing to parse (a clear warning, not a
285
+ crash, in both the library and CLI), `validates :a, :b, uniqueness: true`
286
+ treated as two independent single-column validations, both
287
+ `validates_uniqueness_of` (with and without `scope:`), a `-y`-ending
288
+ class name pluralized correctly, a `uniqueness: { ... }` options hash
289
+ wrapped across multiple lines, `scope:` as an array literal and as
290
+ `%i[]`, and multiple models checked against one shared schema.rb in a
291
+ single project. The CLI is exercised end-to-end against fixture project
292
+ directories, checking text and `--json` output, multi-project-directory
293
+ invocations, and all exit codes.
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jay Tank
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # idxfence
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/idxfence.svg)](https://rubygems.org/gems/idxfence)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+
6
+ Flags a Rails ActiveRecord `validates :column, uniqueness: true` (or
7
+ `uniqueness: { scope: ... }`, or `validates_uniqueness_of`) whose column
8
+ set has **no matching database-level unique index** in `db/schema.rb`.
9
+
10
+ `uniqueness: true` only runs a `SELECT ... WHERE` check at the
11
+ application layer, in the same request, before the `INSERT`. Under real
12
+ concurrency, two requests can both run that `SELECT` and both see "no
13
+ existing row" before either one's `INSERT` commits -- landing a genuine
14
+ duplicate row despite the validation "working" in every manual test and
15
+ every single-threaded spec run. The Rails Guides themselves document
16
+ this exact race and recommend a matching DB-level unique index as the
17
+ only real fix -- the AR validation is a friendly UX nicety, not a
18
+ correctness guarantee. See [DETAILS.md](DETAILS.md) for the exact race,
19
+ the table-name/scope-matching algorithm, and why this genuinely needs
20
+ two passes over two different files.
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ gem install idxfence
26
+ idxfence check /path/to/rails-project
27
+ ```
28
+
29
+ ```
30
+ idxfence: 1 finding(s)
31
+ [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.
32
+ ```
33
+
34
+ Each `<rails-project-dir>` argument must contain `app/models/` and
35
+ `db/schema.rb`. Exits `1` if any finding, `0` otherwise -- wire it into
36
+ CI as a pre-merge gate on the whole project.
37
+
38
+ ## As a library
39
+
40
+ ```ruby
41
+ require "idxfence"
42
+
43
+ findings, warning = Idxfence.check(project_dir: "/path/to/rails-project")
44
+ findings.each { |f| puts "[#{f.code}] #{f.model}##{f.column} -> #{f.table} (#{f.file}:#{f.line})" }
45
+ ```
46
+
47
+ ## What it checks (v0.1)
48
+
49
+ For every `app/models/*.rb` model whose superclass is `ApplicationRecord`
50
+ or `ActiveRecord::Base`:
51
+
52
+ 1. Every `validates :column[, :column2, ...], uniqueness: true` (or
53
+ `uniqueness: { scope: ..., ... }`), and every
54
+ `validates_uniqueness_of :column[, ...][, scope: ...]` -- each column
55
+ named is treated as its own independent validation, matching Rails'
56
+ own runtime behavior.
57
+ 2. The model's table name -- `self.table_name = "..."` if the model sets
58
+ it explicitly, otherwise Rails' own default
59
+ (`demodulize.underscore.pluralize`).
60
+ 3. Cross-referenced against `db/schema.rb`'s `create_table` block for
61
+ that table: is there a `t.index [...], unique: true` whose column set
62
+ (the validated column, plus any `scope:` column(s)) matches exactly,
63
+ in either order?
64
+
65
+ No matching unique index -> flagged. A `scope:` validation additionally
66
+ requires the composite index (a single-column index on just the
67
+ validated column does **not** satisfy a scoped validation -- see
68
+ [DETAILS.md](DETAILS.md)).
69
+
70
+ **Not flagged / explicitly out of scope**:
71
+
72
+ - A uniqueness validation with a matching unique index already in place
73
+ -- that's the correct, race-safe setup.
74
+ - A model with no uniqueness validations at all -- nothing to check.
75
+ - `db/schema.rb` missing or unparseable -- a clear warning, not a crash
76
+ (nothing can be cross-referenced without it).
77
+
78
+ Class boundaries, `create_table` blocks, and `validates` statements are
79
+ found by parsing with Ruby's own `Ripper` for structure and pattern
80
+ matching within it -- see [DETAILS.md](DETAILS.md) for exactly how, the
81
+ full table-name/scope-matching algorithm, and its honestly documented
82
+ limitations (irregular pluralization, case-sensitivity, functional
83
+ indexes).
84
+
85
+ ## Requirements
86
+
87
+ Developed and tested against Ruby 3.0.2, matching this workspace's other
88
+ Ruby packages. No known incompatibility with newer 3.x versions. No
89
+ Rails installation or database connection is required to run
90
+ idxfence -- it's a pure static-source check over `app/models/*.rb` and
91
+ `db/schema.rb`.
92
+
93
+ ## License
94
+
95
+ MIT
data/docs/USAGE.md ADDED
@@ -0,0 +1,107 @@
1
+ # idxfence — Usage
2
+
3
+ ## Library API
4
+
5
+ ```ruby
6
+ require "idxfence"
7
+
8
+ findings, warning = Idxfence.check(project_dir: project_dir)
9
+ ```
10
+
11
+ `project_dir` — path to a Rails project root (the directory containing
12
+ `app/` and `db/`). Model files are globbed from
13
+ `app/models/**/*.rb`, and the schema is read from `db/schema.rb`.
14
+
15
+ For callers that already have their own explicit file list (e.g. tests
16
+ against fixtures not laid out as a full Rails project):
17
+
18
+ ```ruby
19
+ findings, warning = Idxfence.check_paths(
20
+ model_paths: Dir.glob("app/models/**/*.rb"),
21
+ schema_path: "db/schema.rb"
22
+ )
23
+ ```
24
+
25
+ ### Return value
26
+
27
+ A two-element array: `[Array<Idxfence::Finding>, String or nil]`. The
28
+ second element is a warning message (nil if none) — set when
29
+ `db/schema.rb` was missing or failed to parse, in which case findings is
30
+ always `[]` since nothing can be cross-referenced without it.
31
+
32
+ Each `Finding` has:
33
+
34
+ - `code` — always `"IX001"` (one rule: an AR uniqueness validation with
35
+ no matching DB-level unique index)
36
+ - `model` — the model class name, e.g. `"User"`
37
+ - `table` — the resolved table name, e.g. `"users"`
38
+ - `column` — the validated column, e.g. `"email"`
39
+ - `scope` — `Array<String>` of scope column(s) (`[]` for an unscoped
40
+ validation)
41
+ - `file` — path to the model source file
42
+ - `line` — line number of the `validates`/`validates_uniqueness_of` call
43
+ - `message` — human-readable description with the fix
44
+ - `#to_h` — plain hash of the above eight keys
45
+
46
+ ## CLI
47
+
48
+ ```bash
49
+ idxfence check <rails-project-dir> [<rails-project-dir> ...] [--json]
50
+ ```
51
+
52
+ Each `<rails-project-dir>` must contain `app/models/` and
53
+ `db/schema.rb`. At least one is required. Multiple directories can be
54
+ checked in one invocation (e.g. a monorepo with more than one Rails app).
55
+
56
+ ### Exit codes
57
+
58
+ - `0` — no findings (a schema warning alone does not change the exit
59
+ code — check stderr/the warning line if you need to distinguish "clean"
60
+ from "couldn't check")
61
+ - `1` — one or more findings
62
+ - `2` — usage error (no paths, unknown command, bad flag)
63
+
64
+ ### Example
65
+
66
+ ```bash
67
+ $ idxfence check .
68
+
69
+ idxfence: 1 finding(s)
70
+ [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.
71
+ ```
72
+
73
+ ### `--json`
74
+
75
+ ```bash
76
+ $ idxfence check . --json
77
+ {"findings":[{"code":"IX001","model":"User","table":"users","column":"email","scope":[],"file":"app/models/user.rb","line":2,"message":"..."}],"warning":null}
78
+ ```
79
+
80
+ ## What triggers a finding
81
+
82
+ A model class whose superclass is exactly `ApplicationRecord` or
83
+ `ActiveRecord::Base`, declaring:
84
+
85
+ 1. `validates :column[, :column2, ...], uniqueness: true` or
86
+ `uniqueness: { scope: ..., ... }` — each named column is its own
87
+ independent validation.
88
+ 2. `validates_uniqueness_of :column[, :column2, ...][, scope: ...]` —
89
+ same treatment.
90
+
91
+ ...resolved against the model's table (an explicit `self.table_name =
92
+ "..."` if present, otherwise `demodulize.underscore.pluralize` of the
93
+ class name), where `db/schema.rb`'s `create_table` block for that table
94
+ has no `t.index [...], unique: true` whose column set — the validated
95
+ column plus any `scope:` column(s), in either order — matches exactly.
96
+
97
+ `scope:` is recognized as a bare symbol (`scope: :account_id`), an array
98
+ (`scope: [:account_id, :region]`), or `%i[]` (`scope: %i[account_id
99
+ region]`).
100
+
101
+ Not flagged: a validation with a matching unique index already in place,
102
+ a model with no uniqueness validations at all. `db/schema.rb` missing or
103
+ unparseable produces a warning, not a crash, and no findings (nothing to
104
+ cross-reference against). See [DETAILS.md](../DETAILS.md) for the full
105
+ table-name and scope-matching algorithm and every documented limitation
106
+ (irregular pluralization, case-sensitivity, functional/expression
107
+ indexes, STI).
@@ -0,0 +1,8 @@
1
+ # Clean, by contrast: this scoped uniqueness validation IS backed by a
2
+ # matching composite unique index in db/schema.rb -- see the
3
+ # `t.index ["account_id", "email"], unique: true` line on the
4
+ # "memberships" table. idxfence does not flag this one.
5
+ class Membership < ApplicationRecord
6
+ validates :email, uniqueness: { scope: :account_id, case_sensitive: false }
7
+ belongs_to :account
8
+ end
@@ -0,0 +1,11 @@
1
+ # Trips IX001: a completely ordinary-looking Rails model.
2
+ # validates :email, uniqueness: true only runs a SELECT ... WHERE check
3
+ # at the application layer, before the INSERT -- it looks like it
4
+ # guarantees no two users can ever share an email, but db/schema.rb (see
5
+ # db/schema.rb next to this file) has no unique index on users.email at
6
+ # all. Two signups for the same address, a few milliseconds apart, can
7
+ # both pass this validation's SELECT before either INSERT commits.
8
+ class User < ApplicationRecord
9
+ validates :email, presence: true, uniqueness: true
10
+ validates :password, length: { minimum: 8 }
11
+ end
@@ -0,0 +1,26 @@
1
+ # This is what idxfence cross-references app/models/*.rb against. Note:
2
+ # - "users" has no unique index on email at all -> User's
3
+ # `uniqueness: true` on :email gets flagged (IX001).
4
+ # - "memberships" DOES have a matching composite unique index on
5
+ # [account_id, email] -> Membership's scoped uniqueness validation
6
+ # is NOT flagged.
7
+ ActiveRecord::Schema[7.1].define(version: 2026_09_01_000000) do
8
+ create_table "accounts", force: :cascade do |t|
9
+ t.string "name"
10
+ t.timestamps
11
+ end
12
+
13
+ create_table "users", force: :cascade do |t|
14
+ t.string "email"
15
+ t.string "password_digest"
16
+ t.timestamps
17
+ end
18
+
19
+ create_table "memberships", force: :cascade do |t|
20
+ t.bigint "account_id", null: false
21
+ t.string "email"
22
+ t.timestamps
23
+ t.index ["account_id", "email"], name: "index_memberships_on_account_id_and_email", unique: true
24
+ t.index ["account_id"], name: "index_memberships_on_account_id"
25
+ end
26
+ end
data/exe/idxfence ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "idxfence"
5
+ require "idxfence/cli"
6
+
7
+ exit Idxfence::CLI.run(ARGV)
data/idxfence.gemspec ADDED
@@ -0,0 +1,39 @@
1
+ require_relative "lib/idxfence/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "idxfence"
5
+ spec.version = Idxfence::VERSION
6
+ spec.authors = ["Jay"]
7
+ spec.summary = "Flags Rails ActiveRecord uniqueness: true validations with no matching DB-level unique index in db/schema.rb."
8
+ spec.description = <<~DESC
9
+ idxfence cross-references every `validates :column, uniqueness: true`
10
+ / `uniqueness: { scope: ... }` / `validates_uniqueness_of` declaration
11
+ in app/models/*.rb against db/schema.rb's create_table blocks, and
12
+ flags any whose column set (including scope columns) has no matching
13
+ `t.index [...], unique: true`. An ActiveRecord uniqueness validation
14
+ only performs a SELECT ... WHERE check at the application layer
15
+ before an INSERT -- under real concurrency, two requests can both
16
+ pass that check before either commits, landing a genuine duplicate
17
+ row despite the validation "working." Rails Guides themselves
18
+ document this exact race and recommend a matching DB-level unique
19
+ index as the only real fix; plenty of real apps ship with only the
20
+ AR-level check.
21
+ DESC
22
+ spec.homepage = "https://github.com/jay-tank/idxfence"
23
+ spec.license = "MIT"
24
+ spec.required_ruby_version = ">= 3.0.0"
25
+
26
+ spec.metadata["homepage_uri"] = spec.homepage
27
+ spec.metadata["source_code_uri"] = spec.homepage
28
+
29
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
30
+ `git ls-files -z`.split("\x0").reject do |f|
31
+ f.match(%r{\A(?:test|spec|features)/}) || f.match(%r{\A\.github/})
32
+ end
33
+ end
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+
38
+ spec.add_development_dependency "rspec", "~> 3.13"
39
+ end