@supabase/pg-delta 1.0.0-alpha.17 → 1.0.0-alpha.18
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 +27 -0
- package/dist/core/objects/procedure/procedure.diff.js +33 -20
- 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 +4 -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/expand-replace-dependencies.test.ts +118 -0
- package/src/core/expand-replace-dependencies.ts +26 -0
- 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/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 +4 -0
- package/src/core/post-diff-cycle-breaking.test.ts +142 -0
- package/src/core/post-diff-cycle-breaking.ts +83 -2
|
@@ -32,6 +32,25 @@ export function expandReplaceDependencies({ changes, mainCatalog, branchCatalog,
|
|
|
32
32
|
replaceRoots.add(id);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
// Procedure stableIds are signature-qualified
|
|
36
|
+
// (`procedure:schema.name(argtypes)`), so a function whose parameter types
|
|
37
|
+
// change has different ids in `createdIds` and `droppedIds` and would not
|
|
38
|
+
// appear in the intersection above. Treat any dropped procedure whose
|
|
39
|
+
// `(schema, name)` matches a created procedure as a replace root so
|
|
40
|
+
// dependents referencing the old signature via pg_depend get promoted to
|
|
41
|
+
// DROP+CREATE.
|
|
42
|
+
const createdProcedureNames = new Set();
|
|
43
|
+
for (const id of createdIds) {
|
|
44
|
+
const key = parseProcedureSchemaName(id);
|
|
45
|
+
if (key)
|
|
46
|
+
createdProcedureNames.add(key);
|
|
47
|
+
}
|
|
48
|
+
for (const id of droppedIds) {
|
|
49
|
+
const key = parseProcedureSchemaName(id);
|
|
50
|
+
if (key && createdProcedureNames.has(key)) {
|
|
51
|
+
replaceRoots.add(id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
35
54
|
if (replaceRoots.size === 0) {
|
|
36
55
|
return {
|
|
37
56
|
changes,
|
|
@@ -149,6 +168,14 @@ function isOwnedSequenceColumnDependency(referencedId, dependentId, mainCatalog,
|
|
|
149
168
|
return (dependentId ===
|
|
150
169
|
stableId.column(sequence.owned_by_schema, sequence.owned_by_table, sequence.owned_by_column));
|
|
151
170
|
}
|
|
171
|
+
function parseProcedureSchemaName(stableId) {
|
|
172
|
+
if (!stableId.startsWith("procedure:"))
|
|
173
|
+
return null;
|
|
174
|
+
const paren = stableId.indexOf("(");
|
|
175
|
+
if (paren === -1)
|
|
176
|
+
return null;
|
|
177
|
+
return stableId.slice("procedure:".length, paren);
|
|
178
|
+
}
|
|
152
179
|
function normalizeDependentId(dependentId) {
|
|
153
180
|
let id = dependentId;
|
|
154
181
|
while (id.startsWith("comment:")) {
|
|
@@ -17,8 +17,7 @@ import { GrantProcedurePrivileges, RevokeGrantOptionProcedurePrivileges, RevokeP
|
|
|
17
17
|
export function diffProcedures(ctx, main, branch) {
|
|
18
18
|
const { created, dropped, altered } = diffObjects(main, branch);
|
|
19
19
|
const changes = [];
|
|
20
|
-
|
|
21
|
-
const proc = branch[procedureId];
|
|
20
|
+
const appendCreateProcedureChanges = (proc) => {
|
|
22
21
|
changes.push(new CreateProcedure({ procedure: proc }));
|
|
23
22
|
// OWNER: If the procedure should be owned by someone other than the current user,
|
|
24
23
|
// emit ALTER FUNCTION/PROCEDURE ... OWNER TO after creation
|
|
@@ -53,6 +52,9 @@ export function diffProcedures(ctx, main, branch) {
|
|
|
53
52
|
Revoke: RevokeProcedurePrivileges,
|
|
54
53
|
RevokeGrantOption: RevokeGrantOptionProcedurePrivileges,
|
|
55
54
|
}, ctx.version));
|
|
55
|
+
};
|
|
56
|
+
for (const procedureId of created) {
|
|
57
|
+
appendCreateProcedureChanges(branch[procedureId]);
|
|
56
58
|
}
|
|
57
59
|
for (const procedureId of dropped) {
|
|
58
60
|
changes.push(new DropProcedure({ procedure: main[procedureId] }));
|
|
@@ -60,22 +62,18 @@ export function diffProcedures(ctx, main, branch) {
|
|
|
60
62
|
for (const procedureId of altered) {
|
|
61
63
|
const mainProcedure = main[procedureId];
|
|
62
64
|
const branchProcedure = branch[procedureId];
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
65
|
+
// Fields that are part of the function's identity/signature. PostgreSQL
|
|
66
|
+
// rejects `CREATE OR REPLACE FUNCTION` for any of these changes with
|
|
67
|
+
// errors such as:
|
|
68
|
+
// - cannot change return type of existing function
|
|
69
|
+
// - cannot change name of input parameter "..."
|
|
70
|
+
// - cannot change whether a procedure has output parameters
|
|
71
|
+
// - cannot remove parameter defaults from existing function
|
|
72
|
+
// These require `DROP FUNCTION` followed by `CREATE FUNCTION`.
|
|
73
|
+
const SIGNATURE_BREAKING_FIELDS = [
|
|
66
74
|
"kind",
|
|
67
75
|
"return_type",
|
|
68
76
|
"return_type_schema",
|
|
69
|
-
"language",
|
|
70
|
-
// The following properties are alterable in SQL, but our generator may choose
|
|
71
|
-
// to replace on changes not covered by explicit ALTER actions. Keep them out here
|
|
72
|
-
// to allow ALTER for those we implement below.
|
|
73
|
-
// security_definer,
|
|
74
|
-
// volatility,
|
|
75
|
-
// parallel_safety,
|
|
76
|
-
// is_strict,
|
|
77
|
-
// leakproof,
|
|
78
|
-
// Returns-set is part of the signature and not alterable
|
|
79
77
|
"returns_set",
|
|
80
78
|
"argument_count",
|
|
81
79
|
"argument_default_count",
|
|
@@ -84,20 +82,35 @@ export function diffProcedures(ctx, main, branch) {
|
|
|
84
82
|
"all_argument_types",
|
|
85
83
|
"argument_modes",
|
|
86
84
|
"argument_defaults",
|
|
85
|
+
];
|
|
86
|
+
// Fields where `CREATE OR REPLACE` is sufficient - body replacement only.
|
|
87
|
+
// Other fields (security_definer, volatility, parallel_safety, is_strict,
|
|
88
|
+
// leakproof, config) are alterable via dedicated ALTER actions below.
|
|
89
|
+
const OR_REPLACEABLE_NON_ALTERABLE_FIELDS = [
|
|
90
|
+
"language",
|
|
87
91
|
"source_code",
|
|
88
92
|
"binary_path",
|
|
89
93
|
"sql_body",
|
|
90
|
-
// config is alterable via SET/RESET
|
|
91
94
|
];
|
|
92
|
-
const
|
|
95
|
+
const signatureChanged = hasNonAlterableChanges(mainProcedure, branchProcedure, SIGNATURE_BREAKING_FIELDS, {
|
|
93
96
|
argument_names: deepEqual,
|
|
94
97
|
argument_types: deepEqual,
|
|
95
98
|
all_argument_types: deepEqual,
|
|
96
99
|
argument_modes: deepEqual,
|
|
97
|
-
config: deepEqual,
|
|
98
100
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
const nonAlterablePropsChanged = signatureChanged ||
|
|
102
|
+
hasNonAlterableChanges(mainProcedure, branchProcedure, OR_REPLACEABLE_NON_ALTERABLE_FIELDS);
|
|
103
|
+
if (signatureChanged) {
|
|
104
|
+
// PostgreSQL cannot change an existing function's signature via
|
|
105
|
+
// `CREATE OR REPLACE`. Drop the old signature, then recreate.
|
|
106
|
+
// `expandReplaceDependencies` will cascade the replacement to dependent
|
|
107
|
+
// objects (views, triggers, column defaults) via pg_depend edges.
|
|
108
|
+
changes.push(new DropProcedure({ procedure: mainProcedure }));
|
|
109
|
+
appendCreateProcedureChanges(branchProcedure);
|
|
110
|
+
}
|
|
111
|
+
else if (nonAlterablePropsChanged) {
|
|
112
|
+
// Body-only non-alterable change - `CREATE OR REPLACE` preserves the
|
|
113
|
+
// function OID and keeps dependent objects attached.
|
|
101
114
|
changes.push(new CreateProcedure({ procedure: branchProcedure, orReplace: true }));
|
|
102
115
|
if (mainProcedure.comment !== branchProcedure.comment) {
|
|
103
116
|
if (branchProcedure.comment === null) {
|
|
@@ -66,6 +66,7 @@ function createAlterConstraintChange(mainTable, branchTable) {
|
|
|
66
66
|
mainC.validated !== branchC.validated ||
|
|
67
67
|
mainC.is_local !== branchC.is_local ||
|
|
68
68
|
mainC.no_inherit !== branchC.no_inherit ||
|
|
69
|
+
mainC.is_temporal !== branchC.is_temporal ||
|
|
69
70
|
JSON.stringify(mainC.key_columns) !==
|
|
70
71
|
JSON.stringify(branchC.key_columns) ||
|
|
71
72
|
JSON.stringify(mainC.foreign_key_columns) !==
|
|
@@ -23,6 +23,7 @@ declare const tableConstraintPropsSchema: z.ZodObject<{
|
|
|
23
23
|
validated: z.ZodBoolean;
|
|
24
24
|
is_local: z.ZodBoolean;
|
|
25
25
|
no_inherit: z.ZodBoolean;
|
|
26
|
+
is_temporal: z.ZodBoolean;
|
|
26
27
|
is_partition_clone: z.ZodBoolean;
|
|
27
28
|
parent_constraint_schema: z.ZodNullable<z.ZodString>;
|
|
28
29
|
parent_constraint_name: z.ZodNullable<z.ZodString>;
|
|
@@ -125,6 +126,7 @@ declare const tablePropsSchema: z.ZodObject<{
|
|
|
125
126
|
validated: z.ZodBoolean;
|
|
126
127
|
is_local: z.ZodBoolean;
|
|
127
128
|
no_inherit: z.ZodBoolean;
|
|
129
|
+
is_temporal: z.ZodBoolean;
|
|
128
130
|
is_partition_clone: z.ZodBoolean;
|
|
129
131
|
parent_constraint_schema: z.ZodNullable<z.ZodString>;
|
|
130
132
|
parent_constraint_name: z.ZodNullable<z.ZodString>;
|
|
@@ -239,6 +241,7 @@ export declare class Table extends BasePgModel implements TableLikeObject {
|
|
|
239
241
|
validated: boolean;
|
|
240
242
|
is_local: boolean;
|
|
241
243
|
no_inherit: boolean;
|
|
244
|
+
is_temporal: boolean;
|
|
242
245
|
is_partition_clone: boolean;
|
|
243
246
|
parent_constraint_schema: string | null;
|
|
244
247
|
parent_constraint_name: string | null;
|
|
@@ -300,6 +303,7 @@ export declare class Table extends BasePgModel implements TableLikeObject {
|
|
|
300
303
|
validated: boolean;
|
|
301
304
|
is_local: boolean;
|
|
302
305
|
no_inherit: boolean;
|
|
306
|
+
is_temporal: boolean;
|
|
303
307
|
is_partition_clone: boolean;
|
|
304
308
|
parent_constraint_schema: string | null;
|
|
305
309
|
parent_constraint_name: string | null;
|
|
@@ -42,6 +42,7 @@ const tableConstraintPropsSchema = z.object({
|
|
|
42
42
|
validated: z.boolean(),
|
|
43
43
|
is_local: z.boolean(),
|
|
44
44
|
no_inherit: z.boolean(),
|
|
45
|
+
is_temporal: z.boolean(),
|
|
45
46
|
is_partition_clone: z.boolean(),
|
|
46
47
|
parent_constraint_schema: z.string().nullable(),
|
|
47
48
|
parent_constraint_name: z.string().nullable(),
|
|
@@ -253,6 +254,7 @@ select
|
|
|
253
254
|
'validated', c.convalidated,
|
|
254
255
|
'is_local', c.conislocal,
|
|
255
256
|
'no_inherit', c.connoinherit,
|
|
257
|
+
'is_temporal', coalesce((to_jsonb(c)->>'conperiod')::boolean, false),
|
|
256
258
|
|
|
257
259
|
-- NEW: propagated-to-partition tagging (PG15+)
|
|
258
260
|
'is_partition_clone', (c.conparentid <> 0::oid),
|
|
@@ -294,6 +294,7 @@ const pkConstraint = {
|
|
|
294
294
|
validated: true,
|
|
295
295
|
is_local: true,
|
|
296
296
|
no_inherit: false,
|
|
297
|
+
is_temporal: false,
|
|
297
298
|
is_partition_clone: false,
|
|
298
299
|
parent_constraint_schema: null,
|
|
299
300
|
parent_constraint_name: null,
|
|
@@ -324,6 +325,7 @@ const uniqueConstraint = {
|
|
|
324
325
|
validated: true,
|
|
325
326
|
is_local: true,
|
|
326
327
|
no_inherit: false,
|
|
328
|
+
is_temporal: false,
|
|
327
329
|
is_partition_clone: false,
|
|
328
330
|
parent_constraint_schema: null,
|
|
329
331
|
parent_constraint_name: null,
|
|
@@ -353,6 +355,7 @@ const fkConstraint = {
|
|
|
353
355
|
validated: true,
|
|
354
356
|
is_local: true,
|
|
355
357
|
no_inherit: false,
|
|
358
|
+
is_temporal: false,
|
|
356
359
|
is_partition_clone: false,
|
|
357
360
|
parent_constraint_schema: null,
|
|
358
361
|
parent_constraint_name: null,
|
|
@@ -382,6 +385,7 @@ const checkConstraint = {
|
|
|
382
385
|
validated: true,
|
|
383
386
|
is_local: true,
|
|
384
387
|
no_inherit: true,
|
|
388
|
+
is_temporal: false,
|
|
385
389
|
is_partition_clone: false,
|
|
386
390
|
parent_constraint_schema: null,
|
|
387
391
|
parent_constraint_name: null,
|
|
@@ -8,6 +8,13 @@ import type { Change } from "./change.types.ts";
|
|
|
8
8
|
* - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
|
|
9
9
|
* `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
|
|
10
10
|
* redundant and create an unbreakable drop-phase cycle, so we elide them.
|
|
11
|
+
* - When the same `DropTable+CreateTable` pair is present, the expansion
|
|
12
|
+
* also emits one `AlterTableAddConstraint` / `AlterTableValidateConstraint`
|
|
13
|
+
* / `CreateCommentOnConstraint` per branch constraint, which may collide
|
|
14
|
+
* with the same change already emitted by `diffTables()` (for example on a
|
|
15
|
+
* shape flip or a new constraint). We dedupe these keeping only the last
|
|
16
|
+
* occurrence so the expansion's emission survives and the diffTables
|
|
17
|
+
* duplicate is removed.
|
|
11
18
|
* - If two dropped tables reference each other via FK, we insert dedicated
|
|
12
19
|
* `AlterTableDropConstraint` changes and teach the paired `DropTable`
|
|
13
20
|
* changes not to claim those FK stable IDs.
|
|
@@ -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
|
});
|
|
@@ -102,6 +102,25 @@ export function expandReplaceDependencies({
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Procedure stableIds are signature-qualified
|
|
106
|
+
// (`procedure:schema.name(argtypes)`), so a function whose parameter types
|
|
107
|
+
// change has different ids in `createdIds` and `droppedIds` and would not
|
|
108
|
+
// appear in the intersection above. Treat any dropped procedure whose
|
|
109
|
+
// `(schema, name)` matches a created procedure as a replace root so
|
|
110
|
+
// dependents referencing the old signature via pg_depend get promoted to
|
|
111
|
+
// DROP+CREATE.
|
|
112
|
+
const createdProcedureNames = new Set<string>();
|
|
113
|
+
for (const id of createdIds) {
|
|
114
|
+
const key = parseProcedureSchemaName(id);
|
|
115
|
+
if (key) createdProcedureNames.add(key);
|
|
116
|
+
}
|
|
117
|
+
for (const id of droppedIds) {
|
|
118
|
+
const key = parseProcedureSchemaName(id);
|
|
119
|
+
if (key && createdProcedureNames.has(key)) {
|
|
120
|
+
replaceRoots.add(id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
105
124
|
if (replaceRoots.size === 0) {
|
|
106
125
|
return {
|
|
107
126
|
changes,
|
|
@@ -259,6 +278,13 @@ function isOwnedSequenceColumnDependency(
|
|
|
259
278
|
);
|
|
260
279
|
}
|
|
261
280
|
|
|
281
|
+
function parseProcedureSchemaName(stableId: string): string | null {
|
|
282
|
+
if (!stableId.startsWith("procedure:")) return null;
|
|
283
|
+
const paren = stableId.indexOf("(");
|
|
284
|
+
if (paren === -1) return null;
|
|
285
|
+
return stableId.slice("procedure:".length, paren);
|
|
286
|
+
}
|
|
287
|
+
|
|
262
288
|
function normalizeDependentId(dependentId: string): string | null {
|
|
263
289
|
let id = dependentId;
|
|
264
290
|
|
|
@@ -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
|
});
|
|
@@ -49,8 +49,7 @@ export function diffProcedures(
|
|
|
49
49
|
|
|
50
50
|
const changes: ProcedureChange[] = [];
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
const proc = branch[procedureId];
|
|
52
|
+
const appendCreateProcedureChanges = (proc: Procedure) => {
|
|
54
53
|
changes.push(new CreateProcedure({ procedure: proc }));
|
|
55
54
|
|
|
56
55
|
// OWNER: If the procedure should be owned by someone other than the current user,
|
|
@@ -112,6 +111,10 @@ export function diffProcedures(
|
|
|
112
111
|
ctx.version,
|
|
113
112
|
) as ProcedureChange[]),
|
|
114
113
|
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (const procedureId of created) {
|
|
117
|
+
appendCreateProcedureChanges(branch[procedureId]);
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
for (const procedureId of dropped) {
|
|
@@ -122,22 +125,18 @@ export function diffProcedures(
|
|
|
122
125
|
const mainProcedure = main[procedureId];
|
|
123
126
|
const branchProcedure = branch[procedureId];
|
|
124
127
|
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
+
// Fields that are part of the function's identity/signature. PostgreSQL
|
|
129
|
+
// rejects `CREATE OR REPLACE FUNCTION` for any of these changes with
|
|
130
|
+
// errors such as:
|
|
131
|
+
// - cannot change return type of existing function
|
|
132
|
+
// - cannot change name of input parameter "..."
|
|
133
|
+
// - cannot change whether a procedure has output parameters
|
|
134
|
+
// - cannot remove parameter defaults from existing function
|
|
135
|
+
// These require `DROP FUNCTION` followed by `CREATE FUNCTION`.
|
|
136
|
+
const SIGNATURE_BREAKING_FIELDS: Array<keyof Procedure> = [
|
|
128
137
|
"kind",
|
|
129
138
|
"return_type",
|
|
130
139
|
"return_type_schema",
|
|
131
|
-
"language",
|
|
132
|
-
// The following properties are alterable in SQL, but our generator may choose
|
|
133
|
-
// to replace on changes not covered by explicit ALTER actions. Keep them out here
|
|
134
|
-
// to allow ALTER for those we implement below.
|
|
135
|
-
// security_definer,
|
|
136
|
-
// volatility,
|
|
137
|
-
// parallel_safety,
|
|
138
|
-
// is_strict,
|
|
139
|
-
// leakproof,
|
|
140
|
-
// Returns-set is part of the signature and not alterable
|
|
141
140
|
"returns_set",
|
|
142
141
|
"argument_count",
|
|
143
142
|
"argument_default_count",
|
|
@@ -146,26 +145,45 @@ export function diffProcedures(
|
|
|
146
145
|
"all_argument_types",
|
|
147
146
|
"argument_modes",
|
|
148
147
|
"argument_defaults",
|
|
148
|
+
];
|
|
149
|
+
// Fields where `CREATE OR REPLACE` is sufficient - body replacement only.
|
|
150
|
+
// Other fields (security_definer, volatility, parallel_safety, is_strict,
|
|
151
|
+
// leakproof, config) are alterable via dedicated ALTER actions below.
|
|
152
|
+
const OR_REPLACEABLE_NON_ALTERABLE_FIELDS: Array<keyof Procedure> = [
|
|
153
|
+
"language",
|
|
149
154
|
"source_code",
|
|
150
155
|
"binary_path",
|
|
151
156
|
"sql_body",
|
|
152
|
-
// config is alterable via SET/RESET
|
|
153
157
|
];
|
|
154
|
-
const
|
|
158
|
+
const signatureChanged = hasNonAlterableChanges(
|
|
155
159
|
mainProcedure,
|
|
156
160
|
branchProcedure,
|
|
157
|
-
|
|
161
|
+
SIGNATURE_BREAKING_FIELDS,
|
|
158
162
|
{
|
|
159
163
|
argument_names: deepEqual,
|
|
160
164
|
argument_types: deepEqual,
|
|
161
165
|
all_argument_types: deepEqual,
|
|
162
166
|
argument_modes: deepEqual,
|
|
163
|
-
config: deepEqual,
|
|
164
167
|
},
|
|
165
168
|
);
|
|
169
|
+
const nonAlterablePropsChanged =
|
|
170
|
+
signatureChanged ||
|
|
171
|
+
hasNonAlterableChanges(
|
|
172
|
+
mainProcedure,
|
|
173
|
+
branchProcedure,
|
|
174
|
+
OR_REPLACEABLE_NON_ALTERABLE_FIELDS,
|
|
175
|
+
);
|
|
166
176
|
|
|
167
|
-
if (
|
|
168
|
-
//
|
|
177
|
+
if (signatureChanged) {
|
|
178
|
+
// PostgreSQL cannot change an existing function's signature via
|
|
179
|
+
// `CREATE OR REPLACE`. Drop the old signature, then recreate.
|
|
180
|
+
// `expandReplaceDependencies` will cascade the replacement to dependent
|
|
181
|
+
// objects (views, triggers, column defaults) via pg_depend edges.
|
|
182
|
+
changes.push(new DropProcedure({ procedure: mainProcedure }));
|
|
183
|
+
appendCreateProcedureChanges(branchProcedure);
|
|
184
|
+
} else if (nonAlterablePropsChanged) {
|
|
185
|
+
// Body-only non-alterable change - `CREATE OR REPLACE` preserves the
|
|
186
|
+
// function OID and keeps dependent objects attached.
|
|
169
187
|
changes.push(
|
|
170
188
|
new CreateProcedure({ procedure: branchProcedure, orReplace: true }),
|
|
171
189
|
);
|
|
@@ -112,6 +112,7 @@ describe.concurrent("table.diff", () => {
|
|
|
112
112
|
validated: false,
|
|
113
113
|
is_local: true,
|
|
114
114
|
no_inherit: false,
|
|
115
|
+
is_temporal: false,
|
|
115
116
|
is_partition_clone: false,
|
|
116
117
|
parent_constraint_schema: null,
|
|
117
118
|
parent_constraint_name: null,
|
|
@@ -328,6 +329,7 @@ describe.concurrent("table.diff", () => {
|
|
|
328
329
|
validated: false,
|
|
329
330
|
is_local: true,
|
|
330
331
|
no_inherit: false,
|
|
332
|
+
is_temporal: false,
|
|
331
333
|
is_partition_clone: false,
|
|
332
334
|
parent_constraint_schema: null,
|
|
333
335
|
parent_constraint_name: null,
|
|
@@ -453,6 +455,7 @@ describe.concurrent("table.diff", () => {
|
|
|
453
455
|
validated: true,
|
|
454
456
|
is_local: true,
|
|
455
457
|
no_inherit: false,
|
|
458
|
+
is_temporal: false,
|
|
456
459
|
is_partition_clone: false,
|
|
457
460
|
parent_constraint_schema: null,
|
|
458
461
|
parent_constraint_name: null,
|
|
@@ -531,6 +534,7 @@ describe.concurrent("table.diff", () => {
|
|
|
531
534
|
validated: true,
|
|
532
535
|
is_local: true,
|
|
533
536
|
no_inherit: false,
|
|
537
|
+
is_temporal: false,
|
|
534
538
|
is_partition_clone: false,
|
|
535
539
|
parent_constraint_schema: null,
|
|
536
540
|
parent_constraint_name: null,
|
|
@@ -606,6 +610,104 @@ describe.concurrent("table.diff", () => {
|
|
|
606
610
|
);
|
|
607
611
|
});
|
|
608
612
|
|
|
613
|
+
test("altered temporal constraint metadata triggers drop+add", () => {
|
|
614
|
+
const tMain = new Table({
|
|
615
|
+
...base,
|
|
616
|
+
name: "t_temporal",
|
|
617
|
+
columns: [
|
|
618
|
+
{
|
|
619
|
+
name: "room_id",
|
|
620
|
+
position: 1,
|
|
621
|
+
data_type: "integer",
|
|
622
|
+
data_type_str: "integer",
|
|
623
|
+
is_custom_type: false,
|
|
624
|
+
custom_type_type: null,
|
|
625
|
+
custom_type_category: null,
|
|
626
|
+
custom_type_schema: null,
|
|
627
|
+
custom_type_name: null,
|
|
628
|
+
not_null: false,
|
|
629
|
+
is_identity: false,
|
|
630
|
+
is_identity_always: false,
|
|
631
|
+
is_generated: false,
|
|
632
|
+
collation: null,
|
|
633
|
+
default: null,
|
|
634
|
+
comment: null,
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: "booking_period",
|
|
638
|
+
position: 2,
|
|
639
|
+
data_type: "tstzrange",
|
|
640
|
+
data_type_str: "tstzrange",
|
|
641
|
+
is_custom_type: false,
|
|
642
|
+
custom_type_type: null,
|
|
643
|
+
custom_type_category: null,
|
|
644
|
+
custom_type_schema: null,
|
|
645
|
+
custom_type_name: null,
|
|
646
|
+
not_null: false,
|
|
647
|
+
is_identity: false,
|
|
648
|
+
is_identity_always: false,
|
|
649
|
+
is_generated: false,
|
|
650
|
+
collation: null,
|
|
651
|
+
default: null,
|
|
652
|
+
comment: null,
|
|
653
|
+
},
|
|
654
|
+
],
|
|
655
|
+
constraints: [
|
|
656
|
+
{
|
|
657
|
+
name: "bookings_pkey",
|
|
658
|
+
constraint_type: "p",
|
|
659
|
+
deferrable: false,
|
|
660
|
+
initially_deferred: false,
|
|
661
|
+
validated: true,
|
|
662
|
+
is_local: true,
|
|
663
|
+
no_inherit: false,
|
|
664
|
+
is_temporal: false,
|
|
665
|
+
is_partition_clone: false,
|
|
666
|
+
parent_constraint_schema: null,
|
|
667
|
+
parent_constraint_name: null,
|
|
668
|
+
parent_table_schema: null,
|
|
669
|
+
parent_table_name: null,
|
|
670
|
+
key_columns: ["room_id", "booking_period"],
|
|
671
|
+
foreign_key_columns: null,
|
|
672
|
+
foreign_key_table: null,
|
|
673
|
+
foreign_key_schema: null,
|
|
674
|
+
foreign_key_table_is_partition: null,
|
|
675
|
+
foreign_key_parent_schema: null,
|
|
676
|
+
foreign_key_parent_table: null,
|
|
677
|
+
foreign_key_effective_schema: null,
|
|
678
|
+
foreign_key_effective_table: null,
|
|
679
|
+
on_update: null,
|
|
680
|
+
on_delete: null,
|
|
681
|
+
match_type: null,
|
|
682
|
+
check_expression: null,
|
|
683
|
+
owner: "o1",
|
|
684
|
+
definition: "PRIMARY KEY (room_id, booking_period)",
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
});
|
|
688
|
+
const tBranch = new Table({
|
|
689
|
+
...tMain,
|
|
690
|
+
constraints: [
|
|
691
|
+
{
|
|
692
|
+
...tMain.constraints[0],
|
|
693
|
+
is_temporal: true,
|
|
694
|
+
definition: "PRIMARY KEY (room_id, booking_period WITHOUT OVERLAPS)",
|
|
695
|
+
},
|
|
696
|
+
],
|
|
697
|
+
});
|
|
698
|
+
const changes = diffTables(
|
|
699
|
+
testContext,
|
|
700
|
+
{ [tMain.stableId]: tMain },
|
|
701
|
+
{ [tBranch.stableId]: tBranch },
|
|
702
|
+
);
|
|
703
|
+
expect(changes.some((c) => c instanceof AlterTableDropConstraint)).toBe(
|
|
704
|
+
true,
|
|
705
|
+
);
|
|
706
|
+
expect(changes.some((c) => c instanceof AlterTableAddConstraint)).toBe(
|
|
707
|
+
true,
|
|
708
|
+
);
|
|
709
|
+
});
|
|
710
|
+
|
|
609
711
|
test("columns added/dropped/altered (type, default, not null)", () => {
|
|
610
712
|
const main = new Table({ ...base, name: "t2", columns: [] });
|
|
611
713
|
const withCol = new Table({
|
|
@@ -130,6 +130,7 @@ function createAlterConstraintChange(mainTable: Table, branchTable: Table) {
|
|
|
130
130
|
mainC.validated !== branchC.validated ||
|
|
131
131
|
mainC.is_local !== branchC.is_local ||
|
|
132
132
|
mainC.no_inherit !== branchC.no_inherit ||
|
|
133
|
+
mainC.is_temporal !== branchC.is_temporal ||
|
|
133
134
|
JSON.stringify(mainC.key_columns) !==
|
|
134
135
|
JSON.stringify(branchC.key_columns) ||
|
|
135
136
|
JSON.stringify(mainC.foreign_key_columns) !==
|
|
@@ -56,6 +56,7 @@ const tableConstraintPropsSchema = z.object({
|
|
|
56
56
|
validated: z.boolean(),
|
|
57
57
|
is_local: z.boolean(),
|
|
58
58
|
no_inherit: z.boolean(),
|
|
59
|
+
is_temporal: z.boolean(),
|
|
59
60
|
is_partition_clone: z.boolean(),
|
|
60
61
|
parent_constraint_schema: z.string().nullable(),
|
|
61
62
|
parent_constraint_name: z.string().nullable(),
|
|
@@ -284,6 +285,7 @@ select
|
|
|
284
285
|
'validated', c.convalidated,
|
|
285
286
|
'is_local', c.conislocal,
|
|
286
287
|
'no_inherit', c.connoinherit,
|
|
288
|
+
'is_temporal', coalesce((to_jsonb(c)->>'conperiod')::boolean, false),
|
|
287
289
|
|
|
288
290
|
-- NEW: propagated-to-partition tagging (PG15+)
|
|
289
291
|
'is_partition_clone', (c.conparentid <> 0::oid),
|
|
@@ -578,6 +578,7 @@ const pkConstraint = {
|
|
|
578
578
|
validated: true,
|
|
579
579
|
is_local: true,
|
|
580
580
|
no_inherit: false,
|
|
581
|
+
is_temporal: false,
|
|
581
582
|
is_partition_clone: false,
|
|
582
583
|
parent_constraint_schema: null,
|
|
583
584
|
parent_constraint_name: null,
|
|
@@ -609,6 +610,7 @@ const uniqueConstraint = {
|
|
|
609
610
|
validated: true,
|
|
610
611
|
is_local: true,
|
|
611
612
|
no_inherit: false,
|
|
613
|
+
is_temporal: false,
|
|
612
614
|
is_partition_clone: false,
|
|
613
615
|
parent_constraint_schema: null,
|
|
614
616
|
parent_constraint_name: null,
|
|
@@ -639,6 +641,7 @@ const fkConstraint = {
|
|
|
639
641
|
validated: true,
|
|
640
642
|
is_local: true,
|
|
641
643
|
no_inherit: false,
|
|
644
|
+
is_temporal: false,
|
|
642
645
|
is_partition_clone: false,
|
|
643
646
|
parent_constraint_schema: null,
|
|
644
647
|
parent_constraint_name: null,
|
|
@@ -670,6 +673,7 @@ const checkConstraint = {
|
|
|
670
673
|
validated: true,
|
|
671
674
|
is_local: true,
|
|
672
675
|
no_inherit: true,
|
|
676
|
+
is_temporal: false,
|
|
673
677
|
is_partition_clone: false,
|
|
674
678
|
parent_constraint_schema: null,
|
|
675
679
|
parent_constraint_name: null,
|
|
@@ -2,12 +2,15 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { Catalog, createEmptyCatalog } from "./catalog.model.ts";
|
|
3
3
|
import type { Change } from "./change.types.ts";
|
|
4
4
|
import {
|
|
5
|
+
AlterTableAddConstraint,
|
|
5
6
|
AlterTableChangeOwner,
|
|
6
7
|
AlterTableDropColumn,
|
|
7
8
|
AlterTableDropConstraint,
|
|
8
9
|
AlterTableEnableRowLevelSecurity,
|
|
9
10
|
AlterTableSetReplicaIdentity,
|
|
11
|
+
AlterTableValidateConstraint,
|
|
10
12
|
} from "./objects/table/changes/table.alter.ts";
|
|
13
|
+
import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts";
|
|
11
14
|
import { CreateTable } from "./objects/table/changes/table.create.ts";
|
|
12
15
|
import { DropTable } from "./objects/table/changes/table.drop.ts";
|
|
13
16
|
import { GrantTablePrivileges } from "./objects/table/changes/table.privilege.ts";
|
|
@@ -77,6 +80,7 @@ describe("normalizePostDiffCycles", () => {
|
|
|
77
80
|
validated: true,
|
|
78
81
|
is_local: true,
|
|
79
82
|
no_inherit: false,
|
|
83
|
+
is_temporal: false,
|
|
80
84
|
is_partition_clone: false,
|
|
81
85
|
parent_constraint_schema: null,
|
|
82
86
|
parent_constraint_name: null,
|
|
@@ -117,6 +121,7 @@ describe("normalizePostDiffCycles", () => {
|
|
|
117
121
|
validated: true,
|
|
118
122
|
is_local: true,
|
|
119
123
|
no_inherit: false,
|
|
124
|
+
is_temporal: false,
|
|
120
125
|
is_partition_clone: false,
|
|
121
126
|
parent_constraint_schema: null,
|
|
122
127
|
parent_constraint_name: null,
|
|
@@ -237,6 +242,7 @@ describe("normalizePostDiffCycles", () => {
|
|
|
237
242
|
validated: true,
|
|
238
243
|
is_local: true,
|
|
239
244
|
no_inherit: false,
|
|
245
|
+
is_temporal: false,
|
|
240
246
|
is_partition_clone: false,
|
|
241
247
|
parent_constraint_schema: null,
|
|
242
248
|
parent_constraint_name: null,
|
|
@@ -314,4 +320,140 @@ describe("normalizePostDiffCycles", () => {
|
|
|
314
320
|
expect(normalized).toContain(preExistingReplicaIdentity);
|
|
315
321
|
expect(normalized).toContain(preExistingGrant);
|
|
316
322
|
});
|
|
323
|
+
|
|
324
|
+
test("dedupes duplicate constraint Add/Validate/Comment on replaced tables keeping last occurrence", async () => {
|
|
325
|
+
const baseline = await createEmptyCatalog(170000, "postgres");
|
|
326
|
+
const branchChildren = new Table({
|
|
327
|
+
...baseTableProps,
|
|
328
|
+
name: "children",
|
|
329
|
+
columns: [
|
|
330
|
+
{ ...integerColumn("id", 1), not_null: true },
|
|
331
|
+
integerColumn("parent_ref", 2),
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
const otherTable = new Table({
|
|
335
|
+
...baseTableProps,
|
|
336
|
+
name: "other",
|
|
337
|
+
columns: [{ ...integerColumn("id", 1), not_null: true }],
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const fkConstraint = {
|
|
341
|
+
name: "children_parent_ref_fkey",
|
|
342
|
+
constraint_type: "f" as const,
|
|
343
|
+
deferrable: false,
|
|
344
|
+
initially_deferred: false,
|
|
345
|
+
validated: false,
|
|
346
|
+
is_local: true,
|
|
347
|
+
no_inherit: false,
|
|
348
|
+
is_temporal: true,
|
|
349
|
+
is_partition_clone: false,
|
|
350
|
+
parent_constraint_schema: null,
|
|
351
|
+
parent_constraint_name: null,
|
|
352
|
+
parent_table_schema: null,
|
|
353
|
+
parent_table_name: null,
|
|
354
|
+
key_columns: ["parent_ref"],
|
|
355
|
+
foreign_key_columns: ["id"],
|
|
356
|
+
foreign_key_table: "parents",
|
|
357
|
+
foreign_key_schema: "public",
|
|
358
|
+
foreign_key_table_is_partition: false,
|
|
359
|
+
foreign_key_parent_schema: null,
|
|
360
|
+
foreign_key_parent_table: null,
|
|
361
|
+
foreign_key_effective_schema: "public",
|
|
362
|
+
foreign_key_effective_table: "parents",
|
|
363
|
+
on_update: "a" as const,
|
|
364
|
+
on_delete: "a" as const,
|
|
365
|
+
match_type: "s" as const,
|
|
366
|
+
check_expression: null,
|
|
367
|
+
owner: "postgres",
|
|
368
|
+
definition:
|
|
369
|
+
"FOREIGN KEY (parent_ref, PERIOD valid_period) REFERENCES public.parents(id, PERIOD valid_period)",
|
|
370
|
+
comment: "fk comment",
|
|
371
|
+
};
|
|
372
|
+
const otherConstraint = {
|
|
373
|
+
...fkConstraint,
|
|
374
|
+
name: "other_unique",
|
|
375
|
+
constraint_type: "u" as const,
|
|
376
|
+
foreign_key_table: null,
|
|
377
|
+
foreign_key_schema: null,
|
|
378
|
+
foreign_key_effective_schema: null,
|
|
379
|
+
foreign_key_effective_table: null,
|
|
380
|
+
foreign_key_columns: [],
|
|
381
|
+
key_columns: ["id"],
|
|
382
|
+
definition: "UNIQUE (id)",
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const diffTablesAdd = new AlterTableAddConstraint({
|
|
386
|
+
table: branchChildren,
|
|
387
|
+
constraint: fkConstraint,
|
|
388
|
+
});
|
|
389
|
+
const diffTablesValidate = new AlterTableValidateConstraint({
|
|
390
|
+
table: branchChildren,
|
|
391
|
+
constraint: fkConstraint,
|
|
392
|
+
});
|
|
393
|
+
const diffTablesComment = new CreateCommentOnConstraint({
|
|
394
|
+
table: branchChildren,
|
|
395
|
+
constraint: fkConstraint,
|
|
396
|
+
});
|
|
397
|
+
const expansionAdd = new AlterTableAddConstraint({
|
|
398
|
+
table: branchChildren,
|
|
399
|
+
constraint: fkConstraint,
|
|
400
|
+
});
|
|
401
|
+
const expansionValidate = new AlterTableValidateConstraint({
|
|
402
|
+
table: branchChildren,
|
|
403
|
+
constraint: fkConstraint,
|
|
404
|
+
});
|
|
405
|
+
const expansionComment = new CreateCommentOnConstraint({
|
|
406
|
+
table: branchChildren,
|
|
407
|
+
constraint: fkConstraint,
|
|
408
|
+
});
|
|
409
|
+
const soloOtherTableAdd = new AlterTableAddConstraint({
|
|
410
|
+
table: otherTable,
|
|
411
|
+
constraint: otherConstraint,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const changes: Change[] = [
|
|
415
|
+
new DropTable({ table: branchChildren }),
|
|
416
|
+
new CreateTable({ table: branchChildren }),
|
|
417
|
+
diffTablesAdd,
|
|
418
|
+
diffTablesValidate,
|
|
419
|
+
diffTablesComment,
|
|
420
|
+
soloOtherTableAdd,
|
|
421
|
+
expansionAdd,
|
|
422
|
+
expansionValidate,
|
|
423
|
+
expansionComment,
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
const mainCatalog = new Catalog({
|
|
427
|
+
...baseline,
|
|
428
|
+
tables: { [branchChildren.stableId]: branchChildren },
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const normalized = normalizePostDiffCycles({
|
|
432
|
+
changes,
|
|
433
|
+
mainCatalog,
|
|
434
|
+
replacedTableIds: new Set([branchChildren.stableId]),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
expect(normalized).not.toContain(diffTablesAdd);
|
|
438
|
+
expect(normalized).not.toContain(diffTablesValidate);
|
|
439
|
+
expect(normalized).not.toContain(diffTablesComment);
|
|
440
|
+
expect(normalized).toContain(expansionAdd);
|
|
441
|
+
expect(normalized).toContain(expansionValidate);
|
|
442
|
+
expect(normalized).toContain(expansionComment);
|
|
443
|
+
expect(normalized).toContain(soloOtherTableAdd);
|
|
444
|
+
|
|
445
|
+
expect(
|
|
446
|
+
normalized.filter((change) => change instanceof AlterTableAddConstraint),
|
|
447
|
+
).toHaveLength(2);
|
|
448
|
+
expect(
|
|
449
|
+
normalized.filter(
|
|
450
|
+
(change) => change instanceof AlterTableValidateConstraint,
|
|
451
|
+
),
|
|
452
|
+
).toHaveLength(1);
|
|
453
|
+
expect(
|
|
454
|
+
normalized.filter(
|
|
455
|
+
(change) => change instanceof CreateCommentOnConstraint,
|
|
456
|
+
),
|
|
457
|
+
).toHaveLength(1);
|
|
458
|
+
});
|
|
317
459
|
});
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { Catalog } from "./catalog.model.ts";
|
|
2
2
|
import type { Change } from "./change.types.ts";
|
|
3
3
|
import {
|
|
4
|
+
AlterTableAddConstraint,
|
|
4
5
|
AlterTableDropColumn,
|
|
5
6
|
AlterTableDropConstraint,
|
|
7
|
+
AlterTableValidateConstraint,
|
|
6
8
|
} from "./objects/table/changes/table.alter.ts";
|
|
9
|
+
import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.ts";
|
|
7
10
|
import { DropTable } from "./objects/table/changes/table.drop.ts";
|
|
8
11
|
import { stableId } from "./objects/utils.ts";
|
|
9
12
|
|
|
@@ -52,6 +55,72 @@ function isSupersededByTableReplacement(
|
|
|
52
55
|
return replacedTableIds.has(change.table.stableId);
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Drop earlier duplicates of `AlterTableAddConstraint` /
|
|
60
|
+
* `AlterTableValidateConstraint` / `CreateCommentOnConstraint` targeting
|
|
61
|
+
* replaced tables, keeping only the last occurrence of each
|
|
62
|
+
* `(changeType, table.stableId, constraint.name)`.
|
|
63
|
+
*
|
|
64
|
+
* When `expandReplaceDependencies()` promotes a table to a full
|
|
65
|
+
* `DropTable + CreateTable` pair, it also emits one
|
|
66
|
+
* `AlterTableAddConstraint` (plus optional `VALIDATE CONSTRAINT` /
|
|
67
|
+
* `COMMENT ON CONSTRAINT`) per branch constraint. If `diffTables()` already
|
|
68
|
+
* emitted the same change for a shape flip or a new constraint on that
|
|
69
|
+
* table, the plan ends up with two identical `ALTER TABLE ... ADD
|
|
70
|
+
* CONSTRAINT ...` statements and PostgreSQL fails at apply time with
|
|
71
|
+
* `constraint "..." for relation "..." already exists`. Because
|
|
72
|
+
* `expandReplaceDependencies()` appends its additions after the original
|
|
73
|
+
* `diffTables()` output, the last occurrence is the expansion's emission —
|
|
74
|
+
* keeping it preserves correctness while removing the duplicate.
|
|
75
|
+
*/
|
|
76
|
+
function dropReplacedTableDuplicateConstraintChanges(
|
|
77
|
+
changes: Change[],
|
|
78
|
+
replacedTableIds: ReadonlySet<string>,
|
|
79
|
+
): Change[] {
|
|
80
|
+
if (replacedTableIds.size === 0) return changes;
|
|
81
|
+
|
|
82
|
+
const keyFor = (change: Change): string | null => {
|
|
83
|
+
if (
|
|
84
|
+
!(change instanceof AlterTableAddConstraint) &&
|
|
85
|
+
!(change instanceof AlterTableValidateConstraint) &&
|
|
86
|
+
!(change instanceof CreateCommentOnConstraint)
|
|
87
|
+
) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!replacedTableIds.has(change.table.stableId)) return null;
|
|
91
|
+
const tag =
|
|
92
|
+
change instanceof AlterTableAddConstraint
|
|
93
|
+
? "add"
|
|
94
|
+
: change instanceof AlterTableValidateConstraint
|
|
95
|
+
? "validate"
|
|
96
|
+
: "comment";
|
|
97
|
+
return `${tag}:${constraintStableId(change.table, change.constraint.name)}`;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
const reversedKept: Change[] = [];
|
|
102
|
+
let mutated = false;
|
|
103
|
+
|
|
104
|
+
// Walk backwards: the first encounter of each key corresponds to its LAST
|
|
105
|
+
// occurrence in the original order. `expandReplaceDependencies()` appends
|
|
106
|
+
// additions after the original changes, so "last wins" keeps the
|
|
107
|
+
// expansion's emission and drops the earlier diffTables duplicate.
|
|
108
|
+
for (let i = changes.length - 1; i >= 0; i--) {
|
|
109
|
+
const change = changes[i] as Change;
|
|
110
|
+
const key = keyFor(change);
|
|
111
|
+
if (key !== null) {
|
|
112
|
+
if (seen.has(key)) {
|
|
113
|
+
mutated = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
seen.add(key);
|
|
117
|
+
}
|
|
118
|
+
reversedKept.push(change);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return mutated ? reversedKept.reverse() : changes;
|
|
122
|
+
}
|
|
123
|
+
|
|
55
124
|
function collectExplicitConstraintDropIds(changes: Change[]) {
|
|
56
125
|
const explicitConstraintDropIds = new Set<string>();
|
|
57
126
|
|
|
@@ -84,6 +153,13 @@ function hasSameEntries(
|
|
|
84
153
|
* - If replace expansion added `DropTable(T)+CreateTable(T)`, targeted
|
|
85
154
|
* `AlterTableDropColumn(T.*)` / `AlterTableDropConstraint(T.*)` changes are
|
|
86
155
|
* redundant and create an unbreakable drop-phase cycle, so we elide them.
|
|
156
|
+
* - When the same `DropTable+CreateTable` pair is present, the expansion
|
|
157
|
+
* also emits one `AlterTableAddConstraint` / `AlterTableValidateConstraint`
|
|
158
|
+
* / `CreateCommentOnConstraint` per branch constraint, which may collide
|
|
159
|
+
* with the same change already emitted by `diffTables()` (for example on a
|
|
160
|
+
* shape flip or a new constraint). We dedupe these keeping only the last
|
|
161
|
+
* occurrence so the expansion's emission survives and the diffTables
|
|
162
|
+
* duplicate is removed.
|
|
87
163
|
* - If two dropped tables reference each other via FK, we insert dedicated
|
|
88
164
|
* `AlterTableDropConstraint` changes and teach the paired `DropTable`
|
|
89
165
|
* changes not to claim those FK stable IDs.
|
|
@@ -100,10 +176,15 @@ export function normalizePostDiffCycles({
|
|
|
100
176
|
mainCatalog: Catalog;
|
|
101
177
|
replacedTableIds?: ReadonlySet<string>;
|
|
102
178
|
}): Change[] {
|
|
179
|
+
const dedupedChanges = dropReplacedTableDuplicateConstraintChanges(
|
|
180
|
+
changes,
|
|
181
|
+
replacedTableIds,
|
|
182
|
+
);
|
|
183
|
+
|
|
103
184
|
const structurallyNormalizedChanges =
|
|
104
185
|
replacedTableIds.size === 0
|
|
105
|
-
?
|
|
106
|
-
:
|
|
186
|
+
? dedupedChanges
|
|
187
|
+
: dedupedChanges.filter(
|
|
107
188
|
(change) => !isSupersededByTableReplacement(change, replacedTableIds),
|
|
108
189
|
);
|
|
109
190
|
|