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 +7 -0
- data/DETAILS.md +293 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +95 -0
- data/docs/USAGE.md +107 -0
- data/examples/app/models/membership.rb +8 -0
- data/examples/app/models/user.rb +11 -0
- data/examples/db/schema.rb +26 -0
- data/exe/idxfence +7 -0
- data/idxfence.gemspec +39 -0
- data/lib/idxfence/checker.rb +81 -0
- data/lib/idxfence/cli.rb +64 -0
- data/lib/idxfence/finding.rb +26 -0
- data/lib/idxfence/inflector.rb +69 -0
- data/lib/idxfence/model_parser.rb +219 -0
- data/lib/idxfence/render.rb +28 -0
- data/lib/idxfence/schema_parser.rb +127 -0
- data/lib/idxfence/version.rb +3 -0
- data/lib/idxfence.rb +40 -0
- metadata +91 -0
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
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
|
+
[](https://rubygems.org/gems/idxfence)
|
|
4
|
+
[](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
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
|