@metaobjectsdev/migrate-ts 0.9.0 → 0.11.0-rc.1
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/dist/check-expr-compare.d.ts.map +1 -1
- package/dist/check-expr-compare.js +9 -0
- package/dist/check-expr-compare.js.map +1 -1
- package/dist/diff/index.d.ts +16 -0
- package/dist/diff/index.d.ts.map +1 -1
- package/dist/diff/index.js +44 -5
- package/dist/diff/index.js.map +1 -1
- package/dist/emit/postgres.js +13 -1
- package/dist/emit/postgres.js.map +1 -1
- package/dist/expected-schema.d.ts.map +1 -1
- package/dist/expected-schema.js +211 -21
- package/dist/expected-schema.js.map +1 -1
- package/dist/introspect/postgres.d.ts +10 -3
- package/dist/introspect/postgres.d.ts.map +1 -1
- package/dist/introspect/postgres.js +89 -12
- package/dist/introspect/postgres.js.map +1 -1
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -4
- package/src/check-expr-compare.ts +9 -0
- package/src/diff/index.ts +63 -5
- package/src/emit/postgres.ts +13 -1
- package/src/expected-schema.ts +232 -23
- package/src/introspect/postgres.ts +113 -16
- package/src/types.ts +25 -0
package/src/expected-schema.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot, MetaValidator } from "@metaobjectsdev/metadata";
|
|
2
2
|
import {
|
|
3
3
|
VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX,
|
|
4
|
+
VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN,
|
|
5
|
+
VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE,
|
|
4
6
|
VALIDATOR_ATTR_PATTERN,
|
|
7
|
+
VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT,
|
|
8
|
+
VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS,
|
|
5
9
|
TYPE_OBJECT,
|
|
10
|
+
TYPE_FIELD,
|
|
11
|
+
OBJECT_ATTR_DISCRIMINATOR,
|
|
12
|
+
OBJECT_ATTR_DISCRIMINATOR_VALUE,
|
|
6
13
|
MetaSource,
|
|
7
14
|
IDENTITY_ATTR_GENERATION,
|
|
8
15
|
IDENTITY_ATTR_UNIQUE,
|
|
16
|
+
IDENTITY_ATTR_ORDERS,
|
|
17
|
+
IDENTITY_ATTR_WHERE,
|
|
18
|
+
IDENTITY_ATTR_EXPR,
|
|
19
|
+
IDENTITY_ATTR_USING,
|
|
20
|
+
IDENTITY_ATTR_CONSTRAINT_NAME,
|
|
9
21
|
FIELD_ATTR_DEFAULT,
|
|
10
22
|
FIELD_ATTR_MAX_LENGTH,
|
|
11
23
|
FIELD_ATTR_PRECISION,
|
|
@@ -14,8 +26,6 @@ import {
|
|
|
14
26
|
FIELD_SUBTYPE_STRING,
|
|
15
27
|
FIELD_SUBTYPE_INT,
|
|
16
28
|
FIELD_SUBTYPE_LONG,
|
|
17
|
-
FIELD_SUBTYPE_SHORT,
|
|
18
|
-
FIELD_SUBTYPE_BYTE,
|
|
19
29
|
FIELD_SUBTYPE_DOUBLE,
|
|
20
30
|
FIELD_SUBTYPE_FLOAT,
|
|
21
31
|
FIELD_SUBTYPE_DECIMAL,
|
|
@@ -25,7 +35,6 @@ import {
|
|
|
25
35
|
FIELD_SUBTYPE_TIME,
|
|
26
36
|
FIELD_SUBTYPE_TIMESTAMP,
|
|
27
37
|
FIELD_SUBTYPE_OBJECT,
|
|
28
|
-
FIELD_SUBTYPE_CLASS,
|
|
29
38
|
FIELD_SUBTYPE_UUID,
|
|
30
39
|
FIELD_SUBTYPE_ENUM,
|
|
31
40
|
FIELD_ATTR_VALUES,
|
|
@@ -92,6 +101,9 @@ export function buildExpectedSchema(
|
|
|
92
101
|
if (child.type !== TYPE_OBJECT) continue;
|
|
93
102
|
if (child.isAbstract) continue;
|
|
94
103
|
if (child.subType === "value") continue;
|
|
104
|
+
// FR-017 TPH: a subtype shares its discriminator base's single table, so it
|
|
105
|
+
// emits no table of its own. Its own columns are folded into the base below.
|
|
106
|
+
if (isTphSubtype(child)) continue;
|
|
95
107
|
const hasReadOnlySource = child.ownChildren().some(
|
|
96
108
|
(c) => c instanceof MetaSource && c.isReadOnly(),
|
|
97
109
|
);
|
|
@@ -103,7 +115,14 @@ export function buildExpectedSchema(
|
|
|
103
115
|
entities.push({ entity: child as MetaObject, tableName: resolveTableName(child) });
|
|
104
116
|
}
|
|
105
117
|
const entityToTable = new Map(entities.map((e) => [e.entity.name, e.tableName]));
|
|
106
|
-
|
|
118
|
+
// A reference's targetEntity is package-qualified by the loader (e.g.
|
|
119
|
+
// "acme::a::Foo"), but entityToTable is keyed by the bare entity name ("Foo").
|
|
120
|
+
// Fall back to the bare suffix so cross-package FK targets resolve.
|
|
121
|
+
const resolveTargetTable = (entityName: string) =>
|
|
122
|
+
entityToTable.get(entityName) ??
|
|
123
|
+
(entityName.includes("::")
|
|
124
|
+
? entityToTable.get(entityName.slice(entityName.lastIndexOf("::") + 2))
|
|
125
|
+
: undefined);
|
|
107
126
|
|
|
108
127
|
// Pass 2: build full descriptors with FK resolution.
|
|
109
128
|
// Schema is resolved here (not stored in Pass 1) to avoid exactOptionalPropertyTypes
|
|
@@ -179,6 +198,41 @@ function normalizeForSqlite(sqlType: SqlType): SqlType {
|
|
|
179
198
|
}
|
|
180
199
|
}
|
|
181
200
|
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// FR-017 TPH (table-per-hierarchy) — single-table schema emission.
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** The @discriminator-bearing ancestor of `entity`, or undefined for non-TPH. */
|
|
206
|
+
function discriminatorBaseOf(entity: MetaData): MetaData | undefined {
|
|
207
|
+
let a = entity.superResolved;
|
|
208
|
+
while (a !== undefined) {
|
|
209
|
+
if (a.ownAttr(OBJECT_ATTR_DISCRIMINATOR) !== undefined) return a;
|
|
210
|
+
a = a.superResolved;
|
|
211
|
+
}
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** True if `entity` is a TPH SUBTYPE (declares @discriminatorValue + has a
|
|
216
|
+
* discriminator-bearing ancestor). Such an entity emits no table of its own. */
|
|
217
|
+
function isTphSubtype(entity: MetaData): boolean {
|
|
218
|
+
return (
|
|
219
|
+
entity.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE) !== undefined &&
|
|
220
|
+
discriminatorBaseOf(entity) !== undefined
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Concrete TPH subtypes whose discriminator base is `base` (root-level scan). */
|
|
225
|
+
function tphConcreteSubtypes(base: MetaObject, root: MetaData): MetaObject[] {
|
|
226
|
+
if (base.ownAttr(OBJECT_ATTR_DISCRIMINATOR) === undefined) return [];
|
|
227
|
+
return root.ownChildren().filter(
|
|
228
|
+
(c): c is MetaObject =>
|
|
229
|
+
c.type === TYPE_OBJECT &&
|
|
230
|
+
!c.isAbstract &&
|
|
231
|
+
c.ownAttr(OBJECT_ATTR_DISCRIMINATOR_VALUE) !== undefined &&
|
|
232
|
+
discriminatorBaseOf(c) === base,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
182
236
|
function buildTable(
|
|
183
237
|
entity: MetaObject,
|
|
184
238
|
tableName: string,
|
|
@@ -216,6 +270,24 @@ function buildTable(
|
|
|
216
270
|
}
|
|
217
271
|
}
|
|
218
272
|
|
|
273
|
+
// FR-017 TPH: if this is a discriminator base, fold each concrete subtype's
|
|
274
|
+
// OWN fields into the single table as NULLABLE columns (a row of any other
|
|
275
|
+
// subtype stores NULL there), even when the field is @required. Dedupe by
|
|
276
|
+
// column name so an inherited/overridden base column is not re-emitted.
|
|
277
|
+
if (entity.ownAttr(OBJECT_ATTR_DISCRIMINATOR) !== undefined) {
|
|
278
|
+
const existing = new Set(columns.map((c) => c.name));
|
|
279
|
+
for (const sub of tphConcreteSubtypes(entity, root)) {
|
|
280
|
+
for (const field of sub.ownChildren()) {
|
|
281
|
+
if (field.type !== TYPE_FIELD) continue;
|
|
282
|
+
const col = buildColumn(field, false, undefined, strategy);
|
|
283
|
+
if (existing.has(col.name)) continue;
|
|
284
|
+
col.nullable = true; // subtype-only columns are always nullable in TPH
|
|
285
|
+
columns.push(col);
|
|
286
|
+
existing.add(col.name);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
219
291
|
const descriptor: TableDescriptor = {
|
|
220
292
|
name: tableName,
|
|
221
293
|
columns,
|
|
@@ -263,18 +335,42 @@ function buildSecondaryIndexes(
|
|
|
263
335
|
// Drizzle emits the index using the identity's @name attr directly (no table
|
|
264
336
|
// prefix), so the expected name must match.
|
|
265
337
|
for (const identity of entity.secondaryIdentities()) {
|
|
338
|
+
const exprRaw = identity.ownAttr(IDENTITY_ATTR_EXPR);
|
|
339
|
+
const expr = typeof exprRaw === "string" && exprRaw.trim().length > 0 ? exprRaw.trim() : undefined;
|
|
266
340
|
const fieldNames = readIdentityFields(identity);
|
|
267
|
-
|
|
341
|
+
// An expression index keys off @expr (not @fields); a plain index needs @fields.
|
|
342
|
+
if (fieldNames.length === 0 && expr === undefined) continue;
|
|
268
343
|
const cols = fieldNames.map((jsName) => {
|
|
269
344
|
const field = findField(entity, jsName);
|
|
270
345
|
return field ? resolveColumnName(field, strategy) : applyColumnNamingStrategy(jsName, strategy);
|
|
271
346
|
});
|
|
272
347
|
const uniqueAttr = identity.ownAttr(IDENTITY_ATTR_UNIQUE);
|
|
273
|
-
|
|
348
|
+
const index: IndexDescriptor = {
|
|
274
349
|
name: identity.name,
|
|
275
|
-
columns: cols,
|
|
350
|
+
columns: expr ? [] : cols,
|
|
276
351
|
unique: uniqueAttr !== false,
|
|
277
|
-
}
|
|
352
|
+
};
|
|
353
|
+
if (expr) index.expr = expr;
|
|
354
|
+
const usingRaw = identity.ownAttr(IDENTITY_ATTR_USING);
|
|
355
|
+
if (typeof usingRaw === "string" && usingRaw.trim().length > 0 && usingRaw.trim() !== "btree") {
|
|
356
|
+
index.using = usingRaw.trim();
|
|
357
|
+
}
|
|
358
|
+
// @orders — per-field sort direction (positional to @fields). Only attach when
|
|
359
|
+
// at least one field is descending (an all-ascending array is the default and
|
|
360
|
+
// must serialize identically to "no orders" for diff stability).
|
|
361
|
+
const ordersRaw = identity.ownAttr(IDENTITY_ATTR_ORDERS);
|
|
362
|
+
if (Array.isArray(ordersRaw)) {
|
|
363
|
+
const orders = cols.map((_, i) => (ordersRaw[i] === "desc" ? "desc" : "asc")) as (
|
|
364
|
+
"asc" | "desc"
|
|
365
|
+
)[];
|
|
366
|
+
if (orders.some((o) => o === "desc")) index.orders = orders;
|
|
367
|
+
}
|
|
368
|
+
// @where — partial-index predicate.
|
|
369
|
+
const whereRaw = identity.ownAttr(IDENTITY_ATTR_WHERE);
|
|
370
|
+
if (typeof whereRaw === "string" && whereRaw.trim().length > 0) {
|
|
371
|
+
index.where = whereRaw.trim();
|
|
372
|
+
}
|
|
373
|
+
indexes.push(index);
|
|
278
374
|
}
|
|
279
375
|
return indexes;
|
|
280
376
|
}
|
|
@@ -297,20 +393,20 @@ function buildSecondaryIndexes(
|
|
|
297
393
|
* column name verbatim (matching the enum-check convention).
|
|
298
394
|
*/
|
|
299
395
|
function validatorCheck(
|
|
300
|
-
v: MetaValidator,
|
|
396
|
+
v: MetaValidator, qcol: string, tableName: string, col: string, dialect: Dialect | undefined,
|
|
301
397
|
): CheckDescriptor | null {
|
|
302
398
|
switch (v.subType) {
|
|
303
399
|
case VALIDATOR_SUBTYPE_NUMERIC: {
|
|
304
400
|
const parts: string[] = [];
|
|
305
|
-
if (v.min !== undefined) parts.push(`${
|
|
306
|
-
if (v.max !== undefined) parts.push(`${
|
|
401
|
+
if (v.min !== undefined) parts.push(`${qcol} >= ${v.min}`);
|
|
402
|
+
if (v.max !== undefined) parts.push(`${qcol} <= ${v.max}`);
|
|
307
403
|
if (parts.length === 0) return null;
|
|
308
404
|
return { name: `${tableName}_${col}_numeric_chk`, expression: parts.join(" AND ") };
|
|
309
405
|
}
|
|
310
406
|
case VALIDATOR_SUBTYPE_LENGTH: {
|
|
311
407
|
const parts: string[] = [];
|
|
312
|
-
if (v.min !== undefined) parts.push(`length(${
|
|
313
|
-
if (v.max !== undefined) parts.push(`length(${
|
|
408
|
+
if (v.min !== undefined) parts.push(`length(${qcol}) >= ${v.min}`);
|
|
409
|
+
if (v.max !== undefined) parts.push(`length(${qcol}) <= ${v.max}`);
|
|
314
410
|
if (parts.length === 0) return null;
|
|
315
411
|
return { name: `${tableName}_${col}_length_chk`, expression: parts.join(" AND ") };
|
|
316
412
|
}
|
|
@@ -321,7 +417,7 @@ function validatorCheck(
|
|
|
321
417
|
if (typeof pattern !== "string" || pattern.length === 0) return null;
|
|
322
418
|
return {
|
|
323
419
|
name: `${tableName}_${col}_regex_chk`,
|
|
324
|
-
expression: `${
|
|
420
|
+
expression: `${qcol} ~ '${pattern.replace(/'/g, "''")}'`,
|
|
325
421
|
};
|
|
326
422
|
}
|
|
327
423
|
default:
|
|
@@ -329,30 +425,140 @@ function validatorCheck(
|
|
|
329
425
|
}
|
|
330
426
|
}
|
|
331
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Quote a column identifier for embedding in a CHECK expression. Both Postgres
|
|
430
|
+
* and SQLite quote identifiers with double-quotes, so the dialect-neutral
|
|
431
|
+
* expression text can carry a quoted column. Quoting is REQUIRED for a
|
|
432
|
+
* mixed-case column (e.g. `enumVal`): a bare `enumVal IN (...)` folds to
|
|
433
|
+
* lowercase `enumval` and references a non-existent column. The check-expression
|
|
434
|
+
* comparator (`normalizeCheckExpr`) strips quotes so an introspected check still
|
|
435
|
+
* compares equal.
|
|
436
|
+
*/
|
|
437
|
+
function quoteCheckCol(col: string): string {
|
|
438
|
+
return `"${col.replace(/"/g, '""')}"`;
|
|
439
|
+
}
|
|
440
|
+
|
|
332
441
|
function buildChecks(
|
|
333
442
|
entity: MetaObject, tableName: string, strategy: ColumnNamingStrategy, dialect: Dialect | undefined,
|
|
334
443
|
): CheckDescriptor[] {
|
|
335
444
|
const checks: CheckDescriptor[] = [];
|
|
336
445
|
for (const field of entity.fields()) {
|
|
337
446
|
const col = resolveColumnName(field, strategy);
|
|
338
|
-
|
|
447
|
+
const qcol = quoteCheckCol(col);
|
|
448
|
+
// Enum membership check.
|
|
339
449
|
if (field.subType === FIELD_SUBTYPE_ENUM) {
|
|
340
450
|
const raw = field.attr(FIELD_ATTR_VALUES);
|
|
341
451
|
if (Array.isArray(raw) && raw.length > 0) {
|
|
342
452
|
const values = raw.map((v) => String(v));
|
|
343
|
-
const expression = `${
|
|
453
|
+
const expression = `${qcol} IN (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")})`;
|
|
344
454
|
checks.push({ name: `${tableName}_${col}_chk`, expression });
|
|
345
455
|
}
|
|
346
456
|
}
|
|
347
457
|
// Validator-derived checks.
|
|
348
458
|
for (const v of field.validators()) {
|
|
349
|
-
const check = validatorCheck(v,
|
|
459
|
+
const check = validatorCheck(v, qcol, tableName, col, dialect);
|
|
350
460
|
if (check) checks.push(check);
|
|
351
461
|
}
|
|
352
462
|
}
|
|
463
|
+
// Entity-scoped cross-field validators (comparison / requiredWhen / presentIff / atLeastOne).
|
|
464
|
+
for (const v of entity.validators()) {
|
|
465
|
+
const check = crossFieldCheck(v, entity, tableName, strategy, dialect);
|
|
466
|
+
if (check) checks.push(check);
|
|
467
|
+
}
|
|
353
468
|
return checks;
|
|
354
469
|
}
|
|
355
470
|
|
|
471
|
+
const COMPARISON_SQL_OP: Record<string, string> = {
|
|
472
|
+
gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "<>", eq: "=",
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/** Resolve a by-name field reference to its physical column (quoted) + the MetaField, or null. */
|
|
476
|
+
function resolveRef(
|
|
477
|
+
entity: MetaObject, name: unknown, strategy: ColumnNamingStrategy,
|
|
478
|
+
): { qcol: string; field: ReturnType<MetaObject["fields"]>[number] } | null {
|
|
479
|
+
if (typeof name !== "string" || name.length === 0) return null;
|
|
480
|
+
const field = entity.fields().find((f) => f.name === name);
|
|
481
|
+
if (!field) return null;
|
|
482
|
+
return { qcol: quoteCheckCol(resolveColumnName(field, strategy)), field };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Render an @equals gating value as a SQL literal, typed by the gating field's
|
|
487
|
+
* subtype: boolean → TRUE/FALSE (1/0 on sqlite/d1), numeric → bare number,
|
|
488
|
+
* everything else → quoted string.
|
|
489
|
+
*/
|
|
490
|
+
function renderEquals(
|
|
491
|
+
raw: unknown, whenField: ReturnType<MetaObject["fields"]>[number], dialect: Dialect | undefined,
|
|
492
|
+
): string {
|
|
493
|
+
const s = String(raw);
|
|
494
|
+
if (whenField.subType === FIELD_SUBTYPE_BOOLEAN) {
|
|
495
|
+
const truthy = s === "true" || s === "1" || s === "TRUE";
|
|
496
|
+
if (dialect === "sqlite" || dialect === "d1") return truthy ? "1" : "0";
|
|
497
|
+
return truthy ? "TRUE" : "FALSE";
|
|
498
|
+
}
|
|
499
|
+
const numeric = whenField.subType === FIELD_SUBTYPE_INT || whenField.subType === FIELD_SUBTYPE_LONG
|
|
500
|
+
|| whenField.subType === FIELD_SUBTYPE_DOUBLE || whenField.subType === FIELD_SUBTYPE_FLOAT
|
|
501
|
+
|| whenField.subType === FIELD_SUBTYPE_DECIMAL || whenField.subType === FIELD_SUBTYPE_CURRENCY;
|
|
502
|
+
if (numeric && /^-?\d+(\.\d+)?$/.test(s)) return s;
|
|
503
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Derive a CHECK from an entity-scoped cross-field validator. Every reference is
|
|
508
|
+
* resolved to its physical column by name; nothing raw is read from metadata.
|
|
509
|
+
* Returns null (skips the check) if any referenced field is missing.
|
|
510
|
+
*/
|
|
511
|
+
function crossFieldCheck(
|
|
512
|
+
v: MetaValidator, entity: MetaObject, tableName: string,
|
|
513
|
+
strategy: ColumnNamingStrategy, dialect: Dialect | undefined,
|
|
514
|
+
): CheckDescriptor | null {
|
|
515
|
+
switch (v.subType) {
|
|
516
|
+
case VALIDATOR_SUBTYPE_COMPARISON: {
|
|
517
|
+
const left = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_LEFT), strategy);
|
|
518
|
+
const right = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_RIGHT), strategy);
|
|
519
|
+
const op = COMPARISON_SQL_OP[String(v.ownAttr(VALIDATOR_ATTR_OP))];
|
|
520
|
+
if (!left || !right || !op) return null;
|
|
521
|
+
const lc = resolveColumnName(left.field, strategy);
|
|
522
|
+
return { name: `${tableName}_${lc}_cmp_chk`, expression: `${left.qcol} ${op} ${right.qcol}` };
|
|
523
|
+
}
|
|
524
|
+
case VALIDATOR_SUBTYPE_REQUIRED_WHEN: {
|
|
525
|
+
const target = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_FIELD), strategy);
|
|
526
|
+
const when = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_WHEN), strategy);
|
|
527
|
+
if (!target || !when) return null;
|
|
528
|
+
const lit = renderEquals(v.ownAttr(VALIDATOR_ATTR_EQUALS), when.field, dialect);
|
|
529
|
+
const fc = resolveColumnName(target.field, strategy);
|
|
530
|
+
return {
|
|
531
|
+
name: `${tableName}_${fc}_reqwhen_chk`,
|
|
532
|
+
expression: `(${when.qcol} IS DISTINCT FROM ${lit}) OR (${target.qcol} IS NOT NULL)`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
case VALIDATOR_SUBTYPE_PRESENT_IFF: {
|
|
536
|
+
const target = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_FIELD), strategy);
|
|
537
|
+
const when = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_WHEN), strategy);
|
|
538
|
+
if (!target || !when) return null;
|
|
539
|
+
const lit = renderEquals(v.ownAttr(VALIDATOR_ATTR_EQUALS), when.field, dialect);
|
|
540
|
+
const fc = resolveColumnName(target.field, strategy);
|
|
541
|
+
return {
|
|
542
|
+
name: `${tableName}_${fc}_presentiff_chk`,
|
|
543
|
+
expression: `(${target.qcol} IS NOT NULL) = (${when.qcol} IS NOT DISTINCT FROM ${lit})`,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
case VALIDATOR_SUBTYPE_AT_LEAST_ONE: {
|
|
547
|
+
const raw = v.ownAttr(VALIDATOR_ATTR_FIELDS);
|
|
548
|
+
const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []);
|
|
549
|
+
const refs = names.map((n) => resolveRef(entity, n, strategy));
|
|
550
|
+
if (refs.length === 0 || refs.some((r) => r === null)) return null;
|
|
551
|
+
const firstCol = resolveColumnName(refs[0]!.field, strategy);
|
|
552
|
+
return {
|
|
553
|
+
name: `${tableName}_${firstCol}_atleastone_chk`,
|
|
554
|
+
expression: refs.map((r) => `${r!.qcol} IS NOT NULL`).join(" OR "),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
default:
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
356
562
|
function buildForeignKeys(
|
|
357
563
|
entity: MetaObject,
|
|
358
564
|
tableName: string,
|
|
@@ -386,7 +592,13 @@ function buildForeignKeys(
|
|
|
386
592
|
: [applyColumnNamingStrategy(refChild.resolvedTargetPkField(root) ?? "id", strategy)];
|
|
387
593
|
|
|
388
594
|
const { onDelete, onUpdate } = resolveReferentialActions(entity, refChild);
|
|
389
|
-
|
|
595
|
+
// An explicit @constraintName adopts an existing FK name (e.g. a database
|
|
596
|
+
// created by another toolchain); absent → the auto-derived default.
|
|
597
|
+
const constraintNameOverride = refChild.ownAttr(IDENTITY_ATTR_CONSTRAINT_NAME);
|
|
598
|
+
const constraintName =
|
|
599
|
+
typeof constraintNameOverride === "string" && constraintNameOverride.length > 0
|
|
600
|
+
? constraintNameOverride
|
|
601
|
+
: `${tableName}_${fkCols[0]}_fk`;
|
|
390
602
|
|
|
391
603
|
// Guard: ON DELETE SET NULL requires nullable FK columns.
|
|
392
604
|
validateSetNullNullability(entity, refChild, onDelete, constraintName);
|
|
@@ -489,9 +701,7 @@ function subtypeToSqlType(field: MetaData): SqlType {
|
|
|
489
701
|
const m = field.ownAttr(FIELD_ATTR_MAX_LENGTH);
|
|
490
702
|
return typeof m === "number" ? { kind: "text", maxLength: m } : { kind: "text" };
|
|
491
703
|
}
|
|
492
|
-
case FIELD_SUBTYPE_INT:
|
|
493
|
-
case FIELD_SUBTYPE_SHORT:
|
|
494
|
-
case FIELD_SUBTYPE_BYTE: return { kind: "integer", bits: 32 };
|
|
704
|
+
case FIELD_SUBTYPE_INT: return { kind: "integer", bits: 32 };
|
|
495
705
|
case FIELD_SUBTYPE_LONG:
|
|
496
706
|
case FIELD_SUBTYPE_CURRENCY: return { kind: "integer", bits: 64 };
|
|
497
707
|
case FIELD_SUBTYPE_DOUBLE: return { kind: "real" };
|
|
@@ -513,8 +723,7 @@ function subtypeToSqlType(field: MetaData): SqlType {
|
|
|
513
723
|
case FIELD_SUBTYPE_DATE: return { kind: "date" };
|
|
514
724
|
case FIELD_SUBTYPE_TIME: return { kind: "time" }; // Postgres native TIME (whole-second wire form)
|
|
515
725
|
case FIELD_SUBTYPE_TIMESTAMP: return { kind: "timestamp", withTimezone: false };
|
|
516
|
-
case FIELD_SUBTYPE_OBJECT:
|
|
517
|
-
case FIELD_SUBTYPE_CLASS: return { kind: "json" };
|
|
726
|
+
case FIELD_SUBTYPE_OBJECT: return { kind: "json" };
|
|
518
727
|
case FIELD_SUBTYPE_UUID: return { kind: "uuid" }; // R6 Plan 2a — Postgres native uuid
|
|
519
728
|
default: return { kind: "text" }; // unknown → text fallback
|
|
520
729
|
}
|
|
@@ -151,25 +151,36 @@ export function pgTypeToSqlType(dataType: string, maxLength?: number | null): Sq
|
|
|
151
151
|
* Returns undefined if the default is absent or empty.
|
|
152
152
|
*
|
|
153
153
|
* Classification rules:
|
|
154
|
-
* - Expressions: now(), CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME,
|
|
155
|
-
* nextval(...),
|
|
156
|
-
*
|
|
154
|
+
* - Expressions: now(), CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, and any
|
|
155
|
+
* bare function-call (e.g. nextval(...), gen_random_uuid(), uuid_generate_v4()),
|
|
156
|
+
* plus any value that starts with a non-quote character and contains a `::` cast
|
|
157
|
+
* (i.e. bare identifier with cast, like `NULL::text`).
|
|
157
158
|
* - Literals: `'value'` (optionally followed by `::type` cast, which PG
|
|
158
159
|
* commonly appends for clarity). The cast is stripped; the value is unquoted.
|
|
159
160
|
*
|
|
160
161
|
* PG stores literal booleans as `'true'::boolean`, integers as `'42'::integer`,
|
|
161
162
|
* strings as `'hello'::text` — all are literals after stripping the cast.
|
|
163
|
+
*
|
|
164
|
+
* The bare function-call rule keeps this in lockstep with the metadata-side
|
|
165
|
+
* default classifier (expected-schema's EXPR_DEFAULT_PATTERNS, which treats any
|
|
166
|
+
* `()` default as an expression): without it, a `gen_random_uuid()` column default
|
|
167
|
+
* round-trips as a literal here but an expression there, producing a spurious
|
|
168
|
+
* column diff on every uuid-PK table.
|
|
162
169
|
*/
|
|
163
170
|
export function parsePgDefault(raw: string | null | undefined): ColumnDefault | undefined {
|
|
164
171
|
if (raw === undefined || raw === null || raw === "") return undefined;
|
|
165
172
|
|
|
166
|
-
// Function-call or keyword expressions
|
|
173
|
+
// Function-call or keyword expressions. The leading-identifier-then-"(" rule
|
|
174
|
+
// matches any bare function call (gen_random_uuid(), uuid_generate_v4(), …)
|
|
175
|
+
// while never matching a quoted literal (those start with a single quote and are
|
|
176
|
+
// handled below).
|
|
167
177
|
if (
|
|
168
178
|
/^now\(\)$/i.test(raw) ||
|
|
169
179
|
/^current_timestamp\b/i.test(raw) ||
|
|
170
180
|
/^current_date\b/i.test(raw) ||
|
|
171
181
|
/^current_time\b/i.test(raw) ||
|
|
172
|
-
/^nextval\(/i.test(raw)
|
|
182
|
+
/^nextval\(/i.test(raw) ||
|
|
183
|
+
/^[a-zA-Z_][\w.]*\s*\(/.test(raw)
|
|
173
184
|
) {
|
|
174
185
|
return { kind: "expr", value: raw };
|
|
175
186
|
}
|
|
@@ -317,46 +328,132 @@ async function readPgIndexes(k: RawKysely, schema: string, table: string): Promi
|
|
|
317
328
|
// pg-mem gap: array_position() is not implemented, so this query throws on
|
|
318
329
|
// pg-mem. We catch and return [] so non-index tests still pass against pg-mem.
|
|
319
330
|
// Real PG (Postgres 16) handles this correctly.
|
|
320
|
-
|
|
331
|
+
// One row per index KEY, in key order, carrying: the column name (NULL for an
|
|
332
|
+
// expression key — attnum 0), the DESC bit from `indoption`, and the partial-index
|
|
333
|
+
// predicate (`indpred`, constant per index). unnest WITH ORDINALITY over indkey +
|
|
334
|
+
// indoption keeps the key order and pairs each key with its option flags.
|
|
335
|
+
let rows: {
|
|
336
|
+
rows: Array<{
|
|
337
|
+
index_name: string;
|
|
338
|
+
is_unique: boolean;
|
|
339
|
+
is_primary: boolean;
|
|
340
|
+
column_name: string | null;
|
|
341
|
+
ordinal: number;
|
|
342
|
+
is_desc: boolean;
|
|
343
|
+
predicate: string | null;
|
|
344
|
+
access_method: string;
|
|
345
|
+
indexdef: string;
|
|
346
|
+
}>;
|
|
347
|
+
};
|
|
321
348
|
try {
|
|
322
349
|
rows = await sql<{
|
|
323
350
|
index_name: string;
|
|
324
351
|
is_unique: boolean;
|
|
325
352
|
is_primary: boolean;
|
|
326
|
-
column_name: string;
|
|
353
|
+
column_name: string | null;
|
|
327
354
|
ordinal: number;
|
|
355
|
+
is_desc: boolean;
|
|
356
|
+
predicate: string | null;
|
|
357
|
+
access_method: string;
|
|
358
|
+
indexdef: string;
|
|
328
359
|
}>`
|
|
329
360
|
SELECT i.relname AS index_name,
|
|
330
361
|
ix.indisunique AS is_unique,
|
|
331
362
|
ix.indisprimary AS is_primary,
|
|
332
363
|
a.attname AS column_name,
|
|
333
|
-
|
|
364
|
+
k.ord AS ordinal,
|
|
365
|
+
(COALESCE(opt.option, 0) & 1) = 1 AS is_desc,
|
|
366
|
+
pg_get_expr(ix.indpred, ix.indrelid) AS predicate,
|
|
367
|
+
am.amname AS access_method,
|
|
368
|
+
pg_get_indexdef(ix.indexrelid) AS indexdef
|
|
334
369
|
FROM pg_index ix
|
|
335
370
|
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
336
371
|
JOIN pg_class t ON t.oid = ix.indrelid
|
|
337
372
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
338
|
-
JOIN
|
|
373
|
+
JOIN pg_am am ON am.oid = i.relam
|
|
374
|
+
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord)
|
|
375
|
+
LEFT JOIN LATERAL unnest(ix.indoption) WITH ORDINALITY AS opt(option, oord)
|
|
376
|
+
ON opt.oord = k.ord
|
|
377
|
+
LEFT JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum
|
|
339
378
|
WHERE n.nspname = ${schema}
|
|
340
379
|
AND t.relname = ${table}
|
|
341
|
-
ORDER BY i.relname,
|
|
380
|
+
ORDER BY i.relname, k.ord
|
|
342
381
|
`.execute(k);
|
|
343
382
|
} catch {
|
|
344
|
-
// pg-mem:
|
|
383
|
+
// pg-mem: unnest WITH ORDINALITY / pg_get_expr unsupported — return empty list.
|
|
345
384
|
return [];
|
|
346
385
|
}
|
|
347
386
|
|
|
348
|
-
const byName = new Map<
|
|
387
|
+
const byName = new Map<
|
|
388
|
+
string,
|
|
389
|
+
{
|
|
390
|
+
isUnique: boolean;
|
|
391
|
+
isPrimary: boolean;
|
|
392
|
+
cols: string[];
|
|
393
|
+
orders: ("asc" | "desc")[];
|
|
394
|
+
predicate: string | null;
|
|
395
|
+
hasExpressionKey: boolean;
|
|
396
|
+
accessMethod: string;
|
|
397
|
+
indexdef: string;
|
|
398
|
+
}
|
|
399
|
+
>();
|
|
349
400
|
for (const r of rows.rows) {
|
|
350
401
|
let entry = byName.get(r.index_name);
|
|
351
402
|
if (!entry) {
|
|
352
|
-
entry = {
|
|
403
|
+
entry = {
|
|
404
|
+
isUnique: r.is_unique,
|
|
405
|
+
isPrimary: r.is_primary,
|
|
406
|
+
cols: [],
|
|
407
|
+
orders: [],
|
|
408
|
+
predicate: r.predicate,
|
|
409
|
+
hasExpressionKey: false,
|
|
410
|
+
accessMethod: r.access_method,
|
|
411
|
+
indexdef: r.indexdef,
|
|
412
|
+
};
|
|
353
413
|
byName.set(r.index_name, entry);
|
|
354
414
|
}
|
|
355
|
-
|
|
415
|
+
if (r.column_name === null) {
|
|
416
|
+
// Expression key (attnum 0) — captured from the index def below.
|
|
417
|
+
entry.hasExpressionKey = true;
|
|
418
|
+
} else {
|
|
419
|
+
entry.cols.push(r.column_name);
|
|
420
|
+
entry.orders.push(r.is_desc ? "desc" : "asc");
|
|
421
|
+
}
|
|
356
422
|
}
|
|
357
423
|
return Array.from(byName.entries())
|
|
358
|
-
.filter(([, v]) => !v.isPrimary)
|
|
359
|
-
.map(([name, v]) =>
|
|
424
|
+
.filter(([, v]) => !v.isPrimary) // PK index excluded — PK lives in TableDescriptor.primaryKey
|
|
425
|
+
.map(([name, v]) => {
|
|
426
|
+
const using = v.accessMethod !== "btree" ? v.accessMethod : undefined;
|
|
427
|
+
if (v.hasExpressionKey) {
|
|
428
|
+
// Functional/expression index: lift the raw key expression out of the
|
|
429
|
+
// index def (between `USING <am> (` and its matching `)`, before WHERE).
|
|
430
|
+
const ix: IndexDescriptor = { name, columns: [], unique: v.isUnique, expr: indexDefKeyExpr(v.indexdef) };
|
|
431
|
+
if (using) ix.using = using;
|
|
432
|
+
if (v.predicate !== null) ix.where = v.predicate;
|
|
433
|
+
return ix;
|
|
434
|
+
}
|
|
435
|
+
const ix: IndexDescriptor = { name, columns: v.cols, unique: v.isUnique };
|
|
436
|
+
if (using) ix.using = using;
|
|
437
|
+
if (v.orders.some((o) => o === "desc")) ix.orders = v.orders;
|
|
438
|
+
if (v.predicate !== null) ix.where = v.predicate;
|
|
439
|
+
return ix;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Extract the raw key-expression text from a pg index def — the balanced `(...)`
|
|
444
|
+
* after `USING <method> `, stripped of a trailing partial-index `WHERE`. */
|
|
445
|
+
function indexDefKeyExpr(indexdef: string): string {
|
|
446
|
+
const open = indexdef.indexOf("(", indexdef.indexOf(" USING "));
|
|
447
|
+
if (open === -1) return indexdef;
|
|
448
|
+
let depth = 0;
|
|
449
|
+
for (let i = open; i < indexdef.length; i++) {
|
|
450
|
+
if (indexdef[i] === "(") depth++;
|
|
451
|
+
else if (indexdef[i] === ")") {
|
|
452
|
+
depth--;
|
|
453
|
+
if (depth === 0) return indexdef.slice(open + 1, i).trim();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return indexdef.slice(open + 1).trim();
|
|
360
457
|
}
|
|
361
458
|
|
|
362
459
|
async function readPgForeignKeys(k: RawKysely, schema: string, table: string): Promise<FkDescriptor[]> {
|
package/src/types.ts
CHANGED
|
@@ -66,6 +66,31 @@ export interface IndexDescriptor {
|
|
|
66
66
|
name: string;
|
|
67
67
|
columns: string[];
|
|
68
68
|
unique: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Per-column sort direction, positional to `columns`. Omitted (or "asc") = the
|
|
71
|
+
* default ascending order, which Postgres does not render in an index def. A
|
|
72
|
+
* "desc" entry emits `<col> DESC` and is compared against the introspected
|
|
73
|
+
* `pg_index.indoption` DESC bit. Absent ⇒ all-ascending.
|
|
74
|
+
*/
|
|
75
|
+
orders?: ("asc" | "desc")[];
|
|
76
|
+
/**
|
|
77
|
+
* Partial-index predicate (raw SQL, e.g. `delivered_at IS NULL`). Emitted as a
|
|
78
|
+
* trailing `WHERE (<predicate>)`; compared against the introspected
|
|
79
|
+
* `pg_get_expr(indpred, …)` after expression normalization. Absent ⇒ full index.
|
|
80
|
+
*/
|
|
81
|
+
where?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Raw key EXPRESSION for a functional/expression index (e.g. `lower((email)::text)`).
|
|
84
|
+
* When present, the index key is this expression and `columns` is empty —
|
|
85
|
+
* emitted as `(<expr>)` and compared against the introspected `pg_get_indexdef`
|
|
86
|
+
* key after normalization.
|
|
87
|
+
*/
|
|
88
|
+
expr?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Index access method (`gin`, `gist`, `hash`, …). Absent / `btree` is the
|
|
91
|
+
* default and not rendered. Emitted as `USING <method>` before the key list.
|
|
92
|
+
*/
|
|
93
|
+
using?: string;
|
|
69
94
|
}
|
|
70
95
|
|
|
71
96
|
export interface CheckDescriptor {
|