@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/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
+ };