@metaobjectsdev/migrate-ts 0.10.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.
@@ -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 {