@owlmeans/postgres-resource 0.1.18-rc.4 → 0.1.18-rc.40
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.
- package/README.md +185 -101
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/postgres-resource/SKILL.md +110 -21
- package/build/consts.d.ts +8 -1
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +8 -1
- package/build/consts.js.map +1 -1
- package/build/declarations.js +5 -5
- package/build/resource.d.ts +2 -2
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +112 -108
- package/build/resource.js.map +1 -1
- package/build/types.d.ts +34 -19
- package/build/types.d.ts.map +1 -1
- package/build/utils/criteria.d.ts +10 -6
- package/build/utils/criteria.d.ts.map +1 -1
- package/build/utils/criteria.js +156 -11
- package/build/utils/criteria.js.map +1 -1
- package/build/utils/diff.d.ts +13 -0
- package/build/utils/diff.d.ts.map +1 -1
- package/build/utils/diff.js +60 -6
- package/build/utils/diff.js.map +1 -1
- package/build/utils/migrations.d.ts +2 -2
- package/build/utils/migrations.js +2 -2
- package/build/utils/name.d.ts +3 -3
- package/build/utils/name.js +3 -3
- package/build/utils/sql.d.ts +10 -2
- package/build/utils/sql.d.ts.map +1 -1
- package/build/utils/sql.js.map +1 -1
- package/build/utils/table.d.ts +4 -0
- package/build/utils/table.d.ts.map +1 -1
- package/build/utils/table.js +4 -0
- package/build/utils/table.js.map +1 -1
- package/package.json +7 -7
- package/src/consts.ts +8 -1
- package/src/declarations.ts +5 -5
- package/src/resource.ts +139 -135
- package/src/types.ts +36 -18
- package/src/utils/criteria.ts +202 -23
- package/src/utils/diff.ts +62 -7
- package/src/utils/migrations.ts +2 -2
- package/src/utils/name.ts +3 -3
- package/src/utils/sql.ts +0 -0
- package/src/utils/table.ts +5 -1
- package/tests/criteria.spec.ts +237 -0
- package/tests/diff.spec.ts +111 -0
- package/tests/name.spec.ts +3 -3
- package/tests/sql.spec.ts +4 -3
package/src/utils/criteria.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { UnsupportedArgumentError } from '@owlmeans/resource'
|
|
2
|
-
import type {
|
|
3
|
-
import { and, asc, desc, or, sql } from 'drizzle-orm'
|
|
2
|
+
import type { Criteria, FieldOperators, Sort } from '@owlmeans/resource'
|
|
3
|
+
import { and, asc, desc, or, param, sql } from 'drizzle-orm'
|
|
4
4
|
import type { SQL } from 'drizzle-orm'
|
|
5
5
|
|
|
6
6
|
import type { ColumnSpec, PgRuntimeTable, TableSpec } from '../types.js'
|
|
@@ -19,6 +19,58 @@ const value = (raw: unknown, column: ColumnSpec): SQL => {
|
|
|
19
19
|
return sql`${raw}`
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** The operand of an array operator as a list — a scalar is the list of itself. */
|
|
23
|
+
const listOf = (operand: unknown): unknown[] => Array.isArray(operand) ? operand : [operand]
|
|
24
|
+
|
|
25
|
+
const isPlainObject = (raw: unknown): raw is Record<string, unknown> =>
|
|
26
|
+
raw != null && typeof raw === 'object' && !Array.isArray(raw) && !(raw instanceof Date)
|
|
27
|
+
|
|
28
|
+
/** A JSON value as a bound `jsonb` literal. */
|
|
29
|
+
const json = (raw: unknown): SQL => sql`${JSON.stringify(raw instanceof Date ? raw.toISOString() : raw)}::jsonb`
|
|
30
|
+
|
|
31
|
+
/** `ARRAY[$1, $2]::jsonb[]` — one bound JSON literal per value. */
|
|
32
|
+
const jsonList = (values: unknown[]): SQL => values.length === 0
|
|
33
|
+
? sql`ARRAY[]::jsonb[]`
|
|
34
|
+
: sql`ARRAY[${sql.join(values.map(entry => sql`${JSON.stringify(entry instanceof Date ? entry.toISOString() : entry)}`), sql`, `)}]::jsonb[]`
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `@>` / `<@` against a jsonb value: an object is contained as an object, anything else as the
|
|
38
|
+
* JSON array it is (or the array of the one value it is).
|
|
39
|
+
*/
|
|
40
|
+
const jsonContainment = (target: SQL, operator: '@>' | '<@', operand: unknown): SQL =>
|
|
41
|
+
sql`${target} ${sql.raw(operator)} ${json(isPlainObject(operand) ? operand : listOf(operand))}`
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `&&` has no jsonb form: an array value overlaps when one of its elements is one of the operands,
|
|
45
|
+
* and a scalar value — like every other store reads it — when it is one of them.
|
|
46
|
+
*/
|
|
47
|
+
const jsonOverlap = (target: SQL, operand: unknown): SQL => {
|
|
48
|
+
const values = listOf(operand)
|
|
49
|
+
return sql`(CASE WHEN jsonb_typeof(${target}) = 'array' THEN EXISTS (SELECT 1 FROM jsonb_array_elements(${target}) AS element WHERE element = ANY(${jsonList(values)})) ELSE ${target} = ANY(${jsonList(values)}) END)`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `@>` / `<@` / `&&` on a column. A native array column takes ONE bound array parameter cast to the
|
|
54
|
+
* column's own type (a bare JS array in a template would expand into a row constructor), a jsonb
|
|
55
|
+
* column takes JSON, and a scalar operand is the one-element list either way.
|
|
56
|
+
*/
|
|
57
|
+
const arrayOperator = (
|
|
58
|
+
table: PgRuntimeTable, column: ColumnSpec, operator: '$contains' | '$contained' | '$overlaps', operand: unknown
|
|
59
|
+
): SQL => {
|
|
60
|
+
const target = columnRef(table, column)
|
|
61
|
+
if (column.jsonb) {
|
|
62
|
+
return operator === '$overlaps'
|
|
63
|
+
? jsonOverlap(target, operand)
|
|
64
|
+
: jsonContainment(target, operator === '$contains' ? '@>' : '<@', operand)
|
|
65
|
+
}
|
|
66
|
+
const symbol = sql.raw(operator === '$contains' ? '@>' : operator === '$contained' ? '<@' : '&&')
|
|
67
|
+
if (column.array) {
|
|
68
|
+
return sql`${target} ${symbol} ${param(listOf(operand))}::${sql.raw(column.sqlType)}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return sql`${target} ${symbol} ${value(operand, column)}`
|
|
72
|
+
}
|
|
73
|
+
|
|
22
74
|
const inList = (table: PgRuntimeTable, column: ColumnSpec, values: unknown[], negate: boolean): SQL => {
|
|
23
75
|
const present = values.filter(entry => entry != null)
|
|
24
76
|
const hasNull = present.length !== values.length
|
|
@@ -48,7 +100,7 @@ const inList = (table: PgRuntimeTable, column: ColumnSpec, values: unknown[], ne
|
|
|
48
100
|
}
|
|
49
101
|
|
|
50
102
|
const operators = (
|
|
51
|
-
table: PgRuntimeTable, column: ColumnSpec, spec:
|
|
103
|
+
table: PgRuntimeTable, column: ColumnSpec, spec: FieldOperators<any>
|
|
52
104
|
): SQL[] => {
|
|
53
105
|
const conditions: SQL[] = []
|
|
54
106
|
for (const [operator, operand] of Object.entries(spec)) {
|
|
@@ -98,13 +150,112 @@ const operators = (
|
|
|
98
150
|
break
|
|
99
151
|
}
|
|
100
152
|
case '$contains':
|
|
101
|
-
|
|
153
|
+
case '$contained':
|
|
154
|
+
case '$overlaps':
|
|
155
|
+
conditions.push(arrayOperator(table, column, operator, operand))
|
|
156
|
+
break
|
|
157
|
+
default:
|
|
158
|
+
throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return conditions
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const escapeLike = (value: string): string => value.replace(/[\\%_]/g, match => `\\${match}`)
|
|
166
|
+
|
|
167
|
+
// ─── Dotted paths into a jsonb column ────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A value at a path, as the jsonb it is (`#>`) and as text (`#>>`). The path is ONE bound `text[]`
|
|
171
|
+
* parameter, so a segment holding a comma or a brace stays one segment.
|
|
172
|
+
*/
|
|
173
|
+
interface PathTarget {
|
|
174
|
+
value: SQL
|
|
175
|
+
text: SQL
|
|
176
|
+
/** Absent, or a JSON `null` — what every other store reads as "no value". */
|
|
177
|
+
missing: SQL
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const pathTarget = (table: PgRuntimeTable, column: ColumnSpec, path: string[]): PathTarget => {
|
|
181
|
+
const value = sql`(${columnRef(table, column)} #> ${param(path)}::text[])`
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
value,
|
|
185
|
+
text: sql`(${columnRef(table, column)} #>> ${param(path)}::text[])`,
|
|
186
|
+
missing: sql`(${value} IS NULL OR ${value} = 'null'::jsonb)`,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `$in` / `$nin` / a bare list at a path — the column rules: a `null` entry widens to absence. */
|
|
191
|
+
const pathList = (target: PathTarget, values: unknown[], negate: boolean): SQL => {
|
|
192
|
+
const present = values.filter(entry => entry != null)
|
|
193
|
+
const hasNull = present.length !== values.length
|
|
194
|
+
|
|
195
|
+
if (present.length < 1) {
|
|
196
|
+
return hasNull
|
|
197
|
+
? negate ? sql`NOT ${target.missing}` : target.missing
|
|
198
|
+
: negate ? sql`TRUE` : sql`FALSE`
|
|
199
|
+
}
|
|
200
|
+
const membership = sql`${target.value} = ANY(${jsonList(present)})`
|
|
201
|
+
if (negate) {
|
|
202
|
+
return sql`(NOT ${target.missing} AND NOT (${membership}))`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return hasNull ? sql`(${membership} OR ${target.missing})` : membership
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The operators at a path, each read as the same operator reads on a column. */
|
|
209
|
+
const pathOperators = (target: PathTarget, spec: FieldOperators<any>, key: string): SQL[] => {
|
|
210
|
+
const conditions: SQL[] = []
|
|
211
|
+
for (const [operator, operand] of Object.entries(spec)) {
|
|
212
|
+
if (operator in COMPARISON) {
|
|
213
|
+
conditions.push(operand == null
|
|
214
|
+
? operator === '$ne' ? sql`NOT ${target.missing}` : target.missing
|
|
215
|
+
: sql`${target.value} ${sql.raw(COMPARISON[operator])} ${json(operand)}`)
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
switch (operator) {
|
|
219
|
+
case '$in':
|
|
220
|
+
case '$nin':
|
|
221
|
+
conditions.push(pathList(target, listOf(operand), operator === '$nin'))
|
|
222
|
+
break
|
|
223
|
+
case '$exists':
|
|
224
|
+
conditions.push(operand === false ? target.missing : sql`NOT ${target.missing}`)
|
|
225
|
+
break
|
|
226
|
+
case '$null':
|
|
227
|
+
conditions.push(operand === false ? sql`NOT ${target.missing}` : target.missing)
|
|
228
|
+
break
|
|
229
|
+
case '$like':
|
|
230
|
+
conditions.push(sql`${target.text} LIKE ${operand}`)
|
|
231
|
+
break
|
|
232
|
+
case '$ilike':
|
|
233
|
+
conditions.push(sql`${target.text} ILIKE ${operand}`)
|
|
234
|
+
break
|
|
235
|
+
case '$regex':
|
|
236
|
+
conditions.push(sql`${target.text} ~ ${operand}`)
|
|
237
|
+
break
|
|
238
|
+
case '$startsWith':
|
|
239
|
+
conditions.push(sql`${target.text} LIKE ${`${escapeLike(`${operand}`)}%`}`)
|
|
240
|
+
break
|
|
241
|
+
case '$endsWith':
|
|
242
|
+
conditions.push(sql`${target.text} LIKE ${`%${escapeLike(`${operand}`)}`}`)
|
|
243
|
+
break
|
|
244
|
+
case '$between': {
|
|
245
|
+
if (!Array.isArray(operand) || operand.length !== 2) {
|
|
246
|
+
throw new UnsupportedArgumentError(`criteria:$between:${key}`)
|
|
247
|
+
}
|
|
248
|
+
conditions.push(sql`${target.value} BETWEEN ${json(operand[0])} AND ${json(operand[1])}`)
|
|
249
|
+
break
|
|
250
|
+
}
|
|
251
|
+
case '$contains':
|
|
252
|
+
conditions.push(jsonContainment(target.value, '@>', operand))
|
|
102
253
|
break
|
|
103
254
|
case '$contained':
|
|
104
|
-
conditions.push(
|
|
255
|
+
conditions.push(jsonContainment(target.value, '<@', operand))
|
|
105
256
|
break
|
|
106
257
|
case '$overlaps':
|
|
107
|
-
conditions.push(
|
|
258
|
+
conditions.push(jsonOverlap(target.value, operand))
|
|
108
259
|
break
|
|
109
260
|
default:
|
|
110
261
|
throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
|
|
@@ -114,30 +265,55 @@ const operators = (
|
|
|
114
265
|
return conditions
|
|
115
266
|
}
|
|
116
267
|
|
|
117
|
-
|
|
268
|
+
/**
|
|
269
|
+
* A criteria value at a dotted path, with the meaning it has on a column: a bare value is typed
|
|
270
|
+
* JSON equality (a number is not its text), a bare list is membership, `null` is absence, an object
|
|
271
|
+
* of operators applies each, and a plain object is containment.
|
|
272
|
+
*/
|
|
273
|
+
const pathCondition = (target: PathTarget, raw: unknown, key: string): SQL[] => {
|
|
274
|
+
if (raw === null) {
|
|
275
|
+
return [target.missing]
|
|
276
|
+
}
|
|
277
|
+
if (isOperatorSpec(raw)) {
|
|
278
|
+
return pathOperators(target, raw, key)
|
|
279
|
+
}
|
|
280
|
+
if (Array.isArray(raw)) {
|
|
281
|
+
return [pathList(target, raw, false)]
|
|
282
|
+
}
|
|
283
|
+
if (isPlainObject(raw)) {
|
|
284
|
+
return [jsonContainment(target.value, '@>', raw)]
|
|
285
|
+
}
|
|
118
286
|
|
|
119
|
-
|
|
287
|
+
return [sql`${target.value} = ${json(raw)}`]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* An object naming at least one `$` key is a spec, not a value to compare against.
|
|
292
|
+
* A Date and an array are values even though both are objects.
|
|
293
|
+
*/
|
|
294
|
+
const isOperatorSpec = (value: unknown): value is FieldOperators<any> =>
|
|
120
295
|
value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
|
|
121
296
|
&& Object.keys(value).some(key => key.startsWith('$'))
|
|
122
297
|
|
|
123
298
|
/**
|
|
124
|
-
* Translate
|
|
299
|
+
* Translate a {@link Criteria} into a WHERE clause. `undefined` means "no constraint at
|
|
300
|
+
* all" — the caller decides whether that is a full scan or a refusal.
|
|
125
301
|
*
|
|
126
302
|
* An unknown key raises rather than being skipped: a typo silently widening a query to
|
|
127
303
|
* the whole table is the failure mode worth being loud about.
|
|
128
304
|
*
|
|
129
305
|
* @throws {UnsupportedArgumentError}
|
|
130
306
|
*/
|
|
131
|
-
export const criteriaToSql = (
|
|
132
|
-
criteria:
|
|
307
|
+
export const criteriaToSql = <T>(
|
|
308
|
+
criteria: Criteria<T> | undefined, spec: TableSpec, table: PgRuntimeTable
|
|
133
309
|
): SQL | undefined => {
|
|
134
310
|
const conditions = build(criteria, spec, table)
|
|
135
311
|
|
|
136
312
|
return conditions.length > 0 ? and(...conditions) : undefined
|
|
137
313
|
}
|
|
138
314
|
|
|
139
|
-
const build = (
|
|
140
|
-
criteria:
|
|
315
|
+
const build = <T>(
|
|
316
|
+
criteria: Criteria<T> | undefined, spec: TableSpec, table: PgRuntimeTable
|
|
141
317
|
): SQL[] => {
|
|
142
318
|
const conditions: SQL[] = []
|
|
143
319
|
for (const [key, raw] of Object.entries(criteria ?? {})) {
|
|
@@ -147,7 +323,7 @@ const build = (
|
|
|
147
323
|
|
|
148
324
|
if (key === '$and' || key === '$or') {
|
|
149
325
|
const parts = (Array.isArray(raw) ? raw : [raw])
|
|
150
|
-
.map(entry => and(...build(entry as
|
|
326
|
+
.map(entry => and(...build(entry as Criteria<T>, spec, table)))
|
|
151
327
|
.filter((entry): entry is SQL => entry != null)
|
|
152
328
|
if (parts.length > 0) {
|
|
153
329
|
const combined = key === '$and' ? and(...parts) : or(...parts)
|
|
@@ -158,7 +334,7 @@ const build = (
|
|
|
158
334
|
continue
|
|
159
335
|
}
|
|
160
336
|
if (key === '$not') {
|
|
161
|
-
const inner = and(...build(raw as
|
|
337
|
+
const inner = and(...build(raw as Criteria<T>, spec, table))
|
|
162
338
|
if (inner != null) {
|
|
163
339
|
conditions.push(sql`NOT (${inner})`)
|
|
164
340
|
}
|
|
@@ -175,7 +351,7 @@ const build = (
|
|
|
175
351
|
if (!column.jsonb) {
|
|
176
352
|
throw new UnsupportedArgumentError(`criteria-path:${key}`)
|
|
177
353
|
}
|
|
178
|
-
conditions.push(
|
|
354
|
+
conditions.push(...pathCondition(pathTarget(table, column, path), raw, key))
|
|
179
355
|
continue
|
|
180
356
|
}
|
|
181
357
|
|
|
@@ -184,7 +360,7 @@ const build = (
|
|
|
184
360
|
continue
|
|
185
361
|
}
|
|
186
362
|
if (isOperatorSpec(raw)) {
|
|
187
|
-
conditions.push(...operators(table, column, raw
|
|
363
|
+
conditions.push(...operators(table, column, raw))
|
|
188
364
|
continue
|
|
189
365
|
}
|
|
190
366
|
if (Array.isArray(raw)) {
|
|
@@ -206,24 +382,27 @@ const build = (
|
|
|
206
382
|
}
|
|
207
383
|
|
|
208
384
|
/**
|
|
209
|
-
* Translate
|
|
210
|
-
* `
|
|
385
|
+
* Translate a {@link Sort} list into ORDER BY. A bare field name is ascending;
|
|
386
|
+
* `{ field, order: 'desc' }` reverses it — the same meaning every backend gives it.
|
|
211
387
|
*
|
|
212
388
|
* The primary key is always appended as a tiebreak. Postgres has no implicit row order, so
|
|
213
389
|
* paginating on a non-unique sort key silently duplicates and skips rows between pages —
|
|
214
390
|
* a difference from mongo that would otherwise surface as a data bug rather than an error.
|
|
215
391
|
*
|
|
392
|
+
* A dotted path is a column this table does not have: criteria can reach into jsonb, ORDER BY
|
|
393
|
+
* cannot, and refusing is better than ordering by something the caller did not name.
|
|
394
|
+
*
|
|
216
395
|
* @throws {UnsupportedArgumentError}
|
|
217
396
|
*/
|
|
218
|
-
export const sortToSql = (
|
|
219
|
-
sort:
|
|
397
|
+
export const sortToSql = <T>(
|
|
398
|
+
sort: Sort<T>[] | undefined, spec: TableSpec, table: PgRuntimeTable
|
|
220
399
|
): SQL[] => {
|
|
221
400
|
const order: SQL[] = []
|
|
222
401
|
const seen: string[] = []
|
|
223
402
|
|
|
224
403
|
for (const entry of sort ?? []) {
|
|
225
|
-
|
|
226
|
-
const
|
|
404
|
+
const property = typeof entry === 'string' ? entry : entry.field
|
|
405
|
+
const descending = typeof entry !== 'string' && entry.order === 'desc'
|
|
227
406
|
const column = spec.byProperty[property]
|
|
228
407
|
if (column == null) {
|
|
229
408
|
throw new UnsupportedArgumentError(`sort:${property}`)
|
package/src/utils/diff.ts
CHANGED
|
@@ -23,19 +23,44 @@ const defaultExpression = (column: ColumnSpec): string | undefined => {
|
|
|
23
23
|
return undefined
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** A type cast as `pg_get_expr` / `pg_get_indexdef` print one: `::text`, `::character varying(64)[]`. */
|
|
27
|
+
const CAST = /::(?:character varying|double precision|bit varying|timestamp with(?:out)? time zone|time with(?:out)? time zone|[a-z_][a-z0-9_]*)(?:\(\d+(?:\s*,\s*\d+)?\))?(?:\[\])?/g
|
|
28
|
+
|
|
26
29
|
/**
|
|
27
|
-
* Postgres echoes defaults back with
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
+
* Postgres echoes defaults back with explicit casts — a declared `'active'` comes back as
|
|
31
|
+
* `'active'::text`. Compare without them, so an unchanged default doesn't look like drift on
|
|
32
|
+
* every boot.
|
|
30
33
|
*/
|
|
31
34
|
const normalizeDefault = (expression: string | null | undefined): string | null => {
|
|
32
35
|
if (expression == null) return null
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Casts anywhere (a `varchar` id's `gen_random_uuid()::text` is stored as
|
|
39
|
+
* `(gen_random_uuid())::character varying`) and the parentheses Postgres adds around them.
|
|
40
|
+
*/
|
|
41
|
+
return expression.toLowerCase().replace(CAST, '').replace(/[()]/g, '').replace(/\s+/g, ' ').trim()
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The form an index or constraint definition is compared in, so the statement this package emits
|
|
46
|
+
* and the text Postgres reports for the object it created compare equal.
|
|
47
|
+
*
|
|
48
|
+
* Postgres deparses what it stores: it leaves an identifier unquoted when it can, casts every
|
|
49
|
+
* literal (`''::text`), casts a `varchar` column inside an expression, wraps a partial predicate and
|
|
50
|
+
* each of its operator expressions in parentheses, and rewrites `x IN (…)` as `x = ANY (ARRAY[…])`.
|
|
51
|
+
* Comparing the raw texts made every boot drop and recreate every declared index and every enum
|
|
52
|
+
* `CHECK`. So both sides lose case, quotes, casts, whitespace and parentheses, and `= ANY (ARRAY[…])`
|
|
53
|
+
* reads as `IN (…)`. What that cannot tell apart — two definitions differing only in how their
|
|
54
|
+
* operators group — is a change to make under a new name.
|
|
55
|
+
*/
|
|
56
|
+
export const canonicalDefinition = (definition: string): string => definition
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/"/g, '')
|
|
59
|
+
.replace(CAST, '')
|
|
60
|
+
.replace(/[\s()]/g, '')
|
|
61
|
+
.replace(/=anyarray\[([^\]]*)\]/g, 'in$1')
|
|
62
|
+
|
|
63
|
+
const normalizeDefinition = canonicalDefinition
|
|
39
64
|
|
|
40
65
|
const columnList = (columns: string[]): string => columns.map(quoteIdent).join(', ')
|
|
41
66
|
|
|
@@ -194,7 +219,31 @@ export const planSync = (
|
|
|
194
219
|
continue
|
|
195
220
|
}
|
|
196
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A retype drops the column's default first.
|
|
224
|
+
*
|
|
225
|
+
* Postgres refuses `ALTER COLUMN … TYPE` outright when the column carries a DEFAULT it cannot
|
|
226
|
+
* cast to the new type — `42804 default for column "x" cannot be cast automatically` — and it
|
|
227
|
+
* refuses it before looking at a single row, so the `USING` clause never gets a chance to
|
|
228
|
+
* help. The whole plan runs in one transaction, so that refusal aborts the entire
|
|
229
|
+
* reconciliation: the table keeps its old shape and the resource fails every boot with an
|
|
230
|
+
* error about a column it is trying to fix. The standard remedy is to take the default out of
|
|
231
|
+
* the way, retype, and put it back — which is what the default-drift block below does, from
|
|
232
|
+
* the spec rather than from whatever the column happened to be carrying.
|
|
233
|
+
*
|
|
234
|
+
* The `id` column is the one that meets this in practice: it is created with a
|
|
235
|
+
* `gen_random_uuid()::text` default, so any change to its declared type hits this path.
|
|
236
|
+
*/
|
|
237
|
+
let defaultDropped = false
|
|
197
238
|
if (!additive && existing.type !== column.sqlType) {
|
|
239
|
+
if (existing.defaultExpr != null) {
|
|
240
|
+
statements.push({
|
|
241
|
+
kind: 'set-default',
|
|
242
|
+
target: column.column,
|
|
243
|
+
sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} DROP DEFAULT`
|
|
244
|
+
})
|
|
245
|
+
defaultDropped = true
|
|
246
|
+
}
|
|
198
247
|
const using = column.using ?? `${quoteIdent(column.column)}::${column.sqlType}`
|
|
199
248
|
statements.push({
|
|
200
249
|
kind: 'alter-type',
|
|
@@ -205,7 +254,13 @@ export const planSync = (
|
|
|
205
254
|
}
|
|
206
255
|
|
|
207
256
|
const expression = defaultExpression(column)
|
|
208
|
-
|
|
257
|
+
// Once the retype has dropped it, the default has to be re-stated even when it matches what
|
|
258
|
+
// the column used to carry: the comparison is against the pre-plan introspection, which stops
|
|
259
|
+
// describing the column the moment the plan drops its default.
|
|
260
|
+
const defaultDrifted = defaultDropped
|
|
261
|
+
? expression != null
|
|
262
|
+
: normalizeDefault(expression) !== normalizeDefault(existing.defaultExpr)
|
|
263
|
+
if (defaultDrifted) {
|
|
209
264
|
statements.push(expression != null
|
|
210
265
|
? {
|
|
211
266
|
kind: 'set-default',
|
package/src/utils/migrations.ts
CHANGED
|
@@ -47,8 +47,8 @@ export const makeTx = (
|
|
|
47
47
|
/**
|
|
48
48
|
* Migration ledger, one table per Postgres schema.
|
|
49
49
|
*
|
|
50
|
-
* It lives inside the resource's own schema, so
|
|
51
|
-
*
|
|
50
|
+
* It lives inside the resource's own schema, so every schema carries the ledger for the
|
|
51
|
+
* tables it holds — correct, because those tables are what the migrations changed.
|
|
52
52
|
*/
|
|
53
53
|
export const makeMigrationStore = (
|
|
54
54
|
client: PoolClient,
|
package/src/utils/name.ts
CHANGED
|
@@ -10,9 +10,9 @@ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/
|
|
|
10
10
|
/**
|
|
11
11
|
* Coerce an arbitrary name into a legal Postgres identifier of at most 63 bytes.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Postgres cuts anything past `NAMEDATALEN - 1` off server side and says nothing about
|
|
14
|
+
* it, so two names that differ only beyond byte 63 would collapse into one identifier.
|
|
15
|
+
* Clamping here instead leaves room for a hash suffix that keeps them apart.
|
|
16
16
|
*/
|
|
17
17
|
export const pgIdentifier = (name: string): string => {
|
|
18
18
|
const sanitized = name.replace(/[^A-Za-z0-9_$]/g, '_')
|
package/src/utils/sql.ts
CHANGED
|
Binary file
|
package/src/utils/table.ts
CHANGED
|
@@ -24,6 +24,10 @@ const pgRaw = customType<{ data: unknown, driverData: unknown, config: { sqlType
|
|
|
24
24
|
* Build the Drizzle table used for CRUD query construction. Structure DDL is emitted from
|
|
25
25
|
* the {@link TableSpec} directly — Drizzle exposes no runtime DDL generator (that lives in
|
|
26
26
|
* drizzle-kit, which would be a second owner of the same tables).
|
|
27
|
+
*
|
|
28
|
+
* The result is stated as a {@link PgRuntimeTable}: the columns exist, keyed by schema
|
|
29
|
+
* property name, but only this function knows which ones — Drizzle's own inference has a
|
|
30
|
+
* literal column map to work from, and here that map was assembled a few lines ago.
|
|
27
31
|
*/
|
|
28
32
|
export const specToTable = (spec: TableSpec): PgRuntimeTable => {
|
|
29
33
|
const namespace = pgSchema(spec.schema)
|
|
@@ -72,5 +76,5 @@ export const specToTable = (spec: TableSpec): PgRuntimeTable => {
|
|
|
72
76
|
.filter((column: unknown) => column != null) as [any, ...any[]]
|
|
73
77
|
})]
|
|
74
78
|
: [])
|
|
75
|
-
]) as PgRuntimeTable
|
|
79
|
+
]) as unknown as PgRuntimeTable
|
|
76
80
|
}
|