@proteinjs/db 1.18.1 → 1.19.0
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/CHANGELOG.md +11 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.d.ts +13 -0
- package/dist/generated/test/index.d.ts.map +1 -0
- package/dist/generated/test/index.js +91 -0
- package/dist/generated/test/index.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/Columns.d.ts +10 -3
- package/dist/src/Columns.d.ts.map +1 -1
- package/dist/src/Columns.js +3 -16
- package/dist/src/Columns.js.map +1 -1
- package/dist/src/Db.d.ts +17 -0
- package/dist/src/Db.d.ts.map +1 -1
- package/dist/src/Db.js +201 -31
- package/dist/src/Db.js.map +1 -1
- package/dist/test/reusable/CascadeDeleteTests.d.ts +194 -0
- package/dist/test/reusable/CascadeDeleteTests.d.ts.map +1 -0
- package/dist/test/reusable/CascadeDeleteTests.js +643 -0
- package/dist/test/reusable/CascadeDeleteTests.js.map +1 -0
- package/dist/test/reusable/TableManagerTests.d.ts.map +1 -1
- package/dist/test/reusable/TableManagerTests.js.map +1 -1
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +84 -0
- package/index.ts +1 -0
- package/package.json +14 -10
- package/src/Columns.ts +17 -3
- package/src/Db.ts +161 -15
- package/test/reusable/CascadeDeleteTests.ts +478 -0
package/src/Db.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
tableByName,
|
|
9
9
|
addDefaultFieldValues,
|
|
10
10
|
addUpdateFieldValues,
|
|
11
|
+
getTables,
|
|
11
12
|
} from './Table';
|
|
12
13
|
import { Record, RecordSerializer, SerializedRecord } from './Record';
|
|
13
14
|
import { Logger } from '@proteinjs/logger';
|
|
@@ -193,21 +194,6 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
193
194
|
this.auth.canDelete(table);
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
const _query = async <T extends R>(table: Table<T>, query: Query<T>) => {
|
|
197
|
-
const qb = new QueryBuilderFactory().getQueryBuilder(table, query);
|
|
198
|
-
await this.addColumnQueries(table, qb, 'delete');
|
|
199
|
-
|
|
200
|
-
const generateQuery = (config: DbDriverQueryStatementConfig) =>
|
|
201
|
-
qb.toSql(this.statementConfigFactory.getStatementConfig(config));
|
|
202
|
-
const serializedRecords = await this.dbDriver.runQuery(generateQuery, this.currentTransaction);
|
|
203
|
-
const recordSerializer = new RecordSerializer(table);
|
|
204
|
-
const records = await Promise.all(
|
|
205
|
-
serializedRecords.map(async (serializedRecord) => recordSerializer.deserialize(serializedRecord))
|
|
206
|
-
);
|
|
207
|
-
await this.preloadReferences(records);
|
|
208
|
-
return records;
|
|
209
|
-
};
|
|
210
|
-
|
|
211
197
|
const qb = new QueryBuilderFactory().getQueryBuilder(table, query);
|
|
212
198
|
await this.addColumnQueries(table, qb, 'delete');
|
|
213
199
|
const recordsToDelete = await this._query(table, qb);
|
|
@@ -224,6 +210,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
224
210
|
await this.tableWatcherRunner.runBeforeDeleteTableWatchers(table, recordsToDelete, qb, deleteQb);
|
|
225
211
|
const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.currentTransaction);
|
|
226
212
|
await this.runCascadeDeletions(table, recordsToDelete);
|
|
213
|
+
await this.runColumnReverseCascadeDeletions(table, recordsToDelete);
|
|
227
214
|
await this.tableWatcherRunner.runAfterDeleteTableWatchers(table, recordDeleteCount, recordsToDelete, qb, deleteQb);
|
|
228
215
|
return recordDeleteCount;
|
|
229
216
|
}
|
|
@@ -278,6 +265,153 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
278
265
|
}
|
|
279
266
|
}
|
|
280
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Reverse cascades driven by column-level flags on reference columns only.
|
|
270
|
+
* Supports:
|
|
271
|
+
* - ReferenceColumn
|
|
272
|
+
* - DynamicReferenceColumn
|
|
273
|
+
* - ReferenceArrayColumn (stringified JSON) via LIKE-prefilter + exact check
|
|
274
|
+
*/
|
|
275
|
+
private async runColumnReverseCascadeDeletions(table: Table<any>, deletedRecords: Record[]): Promise<void> {
|
|
276
|
+
const deletedIds = deletedRecords.map((r) => r.id);
|
|
277
|
+
if (deletedIds.length === 0) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const deletedIdSet = new Set<string>(deletedIds);
|
|
282
|
+
const allTables = getTables();
|
|
283
|
+
|
|
284
|
+
for (const referencingTable of allTables) {
|
|
285
|
+
for (const colPropName in referencingTable.columns) {
|
|
286
|
+
const col = referencingTable.columns[colPropName] as any;
|
|
287
|
+
|
|
288
|
+
// Only act if the column explicitly opted in
|
|
289
|
+
if (!col || col.reverseCascadeDelete !== true) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// DynamicReferenceColumn: has dynamicRefTableColName
|
|
294
|
+
if (typeof col.dynamicRefTableColName === 'string' && col.dynamicRefTableColName.length > 0) {
|
|
295
|
+
const dynTableProp = getColumnPropertyName(referencingTable, col.dynamicRefTableColName);
|
|
296
|
+
if (!dynTableProp) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const qb = new QueryBuilderFactory().getQueryBuilder(referencingTable);
|
|
301
|
+
await this.addColumnQueries(referencingTable, qb, 'read');
|
|
302
|
+
|
|
303
|
+
qb.condition({ field: dynTableProp as any, operator: '=', value: table.name as any });
|
|
304
|
+
qb.condition({ field: colPropName as any, operator: 'IN', value: deletedIds as any });
|
|
305
|
+
|
|
306
|
+
this.logger.info({
|
|
307
|
+
message: `Executing reverse cascade (dynamic) for table: ${table.name}`,
|
|
308
|
+
obj: { referencingTable: referencingTable.name, columnPropertyName: colPropName, deletedIds },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const deleteCount = await this.delete(referencingTable, qb);
|
|
312
|
+
this.logger.info({
|
|
313
|
+
message: `Reverse cascade (dynamic) deleted ${deleteCount} record${deleteCount == 1 ? '' : 's'}`,
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ReferenceColumn/ReferenceArrayColumn must match the target table
|
|
319
|
+
if (col.referenceTable !== table.name) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const ctorName = col.constructor?.name;
|
|
324
|
+
|
|
325
|
+
if (ctorName === 'ReferenceColumn') {
|
|
326
|
+
const qb = new QueryBuilderFactory().getQueryBuilder(referencingTable);
|
|
327
|
+
await this.addColumnQueries(referencingTable, qb, 'read');
|
|
328
|
+
qb.condition({ field: colPropName as any, operator: 'IN', value: deletedIds as any });
|
|
329
|
+
|
|
330
|
+
this.logger.info({
|
|
331
|
+
message: `Executing reverse cascade (ReferenceColumn) for table: ${table.name}`,
|
|
332
|
+
obj: { referencingTable: referencingTable.name, columnPropertyName: colPropName, deletedIds },
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const deleteCount = await this.delete(referencingTable, qb);
|
|
336
|
+
this.logger.info({
|
|
337
|
+
message: `Reverse cascade (ReferenceColumn) deleted ${deleteCount} record${deleteCount == 1 ? '' : 's'}`,
|
|
338
|
+
});
|
|
339
|
+
} else if (ctorName === 'ReferenceArrayColumn') {
|
|
340
|
+
await this.reverseDeleteReferenceArrayHolders(referencingTable, colPropName, deletedIds, deletedIdSet);
|
|
341
|
+
} else {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Reverse cascade for ReferenceArrayColumn that stores stringified JSON array of IDs.
|
|
350
|
+
* Strategy:
|
|
351
|
+
* 1) LIKE prefilter with %"<id>"% in chunks
|
|
352
|
+
* 2) Exact check in memory via deserialized ReferenceArray._ids
|
|
353
|
+
* 3) Delete by primary key in chunks
|
|
354
|
+
*/
|
|
355
|
+
private async reverseDeleteReferenceArrayHolders(
|
|
356
|
+
referencingTable: Table<any>,
|
|
357
|
+
columnPropertyName: string,
|
|
358
|
+
deletedIds: string[],
|
|
359
|
+
deletedIdSet: Set<string>
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
const likeChunkSize = 100;
|
|
362
|
+
const deleteChunkSize = 1000;
|
|
363
|
+
|
|
364
|
+
this.logger.info({
|
|
365
|
+
message: `Executing reverse cascade (ReferenceArrayColumn) for table`,
|
|
366
|
+
obj: { referencingTable: referencingTable.name, columnPropertyName, deletedIdsCount: deletedIds.length },
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
for (const idsChunk of this.chunk(deletedIds, likeChunkSize)) {
|
|
370
|
+
const qb = new QueryBuilderFactory().getQueryBuilder(referencingTable);
|
|
371
|
+
await this.addColumnQueries(referencingTable, qb, 'read');
|
|
372
|
+
|
|
373
|
+
qb.select({ fields: ['id', columnPropertyName] });
|
|
374
|
+
qb.and([{ field: columnPropertyName, operator: 'IS NOT NULL' }]);
|
|
375
|
+
|
|
376
|
+
const likeConds = idsChunk.map((id) => {
|
|
377
|
+
const escaped = String(id).replace(/"/g, '\\"');
|
|
378
|
+
return { field: columnPropertyName, operator: 'LIKE' as const, value: `%"${escaped}"%` };
|
|
379
|
+
});
|
|
380
|
+
qb.or(likeConds);
|
|
381
|
+
|
|
382
|
+
const candidates = await this._query(referencingTable, qb);
|
|
383
|
+
|
|
384
|
+
const holderIdsToDelete: string[] = [];
|
|
385
|
+
for (const rec of candidates) {
|
|
386
|
+
const refArr = rec[columnPropertyName] as ReferenceArray<Record> | null | undefined;
|
|
387
|
+
const ids = (refArr && (refArr as any)._ids ? (refArr as any)._ids : []) as string[];
|
|
388
|
+
if (!ids?.length) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (ids.some((x) => deletedIdSet.has(x))) {
|
|
392
|
+
holderIdsToDelete.push(rec.id);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (holderIdsToDelete.length === 0) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const uniqueIds = Array.from(new Set(holderIdsToDelete));
|
|
401
|
+
for (const delChunk of this.chunk(uniqueIds, deleteChunkSize)) {
|
|
402
|
+
const delQb = new QueryBuilderFactory()
|
|
403
|
+
.getQueryBuilder(referencingTable)
|
|
404
|
+
.condition({ field: 'id', operator: 'IN', value: delChunk });
|
|
405
|
+
|
|
406
|
+
const deleteCount = await this.delete(referencingTable, delQb);
|
|
407
|
+
this.logger.info({
|
|
408
|
+
message: `Reverse cascade (ReferenceArrayColumn) deleted ${deleteCount} record${deleteCount == 1 ? '' : 's'}`,
|
|
409
|
+
obj: { referencingTable: referencingTable.name, columnPropertyName, batchSize: delChunk.length },
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
281
415
|
async query<T extends R>(table: Table<T>, query: Query<T>, options?: QueryOptions<T>): Promise<T[]> {
|
|
282
416
|
const qb = new QueryBuilderFactory().getQueryBuilder(table, query);
|
|
283
417
|
|
|
@@ -405,4 +539,16 @@ export class Db<R extends Record = Record> implements DbService<R> {
|
|
|
405
539
|
}
|
|
406
540
|
});
|
|
407
541
|
}
|
|
542
|
+
|
|
543
|
+
// Utility: simple chunker
|
|
544
|
+
private chunk<T>(arr: T[], size: number): T[][] {
|
|
545
|
+
if (size <= 0) {
|
|
546
|
+
return [arr];
|
|
547
|
+
}
|
|
548
|
+
const out: T[][] = [];
|
|
549
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
550
|
+
out.push(arr.slice(i, i + size));
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
408
554
|
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import {
|
|
2
|
+
StringColumn,
|
|
3
|
+
ReferenceColumn,
|
|
4
|
+
ReferenceArrayColumn,
|
|
5
|
+
DynamicReferenceColumn,
|
|
6
|
+
DynamicReferenceTableNameColumn,
|
|
7
|
+
} from '../../src/Columns';
|
|
8
|
+
import { Db, DbDriver } from '../../src/Db';
|
|
9
|
+
import { Table } from '../../src/Table';
|
|
10
|
+
import { withRecordColumns, Record } from '../../src/Record';
|
|
11
|
+
import { Reference } from '../../src/reference/Reference';
|
|
12
|
+
import { ReferenceArray } from '../../src/reference/ReferenceArray';
|
|
13
|
+
import { QueryBuilder } from '@proteinjs/db-query';
|
|
14
|
+
import { DefaultTransactionContextFactory } from '../../src/transaction/TransactionContextFactory';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ---------- Test Entities ----------
|
|
18
|
+
* (Isolated per scenario so we never mount multiple cascade columns
|
|
19
|
+
* to the same target on a single table.)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// --- Cascade: ReferenceColumn (GroupRef -> MemberRef)
|
|
23
|
+
interface MemberRef extends Record {
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
interface GroupRef extends Record {
|
|
27
|
+
groupName: string;
|
|
28
|
+
memberRef?: Reference<MemberRef> | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Cascade: ReferenceArrayColumn (GroupArr -> MemberArr[])
|
|
32
|
+
interface MemberArr extends Record {
|
|
33
|
+
name: string;
|
|
34
|
+
}
|
|
35
|
+
interface GroupArr extends Record {
|
|
36
|
+
groupName: string;
|
|
37
|
+
memberRefs?: ReferenceArray<MemberArr> | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Cascade: DynamicReferenceColumn (GroupDyn -> MemberDyn)
|
|
41
|
+
interface MemberDyn extends Record {
|
|
42
|
+
name: string;
|
|
43
|
+
}
|
|
44
|
+
interface GroupDyn extends Record {
|
|
45
|
+
groupName: string;
|
|
46
|
+
memberDynTableName?: string | null;
|
|
47
|
+
memberDynRef?: Reference<MemberDyn> | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Reverse: ReferenceColumn (Comment -> Post)
|
|
51
|
+
interface Post extends Record {
|
|
52
|
+
title: string;
|
|
53
|
+
}
|
|
54
|
+
interface Comment extends Record {
|
|
55
|
+
text: string;
|
|
56
|
+
postRef?: Reference<Post> | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Reverse: ReferenceArrayColumn (GroupArrRev -> MemberArrRev[])
|
|
60
|
+
interface MemberArrRev extends Record {
|
|
61
|
+
name: string;
|
|
62
|
+
}
|
|
63
|
+
interface GroupArrRev extends Record {
|
|
64
|
+
groupName: string;
|
|
65
|
+
memberRefs?: ReferenceArray<MemberArrRev> | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- Reverse: DynamicReferenceColumn (Task -> Worker)
|
|
69
|
+
interface Worker extends Record {
|
|
70
|
+
name: string;
|
|
71
|
+
}
|
|
72
|
+
interface Task extends Record {
|
|
73
|
+
title: string;
|
|
74
|
+
assigneeTableName?: string | null;
|
|
75
|
+
assigneeRef?: Reference<Worker> | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ---------- Table Names ----------
|
|
80
|
+
*/
|
|
81
|
+
// Cascade (ReferenceColumn)
|
|
82
|
+
const MEMBER_REF_TABLE = 'db_test_cd_members_ref';
|
|
83
|
+
const GROUP_REF_TABLE = 'db_test_cd_groups_ref';
|
|
84
|
+
|
|
85
|
+
// Cascade (ReferenceArrayColumn)
|
|
86
|
+
const MEMBER_ARR_TABLE = 'db_test_cd_members_arr';
|
|
87
|
+
const GROUP_ARR_TABLE = 'db_test_cd_groups_arr';
|
|
88
|
+
|
|
89
|
+
// Cascade (DynamicReferenceColumn)
|
|
90
|
+
const MEMBER_DYN_TABLE = 'db_test_cd_members_dyn';
|
|
91
|
+
const GROUP_DYN_TABLE = 'db_test_cd_groups_dyn';
|
|
92
|
+
|
|
93
|
+
// Reverse (ReferenceColumn)
|
|
94
|
+
const POST_TABLE = 'db_test_cd_posts';
|
|
95
|
+
const COMMENT_TABLE = 'db_test_cd_comments';
|
|
96
|
+
|
|
97
|
+
// Reverse (ReferenceArrayColumn)
|
|
98
|
+
const MEMBER_ARR_REV_TABLE = 'db_test_cd_members_arr_rev';
|
|
99
|
+
const GROUP_ARR_REV_TABLE = 'db_test_cd_groups_arr_rev';
|
|
100
|
+
|
|
101
|
+
// Reverse (DynamicReferenceColumn)
|
|
102
|
+
const WORKER_TABLE = 'db_test_cd_workers';
|
|
103
|
+
const TASK_TABLE = 'db_test_cd_tasks';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* ---------- Table Classes ----------
|
|
107
|
+
*/
|
|
108
|
+
// Cascade: ReferenceColumn
|
|
109
|
+
export class MemberRefTable extends Table<MemberRef> {
|
|
110
|
+
name = MEMBER_REF_TABLE;
|
|
111
|
+
columns = withRecordColumns<MemberRef>({
|
|
112
|
+
name: new StringColumn('name'),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export class GroupRefTable extends Table<GroupRef> {
|
|
116
|
+
name = GROUP_REF_TABLE;
|
|
117
|
+
columns = withRecordColumns<GroupRef>({
|
|
118
|
+
groupName: new StringColumn('group_name'),
|
|
119
|
+
memberRef: new ReferenceColumn<MemberRef>(
|
|
120
|
+
'member_id',
|
|
121
|
+
MEMBER_REF_TABLE,
|
|
122
|
+
true // cascade: deleting GroupRef deletes the referenced MemberRef
|
|
123
|
+
),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Cascade: ReferenceArrayColumn
|
|
128
|
+
export class MemberArrTable extends Table<MemberArr> {
|
|
129
|
+
name = MEMBER_ARR_TABLE;
|
|
130
|
+
columns = withRecordColumns<MemberArr>({
|
|
131
|
+
name: new StringColumn('name'),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export class GroupArrTable extends Table<GroupArr> {
|
|
135
|
+
name = GROUP_ARR_TABLE;
|
|
136
|
+
columns = withRecordColumns<GroupArr>({
|
|
137
|
+
groupName: new StringColumn('group_name'),
|
|
138
|
+
memberRefs: new ReferenceArrayColumn<MemberArr>(
|
|
139
|
+
'member_ids',
|
|
140
|
+
MEMBER_ARR_TABLE,
|
|
141
|
+
true // cascade: deleting GroupArr deletes all referenced MemberArr
|
|
142
|
+
),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Cascade: DynamicReferenceColumn
|
|
147
|
+
export class MemberDynTable extends Table<MemberDyn> {
|
|
148
|
+
name = MEMBER_DYN_TABLE;
|
|
149
|
+
columns = withRecordColumns<MemberDyn>({
|
|
150
|
+
name: new StringColumn('name'),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export class GroupDynTable extends Table<GroupDyn> {
|
|
154
|
+
name = GROUP_DYN_TABLE;
|
|
155
|
+
columns = withRecordColumns<GroupDyn>({
|
|
156
|
+
groupName: new StringColumn('group_name'),
|
|
157
|
+
memberDynTableName: new DynamicReferenceTableNameColumn('member_dyn_table_name', 'member_dyn_ref'),
|
|
158
|
+
memberDynRef: new DynamicReferenceColumn<MemberDyn>(
|
|
159
|
+
'member_dyn_ref',
|
|
160
|
+
'member_dyn_table_name',
|
|
161
|
+
true // cascade: deleting GroupDyn deletes dynamically referenced MemberDyn
|
|
162
|
+
),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Reverse: ReferenceColumn
|
|
167
|
+
export class PostTable extends Table<Post> {
|
|
168
|
+
name = POST_TABLE;
|
|
169
|
+
columns = withRecordColumns<Post>({
|
|
170
|
+
title: new StringColumn('title'),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
export class CommentTable extends Table<Comment> {
|
|
174
|
+
name = COMMENT_TABLE;
|
|
175
|
+
columns = withRecordColumns<Comment>({
|
|
176
|
+
text: new StringColumn('text'),
|
|
177
|
+
postRef: new ReferenceColumn<Post>(
|
|
178
|
+
'post_id',
|
|
179
|
+
POST_TABLE,
|
|
180
|
+
false,
|
|
181
|
+
{ reverseCascadeDelete: true } // reverse: deleting Post deletes Comment
|
|
182
|
+
),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Reverse: ReferenceArrayColumn
|
|
187
|
+
export class MemberArrRevTable extends Table<MemberArrRev> {
|
|
188
|
+
name = MEMBER_ARR_REV_TABLE;
|
|
189
|
+
columns = withRecordColumns<MemberArrRev>({
|
|
190
|
+
name: new StringColumn('name'),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
export class GroupArrRevTable extends Table<GroupArrRev> {
|
|
194
|
+
name = GROUP_ARR_REV_TABLE;
|
|
195
|
+
columns = withRecordColumns<GroupArrRev>({
|
|
196
|
+
groupName: new StringColumn('group_name'),
|
|
197
|
+
memberRefs: new ReferenceArrayColumn<MemberArrRev>(
|
|
198
|
+
'member_ids',
|
|
199
|
+
MEMBER_ARR_REV_TABLE,
|
|
200
|
+
false,
|
|
201
|
+
{ reverseCascadeDelete: true } // reverse: deleting any MemberArrRev deletes the GroupArrRev
|
|
202
|
+
),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Reverse: DynamicReferenceColumn
|
|
207
|
+
export class WorkerTable extends Table<Worker> {
|
|
208
|
+
name = WORKER_TABLE;
|
|
209
|
+
columns = withRecordColumns<Worker>({
|
|
210
|
+
name: new StringColumn('name'),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
export class TaskTable extends Table<Task> {
|
|
214
|
+
name = TASK_TABLE;
|
|
215
|
+
columns = withRecordColumns<Task>({
|
|
216
|
+
title: new StringColumn('title'),
|
|
217
|
+
assigneeTableName: new DynamicReferenceTableNameColumn('assignee_table_name', 'assignee_ref'),
|
|
218
|
+
assigneeRef: new DynamicReferenceColumn<Worker>(
|
|
219
|
+
'assignee_ref',
|
|
220
|
+
'assignee_table_name',
|
|
221
|
+
false,
|
|
222
|
+
{ reverseCascadeDelete: true } // reverse: deleting Worker deletes Task
|
|
223
|
+
),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** getTable resolver for Db */
|
|
228
|
+
export const getCascadeDeleteTestTable = (tableName: string) => {
|
|
229
|
+
switch (tableName) {
|
|
230
|
+
// Cascade
|
|
231
|
+
case MEMBER_REF_TABLE:
|
|
232
|
+
return new MemberRefTable();
|
|
233
|
+
case GROUP_REF_TABLE:
|
|
234
|
+
return new GroupRefTable();
|
|
235
|
+
case MEMBER_ARR_TABLE:
|
|
236
|
+
return new MemberArrTable();
|
|
237
|
+
case GROUP_ARR_TABLE:
|
|
238
|
+
return new GroupArrTable();
|
|
239
|
+
case MEMBER_DYN_TABLE:
|
|
240
|
+
return new MemberDynTable();
|
|
241
|
+
case GROUP_DYN_TABLE:
|
|
242
|
+
return new GroupDynTable();
|
|
243
|
+
|
|
244
|
+
// Reverse
|
|
245
|
+
case POST_TABLE:
|
|
246
|
+
return new PostTable();
|
|
247
|
+
case COMMENT_TABLE:
|
|
248
|
+
return new CommentTable();
|
|
249
|
+
case MEMBER_ARR_REV_TABLE:
|
|
250
|
+
return new MemberArrRevTable();
|
|
251
|
+
case GROUP_ARR_REV_TABLE:
|
|
252
|
+
return new GroupArrRevTable();
|
|
253
|
+
case WORKER_TABLE:
|
|
254
|
+
return new WorkerTable();
|
|
255
|
+
case TASK_TABLE:
|
|
256
|
+
return new TaskTable();
|
|
257
|
+
|
|
258
|
+
default:
|
|
259
|
+
throw new Error(`Cannot find test table: ${tableName}`);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Public test suite factory.
|
|
265
|
+
*/
|
|
266
|
+
export const cascadeDeleteTests = (
|
|
267
|
+
driver: DbDriver,
|
|
268
|
+
transactionContextFactory: DefaultTransactionContextFactory,
|
|
269
|
+
dropTable: (table: Table<any>) => Promise<void>
|
|
270
|
+
) => {
|
|
271
|
+
return () => {
|
|
272
|
+
const db = new Db(driver, getCascadeDeleteTestTable, transactionContextFactory);
|
|
273
|
+
|
|
274
|
+
beforeAll(async () => {
|
|
275
|
+
if (driver.start) {
|
|
276
|
+
await driver.start();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
beforeEach(async () => {
|
|
281
|
+
// Ensure tables exist and are in a known state for each test
|
|
282
|
+
await driver.getTableManager().loadTable(new MemberRefTable());
|
|
283
|
+
await driver.getTableManager().loadTable(new GroupRefTable());
|
|
284
|
+
|
|
285
|
+
await driver.getTableManager().loadTable(new MemberArrTable());
|
|
286
|
+
await driver.getTableManager().loadTable(new GroupArrTable());
|
|
287
|
+
|
|
288
|
+
await driver.getTableManager().loadTable(new MemberDynTable());
|
|
289
|
+
await driver.getTableManager().loadTable(new GroupDynTable());
|
|
290
|
+
|
|
291
|
+
await driver.getTableManager().loadTable(new PostTable());
|
|
292
|
+
await driver.getTableManager().loadTable(new CommentTable());
|
|
293
|
+
|
|
294
|
+
await driver.getTableManager().loadTable(new MemberArrRevTable());
|
|
295
|
+
await driver.getTableManager().loadTable(new GroupArrRevTable());
|
|
296
|
+
|
|
297
|
+
await driver.getTableManager().loadTable(new WorkerTable());
|
|
298
|
+
await driver.getTableManager().loadTable(new TaskTable());
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
afterEach(async () => {
|
|
302
|
+
// Drop referencing tables first, then referenced
|
|
303
|
+
await dropTable(new GroupRefTable());
|
|
304
|
+
await dropTable(new MemberRefTable());
|
|
305
|
+
|
|
306
|
+
await dropTable(new GroupArrTable());
|
|
307
|
+
await dropTable(new MemberArrTable());
|
|
308
|
+
|
|
309
|
+
await dropTable(new GroupDynTable());
|
|
310
|
+
await dropTable(new MemberDynTable());
|
|
311
|
+
|
|
312
|
+
await dropTable(new CommentTable());
|
|
313
|
+
await dropTable(new PostTable());
|
|
314
|
+
|
|
315
|
+
await dropTable(new GroupArrRevTable());
|
|
316
|
+
await dropTable(new MemberArrRevTable());
|
|
317
|
+
|
|
318
|
+
await dropTable(new TaskTable());
|
|
319
|
+
await dropTable(new WorkerTable());
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
afterAll(async () => {
|
|
323
|
+
if (driver.stop) {
|
|
324
|
+
await driver.stop();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* -------------------- Cascade Delete (holder → referenced) --------------------
|
|
330
|
+
*/
|
|
331
|
+
describe('Cascade Delete', () => {
|
|
332
|
+
test('ReferenceColumn: deleting holder deletes referenced record', async () => {
|
|
333
|
+
const memberTable = new MemberRefTable();
|
|
334
|
+
const groupTable = new GroupRefTable();
|
|
335
|
+
|
|
336
|
+
const m = await db.insert(memberTable, { name: 'Alice' });
|
|
337
|
+
const g = await db.insert(groupTable, {
|
|
338
|
+
groupName: 'G-Ref',
|
|
339
|
+
memberRef: new Reference<MemberRef>(memberTable.name, m.id),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const delQb = new QueryBuilder<GroupRef>(groupTable.name).condition({
|
|
343
|
+
field: 'id',
|
|
344
|
+
operator: '=',
|
|
345
|
+
value: g.id,
|
|
346
|
+
});
|
|
347
|
+
const deleted = await db.delete(groupTable, delQb);
|
|
348
|
+
expect(deleted).toBe(1);
|
|
349
|
+
|
|
350
|
+
const remaining = await db.query(memberTable, {});
|
|
351
|
+
expect(remaining.length).toBe(0);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('ReferenceArrayColumn: deleting holder deletes all referenced records', async () => {
|
|
355
|
+
const memberTable = new MemberArrTable();
|
|
356
|
+
const groupTable = new GroupArrTable();
|
|
357
|
+
|
|
358
|
+
const m1 = await db.insert(memberTable, { name: 'Bob' });
|
|
359
|
+
const m2 = await db.insert(memberTable, { name: 'Charlie' });
|
|
360
|
+
const m3 = await db.insert(memberTable, { name: 'Dana' });
|
|
361
|
+
|
|
362
|
+
const g = await db.insert(groupTable, {
|
|
363
|
+
groupName: 'G-Arr',
|
|
364
|
+
memberRefs: new ReferenceArray<MemberArr>(memberTable.name, [m1.id, m2.id]),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const delQb = new QueryBuilder<GroupArr>(groupTable.name).condition({
|
|
368
|
+
field: 'id',
|
|
369
|
+
operator: '=',
|
|
370
|
+
value: g.id,
|
|
371
|
+
});
|
|
372
|
+
const deleted = await db.delete(groupTable, delQb);
|
|
373
|
+
expect(deleted).toBe(1);
|
|
374
|
+
|
|
375
|
+
const remaining = await db.query(memberTable, {});
|
|
376
|
+
expect(remaining.length).toBe(1);
|
|
377
|
+
expect(remaining[0].id).toBe(m3.id);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('DynamicReferenceColumn: deleting holder deletes dynamically referenced record', async () => {
|
|
381
|
+
const memberTable = new MemberDynTable();
|
|
382
|
+
const groupTable = new GroupDynTable();
|
|
383
|
+
|
|
384
|
+
const m = await db.insert(memberTable, { name: 'Dina' });
|
|
385
|
+
const g = await db.insert(groupTable, {
|
|
386
|
+
groupName: 'G-Dyn',
|
|
387
|
+
memberDynTableName: memberTable.name,
|
|
388
|
+
memberDynRef: new Reference<MemberDyn>(memberTable.name, m.id),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const delQb = new QueryBuilder<GroupDyn>(groupTable.name).condition({
|
|
392
|
+
field: 'id',
|
|
393
|
+
operator: '=',
|
|
394
|
+
value: g.id,
|
|
395
|
+
});
|
|
396
|
+
const deleted = await db.delete(groupTable, delQb);
|
|
397
|
+
expect(deleted).toBe(1);
|
|
398
|
+
|
|
399
|
+
const remaining = await db.query(memberTable, {});
|
|
400
|
+
expect(remaining.length).toBe(0);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* -------------------- Reverse Cascade Delete (referenced → holder) --------------------
|
|
406
|
+
*/
|
|
407
|
+
describe('Reverse Cascade Delete', () => {
|
|
408
|
+
test('ReferenceColumn: deleting referenced record deletes the holder', async () => {
|
|
409
|
+
const postTable = new PostTable();
|
|
410
|
+
const commentTable = new CommentTable();
|
|
411
|
+
|
|
412
|
+
const post = await db.insert(postTable, { title: 'Hello World' });
|
|
413
|
+
await db.insert(commentTable, {
|
|
414
|
+
text: 'Nice post!',
|
|
415
|
+
postRef: new Reference<Post>(postTable.name, post.id),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const delQb = new QueryBuilder<Post>(postTable.name).condition({
|
|
419
|
+
field: 'id',
|
|
420
|
+
operator: '=',
|
|
421
|
+
value: post.id,
|
|
422
|
+
});
|
|
423
|
+
const deleted = await db.delete(postTable, delQb);
|
|
424
|
+
expect(deleted).toBe(1);
|
|
425
|
+
|
|
426
|
+
const remaining = await db.query(commentTable, {});
|
|
427
|
+
expect(remaining.length).toBe(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('ReferenceArrayColumn: deleting a referenced record deletes the holder', async () => {
|
|
431
|
+
const memberTable = new MemberArrRevTable();
|
|
432
|
+
const groupTable = new GroupArrRevTable();
|
|
433
|
+
|
|
434
|
+
const m1 = await db.insert(memberTable, { name: 'Alice' });
|
|
435
|
+
const m2 = await db.insert(memberTable, { name: 'Bob' });
|
|
436
|
+
|
|
437
|
+
await db.insert(groupTable, {
|
|
438
|
+
groupName: 'G-RevArr',
|
|
439
|
+
memberRefs: new ReferenceArray<MemberArrRev>(memberTable.name, [m1.id, m2.id]),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const delQb = new QueryBuilder<MemberArrRev>(memberTable.name).condition({
|
|
443
|
+
field: 'id',
|
|
444
|
+
operator: '=',
|
|
445
|
+
value: m1.id,
|
|
446
|
+
});
|
|
447
|
+
const deleted = await db.delete(memberTable, delQb);
|
|
448
|
+
expect(deleted).toBe(1);
|
|
449
|
+
|
|
450
|
+
const remainingGroups = await db.query(groupTable, {});
|
|
451
|
+
expect(remainingGroups.length).toBe(0);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('DynamicReferenceColumn: deleting referenced record deletes the holder', async () => {
|
|
455
|
+
const workerTable = new WorkerTable();
|
|
456
|
+
const taskTable = new TaskTable();
|
|
457
|
+
|
|
458
|
+
const worker = await db.insert(workerTable, { name: 'Wally Worker' });
|
|
459
|
+
await db.insert(taskTable, {
|
|
460
|
+
title: 'Assemble Widget',
|
|
461
|
+
assigneeTableName: workerTable.name,
|
|
462
|
+
assigneeRef: new Reference<Worker>(workerTable.name, worker.id),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const delQb = new QueryBuilder<Worker>(workerTable.name).condition({
|
|
466
|
+
field: 'id',
|
|
467
|
+
operator: '=',
|
|
468
|
+
value: worker.id,
|
|
469
|
+
});
|
|
470
|
+
const deleted = await db.delete(workerTable, delQb);
|
|
471
|
+
expect(deleted).toBe(1);
|
|
472
|
+
|
|
473
|
+
const remainingTasks = await db.query(taskTable, {});
|
|
474
|
+
expect(remainingTasks.length).toBe(0);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
};
|
|
478
|
+
};
|