feat(migrations): diff field-level unique: true against the database - #540
Draft
smartive-nicolai[bot] wants to merge 1 commit into
Draft
feat(migrations): diff field-level unique: true against the database#540smartive-nicolai[bot] wants to merge 1 commit into
unique: true against the database#540smartive-nicolai[bot] wants to merge 1 commit into
Conversation
`unique: true` on a field was write-once: `column()` emitted knex's `.unique()` while creating a column, and nothing looked at the flag again. `hasChanged()` -- the function deciding whether an existing column needs an ALTER -- never inspected it, so adding or removing `unique: true` on an existing column produced no migration at all and `check-needs-migration` reported a clean database that did not match the models. The flag read as documentation while the real uniqueness lived only in whatever raw SQL some migration happened to carry. Reflect single-column unique *constraints* (`pg_constraint.contype = 'u'`) and diff them against the flag, emitting `table.unique([col])` / `table.dropUnique` as needed. This is deliberately disjoint from the `kind: 'unique'` constraint path, which owns unique *indexes* not backed by a constraint -- the only form that can be partial. The two sets never contend for the same object, so a column may carry a total constraint and a partial index at once. Keyed by column rather than by name: knex derives the constraint name itself and Postgres folds the unquoted identifier to lower case, so `Portfolio.providerRef` becomes `portfolio_providerref_unique` and the name is not reconstructible from the model. Drops pass the reflected name explicitly, since a constraint gqm did not create need not follow knex's convention. Two details worth flagging for review: - `primary` fields are skipped. The implicit `id` field carries both `primary` and `unique`, matching `column()`'s precedence; a primary key has no separate `contype = 'u'` row, so without this every table would want a migration forever. - `updateFields` now passes `setUnique: false`. With uniqueness diffed separately, emitting `.unique()` from an ALTER would re-add an existing constraint whenever a `unique: true` column is altered for an unrelated reason. Validated end-to-end against zwei-wealth-platform's schema (100+ tables, 29 unique indexes): the generator produces exactly two entries, both real gaps found by hand beforehand -- `User.legacyId` and `PortfolioRequirement.rfpIdentifier` declare `unique: true` with nothing enforcing it. No false positives; `Portfolio.providerRef`, which is declared and does have its constraint, correctly yields nothing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
unique: trueon a field was write-once.column()emitted knex's.unique()while creating a column, and nothing looked at the flag again —hasChanged(), the function that decides whether an existing column needs an ALTER, never inspected it.So adding or removing
unique: trueon an existing column produced no migration at all, andcheck-needs-migrationreported a clean database that did not match the models. The flag read as documentation while the real uniqueness lived only in whatever raw SQL some migration happened to carry — which is exactly how the two sources drift apart.The fix
Reflect single-column unique constraints (
pg_constraint.contype = 'u', which is what knex's.unique()emits) and diff them against the flag in a dedicatedupdateFieldUniquespass, emittingtable.unique([col])/table.dropUnique([col], name)in both directions.This is deliberately disjoint from the existing
kind: 'unique'constraint path, which owns unique indexes not backed by a constraint — the only form that can be partial. The two managed sets never contend for the same database object, so a column may carry a total constraint and a partial index at once.Keyed by column rather than by name: knex derives the constraint name itself and Postgres folds the unquoted identifier to lower case, so
Portfolio.providerRefbecomesportfolio_providerref_uniqueand the name is not reconstructible from the model. Drops pass the reflected name explicitly, since a constraint gqm did not create need not follow knex's convention.Two details worth a reviewer's eye
primaryfields are skipped. The implicitidfield carries bothprimaryandunique, mirroringcolumn()'s own precedence. A primary key has no separatecontype = 'u'row, so without this guard every table would want a migration forever.updateFieldsnow passessetUnique: false. With uniqueness diffed separately, emitting.unique()from an ALTER would re-add an existing constraint whenever aunique: truecolumn is altered for an unrelated reason (a widenedmaxLength, a nullability flip). Arguably a latent bug already.Validation
6 new unit tests in
tests/unit/migration-constraints.spec.ts: add, no-change, drop, relation-FK column, unmanaged column left alone, and new column not double-emitted. Full suite 138 passed / 12 suites;npm run lintandtsc --noEmitclean.Also validated end-to-end against zwei-wealth-platform's schema — 100+ tables, 29 unique indexes. The generator produces exactly two entries, both real gaps that had been found by hand beforehand:
User.legacyIdandPortfolioRequirement.rfpIdentifierdeclareunique: truewith nothing enforcing it. No false positives —Portfolio.providerRef, which is declared and has its constraint, correctly yields nothing.