@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/builders/query/create-query-builder.d.ts +1 -1
  3. package/dist/builders/query/create-query-builder.d.ts.map +1 -1
  4. package/dist/builders/query/create-query-builder.js +56 -10
  5. package/dist/builders/query/types.d.ts +16 -0
  6. package/dist/builders/query/types.d.ts.map +1 -1
  7. package/dist/builders/scan/create-scan-builder.d.ts.map +1 -1
  8. package/dist/builders/scan/create-scan-builder.js +32 -0
  9. package/dist/builders/scan/types.d.ts +35 -0
  10. package/dist/builders/scan/types.d.ts.map +1 -1
  11. package/dist/builders/shared/operators.d.ts.map +1 -1
  12. package/dist/builders/shared/operators.js +8 -0
  13. package/dist/builders/shared/types.d.ts +1 -0
  14. package/dist/builders/shared/types.d.ts.map +1 -1
  15. package/dist/builders/update/create-update-builder.d.ts +8 -2
  16. package/dist/builders/update/create-update-builder.d.ts.map +1 -1
  17. package/dist/builders/update/create-update-builder.js +45 -16
  18. package/dist/builders/update/types.d.ts +13 -0
  19. package/dist/builders/update/types.d.ts.map +1 -1
  20. package/dist/entity/create-entity-api.d.ts.map +1 -1
  21. package/dist/entity/create-entity-api.js +8 -4
  22. package/dist/utils/model-utils.d.ts +28 -0
  23. package/dist/utils/model-utils.d.ts.map +1 -1
  24. package/dist/utils/model-utils.js +34 -1
  25. package/package.json +1 -1
  26. package/src/builders/query/create-query-builder.test.ts +324 -0
  27. package/src/builders/query/create-query-builder.ts +73 -10
  28. package/src/builders/query/types.ts +17 -0
  29. package/src/builders/scan/create-scan-builder.test.ts +121 -0
  30. package/src/builders/scan/create-scan-builder.ts +35 -1
  31. package/src/builders/scan/types.ts +39 -0
  32. package/src/builders/shared/operators.ts +8 -0
  33. package/src/builders/shared/types.ts +1 -0
  34. package/src/builders/update/create-update-builder.test.ts +197 -0
  35. package/src/builders/update/create-update-builder.ts +72 -19
  36. package/src/builders/update/types.ts +14 -0
  37. package/src/entity/create-entity-api.ts +8 -4
  38. package/src/utils/model-utils.test.ts +91 -1
  39. package/src/utils/model-utils.ts +54 -0
  40. 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' };