@supabase/pg-delta 1.0.0-alpha.17 → 1.0.0-alpha.19
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/core/expand-replace-dependencies.js +69 -0
- package/dist/core/objects/index/index.model.js +12 -2
- package/dist/core/objects/procedure/procedure.diff.js +33 -20
- package/dist/core/objects/rls-policy/changes/rls-policy.create.js +23 -0
- package/dist/core/objects/rls-policy/rls-policy.model.d.ts +49 -0
- package/dist/core/objects/rls-policy/rls-policy.model.js +122 -1
- package/dist/core/objects/table/table.diff.js +1 -0
- package/dist/core/objects/table/table.model.d.ts +4 -0
- package/dist/core/objects/table/table.model.js +2 -0
- package/dist/core/plan/sql-format/fixtures.js +8 -0
- package/dist/core/post-diff-cycle-breaking.d.ts +7 -0
- package/dist/core/post-diff-cycle-breaking.js +69 -3
- package/package.json +1 -1
- package/src/core/catalog.snapshot.test.ts +2 -0
- package/src/core/expand-replace-dependencies.test.ts +118 -0
- package/src/core/expand-replace-dependencies.ts +78 -0
- package/src/core/objects/index/index.model.test.ts +83 -0
- package/src/core/objects/index/index.model.ts +13 -4
- package/src/core/objects/procedure/procedure.diff.test.ts +100 -2
- package/src/core/objects/procedure/procedure.diff.ts +39 -21
- package/src/core/objects/rls-policy/changes/rls-policy.alter.test.ts +16 -0
- package/src/core/objects/rls-policy/changes/rls-policy.create.test.ts +128 -0
- package/src/core/objects/rls-policy/changes/rls-policy.create.ts +27 -0
- package/src/core/objects/rls-policy/changes/rls-policy.drop.test.ts +2 -0
- package/src/core/objects/rls-policy/rls-policy.diff.test.ts +2 -0
- package/src/core/objects/rls-policy/rls-policy.model.ts +134 -1
- package/src/core/objects/table/changes/table.alter.test.ts +1 -0
- package/src/core/objects/table/table.diff.test.ts +102 -0
- package/src/core/objects/table/table.diff.ts +1 -0
- package/src/core/objects/table/table.model.ts +2 -0
- package/src/core/plan/sql-format/fixtures.ts +8 -0
- package/src/core/post-diff-cycle-breaking.test.ts +142 -0
- package/src/core/post-diff-cycle-breaking.ts +83 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { AlterTableDropColumn, AlterTableDropConstraint, } from "./objects/table/changes/table.alter.js";
|
|
1
|
+
import { AlterTableAddConstraint, AlterTableDropColumn, AlterTableDropConstraint, AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.js";
|
|
2
|
+
import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.js";
|
|
2
3
|
import { DropTable } from "./objects/table/changes/table.drop.js";
|
|
3
4
|
import { stableId } from "./objects/utils.js";
|
|
4
5
|
function constraintStableId(table, constraintName) {
|
|
@@ -33,6 +34,63 @@ function isSupersededByTableReplacement(change, replacedTableIds) {
|
|
|
33
34
|
}
|
|
34
35
|
return replacedTableIds.has(change.table.stableId);
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Drop earlier duplicates of `AlterTableAddConstraint` /
|
|
39
|
+
* `AlterTableValidateConstraint` / `CreateCommentOnConstraint` targeting
|
|
40
|
+
* replaced tables, keeping only the last occurrence of each
|
|
41
|
+
* `(changeType, table.stableId, constraint.name)`.
|
|
42
|
+
*
|
|
43
|
+
* When `expandReplaceDependencies()` promotes a table to a full
|
|
44
|
+
* `DropTable + CreateTable` pair, it also emits one
|
|
45
|
+
* `AlterTableAddConstraint` (plus optional `VALIDATE CONSTRAINT` /
|
|
46
|
+
* `COMMENT ON CONSTRAINT`) per branch constraint. If `diffTables()` already
|
|
47
|
+
* emitted the same change for a shape flip or a new constraint on that
|
|
48
|
+
* table, the plan ends up with two identical `ALTER TABLE ... ADD
|
|
49
|
+
* CONSTRAINT ...` statements and PostgreSQL fails at apply time with
|
|
50
|
+
* `constraint "..." for relation "..." already exists`. Because
|
|
51
|
+
* `expandReplaceDependencies()` appends its additions after the original
|
|
52
|
+
* `diffTables()` output, the last occurrence is the expansion's emission —
|
|
53
|
+
* keeping it preserves correctness while removing the duplicate.
|
|
54
|
+
*/
|
|
55
|
+
function dropReplacedTableDuplicateConstraintChanges(changes, replacedTableIds) {
|
|
56
|
+
if (replacedTableIds.size === 0)
|
|
57
|
+
return changes;
|
|
58
|
+
const keyFor = (change) => {
|
|
59
|
+
if (!(change instanceof AlterTableAddConstraint) &&
|
|
60
|
+
!(change instanceof AlterTableValidateConstraint) &&
|
|
61
|
+
!(change instanceof CreateCommentOnConstraint)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (!replacedTableIds.has(change.table.stableId))
|
|
65
|
+
return null;
|
|
66
|
+
const tag = change instanceof AlterTableAddConstraint
|
|
67
|
+
? "add"
|
|
68
|
+
: change instanceof AlterTableValidateConstraint
|
|
69
|
+
? "validate"
|
|
70
|
+
: "comment";
|
|
71
|
+
return `${tag}:${constraintStableId(change.table, change.constraint.name)}`;
|
|
72
|
+
};
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const reversedKept = [];
|
|
75
|
+
let mutated = false;
|
|
76
|
+
// Walk backwards: the first encounter of each key corresponds to its LAST
|
|
77
|
+
// occurrence in the original order. `expandReplaceDependencies()` appends
|
|
78
|
+
// additions after the original changes, so "last wins" keeps the
|
|
79
|
+
// expansion's emission and drops the earlier diffTables duplicate.
|
|
80
|
+
for (let i = changes.length - 1; i >= 0; i--) {
|
|
81
|
+
const change = changes[i];
|
|
82
|
+
const key = keyFor(change);
|
|
83
|
+
if (key !== null) {
|
|
84
|
+
if (seen.has(key)) {
|
|
85
|
+
mutated = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
seen.add(key);
|
|
89
|
+
}
|
|
90
|
+
reversedKept.push(change);
|
|
91
|
+
}
|
|
92
|
+
return mutated ? reversedKept.reverse() : changes;
|
|
93
|
+
}
|
|
36
94
|
function collectExplicitConstraintDropIds(changes) {
|
|
37
95
|
const explicitConstraintDropIds = new Set();
|
|
38
96
|
for (const change of changes) {
|
|
@@ -59,6 +117,13 @@ function hasSameEntries(left, right) {
|
|
|
59
117
|
* - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
|
|
60
118
|
* `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
|
|
61
119
|
* redundant and create an unbreakable drop-phase cycle, so we elide them.
|
|
120
|
+
* - When the same `DropTable+CreateTable` pair is present, the expansion
|
|
121
|
+
* also emits one `AlterTableAddConstraint` / `AlterTableValidateConstraint`
|
|
122
|
+
* / `CreateCommentOnConstraint` per branch constraint, which may collide
|
|
123
|
+
* with the same change already emitted by `diffTables()` (for example on a
|
|
124
|
+
* shape flip or a new constraint). We dedupe these keeping only the last
|
|
125
|
+
* occurrence so the expansion's emission survives and the diffTables
|
|
126
|
+
* duplicate is removed.
|
|
62
127
|
* - If two dropped tables reference each other via FK, we insert dedicated
|
|
63
128
|
* `AlterTableDropConstraint` changes and teach the paired `DropTable`
|
|
64
129
|
* changes not to claim those FK stable IDs.
|
|
@@ -67,9 +132,10 @@ function hasSameEntries(left, right) {
|
|
|
67
132
|
* in the corresponding `diff*` function instead of this pass.
|
|
68
133
|
*/
|
|
69
134
|
export function normalizePostDiffCycles({ changes, mainCatalog, replacedTableIds = new Set(), }) {
|
|
135
|
+
const dedupedChanges = dropReplacedTableDuplicateConstraintChanges(changes, replacedTableIds);
|
|
70
136
|
const structurallyNormalizedChanges = replacedTableIds.size === 0
|
|
71
|
-
?
|
|
72
|
-
:
|
|
137
|
+
? dedupedChanges
|
|
138
|
+
: dedupedChanges.filter((change) => !isSupersededByTableReplacement(change, replacedTableIds));
|
|
73
139
|
const dropTableChanges = structurallyNormalizedChanges.filter((change) => change instanceof DropTable);
|
|
74
140
|
if (dropTableChanges.length < 2) {
|
|
75
141
|
return structurallyNormalizedChanges;
|
package/package.json
CHANGED
|
@@ -3,6 +3,9 @@ import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
|
|
|
3
3
|
import type { Change } from "./change.types.ts";
|
|
4
4
|
import { expandReplaceDependencies } from "./expand-replace-dependencies.ts";
|
|
5
5
|
import { DefaultPrivilegeState } from "./objects/base.default-privileges.ts";
|
|
6
|
+
import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts";
|
|
7
|
+
import { DropProcedure } from "./objects/procedure/changes/procedure.drop.ts";
|
|
8
|
+
import { Procedure } from "./objects/procedure/procedure.model.ts";
|
|
6
9
|
import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts";
|
|
7
10
|
import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts";
|
|
8
11
|
import { diffSequences } from "./objects/sequence/sequence.diff.ts";
|
|
@@ -22,6 +25,9 @@ import { Table } from "./objects/table/table.model.ts";
|
|
|
22
25
|
import { CreateEnum } from "./objects/type/enum/changes/enum.create.ts";
|
|
23
26
|
import { DropEnum } from "./objects/type/enum/changes/enum.drop.ts";
|
|
24
27
|
import { Enum } from "./objects/type/enum/enum.model.ts";
|
|
28
|
+
import { CreateView } from "./objects/view/changes/view.create.ts";
|
|
29
|
+
import { DropView } from "./objects/view/changes/view.drop.ts";
|
|
30
|
+
import { View } from "./objects/view/view.model.ts";
|
|
25
31
|
|
|
26
32
|
function mockChange(overrides: {
|
|
27
33
|
creates?: string[];
|
|
@@ -330,6 +336,7 @@ describe("expandReplaceDependencies", () => {
|
|
|
330
336
|
validated: true,
|
|
331
337
|
is_local: true,
|
|
332
338
|
no_inherit: false,
|
|
339
|
+
is_temporal: false,
|
|
333
340
|
is_partition_clone: false,
|
|
334
341
|
parent_constraint_schema: null,
|
|
335
342
|
parent_constraint_name: null,
|
|
@@ -431,4 +438,115 @@ describe("expandReplaceDependencies", () => {
|
|
|
431
438
|
expect(expanded.changes).toContain(preExistingGrant);
|
|
432
439
|
expect(expanded.replacedTableIds.has("table:public.parents")).toBe(false);
|
|
433
440
|
});
|
|
441
|
+
|
|
442
|
+
test("promotes dependent view when a procedure's parameter types change", async () => {
|
|
443
|
+
// Procedure stableIds are signature-qualified, so a parameter-type change
|
|
444
|
+
// produces different stableIds in `createdIds` and `droppedIds`. The
|
|
445
|
+
// expander must still treat the (schema, name)-matched pair as a replace
|
|
446
|
+
// root so a dependent view is promoted from `CREATE OR REPLACE VIEW` to
|
|
447
|
+
// `DROP VIEW` + `CREATE VIEW` (otherwise `DROP FUNCTION` fails with
|
|
448
|
+
// "cannot drop function because other objects depend on it").
|
|
449
|
+
const baseline = await createEmptyCatalog(170000, "postgres");
|
|
450
|
+
const procedureBase = {
|
|
451
|
+
schema: "public",
|
|
452
|
+
name: "format_id",
|
|
453
|
+
kind: "f" as const,
|
|
454
|
+
return_type: "text",
|
|
455
|
+
return_type_schema: "pg_catalog",
|
|
456
|
+
language: "sql",
|
|
457
|
+
security_definer: false,
|
|
458
|
+
volatility: "i" as const,
|
|
459
|
+
parallel_safety: "u" as const,
|
|
460
|
+
execution_cost: 100,
|
|
461
|
+
result_rows: 0,
|
|
462
|
+
is_strict: false,
|
|
463
|
+
leakproof: false,
|
|
464
|
+
returns_set: false,
|
|
465
|
+
argument_count: 1,
|
|
466
|
+
argument_default_count: 0,
|
|
467
|
+
argument_names: ["id"],
|
|
468
|
+
all_argument_types: null,
|
|
469
|
+
argument_modes: null,
|
|
470
|
+
argument_defaults: null,
|
|
471
|
+
source_code: "SELECT 'id:' || id::text",
|
|
472
|
+
binary_path: null,
|
|
473
|
+
sql_body: null,
|
|
474
|
+
config: null,
|
|
475
|
+
owner: "postgres",
|
|
476
|
+
comment: null,
|
|
477
|
+
privileges: [],
|
|
478
|
+
};
|
|
479
|
+
const mainProcedure = new Procedure({
|
|
480
|
+
...procedureBase,
|
|
481
|
+
argument_types: ["int4"],
|
|
482
|
+
definition: "CREATE FUNCTION public.format_id(id integer) ...",
|
|
483
|
+
});
|
|
484
|
+
const branchProcedure = new Procedure({
|
|
485
|
+
...procedureBase,
|
|
486
|
+
argument_types: ["int8"],
|
|
487
|
+
definition: "CREATE FUNCTION public.format_id(id bigint) ...",
|
|
488
|
+
});
|
|
489
|
+
const viewBase = {
|
|
490
|
+
schema: "public",
|
|
491
|
+
name: "items_formatted",
|
|
492
|
+
row_security: false,
|
|
493
|
+
force_row_security: false,
|
|
494
|
+
has_indexes: false,
|
|
495
|
+
has_rules: false,
|
|
496
|
+
has_triggers: false,
|
|
497
|
+
has_subclasses: false,
|
|
498
|
+
is_populated: true,
|
|
499
|
+
replica_identity: "d" as const,
|
|
500
|
+
is_partition: false,
|
|
501
|
+
options: null,
|
|
502
|
+
partition_bound: null,
|
|
503
|
+
owner: "postgres",
|
|
504
|
+
comment: null,
|
|
505
|
+
columns: [],
|
|
506
|
+
privileges: [],
|
|
507
|
+
};
|
|
508
|
+
const mainView = new View({
|
|
509
|
+
...viewBase,
|
|
510
|
+
definition: "SELECT public.format_id(id) FROM public.items",
|
|
511
|
+
});
|
|
512
|
+
const branchView = new View({
|
|
513
|
+
...viewBase,
|
|
514
|
+
definition: "SELECT public.format_id(id::bigint) FROM public.items",
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const changes: Change[] = [
|
|
518
|
+
new DropProcedure({ procedure: mainProcedure }),
|
|
519
|
+
new CreateProcedure({ procedure: branchProcedure }),
|
|
520
|
+
// view.diff emits this because pg_get_viewdef text differs after the
|
|
521
|
+
// underlying function signature changes.
|
|
522
|
+
new CreateView({ view: branchView, orReplace: true }),
|
|
523
|
+
];
|
|
524
|
+
|
|
525
|
+
const mainCatalog = new Catalog({
|
|
526
|
+
...baseline,
|
|
527
|
+
procedures: { [mainProcedure.stableId]: mainProcedure },
|
|
528
|
+
views: { [mainView.stableId]: mainView },
|
|
529
|
+
depends: [
|
|
530
|
+
{
|
|
531
|
+
dependent_stable_id: mainView.stableId,
|
|
532
|
+
referenced_stable_id: mainProcedure.stableId,
|
|
533
|
+
deptype: "n",
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
});
|
|
537
|
+
const branchCatalog = new Catalog({
|
|
538
|
+
...baseline,
|
|
539
|
+
procedures: { [branchProcedure.stableId]: branchProcedure },
|
|
540
|
+
views: { [branchView.stableId]: branchView },
|
|
541
|
+
depends: [],
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const expanded = expandReplaceDependencies({
|
|
545
|
+
changes,
|
|
546
|
+
mainCatalog,
|
|
547
|
+
branchCatalog,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(expanded.changes.some((c) => c instanceof DropView)).toBe(true);
|
|
551
|
+
});
|
|
434
552
|
});
|
|
@@ -2,6 +2,8 @@ import type { Catalog } from "./catalog.model.ts";
|
|
|
2
2
|
import type { Change } from "./change.types.ts";
|
|
3
3
|
import { CreateDomain } from "./objects/domain/changes/domain.create.ts";
|
|
4
4
|
import { DropDomain } from "./objects/domain/changes/domain.drop.ts";
|
|
5
|
+
import { CreateIndex } from "./objects/index/changes/index.create.ts";
|
|
6
|
+
import { DropIndex } from "./objects/index/changes/index.drop.ts";
|
|
5
7
|
import { CreateMaterializedView } from "./objects/materialized-view/changes/materialized-view.create.ts";
|
|
6
8
|
import { DropMaterializedView } from "./objects/materialized-view/changes/materialized-view.drop.ts";
|
|
7
9
|
import { CreateProcedure } from "./objects/procedure/changes/procedure.create.ts";
|
|
@@ -34,6 +36,12 @@ type ResolvedObject =
|
|
|
34
36
|
main: Catalog["views"][string];
|
|
35
37
|
branch: Catalog["views"][string];
|
|
36
38
|
}
|
|
39
|
+
| {
|
|
40
|
+
kind: "index";
|
|
41
|
+
main: Catalog["indexes"][string];
|
|
42
|
+
branch: Catalog["indexes"][string];
|
|
43
|
+
branchIndexableObject: Catalog["indexableObjects"][string] | undefined;
|
|
44
|
+
}
|
|
37
45
|
| {
|
|
38
46
|
kind: "materialized_view";
|
|
39
47
|
main: Catalog["materializedViews"][string];
|
|
@@ -102,6 +110,25 @@ export function expandReplaceDependencies({
|
|
|
102
110
|
}
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
// Procedure stableIds are signature-qualified
|
|
114
|
+
// (`procedure:schema.name(argtypes)`), so a function whose parameter types
|
|
115
|
+
// change has different ids in `createdIds` and `droppedIds` and would not
|
|
116
|
+
// appear in the intersection above. Treat any dropped procedure whose
|
|
117
|
+
// `(schema, name)` matches a created procedure as a replace root so
|
|
118
|
+
// dependents referencing the old signature via pg_depend get promoted to
|
|
119
|
+
// DROP+CREATE.
|
|
120
|
+
const createdProcedureNames = new Set<string>();
|
|
121
|
+
for (const id of createdIds) {
|
|
122
|
+
const key = parseProcedureSchemaName(id);
|
|
123
|
+
if (key) createdProcedureNames.add(key);
|
|
124
|
+
}
|
|
125
|
+
for (const id of droppedIds) {
|
|
126
|
+
const key = parseProcedureSchemaName(id);
|
|
127
|
+
if (key && createdProcedureNames.has(key)) {
|
|
128
|
+
replaceRoots.add(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
if (replaceRoots.size === 0) {
|
|
106
133
|
return {
|
|
107
134
|
changes,
|
|
@@ -259,6 +286,13 @@ function isOwnedSequenceColumnDependency(
|
|
|
259
286
|
);
|
|
260
287
|
}
|
|
261
288
|
|
|
289
|
+
function parseProcedureSchemaName(stableId: string): string | null {
|
|
290
|
+
if (!stableId.startsWith("procedure:")) return null;
|
|
291
|
+
const paren = stableId.indexOf("(");
|
|
292
|
+
if (paren === -1) return null;
|
|
293
|
+
return stableId.slice("procedure:".length, paren);
|
|
294
|
+
}
|
|
295
|
+
|
|
262
296
|
function normalizeDependentId(dependentId: string): string | null {
|
|
263
297
|
let id = dependentId;
|
|
264
298
|
|
|
@@ -320,6 +354,20 @@ function resolveObjectForStableId(
|
|
|
320
354
|
return main && branch ? { kind: "materialized_view", main, branch } : null;
|
|
321
355
|
}
|
|
322
356
|
|
|
357
|
+
if (stableId.startsWith("index:")) {
|
|
358
|
+
const main = mainCatalog.indexes[stableId];
|
|
359
|
+
const branch = branchCatalog.indexes[stableId];
|
|
360
|
+
return main && branch
|
|
361
|
+
? {
|
|
362
|
+
kind: "index",
|
|
363
|
+
main,
|
|
364
|
+
branch,
|
|
365
|
+
branchIndexableObject:
|
|
366
|
+
branchCatalog.indexableObjects[branch.tableStableId],
|
|
367
|
+
}
|
|
368
|
+
: null;
|
|
369
|
+
}
|
|
370
|
+
|
|
323
371
|
if (stableId.startsWith("procedure:")) {
|
|
324
372
|
const main = mainCatalog.procedures[stableId];
|
|
325
373
|
const branch = branchCatalog.procedures[stableId];
|
|
@@ -421,6 +469,36 @@ function buildReplaceChanges(
|
|
|
421
469
|
? [new CreateMaterializedView({ materializedView: resolved.branch })]
|
|
422
470
|
: []),
|
|
423
471
|
];
|
|
472
|
+
case "index":
|
|
473
|
+
// Constraint-owned, primary, and partition-attached indexes are managed
|
|
474
|
+
// by the owning constraint or parent-index DDL, not standalone
|
|
475
|
+
// CREATE INDEX / DROP INDEX. The `case "table":` branch above already
|
|
476
|
+
// recreates constraints via AlterTableAddConstraint; emitting a
|
|
477
|
+
// standalone drop/create here would fail in PostgreSQL
|
|
478
|
+
// ("cannot drop index ... because constraint ... requires it") or
|
|
479
|
+
// duplicate the index the constraint recreates. Skip matches
|
|
480
|
+
// diffIndexes (packages/pg-delta/src/core/objects/index/index.diff.ts).
|
|
481
|
+
if (
|
|
482
|
+
resolved.main.is_owned_by_constraint ||
|
|
483
|
+
resolved.main.is_primary ||
|
|
484
|
+
resolved.main.is_index_partition ||
|
|
485
|
+
resolved.branch.is_owned_by_constraint ||
|
|
486
|
+
resolved.branch.is_primary ||
|
|
487
|
+
resolved.branch.is_index_partition
|
|
488
|
+
) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
return [
|
|
492
|
+
...(addDrop ? [new DropIndex({ index: resolved.main })] : []),
|
|
493
|
+
...(addCreate
|
|
494
|
+
? [
|
|
495
|
+
new CreateIndex({
|
|
496
|
+
index: resolved.branch,
|
|
497
|
+
indexableObject: resolved.branchIndexableObject,
|
|
498
|
+
}),
|
|
499
|
+
]
|
|
500
|
+
: []),
|
|
501
|
+
];
|
|
424
502
|
case "procedure":
|
|
425
503
|
return [
|
|
426
504
|
...(addDrop ? [new DropProcedure({ procedure: resolved.main })] : []),
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import { extractIndexes, Index } from "./index.model.ts";
|
|
4
|
+
|
|
5
|
+
// Minimal fields required by indexPropsSchema; individual tests override the
|
|
6
|
+
// fields relevant to each scenario.
|
|
7
|
+
const baseRow = {
|
|
8
|
+
schema: "public",
|
|
9
|
+
table_name: '"users"',
|
|
10
|
+
storage_params: [] as string[],
|
|
11
|
+
statistics_target: [] as number[],
|
|
12
|
+
index_type: "btree",
|
|
13
|
+
tablespace: null,
|
|
14
|
+
is_unique: false,
|
|
15
|
+
is_primary: false,
|
|
16
|
+
is_exclusion: false,
|
|
17
|
+
nulls_not_distinct: false,
|
|
18
|
+
immediate: true,
|
|
19
|
+
is_clustered: false,
|
|
20
|
+
is_replica_identity: false,
|
|
21
|
+
key_columns: [1],
|
|
22
|
+
column_collations: [null],
|
|
23
|
+
operator_classes: ["default"],
|
|
24
|
+
column_options: [0],
|
|
25
|
+
index_expressions: null,
|
|
26
|
+
partial_predicate: null,
|
|
27
|
+
is_owned_by_constraint: false,
|
|
28
|
+
table_relkind: "r" as const,
|
|
29
|
+
is_partitioned_index: false,
|
|
30
|
+
is_index_partition: false,
|
|
31
|
+
parent_index_name: null,
|
|
32
|
+
comment: null,
|
|
33
|
+
owner: "postgres",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const mockPool = (rows: unknown[]): Pool =>
|
|
37
|
+
({ query: async () => ({ rows }) }) as unknown as Pool;
|
|
38
|
+
|
|
39
|
+
describe("extractIndexes", () => {
|
|
40
|
+
test("skips rows where pg_get_indexdef returned NULL", async () => {
|
|
41
|
+
const indexes = await extractIndexes(
|
|
42
|
+
mockPool([
|
|
43
|
+
{
|
|
44
|
+
...baseRow,
|
|
45
|
+
name: '"good_idx"',
|
|
46
|
+
definition: "CREATE INDEX good_idx ON users (id)",
|
|
47
|
+
},
|
|
48
|
+
{ ...baseRow, name: '"orphan_idx"', definition: null },
|
|
49
|
+
]),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(indexes).toHaveLength(1);
|
|
53
|
+
expect(indexes[0]).toBeInstanceOf(Index);
|
|
54
|
+
expect(indexes[0]?.name).toBe('"good_idx"');
|
|
55
|
+
expect(indexes[0]?.definition).toBe("CREATE INDEX good_idx ON users (id)");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("does not throw ZodError when the only row has a null definition", async () => {
|
|
59
|
+
await expect(
|
|
60
|
+
extractIndexes(
|
|
61
|
+
mockPool([{ ...baseRow, name: '"orphan"', definition: null }]),
|
|
62
|
+
),
|
|
63
|
+
).resolves.toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("returns all indexes when every row has a valid definition", async () => {
|
|
67
|
+
const indexes = await extractIndexes(
|
|
68
|
+
mockPool([
|
|
69
|
+
{
|
|
70
|
+
...baseRow,
|
|
71
|
+
name: '"a"',
|
|
72
|
+
definition: "CREATE INDEX a ON users (id)",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
...baseRow,
|
|
76
|
+
name: '"b"',
|
|
77
|
+
definition: "CREATE INDEX b ON users (id)",
|
|
78
|
+
},
|
|
79
|
+
]),
|
|
80
|
+
);
|
|
81
|
+
expect(indexes.map((i) => i.name)).toEqual(['"a"', '"b"']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -40,6 +40,16 @@ const indexPropsSchema = z.object({
|
|
|
40
40
|
owner: z.string(),
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
// pg_get_indexdef(oid, colno, pretty) invokes pg_get_indexdef_worker with
|
|
44
|
+
// missing_ok = true, so it can return NULL when any internal system-cache lookup
|
|
45
|
+
// fails (race with concurrent DROP, role visibility edge cases, orphaned index
|
|
46
|
+
// metadata, recovery transients). An unreadable index cannot be diffed, so we
|
|
47
|
+
// accept NULL here and filter the row out with a debug log instead of crashing
|
|
48
|
+
// the whole catalog extraction.
|
|
49
|
+
const indexRowSchema = indexPropsSchema.extend({
|
|
50
|
+
definition: z.string().nullable(),
|
|
51
|
+
});
|
|
52
|
+
|
|
43
53
|
/**
|
|
44
54
|
* All properties exposed by CREATE INDEX statement are included in diff output.
|
|
45
55
|
* https://www.postgresql.org/docs/current/sql-createindex.html
|
|
@@ -362,9 +372,8 @@ export async function extractIndexes(pool: Pool): Promise<Index[]> {
|
|
|
362
372
|
|
|
363
373
|
order by 1, 2
|
|
364
374
|
`);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
);
|
|
375
|
+
const validatedRows = indexRows
|
|
376
|
+
.map((row: unknown) => indexRowSchema.parse(row))
|
|
377
|
+
.filter((row): row is IndexProps => row.definition !== null);
|
|
369
378
|
return validatedRows.map((row: IndexProps) => new Index(row));
|
|
370
379
|
}
|
|
@@ -144,12 +144,13 @@ describe.concurrent("procedure.diff", () => {
|
|
|
144
144
|
expect(changes[0]).toBeInstanceOf(AlterProcedureSetParallel);
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
test("create or replace when non-alterable property changes", () => {
|
|
147
|
+
test("create or replace when body-only non-alterable property changes", () => {
|
|
148
|
+
// Changing only the language (or source) is OR-REPLACE-safe: no DROP needed.
|
|
148
149
|
const main = new Procedure(base);
|
|
149
150
|
const branch = new Procedure({
|
|
150
151
|
...base,
|
|
151
|
-
return_type: "text",
|
|
152
152
|
language: "plpgsql",
|
|
153
|
+
source_code: "BEGIN RETURN 1; END",
|
|
153
154
|
});
|
|
154
155
|
const changes = diffProcedures(
|
|
155
156
|
testContext,
|
|
@@ -158,6 +159,82 @@ describe.concurrent("procedure.diff", () => {
|
|
|
158
159
|
);
|
|
159
160
|
expect(changes).toHaveLength(1);
|
|
160
161
|
expect(changes[0]).toBeInstanceOf(CreateProcedure);
|
|
162
|
+
expect((changes[0] as CreateProcedure).orReplace).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("drop + create when return type changes", () => {
|
|
166
|
+
// `CREATE OR REPLACE FUNCTION` cannot change the return type.
|
|
167
|
+
const main = new Procedure(base);
|
|
168
|
+
const branch = new Procedure({ ...base, return_type: "text" });
|
|
169
|
+
const changes = diffProcedures(
|
|
170
|
+
testContext,
|
|
171
|
+
{ [main.stableId]: main },
|
|
172
|
+
{ [branch.stableId]: branch },
|
|
173
|
+
);
|
|
174
|
+
expect(changes[0]).toBeInstanceOf(DropProcedure);
|
|
175
|
+
expect(changes[1]).toBeInstanceOf(CreateProcedure);
|
|
176
|
+
expect((changes[1] as CreateProcedure).orReplace).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("drop + create when parameter name changes but type is identical", () => {
|
|
180
|
+
// Same argument_types means same stableId (altered path), but
|
|
181
|
+
// `CREATE OR REPLACE` rejects changing an IN parameter's name.
|
|
182
|
+
const main = new Procedure({
|
|
183
|
+
...base,
|
|
184
|
+
argument_count: 1,
|
|
185
|
+
argument_names: ["p"],
|
|
186
|
+
argument_types: ["int4"],
|
|
187
|
+
all_argument_types: ["int4"],
|
|
188
|
+
argument_modes: ["i"],
|
|
189
|
+
});
|
|
190
|
+
const branch = new Procedure({
|
|
191
|
+
...base,
|
|
192
|
+
argument_count: 1,
|
|
193
|
+
argument_names: ["renamed"],
|
|
194
|
+
argument_types: ["int4"],
|
|
195
|
+
all_argument_types: ["int4"],
|
|
196
|
+
argument_modes: ["i"],
|
|
197
|
+
});
|
|
198
|
+
const changes = diffProcedures(
|
|
199
|
+
testContext,
|
|
200
|
+
{ [main.stableId]: main },
|
|
201
|
+
{ [branch.stableId]: branch },
|
|
202
|
+
);
|
|
203
|
+
expect(changes[0]).toBeInstanceOf(DropProcedure);
|
|
204
|
+
expect(changes[1]).toBeInstanceOf(CreateProcedure);
|
|
205
|
+
expect((changes[1] as CreateProcedure).orReplace).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("drop + create when a parameter default is removed", () => {
|
|
209
|
+
// `CREATE OR REPLACE` rejects removing parameter defaults.
|
|
210
|
+
const main = new Procedure({
|
|
211
|
+
...base,
|
|
212
|
+
argument_count: 1,
|
|
213
|
+
argument_default_count: 1,
|
|
214
|
+
argument_names: ["p"],
|
|
215
|
+
argument_types: ["int4"],
|
|
216
|
+
all_argument_types: ["int4"],
|
|
217
|
+
argument_modes: ["i"],
|
|
218
|
+
argument_defaults: "0",
|
|
219
|
+
});
|
|
220
|
+
const branch = new Procedure({
|
|
221
|
+
...base,
|
|
222
|
+
argument_count: 1,
|
|
223
|
+
argument_default_count: 0,
|
|
224
|
+
argument_names: ["p"],
|
|
225
|
+
argument_types: ["int4"],
|
|
226
|
+
all_argument_types: ["int4"],
|
|
227
|
+
argument_modes: ["i"],
|
|
228
|
+
argument_defaults: null,
|
|
229
|
+
});
|
|
230
|
+
const changes = diffProcedures(
|
|
231
|
+
testContext,
|
|
232
|
+
{ [main.stableId]: main },
|
|
233
|
+
{ [branch.stableId]: branch },
|
|
234
|
+
);
|
|
235
|
+
expect(changes[0]).toBeInstanceOf(DropProcedure);
|
|
236
|
+
expect(changes[1]).toBeInstanceOf(CreateProcedure);
|
|
237
|
+
expect((changes[1] as CreateProcedure).orReplace).toBe(false);
|
|
161
238
|
});
|
|
162
239
|
|
|
163
240
|
test("create or replace also emits a procedure comment when the comment changes", () => {
|
|
@@ -183,4 +260,25 @@ describe.concurrent("procedure.diff", () => {
|
|
|
183
260
|
changes.some((change) => change instanceof CreateCommentOnProcedure),
|
|
184
261
|
).toBe(true);
|
|
185
262
|
});
|
|
263
|
+
|
|
264
|
+
test("signature change re-emits comment even when comment itself is unchanged", () => {
|
|
265
|
+
// DROP destroys the old comment, so the new CREATE path must re-emit it.
|
|
266
|
+
const main = new Procedure({ ...base, comment: "hello" });
|
|
267
|
+
const branch = new Procedure({
|
|
268
|
+
...base,
|
|
269
|
+
return_type: "text",
|
|
270
|
+
comment: "hello",
|
|
271
|
+
});
|
|
272
|
+
const changes = diffProcedures(
|
|
273
|
+
testContext,
|
|
274
|
+
{ [main.stableId]: main },
|
|
275
|
+
{ [branch.stableId]: branch },
|
|
276
|
+
);
|
|
277
|
+
expect(changes.some((change) => change instanceof DropProcedure)).toBe(
|
|
278
|
+
true,
|
|
279
|
+
);
|
|
280
|
+
expect(
|
|
281
|
+
changes.some((change) => change instanceof CreateCommentOnProcedure),
|
|
282
|
+
).toBe(true);
|
|
283
|
+
});
|
|
186
284
|
});
|