Skip to content

Commit 4565725

Browse files
authored
fix(migrate-ts): stop false CHECK drift on Postgres and compare literals as written (#498)
* fix(migrate-ts): stop false CHECK drift on Postgres and compare literals as written A one-value enum, presentIff and validators on varchar(n) columns reported drift after migrate had just built them, because Postgres stores IN (x) as = x, IS NOT DISTINCT FROM as NOT (IS DISTINCT FROM) and casts varchar columns to text. The normaliser also lower-cased and bracket-stripped quoted literals, so an enum member or regex case change read as clean. char(n) is no longer a text-family column for the (col)::text fold. Adds a Postgres round-trip over every generated CHECK shape. * no-mistakes(review): Fold IS NOT DISTINCT FROM safely; keep text cast in renames * no-mistakes(review): Rewrite only the depth-0 IS DISTINCT FROM operator * no-mistakes(review): Fold authored bare col::text on text columns * test(migrate-ts): pin that a stored length((col)::text) on char(n) is drift Covers the non-regex bpchar case on real Postgres and offline.
1 parent fb2db78 commit 4565725

8 files changed

Lines changed: 602 additions & 43 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,26 @@ here.**
106106
on any failure, so the previous state is intact. `meta deps check` also loads the model once
107107
its hashes match and exits 1 with the load error when it does not load.
108108

109+
- **`meta verify --db` no longer reports false CHECK drift on Postgres for a one-value enum, a
110+
`presentIff` validator, or a validator on a `varchar(n)` column; a changed enum-member case
111+
or regex case no longer reads as clean.** Postgres stores `"k" IN ('x')` as `k = 'x'` and
112+
`a IS NOT DISTINCT FROM b` as `NOT (a IS DISTINCT FROM b)`, and casts a `varchar` column to
113+
text (`(region)::text`) in a comparison, a `length()` and a null-safe test. The comparison now
114+
folds all three back, so a database `migrate` just built verifies clean. The same false drift
115+
reached `meta migrate --from-db`, which proposed a drop-check and add-check on every run, and
116+
the offline `migrate --dry-run` after a `baseline --from-db`. It has done so since 1.0.x. In
117+
the other direction, the comparison lower-cased quoted literals and deleted every bracket,
118+
quote and run of blanks inside them, so `IN ('OPEN')` against `IN ('open')` and `~ '^[A-Z]+$'`
119+
against `~ '^[a-z]+$'` compared as equal. Literals are now compared as written. A check
120+
stored in a different case than the model declares therefore shows as drift once: run
121+
`meta migrate` to bring it back. Separately, `char(n)` is no longer treated as a text column
122+
for the `(col)::text` fold: its own regex operator sees the blank padding the cast trims, and
123+
Postgres never adds the cast there, so an explicit `(col)::text ~ '…'` on a `char(n)` column
124+
now reads as drift. A precedence change (`(a OR b) AND c` against `a OR (b AND c)`) is still
125+
not detected; that needs Postgres to canonicalise the expected side. A new Postgres test
126+
migrates, applies and verifies every CHECK shape `migrate` generates. Schema comparison is
127+
TypeScript-owned, so no other port carries this comparison.
128+
109129
### Documentation
110130

111131
- **Two doc errors corrected against the code.** `docs/recipes/mysql.md` said Python ships a

‎server/typescript/packages/migrate-ts/src/check-expr-compare.ts‎

Lines changed: 169 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@
1818
// - respells a cast TARGET canonically: `x::int` → `(x)::integer`
1919
// All five are canonicalized below so an enum/range CHECK introspected from PG
2020
// compares equal to the one we generate (idempotency on the --from-db / verify paths).
21+
// Three more are folded back by foldPgAnalyzerRewrites and COLUMN_TEXT_CAST: a
22+
// one-element `x IN (v)` stored as `x = v`, `a IS NOT DISTINCT FROM b` stored as
23+
// `NOT (a IS DISTINCT FROM b)`, and `(varchar_col)::text` stored for a text operator.
24+
//
25+
// Everything textual runs OUTSIDE single-quoted literals (mapOutsideLiterals). One thing is
26+
// still NOT canonicalized: dropping every bracket erases precedence, so `(a OR b) AND c` and
27+
// `a OR (b AND c)` compare equal. Fixing that takes the server's own canonical form, not more
28+
// string rules.
2129
//
2230
// The last two are why a cast-bearing `@expr` used to drift FOREVER. The paren-strip
2331
// turns PG's added parens into a space, so `(x)::integer` reduced to `x ::integer` while
@@ -43,38 +51,171 @@ function unwrapBacktickIdentifiers(expr: string): string {
4351

4452
const NO_COLUMNS: ReadonlySet<string> = new Set();
4553

54+
/**
55+
* `(col)::text` — the form Postgres writes when it casts a `varchar` column to `text` to reach
56+
* a text operator or function (`length((code)::text)`, `(a)::text < (b)::text`,
57+
* `((region)::text IS DISTINCT FROM 'eu'::text)`) — and the bare `col::text` an author writes
58+
* for the same cast. Matched BEFORE the brackets are stripped, while `(col)` can still be told
59+
* from a call `f(col)`: the match must not follow a word character or a dot. It is dropped only for a column the caller names as text-family
60+
* (`text` / `varchar`), where the cast changes nothing; on any other column it changes the
61+
* operator (citext) or the value (`char(n)` trims its padding), so it stays and reads as drift.
62+
*/
63+
const COLUMN_TEXT_CAST = /(?<![\w.])(?:\(\s*([a-z_]\w*)\s*\)|([a-z_]\w*))\s*::\s*text\b/g;
64+
65+
/** Split on single-quoted literals ('' escapes honored) and rewrite only the text between them. */
66+
function mapOutsideLiterals(s: string, fn: (part: string, index: number) => string): string {
67+
return s
68+
.split(/('(?:[^']|'')*')/)
69+
.map((part, i) => (i % 2 === 1 ? part : fn(part, i)))
70+
.join("");
71+
}
72+
73+
/**
74+
* Index of the `)` closing the `(` at `open`, skipping single-quoted literals and
75+
* double-quoted identifiers; -1 when unbalanced.
76+
*/
77+
function matchingParen(s: string, open: number): number {
78+
let depth = 0;
79+
for (let i = open; i < s.length; i++) {
80+
const ch = s[i]!;
81+
if (ch === "'" || ch === '"') {
82+
i++;
83+
while (i < s.length && !(s[i] === ch && s[i + 1] !== ch)) i += s[i] === ch ? 2 : 1;
84+
} else if (ch === "(") depth++;
85+
else if (ch === ")" && --depth === 0) return i;
86+
}
87+
return -1;
88+
}
89+
90+
/**
91+
* `inner` with everything below depth 0 blanked (literals, quoted identifiers, bracketed
92+
* groups). Same length as `inner`, so an offset found here is the same offset in `inner`.
93+
*/
94+
function topLevelText(inner: string): string {
95+
let out = "";
96+
let depth = 0;
97+
for (let i = 0; i < inner.length; i++) {
98+
const ch = inner[i]!;
99+
if (ch === "'" || ch === '"') {
100+
const start = i++;
101+
while (i < inner.length && !(inner[i] === ch && inner[i + 1] !== ch)) i += inner[i] === ch ? 2 : 1;
102+
i = Math.min(i, inner.length - 1);
103+
out += " ".repeat(i - start + 1);
104+
} else if (ch === "(" || ch === "[") { depth++; out += " "; }
105+
else if (ch === ")" || ch === "]") { depth--; out += " "; }
106+
else out += depth === 0 ? ch : " ";
107+
}
108+
return out;
109+
}
110+
111+
/**
112+
* `NOT (A IS DISTINCT FROM B)` → `(A IS NOT DISTINCT FROM B)`: Postgres has a node only for
113+
* `IS DISTINCT FROM`, so it stores `a IS NOT DISTINCT FROM b` in the first spelling. Run on
114+
* the RAW expression, while the brackets still show what `NOT` applies to: only `NOT`
115+
* followed by one balanced bracketed operand that is exactly a single `IS DISTINCT FROM`
116+
* comparison (no depth-0 `and` / `or` / `not`) is folded. `(NOT x) IS DISTINCT FROM y`
117+
* has no such operand and stays as written, so the fold cannot invent an equality.
118+
*/
119+
function foldNotDistinctFrom(expr: string): string {
120+
let out = "";
121+
let i = 0;
122+
while (i < expr.length) {
123+
const ch = expr[i]!;
124+
if (ch === "'" || ch === '"') {
125+
const start = i++;
126+
while (i < expr.length && !(expr[i] === ch && expr[i + 1] !== ch)) i += expr[i] === ch ? 2 : 1;
127+
i++;
128+
out += expr.slice(start, i);
129+
continue;
130+
}
131+
const not = /^not\s*\(/i.exec(expr.slice(i));
132+
if (not && !/[\w.]/.test(expr[i - 1] ?? " ")) {
133+
const open = i + not[0].length - 1;
134+
const close = matchingParen(expr, open);
135+
if (close !== -1) {
136+
const inner = expr.slice(open + 1, close);
137+
const top = topLevelText(inner);
138+
const operators = [...top.matchAll(/\bis\s+distinct\s+from\b/gi)];
139+
if (operators.length === 1 && !/\b(?:and|or|not)\b/i.test(top)) {
140+
const op = operators[0]!;
141+
out += `(${inner.slice(0, op.index)}is not distinct from${inner.slice(op.index + op[0].length)})`;
142+
i = close + 1;
143+
continue;
144+
}
145+
}
146+
}
147+
out += ch;
148+
i++;
149+
}
150+
return out;
151+
}
152+
153+
/**
154+
* Two rewrites Postgres makes while ANALYZING an expression, which no textual normalisation
155+
* of its output can undo, so they are folded back to the spelling migrate emits:
156+
*
157+
* - a ONE-element `x IN (v)` is stored as `x = v` (`IN` only becomes `= ANY (ARRAY[…])` from
158+
* two elements, which the `any array` fold above already handles). A literal list is told
159+
* apart by the comma after its first element, so `in 'a', 'b'` is left alone.
160+
* - `a IS NOT DISTINCT FROM b` is stored as `NOT (a IS DISTINCT FROM b)`; see
161+
* foldNotDistinctFrom, which runs earlier because it needs the brackets.
162+
*
163+
* The IN fold fires on text that SQLite and D1 never store and runs outside literals. It sits on
164+
* the normalised string (brackets already gone), where it is a plain word pattern.
165+
*/
166+
function foldPgAnalyzerRewrites(s: string): string {
167+
const parts = s.split(/('(?:[^']|'')*')/);
168+
return parts
169+
.map((part, i) => {
170+
if (i % 2 === 1) return part;
171+
let p = part;
172+
// `<lhs> in 'v'` — the literal is the next part; a following comma means a list.
173+
if (/(?<!\bnot) in $/.test(p) && i + 1 < parts.length && !(parts[i + 2] ?? "").trimStart().startsWith(",")) {
174+
p = p.replace(/ in $/, "=");
175+
}
176+
return p.replace(/(?<!\bnot) in (-?\d+(?:\.\d+)?)(?![\w.]|\s*,)/g, "=$1");
177+
})
178+
.join("");
179+
}
180+
46181
/**
47182
* Canonical form: drop casts/brackets/parens, fold `= ANY (ARRAY[…])` back to `IN`,
48183
* lower-case, collapse whitespace, canonicalize cast target types, and collapse spacing
49184
* around commas, casts and SYMBOLIC operators (outside single-quoted literals).
50185
*
51-
* `textColumns` (lower-cased names of text-family columns) lets a `(col)::text` cast before
52-
* a regex operator fold away; see isElidableCast.
186+
* `textColumns` (lower-cased names of `text` / `varchar` columns) lets a `(col)::text` cast
187+
* fold away; see COLUMN_TEXT_CAST and isElidableCast.
53188
*/
54189
export function normalizeCheckExpr(expr: string, textColumns: ReadonlySet<string> = NO_COLUMNS): string {
55-
const stripped = unwrapBacktickIdentifiers(expr)
56-
.toLowerCase()
57-
// Drop `::text` / `::"MyType"` type casts PG adds to literals. The lookbehind
58-
// restricts the strip to a cast that immediately follows a CLOSING single
59-
// quote (`'open'::text`), so a `::` appearing INSIDE a regex pattern literal
60-
// (`slug ~ 'a::foo'`) is preserved — otherwise two distinct regex CHECKs would
61-
// normalize equal and a pattern change would be silently missed.
62-
.replace(/(?<=')::\s*"?\w+"?/g, "")
63-
// Unwrap double-quoted IDENTIFIERS only (`"enumval"` → `enumval`). We emit a
64-
// quoted column (`"enumVal" IN (...)`) so a mixed-case column is valid SQL;
65-
// PG's introspected definition quotes mixed-case identifiers too. Unwrapping
66-
// makes the generated and introspected forms compare equal, and keeps a
67-
// legacy unquoted check matching its quoted re-emit. The pattern matches only
68-
// a quote-pair wrapping a bare identifier (word chars), so a `"` INSIDE a
69-
// single-quoted string/regex literal (`slug ~ 'a"b'`) is preserved — two
70-
// distinct patterns must not normalize equal.
71-
.replace(/"(\w+)"/g, "$1")
72-
.replace(/[()[\]]/g, " ") // drop parens AND square brackets (ARRAY[…])
73-
.replace(/\s+/g, " ")
74-
.trim();
75-
// PG stores `col IN (…)` as `col = ANY (ARRAY[…])`; after the bracket strip above
76-
// that reads `col = any array …`. Fold it back to the `col in …` form we emit.
77-
const folded = stripped.replace(/=\s*any\s+array/g, "in").replace(/\s+/g, " ").trim();
190+
// Every textual rewrite below runs OUTSIDE single-quoted literals. A literal is DATA: its
191+
// case, its brackets, its `"quoted"` words and its runs of spaces all change what the
192+
// constraint accepts, so two checks differing only inside a literal (an enum member's
193+
// case, a regex's character class, a `(` in a pattern) must stay distinct. Reading them
194+
// as equal is this file's failure in the worse direction: a real change reported clean.
195+
const stripped = mapOutsideLiterals(foldNotDistinctFrom(unwrapBacktickIdentifiers(expr)), (part, index) =>
196+
// Drop `::text` / `::"MyType"` type casts PG adds to literals. Only a cast that
197+
// immediately follows a CLOSING single quote (`'open'::text`) is one, which is exactly
198+
// the head of an outside part that follows a literal — so a `::` INSIDE a regex pattern
199+
// literal (`slug ~ 'a::foo'`) is never seen here.
200+
(index > 0 ? part.replace(/^::\s*"?\w+"?/, "") : part)
201+
.toLowerCase()
202+
// Unwrap double-quoted IDENTIFIERS only (`"enumval"` → `enumval`). We emit a
203+
// quoted column (`"enumVal" IN (...)`) so a mixed-case column is valid SQL;
204+
// PG's introspected definition quotes mixed-case identifiers too. Unwrapping
205+
// makes the generated and introspected forms compare equal, and keeps a
206+
// legacy unquoted check matching its quoted re-emit.
207+
.replace(/"(\w+)"/g, "$1")
208+
.replace(COLUMN_TEXT_CAST, (whole, parenthesised: string | undefined, bare: string | undefined) => {
209+
const column = parenthesised ?? bare!;
210+
return textColumns.has(column) ? column : whole;
211+
})
212+
.replace(/[()[\]]/g, " ") // drop parens AND square brackets (ARRAY[…])
213+
.replace(/\s+/g, " ")
214+
// PG stores `col IN (…)` as `col = ANY (ARRAY[…])`; after the bracket strip above
215+
// that reads `col = any array …`. Fold it back to the `col in …` form we emit.
216+
.replace(/=\s*any\s+array/g, "in"),
217+
).trim();
218+
const folded = foldPgAnalyzerRewrites(stripped);
78219
// Normalize spacing around COMMAS and SYMBOLIC OPERATORS, so the spelling a human
79220
// writes compares equal to the one Postgres hands back.
80221
//
@@ -199,8 +340,9 @@ function readCastTarget(s: string, i: number): { type: string; consumed: number
199340
*/
200341
function isElidableCast(type: string, lhs: string, rest: string, textColumns: ReadonlySet<string>): boolean {
201342
// `::text` on a COLUMN directly before a regex-match operator (`~`, `~*`, `!~`, `!~*`),
202-
// and only when the caller names that column as text-family (text, character varying,
203-
// character). On such a column PG adds the cast itself (`"key" ~ '…'` comes back
343+
// and only when the caller names that column as text-family (text, character varying;
344+
// NOT char(n), whose own operator sees the blank padding the cast trims). On such a
345+
// column PG adds the cast itself (`"key" ~ '…'` comes back
204346
// `((key)::text ~ '…'::text)`). On any other column, citext included, the cast changes
205347
// the operator (`col::text ~ 'A'` is case-sensitive, `col ~ 'A'` on citext is not), so it
206348
// survives. With no set supplied nothing is elided.

‎server/typescript/packages/migrate-ts/src/diff/index.ts‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,10 @@ export async function diff(
283283
// Pass 3: column renames (table renames were resolved in Pass 1c).
284284
await detectColumnRenames(changes, args.onAmbiguous, args.renames);
285285
// Pass 3b: a renamed column carries the constraints and indexes named after it.
286-
carryThroughColumnRenames(changes, args.dialect);
286+
carryThroughColumnRenames(changes, args.dialect, (id) => {
287+
const live = predecessors.get(id) ?? actualTables.get(id);
288+
return live === undefined ? NO_TEXT_COLUMNS : textColumnsOf(live);
289+
});
287290

288291
// Strip the rename-detection side-channel fields before status assignment / return.
289292
for (const c of changes) {
@@ -369,7 +372,7 @@ function diffRenamedTables(
369372
const carried = withRenamedRefTables({ ...actual, name: r.to }, renamed);
370373
const tableChanges: Change[] = [];
371374
diffTablePair(expected, carried, tableChanges, dialect);
372-
changes.push(...(dialect === "postgres" ? foldNameOnlyChanges(r, tableChanges) : tableChanges));
375+
changes.push(...(dialect === "postgres" ? foldNameOnlyChanges(r, tableChanges, textColumnsOf(actual)) : tableChanges));
373376
}
374377
return { renamed, predecessors };
375378
}
@@ -422,7 +425,11 @@ function withRenamedRefTables(t: TableDescriptor, renamed: ReadonlyMap<string, s
422425
* sits in the always-allowed status bucket, so a fold over a pair that differed in shape
423426
* would silently bypass the gate.
424427
*/
425-
function foldNameOnlyChanges(r: Extract<Change, { kind: "rename-table" }>, tableChanges: Change[]): Change[] {
428+
function foldNameOnlyChanges(
429+
r: Extract<Change, { kind: "rename-table" }>,
430+
tableChanges: Change[],
431+
textColumns: ReadonlySet<string>,
432+
): Change[] {
426433
const constraintRenames: NameChange[] = [];
427434
const indexRenames: NameChange[] = [];
428435
const folded = new Set<Change>();
@@ -436,7 +443,7 @@ function foldNameOnlyChanges(r: Extract<Change, { kind: "rename-table" }>, table
436443
} else if (d.kind === "drop-check" && d.restore !== undefined) {
437444
const restore = d.restore;
438445
const a = tableChanges.find((c): c is Extract<Change, { kind: "add-check" }> =>
439-
c.kind === "add-check" && !folded.has(c) && checkExprEquals(c.check.expression, restore.expression));
446+
c.kind === "add-check" && !folded.has(c) && checkExprEquals(c.check.expression, restore.expression, textColumns));
440447
if (a) { folded.add(d).add(a); constraintRenames.push({ from: d.check, to: a.check.name }); }
441448
} else if (d.kind === "drop-index" && d.restore !== undefined) {
442449
const restore = d.restore;
@@ -474,7 +481,11 @@ function foldNameOnlyChanges(r: Extract<Change, { kind: "rename-table" }>, table
474481
*
475482
* Anything that is not EXACTLY the old rule under the new names stays a gated drop+add.
476483
*/
477-
function carryThroughColumnRenames(changes: Change[], dialect: Dialect | undefined): void {
484+
function carryThroughColumnRenames(
485+
changes: Change[],
486+
dialect: Dialect | undefined,
487+
textColumnsOfTable: (tableId: string) => ReadonlySet<string>,
488+
): void {
478489
const renamesByTable = new Map<string, Map<string, string>>();
479490
const renameChangeByTable = new Map<string, Extract<Change, { kind: "rename-column" }>>();
480491
for (const c of changes) {
@@ -503,8 +514,10 @@ function carryThroughColumnRenames(changes: Change[], dialect: Dialect | undefin
503514
const sameTable = (c: Change): boolean => !paired.has(c) && idOf(c) === table;
504515
if (d.kind === "drop-check" && d.restore !== undefined) {
505516
const want = renameExprIdentifiers(d.restore.expression, renames);
517+
const lowerRenames = new Map([...renames].map(([from, to]) => [from.toLowerCase(), to.toLowerCase()]));
518+
const textColumns = new Set([...textColumnsOfTable(table)].map((col) => lowerRenames.get(col) ?? col));
506519
const a = changes.find((c): c is Extract<Change, { kind: "add-check" }> =>
507-
c.kind === "add-check" && sameTable(c) && checkExprEquals(c.check.expression, want));
520+
c.kind === "add-check" && sameTable(c) && checkExprEquals(c.check.expression, want, textColumns));
508521
if (a) { paired.add(d).add(a); pairs.push({ drop: d, add: a, from: d.check, to: a.check.name, index: false, table }); }
509522
} else if (d.kind === "drop-fk" && d.restore !== undefined) {
510523
const want: FkDescriptor = { ...d.restore, columns: mapCols(d.restore.columns, renames) };
@@ -809,7 +822,15 @@ function respelledSpread(expected: CheckDescriptor, actual: CheckDescriptor): {
809822
return checkExprRespelledNullSafe(expected.expression, actual.expression) ? { respelled: true } : {};
810823
}
811824

812-
const PG_TEXT_FAMILY_UDTS: ReadonlySet<string> = new Set(["text", "varchar", "bpchar"]);
825+
const PG_TEXT_FAMILY_UDTS: ReadonlySet<string> = new Set(["text", "varchar"]);
826+
827+
const NO_TEXT_COLUMNS: ReadonlySet<string> = new Set();
828+
829+
function textColumnsOf(table: TableDescriptor): Set<string> {
830+
return new Set(
831+
table.columns.filter((c) => c.udtName !== undefined && PG_TEXT_FAMILY_UDTS.has(c.udtName)).map((c) => c.name.toLowerCase()),
832+
);
833+
}
813834

814835
function diffTableChecks(
815836
expected: TableDescriptor,
@@ -831,9 +852,7 @@ function diffTableChecks(
831852
const exprFallback = dialect === "sqlite" || dialect === "d1";
832853
const actualByName = new Map(actual.checks.filter((c) => c.name !== "").map((c) => [c.name, c]));
833854
const consumed = new Set<CheckDescriptor>();
834-
const textColumns = new Set(
835-
actual.columns.filter((c) => c.udtName !== undefined && PG_TEXT_FAMILY_UDTS.has(c.udtName)).map((c) => c.name.toLowerCase()),
836-
);
855+
const textColumns = textColumnsOf(actual);
837856

838857
for (const ec of expected.checks) {
839858
const ac = actualByName.get(ec.name);

0 commit comments

Comments
 (0)