@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.
@@ -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
- const resolveTargetTable = (entityName: string) => entityToTable.get(entityName);
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
- if (fieldNames.length === 0) continue;
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
- indexes.push({
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, col: string, tableName: string, dialect: Dialect | undefined,
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(`${col} >= ${v.min}`);
306
- if (v.max !== undefined) parts.push(`${col} <= ${v.max}`);
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(${col}) >= ${v.min}`);
313
- if (v.max !== undefined) parts.push(`length(${col}) <= ${v.max}`);
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: `${col} ~ '${pattern.replace(/'/g, "''")}'`,
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
- // Enum membership check (unchanged).
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 = `${col} IN (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")})`;
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, col, tableName, dialect);
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
- const constraintName = `${tableName}_${fkCols[0]}_fk`;
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(...), and any value that starts with a non-quote character and
156
- * contains a `::` cast (i.e. bare identifier with cast, like `NULL::text`).
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
- let rows: { rows: Array<{ index_name: string; is_unique: boolean; is_primary: boolean; column_name: string; ordinal: number }> };
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
- array_position(ix.indkey, a.attnum) AS ordinal
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 pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
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, ordinal
380
+ ORDER BY i.relname, k.ord
342
381
  `.execute(k);
343
382
  } catch {
344
- // pg-mem: array_position() not implemented — return empty index list.
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<string, { isUnique: boolean; isPrimary: boolean; cols: string[] }>();
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 = { isUnique: r.is_unique, isPrimary: r.is_primary, cols: [] };
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
- entry.cols.push(r.column_name);
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) // PK index excluded — PK lives in TableDescriptor.primaryKey
359
- .map(([name, v]) => ({ name, columns: v.cols, unique: v.isUnique }));
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 {