@ftschopp/dynatable-core 1.2.5 → 1.4.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 +14 -0
- package/dist/builders/query/create-query-builder.d.ts +1 -1
- package/dist/builders/query/create-query-builder.d.ts.map +1 -1
- package/dist/builders/query/create-query-builder.js +56 -10
- package/dist/builders/query/types.d.ts +16 -0
- package/dist/builders/query/types.d.ts.map +1 -1
- package/dist/builders/scan/create-scan-builder.d.ts.map +1 -1
- package/dist/builders/scan/create-scan-builder.js +32 -0
- package/dist/builders/scan/types.d.ts +35 -0
- package/dist/builders/scan/types.d.ts.map +1 -1
- package/dist/builders/shared/operators.d.ts.map +1 -1
- package/dist/builders/shared/operators.js +8 -0
- package/dist/builders/shared/types.d.ts +1 -0
- package/dist/builders/shared/types.d.ts.map +1 -1
- package/dist/builders/update/create-update-builder.d.ts +8 -2
- package/dist/builders/update/create-update-builder.d.ts.map +1 -1
- package/dist/builders/update/create-update-builder.js +45 -16
- package/dist/builders/update/types.d.ts +13 -0
- package/dist/builders/update/types.d.ts.map +1 -1
- package/dist/entity/create-entity-api.d.ts.map +1 -1
- package/dist/entity/create-entity-api.js +8 -4
- package/dist/utils/model-utils.d.ts +28 -0
- package/dist/utils/model-utils.d.ts.map +1 -1
- package/dist/utils/model-utils.js +34 -1
- package/package.json +1 -1
- package/src/builders/query/create-query-builder.test.ts +324 -0
- package/src/builders/query/create-query-builder.ts +73 -10
- package/src/builders/query/types.ts +17 -0
- package/src/builders/scan/create-scan-builder.test.ts +121 -0
- package/src/builders/scan/create-scan-builder.ts +35 -1
- package/src/builders/scan/types.ts +39 -0
- package/src/builders/shared/operators.ts +8 -0
- package/src/builders/shared/types.ts +1 -0
- package/src/builders/update/create-update-builder.test.ts +197 -0
- package/src/builders/update/create-update-builder.ts +72 -19
- package/src/builders/update/types.ts +14 -0
- package/src/entity/create-entity-api.ts +8 -4
- package/src/utils/model-utils.test.ts +91 -1
- package/src/utils/model-utils.ts +54 -0
- package/tests/integration/instagram-clone.integration.test.ts +23 -3
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
+
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
4
|
+
import { mockClient } from 'aws-sdk-client-mock';
|
|
3
5
|
import { createScanBuilder } from './create-scan-builder';
|
|
4
6
|
|
|
7
|
+
const ddbMock = mockClient(DynamoDBClient);
|
|
8
|
+
|
|
5
9
|
describe('ScanBuilder', () => {
|
|
6
10
|
const client = new DynamoDBClient({});
|
|
7
11
|
const tableName = 'TestTable';
|
|
@@ -257,4 +261,121 @@ describe('ScanBuilder', () => {
|
|
|
257
261
|
expect(Object.values(params.ExpressionAttributeValues || {})).toContain(65);
|
|
258
262
|
});
|
|
259
263
|
});
|
|
264
|
+
|
|
265
|
+
describe('executeWithPagination', () => {
|
|
266
|
+
beforeEach(() => {
|
|
267
|
+
ddbMock.reset();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('returns items, lastEvaluatedKey and counts', async () => {
|
|
271
|
+
const items = [
|
|
272
|
+
{ pk: 'USER#alice', sk: 'USER#alice', name: 'Alice' },
|
|
273
|
+
{ pk: 'USER#bob', sk: 'USER#bob', name: 'Bob' },
|
|
274
|
+
];
|
|
275
|
+
const lastKey = { pk: 'USER#bob', sk: 'USER#bob' };
|
|
276
|
+
|
|
277
|
+
ddbMock.on(ScanCommand).resolves({
|
|
278
|
+
Items: items,
|
|
279
|
+
LastEvaluatedKey: lastKey,
|
|
280
|
+
Count: 2,
|
|
281
|
+
ScannedCount: 5,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const result = await createScanBuilder<TestModel>(tableName, client).executeWithPagination();
|
|
285
|
+
|
|
286
|
+
expect(result.items).toEqual(items);
|
|
287
|
+
expect(result.lastEvaluatedKey).toEqual(lastKey);
|
|
288
|
+
expect(result.count).toBe(2);
|
|
289
|
+
expect(result.scannedCount).toBe(5);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('returns undefined lastEvaluatedKey on the final page', async () => {
|
|
293
|
+
ddbMock.on(ScanCommand).resolves({ Items: [], Count: 0, ScannedCount: 0 });
|
|
294
|
+
|
|
295
|
+
const result = await createScanBuilder<TestModel>(tableName, client).executeWithPagination();
|
|
296
|
+
|
|
297
|
+
expect(result.items).toEqual([]);
|
|
298
|
+
expect(result.lastEvaluatedKey).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('iterate', () => {
|
|
303
|
+
beforeEach(() => {
|
|
304
|
+
ddbMock.reset();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('walks every page transparently and yields items in order', async () => {
|
|
308
|
+
const page1 = [
|
|
309
|
+
{ pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
|
|
310
|
+
{ pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
|
|
311
|
+
];
|
|
312
|
+
const page2 = [{ pk: 'USER#3', sk: 'USER#3', name: 'User 3' }];
|
|
313
|
+
|
|
314
|
+
ddbMock
|
|
315
|
+
.on(ScanCommand)
|
|
316
|
+
.resolvesOnce({ Items: page1, LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' } })
|
|
317
|
+
.resolvesOnce({ Items: page2 });
|
|
318
|
+
|
|
319
|
+
const collected: TestModel[] = [];
|
|
320
|
+
for await (const item of createScanBuilder<TestModel>(tableName, client).iterate()) {
|
|
321
|
+
collected.push(item);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
expect(collected.map((u) => u.name)).toEqual(['User 1', 'User 2', 'User 3']);
|
|
325
|
+
expect(ddbMock.calls()).toHaveLength(2);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('forwards LastEvaluatedKey from each response into the next call', async () => {
|
|
329
|
+
const cursor1 = { pk: 'USER#2', sk: 'USER#2' };
|
|
330
|
+
|
|
331
|
+
ddbMock
|
|
332
|
+
.on(ScanCommand)
|
|
333
|
+
.resolvesOnce({ Items: [{ pk: 'USER#1', sk: 'USER#1' }], LastEvaluatedKey: cursor1 })
|
|
334
|
+
.resolvesOnce({ Items: [{ pk: 'USER#3', sk: 'USER#3' }] });
|
|
335
|
+
|
|
336
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
337
|
+
for await (const _ of createScanBuilder<TestModel>(tableName, client).iterate()) {
|
|
338
|
+
// drain
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const calls = ddbMock.calls();
|
|
342
|
+
expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toBeUndefined();
|
|
343
|
+
expect((calls[1]!.args[0].input as any).ExclusiveStartKey).toEqual(cursor1);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('starts from the user-provided cursor on the first call', async () => {
|
|
347
|
+
const startKey = { pk: 'USER#10', sk: 'USER#10' };
|
|
348
|
+
|
|
349
|
+
ddbMock.on(ScanCommand).resolves({ Items: [{ pk: 'USER#11', sk: 'USER#11' }] });
|
|
350
|
+
|
|
351
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
352
|
+
for await (const _ of createScanBuilder<TestModel>(tableName, client)
|
|
353
|
+
.startFrom(startKey)
|
|
354
|
+
.iterate()) {
|
|
355
|
+
// drain
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const calls = ddbMock.calls();
|
|
359
|
+
expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toEqual(startKey);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('break out of the loop stops further DynamoDB calls', async () => {
|
|
363
|
+
ddbMock.on(ScanCommand).resolvesOnce({
|
|
364
|
+
Items: [
|
|
365
|
+
{ pk: 'USER#1', sk: 'USER#1' },
|
|
366
|
+
{ pk: 'USER#2', sk: 'USER#2' },
|
|
367
|
+
],
|
|
368
|
+
LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' },
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const collected: TestModel[] = [];
|
|
372
|
+
for await (const item of createScanBuilder<TestModel>(tableName, client).iterate()) {
|
|
373
|
+
collected.push(item);
|
|
374
|
+
if (collected.length >= 1) break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
expect(collected).toHaveLength(1);
|
|
378
|
+
expect(ddbMock.calls()).toHaveLength(1);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
260
381
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
3
|
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
4
4
|
import { buildExpression, AttrBuilder, Condition, createOpBuilder } from '../shared';
|
|
5
|
-
import { ScanBuilder } from './types';
|
|
5
|
+
import { ScanBuilder, ScanResult } from './types';
|
|
6
6
|
import { DynamoDBLogger } from '../../utils/dynamodb-logger';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -205,12 +205,46 @@ export function createScanBuilder<Model>(
|
|
|
205
205
|
return params;
|
|
206
206
|
},
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* ⚠️ Returns only the FIRST page of results. DynamoDB caps each Scan
|
|
210
|
+
* response at ~1MB (or `Limit` if set). If the matching set is larger,
|
|
211
|
+
* the remaining items are silently dropped.
|
|
212
|
+
*
|
|
213
|
+
* Use {@link executeWithPagination} when you need to drive pagination
|
|
214
|
+
* yourself, or {@link iterate} to walk every matching item lazily.
|
|
215
|
+
*/
|
|
208
216
|
async execute() {
|
|
209
217
|
const params = build().dbParams();
|
|
210
218
|
const response = await client.send(new ScanCommand(params));
|
|
211
219
|
logger?.log('ScanCommand', params, response);
|
|
212
220
|
return (response.Items || []) as Model[];
|
|
213
221
|
},
|
|
222
|
+
|
|
223
|
+
async executeWithPagination(): Promise<ScanResult<Model>> {
|
|
224
|
+
const params = build().dbParams();
|
|
225
|
+
const response = await client.send(new ScanCommand(params));
|
|
226
|
+
logger?.log('ScanCommand', params, response);
|
|
227
|
+
return {
|
|
228
|
+
items: (response.Items ?? []) as Model[],
|
|
229
|
+
lastEvaluatedKey: response.LastEvaluatedKey,
|
|
230
|
+
count: response.Count,
|
|
231
|
+
scannedCount: response.ScannedCount,
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async *iterate(): AsyncIterableIterator<Model> {
|
|
236
|
+
const baseParams = build().dbParams();
|
|
237
|
+
let cursor: Record<string, any> | undefined = baseParams.ExclusiveStartKey;
|
|
238
|
+
do {
|
|
239
|
+
const params = { ...baseParams, ExclusiveStartKey: cursor };
|
|
240
|
+
const response = await client.send(new ScanCommand(params));
|
|
241
|
+
logger?.log('ScanCommand', params, response);
|
|
242
|
+
for (const item of (response.Items ?? []) as Model[]) {
|
|
243
|
+
yield item;
|
|
244
|
+
}
|
|
245
|
+
cursor = response.LastEvaluatedKey;
|
|
246
|
+
} while (cursor);
|
|
247
|
+
},
|
|
214
248
|
});
|
|
215
249
|
|
|
216
250
|
return build();
|
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
1
2
|
import { ExecutableBuilder, AttrBuilder, OpBuilder, Condition } from '../shared';
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A single page of scan results plus metadata for manual pagination.
|
|
6
|
+
*/
|
|
7
|
+
export interface ScanResult<Model> {
|
|
8
|
+
/** Items returned in this page. */
|
|
9
|
+
items: Model[];
|
|
10
|
+
/** Cursor for the next page; `undefined` when there are no more results. */
|
|
11
|
+
lastEvaluatedKey?: Record<string, any>;
|
|
12
|
+
/** Number of items returned after applying the filter. */
|
|
13
|
+
count?: number;
|
|
14
|
+
/** Number of items examined before applying the filter. */
|
|
15
|
+
scannedCount?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
/**
|
|
4
19
|
* Builder for DynamoDB Scan operations.
|
|
5
20
|
* Scans the entire table or index without requiring key conditions.
|
|
@@ -46,4 +61,28 @@ export interface ScanBuilder<Model> extends ExecutableBuilder<Model[]> {
|
|
|
46
61
|
* Returns a new immutable builder.
|
|
47
62
|
*/
|
|
48
63
|
segment(segmentNumber: number, totalSegments: number): ScanBuilder<Model>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Executes a single Scan request and returns the page along with the
|
|
67
|
+
* `lastEvaluatedKey` cursor and counts. Use this when you want to drive
|
|
68
|
+
* pagination yourself (e.g. expose a cursor to a client).
|
|
69
|
+
*/
|
|
70
|
+
executeWithPagination(): Promise<ScanResult<Model>>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns an async iterator that paginates internally and yields one item at
|
|
74
|
+
* a time. Memory stays at one page; you can `break` out of the loop early.
|
|
75
|
+
*
|
|
76
|
+
* `Limit` set via `.limit()` is forwarded to DynamoDB as a *per-request*
|
|
77
|
+
* cap, not a total cap. To cap the total, count yourself inside the loop:
|
|
78
|
+
*
|
|
79
|
+
* ```ts
|
|
80
|
+
* let n = 0;
|
|
81
|
+
* for await (const item of builder.iterate()) {
|
|
82
|
+
* if (n++ >= 1000) break;
|
|
83
|
+
* process(item);
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
iterate(): AsyncIterableIterator<Model>;
|
|
49
88
|
}
|
|
@@ -21,6 +21,7 @@ export function createOpBuilder(): OpBuilder {
|
|
|
21
21
|
expression: `#${attr.name} = :${valueName}`,
|
|
22
22
|
names: { [`#${attr.name}`]: attr.name },
|
|
23
23
|
values: { [`:${valueName}`]: value },
|
|
24
|
+
keyOperator: 'eq',
|
|
24
25
|
};
|
|
25
26
|
},
|
|
26
27
|
ne: (attr, value) => {
|
|
@@ -29,6 +30,7 @@ export function createOpBuilder(): OpBuilder {
|
|
|
29
30
|
expression: `#${attr.name} <> :${valueName}`,
|
|
30
31
|
names: { [`#${attr.name}`]: attr.name },
|
|
31
32
|
values: { [`:${valueName}`]: value },
|
|
33
|
+
keyOperator: 'ne',
|
|
32
34
|
};
|
|
33
35
|
},
|
|
34
36
|
lt: (attr, value) => {
|
|
@@ -37,6 +39,7 @@ export function createOpBuilder(): OpBuilder {
|
|
|
37
39
|
expression: `#${attr.name} < :${valueName}`,
|
|
38
40
|
names: { [`#${attr.name}`]: attr.name },
|
|
39
41
|
values: { [`:${valueName}`]: value },
|
|
42
|
+
keyOperator: 'lt',
|
|
40
43
|
};
|
|
41
44
|
},
|
|
42
45
|
lte: (attr, value) => {
|
|
@@ -45,6 +48,7 @@ export function createOpBuilder(): OpBuilder {
|
|
|
45
48
|
expression: `#${attr.name} <= :${valueName}`,
|
|
46
49
|
names: { [`#${attr.name}`]: attr.name },
|
|
47
50
|
values: { [`:${valueName}`]: value },
|
|
51
|
+
keyOperator: 'lte',
|
|
48
52
|
};
|
|
49
53
|
},
|
|
50
54
|
gt: (attr, value) => {
|
|
@@ -53,6 +57,7 @@ export function createOpBuilder(): OpBuilder {
|
|
|
53
57
|
expression: `#${attr.name} > :${valueName}`,
|
|
54
58
|
names: { [`#${attr.name}`]: attr.name },
|
|
55
59
|
values: { [`:${valueName}`]: value },
|
|
60
|
+
keyOperator: 'gt',
|
|
56
61
|
};
|
|
57
62
|
},
|
|
58
63
|
gte: (attr, value) => {
|
|
@@ -61,6 +66,7 @@ export function createOpBuilder(): OpBuilder {
|
|
|
61
66
|
expression: `#${attr.name} >= :${valueName}`,
|
|
62
67
|
names: { [`#${attr.name}`]: attr.name },
|
|
63
68
|
values: { [`:${valueName}`]: value },
|
|
69
|
+
keyOperator: 'gte',
|
|
64
70
|
};
|
|
65
71
|
},
|
|
66
72
|
between: (attr, low, high) => {
|
|
@@ -73,6 +79,7 @@ export function createOpBuilder(): OpBuilder {
|
|
|
73
79
|
[`:${lowName}`]: low,
|
|
74
80
|
[`:${highName}`]: high,
|
|
75
81
|
},
|
|
82
|
+
keyOperator: 'between',
|
|
76
83
|
};
|
|
77
84
|
},
|
|
78
85
|
beginsWith: (attr, value) => {
|
|
@@ -81,6 +88,7 @@ export function createOpBuilder(): OpBuilder {
|
|
|
81
88
|
expression: `begins_with(#${attr.name}, :${valueName})`,
|
|
82
89
|
names: { [`#${attr.name}`]: attr.name },
|
|
83
90
|
values: { [`:${valueName}`]: value },
|
|
91
|
+
keyOperator: 'beginsWith',
|
|
84
92
|
};
|
|
85
93
|
},
|
|
86
94
|
contains: (attr, value) => {
|
|
@@ -11,6 +11,7 @@ export type Condition = {
|
|
|
11
11
|
names?: Record<string, string>;
|
|
12
12
|
values?: Record<string, any>;
|
|
13
13
|
operator?: 'AND' | 'OR';
|
|
14
|
+
keyOperator?: 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'between' | 'beginsWith';
|
|
14
15
|
isNegated?: boolean;
|
|
15
16
|
children?: Condition[];
|
|
16
17
|
};
|
|
@@ -76,6 +76,30 @@ describe('UpdateBuilder', () => {
|
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test('should build params with single-property object whose key is "name"', () => {
|
|
80
|
+
const key: Partial<TestModel> = { pk: 'USER#1', sk: 'USER#1' };
|
|
81
|
+
const params = createUpdateBuilder<TestModel>(tableName, key, client)
|
|
82
|
+
.set({ name: 'Ministro Pistarini' })
|
|
83
|
+
.dbParams();
|
|
84
|
+
|
|
85
|
+
expect(params.UpdateExpression).toBe('SET #name = :name_0');
|
|
86
|
+
expect(params.ExpressionAttributeNames).toEqual({ '#name': 'name' });
|
|
87
|
+
expect(params.ExpressionAttributeValues).toEqual({
|
|
88
|
+
':name_0': 'Ministro Pistarini',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('should build params with single-property object whose key is not "name"', () => {
|
|
93
|
+
const key: Partial<TestModel> = { pk: 'USER#1', sk: 'USER#1' };
|
|
94
|
+
const params = createUpdateBuilder<TestModel>(tableName, key, client)
|
|
95
|
+
.set({ age: 30 })
|
|
96
|
+
.dbParams();
|
|
97
|
+
|
|
98
|
+
expect(params.UpdateExpression).toBe('SET #age = :age_0');
|
|
99
|
+
expect(params.ExpressionAttributeNames).toEqual({ '#age': 'age' });
|
|
100
|
+
expect(params.ExpressionAttributeValues).toEqual({ ':age_0': 30 });
|
|
101
|
+
});
|
|
102
|
+
|
|
79
103
|
test('should combine single and multiple SET operations', () => {
|
|
80
104
|
const key: Partial<TestModel> = { pk: 'USER#1', sk: 'USER#1' };
|
|
81
105
|
const params = createUpdateBuilder<TestModel>(tableName, key, client)
|
|
@@ -344,6 +368,179 @@ describe('UpdateBuilder', () => {
|
|
|
344
368
|
});
|
|
345
369
|
});
|
|
346
370
|
|
|
371
|
+
describe('Secondary-index recomputation (indexContext)', () => {
|
|
372
|
+
interface PersonnelModel {
|
|
373
|
+
id: string;
|
|
374
|
+
airportId: string;
|
|
375
|
+
firstName: string;
|
|
376
|
+
lastName: string;
|
|
377
|
+
role: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const personnelModel = {
|
|
381
|
+
key: {
|
|
382
|
+
PK: { type: String, value: 'PERSON#${id}' },
|
|
383
|
+
SK: { type: String, value: 'PROFILE' },
|
|
384
|
+
},
|
|
385
|
+
index: {
|
|
386
|
+
GSI1PK: { type: String, value: 'AIRPORT#${airportId}' },
|
|
387
|
+
GSI1SK: { type: String, value: 'PERSON#${lastName}#${firstName}' },
|
|
388
|
+
},
|
|
389
|
+
attributes: {
|
|
390
|
+
id: { type: String, required: true },
|
|
391
|
+
airportId: { type: String, required: true },
|
|
392
|
+
firstName: { type: String, required: true },
|
|
393
|
+
lastName: { type: String, required: true },
|
|
394
|
+
role: { type: String },
|
|
395
|
+
},
|
|
396
|
+
} as const;
|
|
397
|
+
|
|
398
|
+
test('does nothing when no .set() field appears in any index template', () => {
|
|
399
|
+
const params = createUpdateBuilder<PersonnelModel>(
|
|
400
|
+
tableName,
|
|
401
|
+
{ id: '1' } as Partial<PersonnelModel>,
|
|
402
|
+
client,
|
|
403
|
+
[],
|
|
404
|
+
{ set: [], remove: [], add: [], delete: [] },
|
|
405
|
+
'NONE',
|
|
406
|
+
0,
|
|
407
|
+
false,
|
|
408
|
+
undefined,
|
|
409
|
+
{ model: personnelModel as any, keyVars: { id: '1' } }
|
|
410
|
+
)
|
|
411
|
+
.set('role', 'pilot')
|
|
412
|
+
.dbParams();
|
|
413
|
+
|
|
414
|
+
expect(params.UpdateExpression).toBe('SET #role = :role_0');
|
|
415
|
+
expect(params.ExpressionAttributeNames).toEqual({ '#role': 'role' });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('recomputes affected index when all template vars are present in updates', () => {
|
|
419
|
+
const params = createUpdateBuilder<PersonnelModel>(
|
|
420
|
+
tableName,
|
|
421
|
+
{ id: '1' } as Partial<PersonnelModel>,
|
|
422
|
+
client,
|
|
423
|
+
[],
|
|
424
|
+
{ set: [], remove: [], add: [], delete: [] },
|
|
425
|
+
'NONE',
|
|
426
|
+
0,
|
|
427
|
+
false,
|
|
428
|
+
undefined,
|
|
429
|
+
{ model: personnelModel as any, keyVars: { id: '1' } }
|
|
430
|
+
)
|
|
431
|
+
.set({ firstName: 'Ada', lastName: 'Lovelace' })
|
|
432
|
+
.dbParams();
|
|
433
|
+
|
|
434
|
+
// GSI1SK depends on lastName + firstName — both supplied → recomputed.
|
|
435
|
+
// GSI1PK depends on airportId — untouched, no recompute.
|
|
436
|
+
expect(params.UpdateExpression).toBe(
|
|
437
|
+
'SET #firstName = :firstName_0, #lastName = :lastName_1, #GSI1SK = :GSI1SK_2'
|
|
438
|
+
);
|
|
439
|
+
expect(params.ExpressionAttributeValues).toMatchObject({
|
|
440
|
+
':firstName_0': 'Ada',
|
|
441
|
+
':lastName_1': 'Lovelace',
|
|
442
|
+
':GSI1SK_2': 'PERSON#Lovelace#Ada',
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('throws when an affected index template references a field not in updates or key', () => {
|
|
447
|
+
const builder = createUpdateBuilder<PersonnelModel>(
|
|
448
|
+
tableName,
|
|
449
|
+
{ id: '1' } as Partial<PersonnelModel>,
|
|
450
|
+
client,
|
|
451
|
+
[],
|
|
452
|
+
{ set: [], remove: [], add: [], delete: [] },
|
|
453
|
+
'NONE',
|
|
454
|
+
0,
|
|
455
|
+
false,
|
|
456
|
+
undefined,
|
|
457
|
+
{ model: personnelModel as any, keyVars: { id: '1' } }
|
|
458
|
+
).set({ lastName: 'Lovelace' });
|
|
459
|
+
|
|
460
|
+
// GSI1SK template = "PERSON#${lastName}#${firstName}" — firstName is missing.
|
|
461
|
+
expect(() => builder.dbParams()).toThrow(/firstName/);
|
|
462
|
+
expect(() => builder.dbParams()).toThrow(/GSI1SK/);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('uses primary-key template vars when resolving index templates', () => {
|
|
466
|
+
const userModel = {
|
|
467
|
+
key: {
|
|
468
|
+
PK: { type: String, value: 'USER#${username}' },
|
|
469
|
+
SK: { type: String, value: 'USER#${username}' },
|
|
470
|
+
},
|
|
471
|
+
index: {
|
|
472
|
+
GSI1PK: { type: String, value: 'USER#${username}#STATUS#${status}' },
|
|
473
|
+
},
|
|
474
|
+
attributes: {
|
|
475
|
+
username: { type: String, required: true },
|
|
476
|
+
status: { type: String, required: true },
|
|
477
|
+
},
|
|
478
|
+
} as const;
|
|
479
|
+
|
|
480
|
+
const params = createUpdateBuilder<{ username: string; status: string }>(
|
|
481
|
+
tableName,
|
|
482
|
+
{ username: 'jane' } as any,
|
|
483
|
+
client,
|
|
484
|
+
[],
|
|
485
|
+
{ set: [], remove: [], add: [], delete: [] },
|
|
486
|
+
'NONE',
|
|
487
|
+
0,
|
|
488
|
+
false,
|
|
489
|
+
undefined,
|
|
490
|
+
{ model: userModel as any, keyVars: { username: 'jane' } }
|
|
491
|
+
)
|
|
492
|
+
.set('status', 'active')
|
|
493
|
+
.dbParams();
|
|
494
|
+
|
|
495
|
+
expect(params.UpdateExpression).toBe(
|
|
496
|
+
'SET #status = :status_0, #GSI1PK = :GSI1PK_1'
|
|
497
|
+
);
|
|
498
|
+
expect(params.ExpressionAttributeValues).toMatchObject({
|
|
499
|
+
':status_0': 'active',
|
|
500
|
+
':GSI1PK_1': 'USER#jane#STATUS#active',
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('accumulates set inputs across chained calls before resolving', () => {
|
|
505
|
+
const params = createUpdateBuilder<PersonnelModel>(
|
|
506
|
+
tableName,
|
|
507
|
+
{ id: '1' } as Partial<PersonnelModel>,
|
|
508
|
+
client,
|
|
509
|
+
[],
|
|
510
|
+
{ set: [], remove: [], add: [], delete: [] },
|
|
511
|
+
'NONE',
|
|
512
|
+
0,
|
|
513
|
+
false,
|
|
514
|
+
undefined,
|
|
515
|
+
{ model: personnelModel as any, keyVars: { id: '1' } }
|
|
516
|
+
)
|
|
517
|
+
.set('lastName', 'Lovelace')
|
|
518
|
+
.set('firstName', 'Ada')
|
|
519
|
+
.dbParams();
|
|
520
|
+
|
|
521
|
+
// Each individual .set() was missing one var, but the accumulator has both
|
|
522
|
+
// by the time dbParams() runs.
|
|
523
|
+
expect(params.UpdateExpression).toBe(
|
|
524
|
+
'SET #lastName = :lastName_0, #firstName = :firstName_1, #GSI1SK = :GSI1SK_2'
|
|
525
|
+
);
|
|
526
|
+
expect(params.ExpressionAttributeValues).toMatchObject({
|
|
527
|
+
':GSI1SK_2': 'PERSON#Lovelace#Ada',
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('does nothing when indexContext is omitted (backwards-compatible default)', () => {
|
|
532
|
+
const params = createUpdateBuilder<PersonnelModel>(
|
|
533
|
+
tableName,
|
|
534
|
+
{ id: '1' } as Partial<PersonnelModel>,
|
|
535
|
+
client
|
|
536
|
+
)
|
|
537
|
+
.set({ lastName: 'Lovelace' })
|
|
538
|
+
.dbParams();
|
|
539
|
+
|
|
540
|
+
expect(params.UpdateExpression).toBe('SET #lastName = :lastName_0');
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
347
544
|
describe('Complex scenarios', () => {
|
|
348
545
|
test('should handle complex update with all features', () => {
|
|
349
546
|
const key: Partial<TestModel> = { pk: 'USER#1', sk: 'USER#1' };
|