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
4452const 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 * t e x t \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 = / ^ n o t \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 ( / \b i s \s + d i s t i n c t \s + f r o m \b / gi) ] ;
139+ if ( operators . length === 1 && ! / \b (?: a n d | o r | n o t ) \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 ( / (?< ! \b n o t ) i n $ / . test ( p ) && i + 1 < parts . length && ! ( parts [ i + 2 ] ?? "" ) . trimStart ( ) . startsWith ( "," ) ) {
174+ p = p . replace ( / i n $ / , "=" ) ;
175+ }
176+ return p . replace ( / (?< ! \b n o t ) i n ( - ? \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 */
54189export 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 * a n y \s + a r r a y / 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 * a n y \s + a r r a y / 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 */
200341function 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.
0 commit comments