@supabase/pg-delta 1.0.0-alpha.23 → 1.0.0-alpha.24
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/post-diff-normalization.d.ts +7 -0
- package/dist/core/post-diff-normalization.js +33 -4
- package/dist/core/sort/cycle-breakers.js +139 -17
- package/package.json +1 -1
- package/src/core/post-diff-normalization.test.ts +123 -0
- package/src/core/post-diff-normalization.ts +40 -4
- package/src/core/sort/cycle-breakers.test.ts +236 -2
- package/src/core/sort/cycle-breakers.ts +184 -24
- package/src/core/sort/sort-changes.test.ts +317 -0
|
@@ -15,6 +15,13 @@ import type { Table } from "./objects/table/table.model.ts";
|
|
|
15
15
|
* `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase
|
|
16
16
|
* would try to drop a column that no longer exists in the freshly
|
|
17
17
|
* recreated table.
|
|
18
|
+
* - Prunes `DropSequence(S)` changes when `S` is `OWNED BY` a column on a
|
|
19
|
+
* table promoted to `DropTable + CreateTable` by the expander. The
|
|
20
|
+
* `DROP TABLE` cascade drops the sequence at apply time; emitting an
|
|
21
|
+
* explicit `DROP SEQUENCE` in the same drop phase both duplicates the
|
|
22
|
+
* cascade and forms an unbreakable `DropSequence ↔ DropTable` cycle on
|
|
23
|
+
* the bidirectional pg_depend edges between the sequence and the
|
|
24
|
+
* owning column.
|
|
18
25
|
* - Dedupes duplicate `AlterTableAddConstraint` /
|
|
19
26
|
* `AlterTableValidateConstraint` / `CreateCommentOnConstraint` changes
|
|
20
27
|
* produced when `diffTables()` and `expandReplaceDependencies()` both
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CreateIndex } from "./objects/index/changes/index.create.js";
|
|
2
2
|
import { DropIndex } from "./objects/index/changes/index.drop.js";
|
|
3
|
+
import { DropSequence } from "./objects/sequence/changes/sequence.drop.js";
|
|
3
4
|
import { AlterTableAddConstraint, AlterTableDropColumn, AlterTableDropConstraint, AlterTableSetReplicaIdentity, AlterTableValidateConstraint, } from "./objects/table/changes/table.alter.js";
|
|
4
5
|
import { CreateCommentOnConstraint } from "./objects/table/changes/table.comment.js";
|
|
5
6
|
import { stableId } from "./objects/utils.js";
|
|
@@ -7,11 +8,32 @@ function constraintStableId(table, constraintName) {
|
|
|
7
8
|
return stableId.constraint(table.schema, table.name, constraintName);
|
|
8
9
|
}
|
|
9
10
|
function isSupersededByTableReplacement(change, replacedTableIds) {
|
|
10
|
-
if (
|
|
11
|
-
|
|
12
|
-
return
|
|
11
|
+
if (change instanceof AlterTableDropColumn ||
|
|
12
|
+
change instanceof AlterTableDropConstraint) {
|
|
13
|
+
return replacedTableIds.has(change.table.stableId);
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
// `DropSequence(S)` is superseded when S is OWNED BY a column on a table
|
|
16
|
+
// that `expandReplaceDependencies` has promoted to `DropTable + CreateTable`
|
|
17
|
+
// in the same plan. PostgreSQL cascade-drops the OWNED BY sequence as part
|
|
18
|
+
// of the DROP TABLE, so the explicit DROP SEQUENCE is redundant and — more
|
|
19
|
+
// importantly — closes an unbreakable `DropSequence ↔ DropTable` cycle in
|
|
20
|
+
// the drop phase via the bidirectional pg_depend edges between the
|
|
21
|
+
// sequence and its owning column (`column → sequence` for the DEFAULT
|
|
22
|
+
// nextval reference, `sequence → column` for the OWNED BY auto-dependency).
|
|
23
|
+
// The alpha.15 short-circuit in `diffSequences.dropped` only suppresses
|
|
24
|
+
// `DropSequence` when the owning table itself is gone from `branchTables`;
|
|
25
|
+
// here the table survives in branch and the replacement is added later by
|
|
26
|
+
// the expander, so this whole-plan rewrite has to happen post-diff.
|
|
27
|
+
if (change instanceof DropSequence) {
|
|
28
|
+
if (!change.sequence.owned_by_schema ||
|
|
29
|
+
!change.sequence.owned_by_table ||
|
|
30
|
+
!change.sequence.owned_by_column) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const ownedByTableId = stableId.table(change.sequence.owned_by_schema, change.sequence.owned_by_table);
|
|
34
|
+
return replacedTableIds.has(ownedByTableId);
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
15
37
|
}
|
|
16
38
|
/**
|
|
17
39
|
* Drop earlier duplicates of `AlterTableAddConstraint` /
|
|
@@ -179,6 +201,13 @@ function restoreReplicaIdentityAfterIndexReplace(changes, branchTables) {
|
|
|
179
201
|
* `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase
|
|
180
202
|
* would try to drop a column that no longer exists in the freshly
|
|
181
203
|
* recreated table.
|
|
204
|
+
* - Prunes `DropSequence(S)` changes when `S` is `OWNED BY` a column on a
|
|
205
|
+
* table promoted to `DropTable + CreateTable` by the expander. The
|
|
206
|
+
* `DROP TABLE` cascade drops the sequence at apply time; emitting an
|
|
207
|
+
* explicit `DROP SEQUENCE` in the same drop phase both duplicates the
|
|
208
|
+
* cascade and forms an unbreakable `DropSequence ↔ DropTable` cycle on
|
|
209
|
+
* the bidirectional pg_depend edges between the sequence and the
|
|
210
|
+
* owning column.
|
|
182
211
|
* - Dedupes duplicate `AlterTableAddConstraint` /
|
|
183
212
|
* `AlterTableValidateConstraint` / `CreateCommentOnConstraint` changes
|
|
184
213
|
* produced when `diffTables()` and `expandReplaceDependencies()` both
|
|
@@ -66,6 +66,32 @@ export function tryBreakCycleByChangeInjection(cycleNodeIndexes, phaseChanges) {
|
|
|
66
66
|
const pubColBroken = tryBreakPublicationColumnCycle(cycleNodeIndexes, phaseChanges);
|
|
67
67
|
if (pubColBroken)
|
|
68
68
|
return pubColBroken;
|
|
69
|
+
// ─── Branch C: Publication ↔ dropped FK chain ↔ constraint drop ──────
|
|
70
|
+
// Triggered when publication membership is being removed for tables in
|
|
71
|
+
// the same drop phase as a FK chain, and the chain ends at a separately
|
|
72
|
+
// emitted `AlterTableDropConstraint` on a table that is also being
|
|
73
|
+
// removed from the publication.
|
|
74
|
+
//
|
|
75
|
+
// Example (4-change cycle):
|
|
76
|
+
// AlterPublicationDropTables(p, [labs, posts, post_attachments])
|
|
77
|
+
// DropTable(post_attachments)
|
|
78
|
+
// DropTable(posts)
|
|
79
|
+
// AlterTableDropConstraint(labs.unique_lab_id)
|
|
80
|
+
//
|
|
81
|
+
// Cycle:
|
|
82
|
+
// publication:p → table:post_attachments
|
|
83
|
+
// post_attachments.post_id_fkey → column:posts.id
|
|
84
|
+
// posts.lab_id_fkey → constraint:labs.unique_lab_id
|
|
85
|
+
// constraint:labs.unique_lab_id → table:labs
|
|
86
|
+
//
|
|
87
|
+
// Fix: inject explicit FK drops for the FK constraints claimed by the
|
|
88
|
+
// DropTables in the cycle, including FKs that point at the terminal
|
|
89
|
+
// dropped constraint. The publication and terminal constraint changes
|
|
90
|
+
// stay unchanged; only the intermediate FK ownership is reassigned from
|
|
91
|
+
// DropTable to dedicated AlterTableDropConstraint changes.
|
|
92
|
+
const pubFkConstraintBroken = tryBreakPublicationFkConstraintDropCycle(cycleNodeIndexes, phaseChanges);
|
|
93
|
+
if (pubFkConstraintBroken)
|
|
94
|
+
return pubFkConstraintBroken;
|
|
69
95
|
// No known pattern. Returning null lets sortPhaseChanges throw the
|
|
70
96
|
// formatted CycleError with full diagnostic — better a clear bug
|
|
71
97
|
// report than silently shipping a broken plan.
|
|
@@ -90,6 +116,19 @@ function tryBreakFkCycle(cycleNodeIndexes, phaseChanges) {
|
|
|
90
116
|
cycleDropTables.push(change);
|
|
91
117
|
}
|
|
92
118
|
const cycleTableIds = new Set(cycleDropTables.map((change) => change.table.stableId));
|
|
119
|
+
return injectFkConstraintDropsForDropTables({
|
|
120
|
+
phaseChanges,
|
|
121
|
+
dropTables: cycleDropTables,
|
|
122
|
+
shouldInject: (fk, tableId) => isCrossCycleFkConstraint(fk, tableId, cycleTableIds),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Shared FK-drop injection used by Branch A and Branch C. The caller owns
|
|
127
|
+
* the cycle-specific matcher; this helper only handles the mechanical
|
|
128
|
+
* rewrite: add dedicated `AlterTableDropConstraint` changes and rebuild
|
|
129
|
+
* affected `DropTable`s with updated `externallyDroppedConstraints`.
|
|
130
|
+
*/
|
|
131
|
+
function injectFkConstraintDropsForDropTables({ phaseChanges, dropTables, shouldInject, }) {
|
|
93
132
|
// For each DropTable in the cycle, find every FK whose referenced table
|
|
94
133
|
// is also in the cycle. Each such FK becomes one injected
|
|
95
134
|
// `AlterTableDropConstraint` and one entry on the source table's
|
|
@@ -100,11 +139,13 @@ function tryBreakFkCycle(cycleNodeIndexes, phaseChanges) {
|
|
|
100
139
|
const injectedDropsByTableId = new Map();
|
|
101
140
|
const updatedExternalsByTableId = new Map();
|
|
102
141
|
let didMutate = false;
|
|
103
|
-
for (const dropTable of
|
|
142
|
+
for (const dropTable of dropTables) {
|
|
104
143
|
const tableId = dropTable.table.stableId;
|
|
105
144
|
const existingExternals = new Set(dropTable.externallyDroppedConstraints);
|
|
106
145
|
let tableMutated = false;
|
|
107
|
-
for (const fk of
|
|
146
|
+
for (const fk of iterFkConstraints(dropTable.table.constraints)) {
|
|
147
|
+
if (!shouldInject(fk, tableId))
|
|
148
|
+
continue;
|
|
108
149
|
// Skip if a same-table `AlterTableDropConstraint` is already in the
|
|
109
150
|
// change list — could happen if a previous breaker iteration
|
|
110
151
|
// injected one, or the diff layer emitted one explicitly.
|
|
@@ -159,31 +200,37 @@ function tryBreakFkCycle(cycleNodeIndexes, phaseChanges) {
|
|
|
159
200
|
return rewritten;
|
|
160
201
|
}
|
|
161
202
|
/**
|
|
162
|
-
* Yield FK constraints on `constraints
|
|
163
|
-
* member of the cycle (i.e. an FK strictly between two cycle DropTables).
|
|
203
|
+
* Yield FK constraints on `constraints`.
|
|
164
204
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* own; injecting an `AlterTableDropConstraint` for a self-FK would just
|
|
168
|
-
* add noise.
|
|
205
|
+
* Partition clones are skipped because PostgreSQL drops them when the
|
|
206
|
+
* parent constraint is dropped.
|
|
169
207
|
*/
|
|
170
|
-
function*
|
|
208
|
+
function* iterFkConstraints(constraints) {
|
|
171
209
|
for (const constraint of constraints) {
|
|
172
210
|
if (constraint.constraint_type !== "f")
|
|
173
211
|
continue;
|
|
174
212
|
if (constraint.is_partition_clone)
|
|
175
213
|
continue;
|
|
176
|
-
if (!constraint.foreign_key_schema || !constraint.foreign_key_table) {
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
const referencedId = stableId.table(constraint.foreign_key_schema, constraint.foreign_key_table);
|
|
180
|
-
if (referencedId === ownTableId)
|
|
181
|
-
continue;
|
|
182
|
-
if (!cycleTableIds.has(referencedId))
|
|
183
|
-
continue;
|
|
184
214
|
yield constraint;
|
|
185
215
|
}
|
|
186
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* True when `constraint` references another DropTable in the cycle.
|
|
219
|
+
*
|
|
220
|
+
* Self-referencing FKs are skipped — they create a self-loop in the
|
|
221
|
+
* dependency graph which the existing sort-phase handler resolves on its
|
|
222
|
+
* own; injecting an `AlterTableDropConstraint` for a self-FK would just
|
|
223
|
+
* add noise.
|
|
224
|
+
*/
|
|
225
|
+
function isCrossCycleFkConstraint(constraint, ownTableId, cycleTableIds) {
|
|
226
|
+
if (!constraint.foreign_key_schema || !constraint.foreign_key_table) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
const referencedId = stableId.table(constraint.foreign_key_schema, constraint.foreign_key_table);
|
|
230
|
+
if (referencedId === ownTableId)
|
|
231
|
+
return false;
|
|
232
|
+
return cycleTableIds.has(referencedId);
|
|
233
|
+
}
|
|
187
234
|
/**
|
|
188
235
|
* True iff `phaseChanges` already contains an explicit
|
|
189
236
|
* `AlterTableDropConstraint(table, constraint)` for the given pair —
|
|
@@ -267,3 +314,78 @@ function tryBreakPublicationColumnCycle(cycleNodeIndexes, phaseChanges) {
|
|
|
267
314
|
});
|
|
268
315
|
return rewritten;
|
|
269
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Branch C worker — break a publication membership removal cycle where
|
|
319
|
+
* dropped tables form a FK chain ending at a separately dropped referenced
|
|
320
|
+
* constraint.
|
|
321
|
+
*/
|
|
322
|
+
function tryBreakPublicationFkConstraintDropCycle(cycleNodeIndexes, phaseChanges) {
|
|
323
|
+
let pubChange = null;
|
|
324
|
+
let terminalConstraintDrop = null;
|
|
325
|
+
const dropTables = [];
|
|
326
|
+
for (const nodeIndex of cycleNodeIndexes) {
|
|
327
|
+
const change = phaseChanges[nodeIndex];
|
|
328
|
+
if (change instanceof AlterPublicationDropTables) {
|
|
329
|
+
if (pubChange !== null)
|
|
330
|
+
return null;
|
|
331
|
+
pubChange = change;
|
|
332
|
+
}
|
|
333
|
+
else if (change instanceof AlterTableDropConstraint) {
|
|
334
|
+
if (terminalConstraintDrop !== null)
|
|
335
|
+
return null;
|
|
336
|
+
terminalConstraintDrop = change;
|
|
337
|
+
}
|
|
338
|
+
else if (change instanceof DropTable) {
|
|
339
|
+
dropTables.push(change);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (pubChange === null ||
|
|
346
|
+
terminalConstraintDrop === null ||
|
|
347
|
+
dropTables.length === 0) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const publicationTableIds = new Set(pubChange.tables.map((table) => stableId.table(table.schema, table.name)));
|
|
351
|
+
if (!publicationTableIds.has(terminalConstraintDrop.table.stableId)) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
for (const dropTable of dropTables) {
|
|
355
|
+
if (!publicationTableIds.has(dropTable.table.stableId))
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const cycleDropTableIds = new Set(dropTables.map((change) => change.table.stableId));
|
|
359
|
+
let hasFkToTerminalConstraint = false;
|
|
360
|
+
for (const dropTable of dropTables) {
|
|
361
|
+
for (const fk of iterFkConstraints(dropTable.table.constraints)) {
|
|
362
|
+
if (fkReferencesConstraint(fk, terminalConstraintDrop)) {
|
|
363
|
+
hasFkToTerminalConstraint = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (hasFkToTerminalConstraint)
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
if (!hasFkToTerminalConstraint)
|
|
371
|
+
return null;
|
|
372
|
+
return injectFkConstraintDropsForDropTables({
|
|
373
|
+
phaseChanges,
|
|
374
|
+
dropTables,
|
|
375
|
+
shouldInject: (fk, tableId) => isCrossCycleFkConstraint(fk, tableId, cycleDropTableIds) ||
|
|
376
|
+
fkReferencesConstraint(fk, terminalConstraintDrop),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
function fkReferencesConstraint(fk, constraintDrop) {
|
|
380
|
+
if (fk.foreign_key_schema !== constraintDrop.table.schema ||
|
|
381
|
+
fk.foreign_key_table !== constraintDrop.table.name ||
|
|
382
|
+
fk.foreign_key_columns === null) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
return sameOrderedStrings(fk.foreign_key_columns, constraintDrop.constraint.key_columns);
|
|
386
|
+
}
|
|
387
|
+
function sameOrderedStrings(left, right) {
|
|
388
|
+
if (left.length !== right.length)
|
|
389
|
+
return false;
|
|
390
|
+
return left.every((value, index) => value === right[index]);
|
|
391
|
+
}
|
package/package.json
CHANGED
|
@@ -3,6 +3,12 @@ import type { Change } from "./change.types.ts";
|
|
|
3
3
|
import { CreateIndex } from "./objects/index/changes/index.create.ts";
|
|
4
4
|
import { DropIndex } from "./objects/index/changes/index.drop.ts";
|
|
5
5
|
import { Index, type IndexProps } from "./objects/index/index.model.ts";
|
|
6
|
+
import { CreateSequence } from "./objects/sequence/changes/sequence.create.ts";
|
|
7
|
+
import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts";
|
|
8
|
+
import {
|
|
9
|
+
Sequence,
|
|
10
|
+
type SequenceProps,
|
|
11
|
+
} from "./objects/sequence/sequence.model.ts";
|
|
6
12
|
import {
|
|
7
13
|
AlterTableAddConstraint,
|
|
8
14
|
AlterTableChangeOwner,
|
|
@@ -304,6 +310,123 @@ describe("normalizePostDiffChanges", () => {
|
|
|
304
310
|
).toHaveLength(1);
|
|
305
311
|
});
|
|
306
312
|
|
|
313
|
+
describe("DropSequence pruning on replaced tables", () => {
|
|
314
|
+
const baseSequenceProps: SequenceProps = {
|
|
315
|
+
schema: "public",
|
|
316
|
+
name: "project_link_type_id_seq",
|
|
317
|
+
data_type: "integer",
|
|
318
|
+
start_value: 1,
|
|
319
|
+
minimum_value: 1n,
|
|
320
|
+
maximum_value: 2147483647n,
|
|
321
|
+
increment: 1,
|
|
322
|
+
cycle_option: false,
|
|
323
|
+
cache_size: 1,
|
|
324
|
+
persistence: "p",
|
|
325
|
+
owned_by_schema: "public",
|
|
326
|
+
owned_by_table: "project_link_type",
|
|
327
|
+
owned_by_column: "id",
|
|
328
|
+
comment: null,
|
|
329
|
+
privileges: [],
|
|
330
|
+
owner: "postgres",
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
test("prunes DropSequence when its OWNED BY table is in replacedTableIds", () => {
|
|
334
|
+
const replacedTable = new Table({
|
|
335
|
+
...baseTableProps,
|
|
336
|
+
name: "project_link_type",
|
|
337
|
+
columns: [{ ...integerColumn("id", 1), not_null: true }],
|
|
338
|
+
});
|
|
339
|
+
const ownedSequence = new Sequence(baseSequenceProps);
|
|
340
|
+
|
|
341
|
+
const dropSequence = new DropSequence({ sequence: ownedSequence });
|
|
342
|
+
const dropTable = new DropTable({ table: replacedTable });
|
|
343
|
+
const createTable = new CreateTable({ table: replacedTable });
|
|
344
|
+
|
|
345
|
+
const changes: Change[] = [dropSequence, dropTable, createTable];
|
|
346
|
+
|
|
347
|
+
const normalized = normalizePostDiffChanges({
|
|
348
|
+
changes,
|
|
349
|
+
replacedTableIds: new Set([replacedTable.stableId]),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(normalized.some((change) => change instanceof DropSequence)).toBe(
|
|
353
|
+
false,
|
|
354
|
+
);
|
|
355
|
+
expect(normalized).toContain(dropTable);
|
|
356
|
+
expect(normalized).toContain(createTable);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("keeps DropSequence whose OWNED BY table is not in replacedTableIds", () => {
|
|
360
|
+
const survivingTable = new Table({
|
|
361
|
+
...baseTableProps,
|
|
362
|
+
name: "project_link_type",
|
|
363
|
+
columns: [{ ...integerColumn("id", 1), not_null: true }],
|
|
364
|
+
});
|
|
365
|
+
const ownedSequence = new Sequence(baseSequenceProps);
|
|
366
|
+
|
|
367
|
+
const dropSequence = new DropSequence({ sequence: ownedSequence });
|
|
368
|
+
|
|
369
|
+
const normalized = normalizePostDiffChanges({
|
|
370
|
+
changes: [dropSequence],
|
|
371
|
+
// Different table is being replaced; the sequence's OWNED BY does
|
|
372
|
+
// not match, so DropSequence must survive.
|
|
373
|
+
replacedTableIds: new Set([
|
|
374
|
+
`table:${survivingTable.schema}.unrelated_table` as const,
|
|
375
|
+
]),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(normalized).toContain(dropSequence);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("keeps DropSequence with no OWNED BY when replacedTableIds is non-empty", () => {
|
|
382
|
+
const orphanSequence = new Sequence({
|
|
383
|
+
...baseSequenceProps,
|
|
384
|
+
owned_by_schema: null,
|
|
385
|
+
owned_by_table: null,
|
|
386
|
+
owned_by_column: null,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const dropSequence = new DropSequence({ sequence: orphanSequence });
|
|
390
|
+
|
|
391
|
+
const normalized = normalizePostDiffChanges({
|
|
392
|
+
changes: [dropSequence],
|
|
393
|
+
replacedTableIds: new Set(["table:public.project_link_type" as const]),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(normalized).toContain(dropSequence);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("keeps unrelated CreateSequence and DropSequence even when its non-owning table is replaced", () => {
|
|
400
|
+
const sequenceA = new Sequence(baseSequenceProps);
|
|
401
|
+
const sequenceB = new Sequence({
|
|
402
|
+
...baseSequenceProps,
|
|
403
|
+
name: "unrelated_seq",
|
|
404
|
+
owned_by_schema: null,
|
|
405
|
+
owned_by_table: null,
|
|
406
|
+
owned_by_column: null,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const dropOwned = new DropSequence({ sequence: sequenceA });
|
|
410
|
+
const createUnrelated = new CreateSequence({ sequence: sequenceB });
|
|
411
|
+
|
|
412
|
+
const replacedTable = new Table({
|
|
413
|
+
...baseTableProps,
|
|
414
|
+
name: "project_link_type",
|
|
415
|
+
columns: [{ ...integerColumn("id", 1), not_null: true }],
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const normalized = normalizePostDiffChanges({
|
|
419
|
+
changes: [dropOwned, createUnrelated],
|
|
420
|
+
replacedTableIds: new Set([replacedTable.stableId]),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
expect(normalized.some((change) => change instanceof DropSequence)).toBe(
|
|
424
|
+
false,
|
|
425
|
+
);
|
|
426
|
+
expect(normalized).toContain(createUnrelated);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
307
430
|
describe("restoreReplicaIdentityAfterIndexReplace", () => {
|
|
308
431
|
const baseIndexProps: IndexProps = {
|
|
309
432
|
schema: "public",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Change } from "./change.types.ts";
|
|
2
2
|
import { CreateIndex } from "./objects/index/changes/index.create.ts";
|
|
3
3
|
import { DropIndex } from "./objects/index/changes/index.drop.ts";
|
|
4
|
+
import { DropSequence } from "./objects/sequence/changes/sequence.drop.ts";
|
|
4
5
|
import {
|
|
5
6
|
AlterTableAddConstraint,
|
|
6
7
|
AlterTableDropColumn,
|
|
@@ -24,12 +25,40 @@ function isSupersededByTableReplacement(
|
|
|
24
25
|
replacedTableIds: ReadonlySet<string>,
|
|
25
26
|
): boolean {
|
|
26
27
|
if (
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
change instanceof AlterTableDropColumn ||
|
|
29
|
+
change instanceof AlterTableDropConstraint
|
|
29
30
|
) {
|
|
30
|
-
return
|
|
31
|
+
return replacedTableIds.has(change.table.stableId);
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
+
|
|
34
|
+
// `DropSequence(S)` is superseded when S is OWNED BY a column on a table
|
|
35
|
+
// that `expandReplaceDependencies` has promoted to `DropTable + CreateTable`
|
|
36
|
+
// in the same plan. PostgreSQL cascade-drops the OWNED BY sequence as part
|
|
37
|
+
// of the DROP TABLE, so the explicit DROP SEQUENCE is redundant and — more
|
|
38
|
+
// importantly — closes an unbreakable `DropSequence ↔ DropTable` cycle in
|
|
39
|
+
// the drop phase via the bidirectional pg_depend edges between the
|
|
40
|
+
// sequence and its owning column (`column → sequence` for the DEFAULT
|
|
41
|
+
// nextval reference, `sequence → column` for the OWNED BY auto-dependency).
|
|
42
|
+
// The alpha.15 short-circuit in `diffSequences.dropped` only suppresses
|
|
43
|
+
// `DropSequence` when the owning table itself is gone from `branchTables`;
|
|
44
|
+
// here the table survives in branch and the replacement is added later by
|
|
45
|
+
// the expander, so this whole-plan rewrite has to happen post-diff.
|
|
46
|
+
if (change instanceof DropSequence) {
|
|
47
|
+
if (
|
|
48
|
+
!change.sequence.owned_by_schema ||
|
|
49
|
+
!change.sequence.owned_by_table ||
|
|
50
|
+
!change.sequence.owned_by_column
|
|
51
|
+
) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const ownedByTableId = stableId.table(
|
|
55
|
+
change.sequence.owned_by_schema,
|
|
56
|
+
change.sequence.owned_by_table,
|
|
57
|
+
);
|
|
58
|
+
return replacedTableIds.has(ownedByTableId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return false;
|
|
33
62
|
}
|
|
34
63
|
|
|
35
64
|
/**
|
|
@@ -219,6 +248,13 @@ function restoreReplicaIdentityAfterIndexReplace(
|
|
|
219
248
|
* `DropTable(T) + CreateTable(T)` pair. Without this, the apply phase
|
|
220
249
|
* would try to drop a column that no longer exists in the freshly
|
|
221
250
|
* recreated table.
|
|
251
|
+
* - Prunes `DropSequence(S)` changes when `S` is `OWNED BY` a column on a
|
|
252
|
+
* table promoted to `DropTable + CreateTable` by the expander. The
|
|
253
|
+
* `DROP TABLE` cascade drops the sequence at apply time; emitting an
|
|
254
|
+
* explicit `DROP SEQUENCE` in the same drop phase both duplicates the
|
|
255
|
+
* cascade and forms an unbreakable `DropSequence ↔ DropTable` cycle on
|
|
256
|
+
* the bidirectional pg_depend edges between the sequence and the
|
|
257
|
+
* owning column.
|
|
222
258
|
* - Dedupes duplicate `AlterTableAddConstraint` /
|
|
223
259
|
* `AlterTableValidateConstraint` / `CreateCommentOnConstraint` changes
|
|
224
260
|
* produced when `diffTables()` and `expandReplaceDependencies()` both
|
|
@@ -62,7 +62,9 @@ function fkConstraint(props: {
|
|
|
62
62
|
fkColumn: string;
|
|
63
63
|
targetSchema: string;
|
|
64
64
|
targetTable: string;
|
|
65
|
+
targetColumn?: string;
|
|
65
66
|
}) {
|
|
67
|
+
const targetColumn = props.targetColumn ?? "id";
|
|
66
68
|
return {
|
|
67
69
|
name: props.name,
|
|
68
70
|
constraint_type: "f" as const,
|
|
@@ -78,7 +80,7 @@ function fkConstraint(props: {
|
|
|
78
80
|
parent_table_schema: null,
|
|
79
81
|
parent_table_name: null,
|
|
80
82
|
key_columns: [props.fkColumn],
|
|
81
|
-
foreign_key_columns: [
|
|
83
|
+
foreign_key_columns: [targetColumn],
|
|
82
84
|
foreign_key_table: props.targetTable,
|
|
83
85
|
foreign_key_schema: props.targetSchema,
|
|
84
86
|
foreign_key_table_is_partition: false,
|
|
@@ -91,7 +93,41 @@ function fkConstraint(props: {
|
|
|
91
93
|
match_type: "s" as const,
|
|
92
94
|
check_expression: null,
|
|
93
95
|
owner: "postgres",
|
|
94
|
-
definition: `FOREIGN KEY (${props.fkColumn}) REFERENCES ${props.targetSchema}.${props.targetTable}(
|
|
96
|
+
definition: `FOREIGN KEY (${props.fkColumn}) REFERENCES ${props.targetSchema}.${props.targetTable}(${targetColumn})`,
|
|
97
|
+
comment: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function uniqueConstraint(name: string, column: string) {
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
constraint_type: "u" as const,
|
|
105
|
+
deferrable: false,
|
|
106
|
+
initially_deferred: false,
|
|
107
|
+
validated: true,
|
|
108
|
+
is_local: true,
|
|
109
|
+
no_inherit: false,
|
|
110
|
+
is_temporal: false,
|
|
111
|
+
is_partition_clone: false,
|
|
112
|
+
parent_constraint_schema: null,
|
|
113
|
+
parent_constraint_name: null,
|
|
114
|
+
parent_table_schema: null,
|
|
115
|
+
parent_table_name: null,
|
|
116
|
+
key_columns: [column],
|
|
117
|
+
foreign_key_columns: null,
|
|
118
|
+
foreign_key_table: null,
|
|
119
|
+
foreign_key_schema: null,
|
|
120
|
+
foreign_key_table_is_partition: null,
|
|
121
|
+
foreign_key_parent_schema: null,
|
|
122
|
+
foreign_key_parent_table: null,
|
|
123
|
+
foreign_key_effective_schema: null,
|
|
124
|
+
foreign_key_effective_table: null,
|
|
125
|
+
on_update: null,
|
|
126
|
+
on_delete: null,
|
|
127
|
+
match_type: null,
|
|
128
|
+
check_expression: null,
|
|
129
|
+
owner: "postgres",
|
|
130
|
+
definition: `UNIQUE (${column})`,
|
|
95
131
|
comment: null,
|
|
96
132
|
};
|
|
97
133
|
}
|
|
@@ -448,6 +484,204 @@ describe("tryBreakCycleByChangeInjection", () => {
|
|
|
448
484
|
expect(broken).toBeNull();
|
|
449
485
|
});
|
|
450
486
|
|
|
487
|
+
test("publication FK-chain constraint-drop 3-cycle: injects terminal FK drop", () => {
|
|
488
|
+
// Schema:
|
|
489
|
+
// publication p includes labs and posts
|
|
490
|
+
// posts.lab_id REFERENCES labs(id)
|
|
491
|
+
// Diff drops posts and drops labs.unique_lab_id while also removing both
|
|
492
|
+
// tables from the publication. The FK edge from posts to the terminal
|
|
493
|
+
// constraint drop forms:
|
|
494
|
+
// AlterPublicationDropTables → DropTable(posts)
|
|
495
|
+
// DropTable(posts) → AlterTableDropConstraint(labs.unique_lab_id)
|
|
496
|
+
// AlterTableDropConstraint(labs.unique_lab_id) → AlterPublicationDropTables
|
|
497
|
+
const tableLabs = new Table({
|
|
498
|
+
...baseTableProps,
|
|
499
|
+
name: "labs",
|
|
500
|
+
columns: [{ ...integerColumn("id", 1), not_null: true }],
|
|
501
|
+
constraints: [uniqueConstraint("unique_lab_id", "id")],
|
|
502
|
+
});
|
|
503
|
+
const tablePosts = new Table({
|
|
504
|
+
...baseTableProps,
|
|
505
|
+
name: "posts",
|
|
506
|
+
columns: [
|
|
507
|
+
{ ...integerColumn("id", 1), not_null: true },
|
|
508
|
+
integerColumn("lab_id", 2),
|
|
509
|
+
],
|
|
510
|
+
constraints: [
|
|
511
|
+
fkConstraint({
|
|
512
|
+
name: "posts_lab_id_fkey",
|
|
513
|
+
fkColumn: "lab_id",
|
|
514
|
+
targetSchema: "public",
|
|
515
|
+
targetTable: "labs",
|
|
516
|
+
}),
|
|
517
|
+
],
|
|
518
|
+
});
|
|
519
|
+
const publication = new Publication({
|
|
520
|
+
name: "p",
|
|
521
|
+
owner: "postgres",
|
|
522
|
+
comment: null,
|
|
523
|
+
all_tables: false,
|
|
524
|
+
publish_insert: true,
|
|
525
|
+
publish_update: true,
|
|
526
|
+
publish_delete: true,
|
|
527
|
+
publish_truncate: true,
|
|
528
|
+
publish_via_partition_root: false,
|
|
529
|
+
tables: [
|
|
530
|
+
{ schema: "public", name: "labs", columns: null, row_filter: null },
|
|
531
|
+
{ schema: "public", name: "posts", columns: null, row_filter: null },
|
|
532
|
+
],
|
|
533
|
+
schemas: [],
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const terminalDrop = new AlterTableDropConstraint({
|
|
537
|
+
table: tableLabs,
|
|
538
|
+
constraint: tableLabs.constraints[0],
|
|
539
|
+
});
|
|
540
|
+
const changes: Change[] = [
|
|
541
|
+
new AlterPublicationDropTables({
|
|
542
|
+
publication,
|
|
543
|
+
tables: publication.tables,
|
|
544
|
+
}),
|
|
545
|
+
new DropTable({ table: tablePosts }),
|
|
546
|
+
terminalDrop,
|
|
547
|
+
];
|
|
548
|
+
|
|
549
|
+
const broken = tryBreakCycleByChangeInjection([0, 1, 2], changes);
|
|
550
|
+
if (broken === null) throw new Error("expected breaker to fire");
|
|
551
|
+
|
|
552
|
+
const injectedDrops = broken.filter(
|
|
553
|
+
(change): change is AlterTableDropConstraint =>
|
|
554
|
+
change instanceof AlterTableDropConstraint &&
|
|
555
|
+
change.table.stableId === tablePosts.stableId,
|
|
556
|
+
);
|
|
557
|
+
expect(injectedDrops).toHaveLength(1);
|
|
558
|
+
expect(injectedDrops[0].constraint.name).toBe("posts_lab_id_fkey");
|
|
559
|
+
|
|
560
|
+
const rewrittenPostsDrop = broken.find(
|
|
561
|
+
(change): change is DropTable =>
|
|
562
|
+
change instanceof DropTable &&
|
|
563
|
+
change.table.stableId === tablePosts.stableId,
|
|
564
|
+
);
|
|
565
|
+
if (!rewrittenPostsDrop) throw new Error("missing rewritten DropTable");
|
|
566
|
+
expect(
|
|
567
|
+
rewrittenPostsDrop.externallyDroppedConstraints.has("posts_lab_id_fkey"),
|
|
568
|
+
).toBe(true);
|
|
569
|
+
expect(broken).toContain(terminalDrop);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("publication FK-chain constraint-drop 4-cycle: injects FK drops along the dropped-table chain", () => {
|
|
573
|
+
// Schema:
|
|
574
|
+
// publication p includes labs, posts, and post_attachments
|
|
575
|
+
// post_attachments.post_id REFERENCES posts(id)
|
|
576
|
+
// posts.lab_id REFERENCES labs(id)
|
|
577
|
+
// Diff drops post_attachments and posts, drops labs.unique_lab_id,
|
|
578
|
+
// and removes all three tables from the publication.
|
|
579
|
+
const tableLabs = new Table({
|
|
580
|
+
...baseTableProps,
|
|
581
|
+
name: "labs",
|
|
582
|
+
columns: [{ ...integerColumn("id", 1), not_null: true }],
|
|
583
|
+
constraints: [uniqueConstraint("unique_lab_id", "id")],
|
|
584
|
+
});
|
|
585
|
+
const tablePosts = new Table({
|
|
586
|
+
...baseTableProps,
|
|
587
|
+
name: "posts",
|
|
588
|
+
columns: [
|
|
589
|
+
{ ...integerColumn("id", 1), not_null: true },
|
|
590
|
+
integerColumn("lab_id", 2),
|
|
591
|
+
],
|
|
592
|
+
constraints: [
|
|
593
|
+
fkConstraint({
|
|
594
|
+
name: "posts_lab_id_fkey",
|
|
595
|
+
fkColumn: "lab_id",
|
|
596
|
+
targetSchema: "public",
|
|
597
|
+
targetTable: "labs",
|
|
598
|
+
}),
|
|
599
|
+
],
|
|
600
|
+
});
|
|
601
|
+
const tablePostAttachments = new Table({
|
|
602
|
+
...baseTableProps,
|
|
603
|
+
name: "post_attachments",
|
|
604
|
+
columns: [
|
|
605
|
+
{ ...integerColumn("id", 1), not_null: true },
|
|
606
|
+
integerColumn("post_id", 2),
|
|
607
|
+
],
|
|
608
|
+
constraints: [
|
|
609
|
+
fkConstraint({
|
|
610
|
+
name: "post_attachments_post_id_fkey",
|
|
611
|
+
fkColumn: "post_id",
|
|
612
|
+
targetSchema: "public",
|
|
613
|
+
targetTable: "posts",
|
|
614
|
+
}),
|
|
615
|
+
],
|
|
616
|
+
});
|
|
617
|
+
const publication = new Publication({
|
|
618
|
+
name: "p",
|
|
619
|
+
owner: "postgres",
|
|
620
|
+
comment: null,
|
|
621
|
+
all_tables: false,
|
|
622
|
+
publish_insert: true,
|
|
623
|
+
publish_update: true,
|
|
624
|
+
publish_delete: true,
|
|
625
|
+
publish_truncate: true,
|
|
626
|
+
publish_via_partition_root: false,
|
|
627
|
+
tables: [
|
|
628
|
+
{ schema: "public", name: "labs", columns: null, row_filter: null },
|
|
629
|
+
{
|
|
630
|
+
schema: "public",
|
|
631
|
+
name: "post_attachments",
|
|
632
|
+
columns: null,
|
|
633
|
+
row_filter: null,
|
|
634
|
+
},
|
|
635
|
+
{ schema: "public", name: "posts", columns: null, row_filter: null },
|
|
636
|
+
],
|
|
637
|
+
schemas: [],
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const terminalDrop = new AlterTableDropConstraint({
|
|
641
|
+
table: tableLabs,
|
|
642
|
+
constraint: tableLabs.constraints[0],
|
|
643
|
+
});
|
|
644
|
+
const changes: Change[] = [
|
|
645
|
+
new AlterPublicationDropTables({
|
|
646
|
+
publication,
|
|
647
|
+
tables: publication.tables,
|
|
648
|
+
}),
|
|
649
|
+
new DropTable({ table: tablePostAttachments }),
|
|
650
|
+
new DropTable({ table: tablePosts }),
|
|
651
|
+
terminalDrop,
|
|
652
|
+
];
|
|
653
|
+
|
|
654
|
+
const broken = tryBreakCycleByChangeInjection([0, 1, 2, 3], changes);
|
|
655
|
+
if (broken === null) throw new Error("expected breaker to fire");
|
|
656
|
+
|
|
657
|
+
const injectedDropNames = broken
|
|
658
|
+
.filter(
|
|
659
|
+
(change): change is AlterTableDropConstraint =>
|
|
660
|
+
change instanceof AlterTableDropConstraint && change !== terminalDrop,
|
|
661
|
+
)
|
|
662
|
+
.map((change) => change.constraint.name)
|
|
663
|
+
.sort();
|
|
664
|
+
expect(injectedDropNames).toEqual([
|
|
665
|
+
"post_attachments_post_id_fkey",
|
|
666
|
+
"posts_lab_id_fkey",
|
|
667
|
+
]);
|
|
668
|
+
|
|
669
|
+
for (const [tableId, constraintName] of [
|
|
670
|
+
[tablePostAttachments.stableId, "post_attachments_post_id_fkey"],
|
|
671
|
+
[tablePosts.stableId, "posts_lab_id_fkey"],
|
|
672
|
+
] as const) {
|
|
673
|
+
const rewrittenDrop = broken.find(
|
|
674
|
+
(change): change is DropTable =>
|
|
675
|
+
change instanceof DropTable && change.table.stableId === tableId,
|
|
676
|
+
);
|
|
677
|
+
if (!rewrittenDrop) throw new Error(`missing DropTable for ${tableId}`);
|
|
678
|
+
expect(
|
|
679
|
+
rewrittenDrop.externallyDroppedConstraints.has(constraintName),
|
|
680
|
+
).toBe(true);
|
|
681
|
+
}
|
|
682
|
+
expect(broken).toContain(terminalDrop);
|
|
683
|
+
});
|
|
684
|
+
|
|
451
685
|
test("returns null for a cycle with no recognised pattern (e.g. publication-only)", () => {
|
|
452
686
|
// Cycle of `AlterPublicationSetOwner` changes — neither FK nor
|
|
453
687
|
// publication-column shape. Breaker must bail so the formatted
|
|
@@ -78,6 +78,35 @@ export function tryBreakCycleByChangeInjection(
|
|
|
78
78
|
);
|
|
79
79
|
if (pubColBroken) return pubColBroken;
|
|
80
80
|
|
|
81
|
+
// ─── Branch C: Publication ↔ dropped FK chain ↔ constraint drop ──────
|
|
82
|
+
// Triggered when publication membership is being removed for tables in
|
|
83
|
+
// the same drop phase as a FK chain, and the chain ends at a separately
|
|
84
|
+
// emitted `AlterTableDropConstraint` on a table that is also being
|
|
85
|
+
// removed from the publication.
|
|
86
|
+
//
|
|
87
|
+
// Example (4-change cycle):
|
|
88
|
+
// AlterPublicationDropTables(p, [labs, posts, post_attachments])
|
|
89
|
+
// DropTable(post_attachments)
|
|
90
|
+
// DropTable(posts)
|
|
91
|
+
// AlterTableDropConstraint(labs.unique_lab_id)
|
|
92
|
+
//
|
|
93
|
+
// Cycle:
|
|
94
|
+
// publication:p → table:post_attachments
|
|
95
|
+
// post_attachments.post_id_fkey → column:posts.id
|
|
96
|
+
// posts.lab_id_fkey → constraint:labs.unique_lab_id
|
|
97
|
+
// constraint:labs.unique_lab_id → table:labs
|
|
98
|
+
//
|
|
99
|
+
// Fix: inject explicit FK drops for the FK constraints claimed by the
|
|
100
|
+
// DropTables in the cycle, including FKs that point at the terminal
|
|
101
|
+
// dropped constraint. The publication and terminal constraint changes
|
|
102
|
+
// stay unchanged; only the intermediate FK ownership is reassigned from
|
|
103
|
+
// DropTable to dedicated AlterTableDropConstraint changes.
|
|
104
|
+
const pubFkConstraintBroken = tryBreakPublicationFkConstraintDropCycle(
|
|
105
|
+
cycleNodeIndexes,
|
|
106
|
+
phaseChanges,
|
|
107
|
+
);
|
|
108
|
+
if (pubFkConstraintBroken) return pubFkConstraintBroken;
|
|
109
|
+
|
|
81
110
|
// No known pattern. Returning null lets sortPhaseChanges throw the
|
|
82
111
|
// formatted CycleError with full diagnostic — better a clear bug
|
|
83
112
|
// report than silently shipping a broken plan.
|
|
@@ -109,6 +138,34 @@ function tryBreakFkCycle(
|
|
|
109
138
|
cycleDropTables.map((change) => change.table.stableId),
|
|
110
139
|
);
|
|
111
140
|
|
|
141
|
+
return injectFkConstraintDropsForDropTables({
|
|
142
|
+
phaseChanges,
|
|
143
|
+
dropTables: cycleDropTables,
|
|
144
|
+
shouldInject: (fk, tableId) =>
|
|
145
|
+
isCrossCycleFkConstraint(fk, tableId, cycleTableIds),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type FkConstraintPredicate = (
|
|
150
|
+
fk: TableConstraintProps,
|
|
151
|
+
tableId: string,
|
|
152
|
+
) => boolean;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Shared FK-drop injection used by Branch A and Branch C. The caller owns
|
|
156
|
+
* the cycle-specific matcher; this helper only handles the mechanical
|
|
157
|
+
* rewrite: add dedicated `AlterTableDropConstraint` changes and rebuild
|
|
158
|
+
* affected `DropTable`s with updated `externallyDroppedConstraints`.
|
|
159
|
+
*/
|
|
160
|
+
function injectFkConstraintDropsForDropTables({
|
|
161
|
+
phaseChanges,
|
|
162
|
+
dropTables,
|
|
163
|
+
shouldInject,
|
|
164
|
+
}: {
|
|
165
|
+
phaseChanges: readonly Change[];
|
|
166
|
+
dropTables: readonly DropTable[];
|
|
167
|
+
shouldInject: FkConstraintPredicate;
|
|
168
|
+
}): Change[] | null {
|
|
112
169
|
// For each DropTable in the cycle, find every FK whose referenced table
|
|
113
170
|
// is also in the cycle. Each such FK becomes one injected
|
|
114
171
|
// `AlterTableDropConstraint` and one entry on the source table's
|
|
@@ -120,16 +177,14 @@ function tryBreakFkCycle(
|
|
|
120
177
|
const updatedExternalsByTableId = new Map<string, Set<string>>();
|
|
121
178
|
let didMutate = false;
|
|
122
179
|
|
|
123
|
-
for (const dropTable of
|
|
180
|
+
for (const dropTable of dropTables) {
|
|
124
181
|
const tableId = dropTable.table.stableId;
|
|
125
182
|
const existingExternals = new Set(dropTable.externallyDroppedConstraints);
|
|
126
183
|
let tableMutated = false;
|
|
127
184
|
|
|
128
|
-
for (const fk of
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
cycleTableIds,
|
|
132
|
-
)) {
|
|
185
|
+
for (const fk of iterFkConstraints(dropTable.table.constraints)) {
|
|
186
|
+
if (!shouldInject(fk, tableId)) continue;
|
|
187
|
+
|
|
133
188
|
// Skip if a same-table `AlterTableDropConstraint` is already in the
|
|
134
189
|
// change list — could happen if a previous breaker iteration
|
|
135
190
|
// injected one, or the diff layer emitted one explicitly.
|
|
@@ -187,35 +242,45 @@ function tryBreakFkCycle(
|
|
|
187
242
|
}
|
|
188
243
|
|
|
189
244
|
/**
|
|
190
|
-
* Yield FK constraints on `constraints
|
|
191
|
-
* member of the cycle (i.e. an FK strictly between two cycle DropTables).
|
|
245
|
+
* Yield FK constraints on `constraints`.
|
|
192
246
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* own; injecting an `AlterTableDropConstraint` for a self-FK would just
|
|
196
|
-
* add noise.
|
|
247
|
+
* Partition clones are skipped because PostgreSQL drops them when the
|
|
248
|
+
* parent constraint is dropped.
|
|
197
249
|
*/
|
|
198
|
-
function*
|
|
250
|
+
function* iterFkConstraints(
|
|
199
251
|
constraints: readonly TableConstraintProps[],
|
|
200
|
-
ownTableId: string,
|
|
201
|
-
cycleTableIds: ReadonlySet<string>,
|
|
202
252
|
): Iterable<TableConstraintProps> {
|
|
203
253
|
for (const constraint of constraints) {
|
|
204
254
|
if (constraint.constraint_type !== "f") continue;
|
|
205
255
|
if (constraint.is_partition_clone) continue;
|
|
206
|
-
if (!constraint.foreign_key_schema || !constraint.foreign_key_table) {
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
const referencedId = stableId.table(
|
|
210
|
-
constraint.foreign_key_schema,
|
|
211
|
-
constraint.foreign_key_table,
|
|
212
|
-
);
|
|
213
|
-
if (referencedId === ownTableId) continue;
|
|
214
|
-
if (!cycleTableIds.has(referencedId)) continue;
|
|
215
256
|
yield constraint;
|
|
216
257
|
}
|
|
217
258
|
}
|
|
218
259
|
|
|
260
|
+
/**
|
|
261
|
+
* True when `constraint` references another DropTable in the cycle.
|
|
262
|
+
*
|
|
263
|
+
* Self-referencing FKs are skipped — they create a self-loop in the
|
|
264
|
+
* dependency graph which the existing sort-phase handler resolves on its
|
|
265
|
+
* own; injecting an `AlterTableDropConstraint` for a self-FK would just
|
|
266
|
+
* add noise.
|
|
267
|
+
*/
|
|
268
|
+
function isCrossCycleFkConstraint(
|
|
269
|
+
constraint: TableConstraintProps,
|
|
270
|
+
ownTableId: string,
|
|
271
|
+
cycleTableIds: ReadonlySet<string>,
|
|
272
|
+
): boolean {
|
|
273
|
+
if (!constraint.foreign_key_schema || !constraint.foreign_key_table) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
const referencedId = stableId.table(
|
|
277
|
+
constraint.foreign_key_schema,
|
|
278
|
+
constraint.foreign_key_table,
|
|
279
|
+
);
|
|
280
|
+
if (referencedId === ownTableId) return false;
|
|
281
|
+
return cycleTableIds.has(referencedId);
|
|
282
|
+
}
|
|
283
|
+
|
|
219
284
|
/**
|
|
220
285
|
* True iff `phaseChanges` already contains an explicit
|
|
221
286
|
* `AlterTableDropConstraint(table, constraint)` for the given pair —
|
|
@@ -309,3 +374,98 @@ function tryBreakPublicationColumnCycle(
|
|
|
309
374
|
});
|
|
310
375
|
return rewritten;
|
|
311
376
|
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Branch C worker — break a publication membership removal cycle where
|
|
380
|
+
* dropped tables form a FK chain ending at a separately dropped referenced
|
|
381
|
+
* constraint.
|
|
382
|
+
*/
|
|
383
|
+
function tryBreakPublicationFkConstraintDropCycle(
|
|
384
|
+
cycleNodeIndexes: readonly number[],
|
|
385
|
+
phaseChanges: readonly Change[],
|
|
386
|
+
): Change[] | null {
|
|
387
|
+
let pubChange: AlterPublicationDropTables | null = null;
|
|
388
|
+
let terminalConstraintDrop: AlterTableDropConstraint | null = null;
|
|
389
|
+
const dropTables: DropTable[] = [];
|
|
390
|
+
|
|
391
|
+
for (const nodeIndex of cycleNodeIndexes) {
|
|
392
|
+
const change = phaseChanges[nodeIndex];
|
|
393
|
+
if (change instanceof AlterPublicationDropTables) {
|
|
394
|
+
if (pubChange !== null) return null;
|
|
395
|
+
pubChange = change;
|
|
396
|
+
} else if (change instanceof AlterTableDropConstraint) {
|
|
397
|
+
if (terminalConstraintDrop !== null) return null;
|
|
398
|
+
terminalConstraintDrop = change;
|
|
399
|
+
} else if (change instanceof DropTable) {
|
|
400
|
+
dropTables.push(change);
|
|
401
|
+
} else {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (
|
|
407
|
+
pubChange === null ||
|
|
408
|
+
terminalConstraintDrop === null ||
|
|
409
|
+
dropTables.length === 0
|
|
410
|
+
) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const publicationTableIds = new Set<string>(
|
|
415
|
+
pubChange.tables.map((table) => stableId.table(table.schema, table.name)),
|
|
416
|
+
);
|
|
417
|
+
if (!publicationTableIds.has(terminalConstraintDrop.table.stableId)) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
for (const dropTable of dropTables) {
|
|
422
|
+
if (!publicationTableIds.has(dropTable.table.stableId)) return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const cycleDropTableIds = new Set(
|
|
426
|
+
dropTables.map((change) => change.table.stableId),
|
|
427
|
+
);
|
|
428
|
+
let hasFkToTerminalConstraint = false;
|
|
429
|
+
|
|
430
|
+
for (const dropTable of dropTables) {
|
|
431
|
+
for (const fk of iterFkConstraints(dropTable.table.constraints)) {
|
|
432
|
+
if (fkReferencesConstraint(fk, terminalConstraintDrop)) {
|
|
433
|
+
hasFkToTerminalConstraint = true;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (hasFkToTerminalConstraint) break;
|
|
438
|
+
}
|
|
439
|
+
if (!hasFkToTerminalConstraint) return null;
|
|
440
|
+
|
|
441
|
+
return injectFkConstraintDropsForDropTables({
|
|
442
|
+
phaseChanges,
|
|
443
|
+
dropTables,
|
|
444
|
+
shouldInject: (fk, tableId) =>
|
|
445
|
+
isCrossCycleFkConstraint(fk, tableId, cycleDropTableIds) ||
|
|
446
|
+
fkReferencesConstraint(fk, terminalConstraintDrop),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function fkReferencesConstraint(
|
|
451
|
+
fk: TableConstraintProps,
|
|
452
|
+
constraintDrop: AlterTableDropConstraint,
|
|
453
|
+
): boolean {
|
|
454
|
+
if (
|
|
455
|
+
fk.foreign_key_schema !== constraintDrop.table.schema ||
|
|
456
|
+
fk.foreign_key_table !== constraintDrop.table.name ||
|
|
457
|
+
fk.foreign_key_columns === null
|
|
458
|
+
) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return sameOrderedStrings(
|
|
463
|
+
fk.foreign_key_columns,
|
|
464
|
+
constraintDrop.constraint.key_columns,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function sameOrderedStrings(left: readonly string[], right: readonly string[]) {
|
|
469
|
+
if (left.length !== right.length) return false;
|
|
470
|
+
return left.every((value, index) => value === right[index]);
|
|
471
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Catalog, createEmptyCatalog } from "../catalog.model.ts";
|
|
3
|
+
import type { Change } from "../change.types.ts";
|
|
4
|
+
import type { PgDepend } from "../depend.ts";
|
|
5
|
+
import { AlterPublicationDropTables } from "../objects/publication/changes/publication.alter.ts";
|
|
6
|
+
import { Publication } from "../objects/publication/publication.model.ts";
|
|
7
|
+
import { AlterTableDropConstraint } from "../objects/table/changes/table.alter.ts";
|
|
8
|
+
import { DropTable } from "../objects/table/changes/table.drop.ts";
|
|
9
|
+
import { Table } from "../objects/table/table.model.ts";
|
|
10
|
+
import { sortChanges } from "./sort-changes.ts";
|
|
11
|
+
|
|
12
|
+
const baseTableProps = {
|
|
13
|
+
schema: "public",
|
|
14
|
+
persistence: "p" as const,
|
|
15
|
+
row_security: false,
|
|
16
|
+
force_row_security: false,
|
|
17
|
+
has_indexes: false,
|
|
18
|
+
has_rules: false,
|
|
19
|
+
has_triggers: false,
|
|
20
|
+
has_subclasses: false,
|
|
21
|
+
is_populated: true,
|
|
22
|
+
replica_identity: "d" as const,
|
|
23
|
+
is_partition: false,
|
|
24
|
+
options: null,
|
|
25
|
+
partition_bound: null,
|
|
26
|
+
partition_by: null,
|
|
27
|
+
owner: "postgres",
|
|
28
|
+
comment: null,
|
|
29
|
+
parent_schema: null,
|
|
30
|
+
parent_name: null,
|
|
31
|
+
privileges: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function integerColumn(name: string, position: number) {
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
position,
|
|
38
|
+
data_type: "integer",
|
|
39
|
+
data_type_str: "integer",
|
|
40
|
+
is_custom_type: false,
|
|
41
|
+
custom_type_type: null,
|
|
42
|
+
custom_type_category: null,
|
|
43
|
+
custom_type_schema: null,
|
|
44
|
+
custom_type_name: null,
|
|
45
|
+
not_null: false,
|
|
46
|
+
is_identity: false,
|
|
47
|
+
is_identity_always: false,
|
|
48
|
+
is_generated: false,
|
|
49
|
+
collation: null,
|
|
50
|
+
default: null,
|
|
51
|
+
comment: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fkConstraint(props: {
|
|
56
|
+
name: string;
|
|
57
|
+
fkColumn: string;
|
|
58
|
+
targetTable: string;
|
|
59
|
+
targetColumn?: string;
|
|
60
|
+
}) {
|
|
61
|
+
const targetColumn = props.targetColumn ?? "id";
|
|
62
|
+
return {
|
|
63
|
+
name: props.name,
|
|
64
|
+
constraint_type: "f" as const,
|
|
65
|
+
deferrable: false,
|
|
66
|
+
initially_deferred: false,
|
|
67
|
+
validated: true,
|
|
68
|
+
is_local: true,
|
|
69
|
+
no_inherit: false,
|
|
70
|
+
is_temporal: false,
|
|
71
|
+
is_partition_clone: false,
|
|
72
|
+
parent_constraint_schema: null,
|
|
73
|
+
parent_constraint_name: null,
|
|
74
|
+
parent_table_schema: null,
|
|
75
|
+
parent_table_name: null,
|
|
76
|
+
key_columns: [props.fkColumn],
|
|
77
|
+
foreign_key_columns: [targetColumn],
|
|
78
|
+
foreign_key_table: props.targetTable,
|
|
79
|
+
foreign_key_schema: "public",
|
|
80
|
+
foreign_key_table_is_partition: false,
|
|
81
|
+
foreign_key_parent_schema: null,
|
|
82
|
+
foreign_key_parent_table: null,
|
|
83
|
+
foreign_key_effective_schema: "public",
|
|
84
|
+
foreign_key_effective_table: props.targetTable,
|
|
85
|
+
on_update: "a" as const,
|
|
86
|
+
on_delete: "a" as const,
|
|
87
|
+
match_type: "s" as const,
|
|
88
|
+
check_expression: null,
|
|
89
|
+
owner: "postgres",
|
|
90
|
+
definition: `FOREIGN KEY (${props.fkColumn}) REFERENCES public.${props.targetTable}(${targetColumn})`,
|
|
91
|
+
comment: null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function uniqueConstraint(name: string, column: string) {
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
constraint_type: "u" as const,
|
|
99
|
+
deferrable: false,
|
|
100
|
+
initially_deferred: false,
|
|
101
|
+
validated: true,
|
|
102
|
+
is_local: true,
|
|
103
|
+
no_inherit: false,
|
|
104
|
+
is_temporal: false,
|
|
105
|
+
is_partition_clone: false,
|
|
106
|
+
parent_constraint_schema: null,
|
|
107
|
+
parent_constraint_name: null,
|
|
108
|
+
parent_table_schema: null,
|
|
109
|
+
parent_table_name: null,
|
|
110
|
+
key_columns: [column],
|
|
111
|
+
foreign_key_columns: null,
|
|
112
|
+
foreign_key_table: null,
|
|
113
|
+
foreign_key_schema: null,
|
|
114
|
+
foreign_key_table_is_partition: null,
|
|
115
|
+
foreign_key_parent_schema: null,
|
|
116
|
+
foreign_key_parent_table: null,
|
|
117
|
+
foreign_key_effective_schema: null,
|
|
118
|
+
foreign_key_effective_table: null,
|
|
119
|
+
on_update: null,
|
|
120
|
+
on_delete: null,
|
|
121
|
+
match_type: null,
|
|
122
|
+
check_expression: null,
|
|
123
|
+
owner: "postgres",
|
|
124
|
+
definition: `UNIQUE (${column})`,
|
|
125
|
+
comment: null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function table(
|
|
130
|
+
name: string,
|
|
131
|
+
constraints: ConstructorParameters<typeof Table>[0]["constraints"] = [],
|
|
132
|
+
) {
|
|
133
|
+
return new Table({
|
|
134
|
+
...baseTableProps,
|
|
135
|
+
name,
|
|
136
|
+
columns: [
|
|
137
|
+
{ ...integerColumn("id", 1), not_null: true },
|
|
138
|
+
integerColumn("post_id", 2),
|
|
139
|
+
integerColumn("lab_id", 3),
|
|
140
|
+
],
|
|
141
|
+
constraints,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function catalogWithDepends(depends: PgDepend[]) {
|
|
146
|
+
const base = await createEmptyCatalog(170000, "postgres");
|
|
147
|
+
return new Catalog({ ...base, depends });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function changeLabel(change: Change) {
|
|
151
|
+
if (change instanceof AlterTableDropConstraint) {
|
|
152
|
+
return `${change.constructor.name}:${change.table.name}.${change.constraint.name}`;
|
|
153
|
+
}
|
|
154
|
+
if (change instanceof DropTable) {
|
|
155
|
+
return `${change.constructor.name}:${change.table.name}`;
|
|
156
|
+
}
|
|
157
|
+
return change.constructor.name;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe("sortChanges", () => {
|
|
161
|
+
test("breaks publication FK-chain constraint-drop cycle with one dropped table", async () => {
|
|
162
|
+
const labs = table("labs", [uniqueConstraint("unique_lab_id", "id")]);
|
|
163
|
+
const posts = table("posts", [
|
|
164
|
+
fkConstraint({
|
|
165
|
+
name: "posts_lab_id_fkey",
|
|
166
|
+
fkColumn: "lab_id",
|
|
167
|
+
targetTable: "labs",
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
const publication = new Publication({
|
|
171
|
+
name: "supabase_realtime",
|
|
172
|
+
owner: "postgres",
|
|
173
|
+
comment: null,
|
|
174
|
+
all_tables: false,
|
|
175
|
+
publish_insert: true,
|
|
176
|
+
publish_update: true,
|
|
177
|
+
publish_delete: true,
|
|
178
|
+
publish_truncate: true,
|
|
179
|
+
publish_via_partition_root: false,
|
|
180
|
+
tables: [
|
|
181
|
+
{
|
|
182
|
+
schema: "public",
|
|
183
|
+
name: "labs",
|
|
184
|
+
columns: null,
|
|
185
|
+
row_filter: null,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
schema: "public",
|
|
189
|
+
name: "posts",
|
|
190
|
+
columns: null,
|
|
191
|
+
row_filter: null,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
schemas: [],
|
|
195
|
+
});
|
|
196
|
+
const changes: Change[] = [
|
|
197
|
+
new AlterPublicationDropTables({
|
|
198
|
+
publication,
|
|
199
|
+
tables: publication.tables,
|
|
200
|
+
}),
|
|
201
|
+
new DropTable({ table: posts }),
|
|
202
|
+
new AlterTableDropConstraint({
|
|
203
|
+
table: labs,
|
|
204
|
+
constraint: labs.constraints[0],
|
|
205
|
+
}),
|
|
206
|
+
];
|
|
207
|
+
const mainCatalog = await catalogWithDepends([
|
|
208
|
+
{
|
|
209
|
+
dependent_stable_id: "publication:supabase_realtime",
|
|
210
|
+
referenced_stable_id: "table:public.posts",
|
|
211
|
+
deptype: "n",
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
dependent_stable_id: "constraint:public.posts.posts_lab_id_fkey",
|
|
215
|
+
referenced_stable_id: "constraint:public.labs.unique_lab_id",
|
|
216
|
+
deptype: "n",
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
const branchCatalog = await catalogWithDepends([]);
|
|
220
|
+
|
|
221
|
+
const sorted = sortChanges({ mainCatalog, branchCatalog }, changes);
|
|
222
|
+
|
|
223
|
+
expect(sorted.map(changeLabel)).toContain(
|
|
224
|
+
"AlterTableDropConstraint:posts.posts_lab_id_fkey",
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("breaks publication FK-chain constraint-drop cycle in the drop phase", async () => {
|
|
229
|
+
const labs = table("labs", [uniqueConstraint("unique_lab_id", "id")]);
|
|
230
|
+
const posts = table("posts", [
|
|
231
|
+
fkConstraint({
|
|
232
|
+
name: "posts_lab_id_fkey",
|
|
233
|
+
fkColumn: "lab_id",
|
|
234
|
+
targetTable: "labs",
|
|
235
|
+
}),
|
|
236
|
+
]);
|
|
237
|
+
const postAttachments = table("post_attachments", [
|
|
238
|
+
fkConstraint({
|
|
239
|
+
name: "post_attachments_post_id_fkey",
|
|
240
|
+
fkColumn: "post_id",
|
|
241
|
+
targetTable: "posts",
|
|
242
|
+
}),
|
|
243
|
+
]);
|
|
244
|
+
const publication = new Publication({
|
|
245
|
+
name: "supabase_realtime",
|
|
246
|
+
owner: "postgres",
|
|
247
|
+
comment: null,
|
|
248
|
+
all_tables: false,
|
|
249
|
+
publish_insert: true,
|
|
250
|
+
publish_update: true,
|
|
251
|
+
publish_delete: true,
|
|
252
|
+
publish_truncate: true,
|
|
253
|
+
publish_via_partition_root: false,
|
|
254
|
+
tables: [
|
|
255
|
+
{
|
|
256
|
+
schema: "public",
|
|
257
|
+
name: "labs",
|
|
258
|
+
columns: null,
|
|
259
|
+
row_filter: null,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
schema: "public",
|
|
263
|
+
name: "post_attachments",
|
|
264
|
+
columns: null,
|
|
265
|
+
row_filter: null,
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
schema: "public",
|
|
269
|
+
name: "posts",
|
|
270
|
+
columns: null,
|
|
271
|
+
row_filter: null,
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
schemas: [],
|
|
275
|
+
});
|
|
276
|
+
const changes: Change[] = [
|
|
277
|
+
new AlterPublicationDropTables({
|
|
278
|
+
publication,
|
|
279
|
+
tables: publication.tables,
|
|
280
|
+
}),
|
|
281
|
+
new DropTable({ table: postAttachments }),
|
|
282
|
+
new DropTable({ table: posts }),
|
|
283
|
+
new AlterTableDropConstraint({
|
|
284
|
+
table: labs,
|
|
285
|
+
constraint: labs.constraints[0],
|
|
286
|
+
}),
|
|
287
|
+
];
|
|
288
|
+
const mainCatalog = await catalogWithDepends([
|
|
289
|
+
{
|
|
290
|
+
dependent_stable_id: "publication:supabase_realtime",
|
|
291
|
+
referenced_stable_id: "table:public.post_attachments",
|
|
292
|
+
deptype: "n",
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
dependent_stable_id:
|
|
296
|
+
"constraint:public.post_attachments.post_attachments_post_id_fkey",
|
|
297
|
+
referenced_stable_id: "column:public.posts.id",
|
|
298
|
+
deptype: "n",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
dependent_stable_id: "constraint:public.posts.posts_lab_id_fkey",
|
|
302
|
+
referenced_stable_id: "constraint:public.labs.unique_lab_id",
|
|
303
|
+
deptype: "n",
|
|
304
|
+
},
|
|
305
|
+
]);
|
|
306
|
+
const branchCatalog = await catalogWithDepends([]);
|
|
307
|
+
|
|
308
|
+
const sorted = sortChanges({ mainCatalog, branchCatalog }, changes);
|
|
309
|
+
|
|
310
|
+
expect(sorted.map(changeLabel)).toContain(
|
|
311
|
+
"AlterTableDropConstraint:post_attachments.post_attachments_post_id_fkey",
|
|
312
|
+
);
|
|
313
|
+
expect(sorted.map(changeLabel)).toContain(
|
|
314
|
+
"AlterTableDropConstraint:posts.posts_lab_id_fkey",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
});
|