Skip to content

feat(migrations): diff field-level unique: true against the database - #540

Draft
smartive-nicolai[bot] wants to merge 1 commit into
mainfrom
feat/unique-in-check-needs-migration
Draft

feat(migrations): diff field-level unique: true against the database#540
smartive-nicolai[bot] wants to merge 1 commit into
mainfrom
feat/unique-in-check-needs-migration

Conversation

@smartive-nicolai

Copy link
Copy Markdown
Contributor

The problem

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 that decides 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 — 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 dedicated updateFieldUniques pass, emitting table.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.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 a reviewer's eye

  • primary fields are skipped. The implicit id field carries both primary and unique, mirroring column()'s own precedence. A primary key has no separate contype = 'u' row, so without this guard 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 (a widened maxLength, 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 lint and tsc --noEmit clean.

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.legacyId and PortfolioRequirement.rfpIdentifier declare unique: true with nothing enforcing it. No false positives — Portfolio.providerRef, which is declared and has its constraint, correctly yields nothing.

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants