@ftschopp/dynatable-core 1.2.5 → 1.3.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 +7 -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 +35 -10
- 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 +204 -0
- package/src/builders/query/create-query-builder.ts +51 -10
- 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
|
@@ -298,3 +298,207 @@ describe('QueryBuilder - GSI key recognition', () => {
|
|
|
298
298
|
expect(params.FilterExpression).toBeUndefined();
|
|
299
299
|
});
|
|
300
300
|
});
|
|
301
|
+
|
|
302
|
+
describe('QueryBuilder - multi-variable key templates', () => {
|
|
303
|
+
const client = new DynamoDBClient({});
|
|
304
|
+
const tableName = 'TestTable';
|
|
305
|
+
|
|
306
|
+
interface AirportResource {
|
|
307
|
+
id: string;
|
|
308
|
+
airport: string;
|
|
309
|
+
category: string;
|
|
310
|
+
code: string;
|
|
311
|
+
status: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const airportResourceModel: ModelDefinition = {
|
|
315
|
+
key: {
|
|
316
|
+
PK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
|
|
317
|
+
SK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
|
|
318
|
+
},
|
|
319
|
+
index: {
|
|
320
|
+
GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
|
|
321
|
+
GSI1SK: { type: String, value: 'RES#${category}#${code}' },
|
|
322
|
+
},
|
|
323
|
+
attributes: {
|
|
324
|
+
id: { type: String, required: true },
|
|
325
|
+
airport: { type: String, required: true },
|
|
326
|
+
category: { type: String, required: true },
|
|
327
|
+
code: { type: String, required: true },
|
|
328
|
+
status: { type: String, required: true },
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
test('beginsWith on attribute that is part of multi-var template should truncate at the first unfilled variable', () => {
|
|
333
|
+
const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
|
|
334
|
+
.where((attr, op) =>
|
|
335
|
+
op.and(op.eq(attr.airport, 'EZE'), op.beginsWith(attr.category, 'GPU'))
|
|
336
|
+
)
|
|
337
|
+
.useIndex('GSI1')
|
|
338
|
+
.dbParams();
|
|
339
|
+
|
|
340
|
+
expect(params.KeyConditionExpression).toBeDefined();
|
|
341
|
+
expect(params.KeyConditionExpression).toContain('#GSI1PK');
|
|
342
|
+
expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
|
|
343
|
+
expect(params.IndexName).toBe('GSI1');
|
|
344
|
+
|
|
345
|
+
const values = params.ExpressionAttributeValues ?? {};
|
|
346
|
+
expect(Object.values(values)).toContain('AIRPORT#EZE');
|
|
347
|
+
// Should be the prefix up to the next unfilled var, NOT 'RES#GPU#${code}'
|
|
348
|
+
expect(Object.values(values)).toContain('RES#GPU#');
|
|
349
|
+
expect(Object.values(values).every((v) => !String(v).includes('${'))).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('eq on attribute with multi-var template should throw a clear error', () => {
|
|
353
|
+
expect(() =>
|
|
354
|
+
createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
|
|
355
|
+
.where((attr, op) => op.eq(attr.category, 'GPU'))
|
|
356
|
+
.useIndex('GSI1')
|
|
357
|
+
.dbParams()
|
|
358
|
+
).toThrow(/template/i);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('beginsWith on a single-var template still works (no truncation needed)', () => {
|
|
362
|
+
const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
|
|
363
|
+
.where((attr, op) => op.beginsWith(attr.airport, 'EZ'))
|
|
364
|
+
.useIndex('GSI1')
|
|
365
|
+
.dbParams();
|
|
366
|
+
|
|
367
|
+
const values = params.ExpressionAttributeValues ?? {};
|
|
368
|
+
expect(Object.values(values)).toContain('AIRPORT#EZ');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('QueryBuilder - entity type auto filter', () => {
|
|
373
|
+
const client = new DynamoDBClient({});
|
|
374
|
+
const tableName = 'TestTable';
|
|
375
|
+
|
|
376
|
+
interface AirportPersonnel {
|
|
377
|
+
id: string;
|
|
378
|
+
airport: string;
|
|
379
|
+
role: string;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Mirrors the reported scenario: PK is entity-specific, GSI1 is shared
|
|
383
|
+
// across multiple entities (Airport, AirportPersonnel, etc.)
|
|
384
|
+
const airportPersonnelModel: ModelDefinition = {
|
|
385
|
+
key: {
|
|
386
|
+
PK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
|
|
387
|
+
SK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
|
|
388
|
+
},
|
|
389
|
+
index: {
|
|
390
|
+
GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
|
|
391
|
+
GSI1SK: { type: String, value: 'PERSONNEL#${role}' },
|
|
392
|
+
},
|
|
393
|
+
attributes: {
|
|
394
|
+
id: { type: String, required: true },
|
|
395
|
+
airport: { type: String, required: true },
|
|
396
|
+
role: { type: String, required: true },
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
test('does NOT add _type filter when entityType is not provided (back-compat)', () => {
|
|
401
|
+
const params = createQueryBuilder<AirportPersonnel>(
|
|
402
|
+
tableName,
|
|
403
|
+
client,
|
|
404
|
+
airportPersonnelModel
|
|
405
|
+
)
|
|
406
|
+
.where((attr, op) => op.eq(attr.airport, 'EZE'))
|
|
407
|
+
.useIndex('GSI1')
|
|
408
|
+
.dbParams();
|
|
409
|
+
|
|
410
|
+
expect(params.FilterExpression).toBeUndefined();
|
|
411
|
+
const names = params.ExpressionAttributeNames ?? {};
|
|
412
|
+
expect(names['#_type']).toBeUndefined();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('adds _type filter when entityType is provided and there are no other filters', () => {
|
|
416
|
+
const params = createQueryBuilder<AirportPersonnel>(
|
|
417
|
+
tableName,
|
|
418
|
+
client,
|
|
419
|
+
airportPersonnelModel,
|
|
420
|
+
undefined,
|
|
421
|
+
'AirportPersonnel'
|
|
422
|
+
)
|
|
423
|
+
.where((attr, op) => op.eq(attr.airport, 'EZE'))
|
|
424
|
+
.useIndex('GSI1')
|
|
425
|
+
.dbParams();
|
|
426
|
+
|
|
427
|
+
// Key cond goes to KeyConditionExpression, _type goes to FilterExpression
|
|
428
|
+
expect(params.KeyConditionExpression).toBeDefined();
|
|
429
|
+
expect(params.KeyConditionExpression).toContain('#GSI1PK');
|
|
430
|
+
expect(params.FilterExpression).toBe('#_type = :_type');
|
|
431
|
+
expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
|
|
432
|
+
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('combines _type filter with a user-provided non-key filter via AND', () => {
|
|
436
|
+
const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
|
|
437
|
+
tableName,
|
|
438
|
+
client,
|
|
439
|
+
airportPersonnelModel,
|
|
440
|
+
undefined,
|
|
441
|
+
'AirportPersonnel'
|
|
442
|
+
)
|
|
443
|
+
.where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
|
|
444
|
+
.useIndex('GSI1')
|
|
445
|
+
.dbParams();
|
|
446
|
+
|
|
447
|
+
expect(params.KeyConditionExpression).toContain('#GSI1PK');
|
|
448
|
+
expect(params.FilterExpression).toBeDefined();
|
|
449
|
+
expect(params.FilterExpression).toContain('#status');
|
|
450
|
+
expect(params.FilterExpression).toContain('#_type = :_type');
|
|
451
|
+
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('repro of reported bug: GSI1 query with conditional beginsWith still filters by _type', () => {
|
|
455
|
+
// The exact shape from the user report:
|
|
456
|
+
// .where((attr, op) =>
|
|
457
|
+
// role
|
|
458
|
+
// ? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
|
|
459
|
+
// : op.eq(attr.airport, airport)
|
|
460
|
+
// )
|
|
461
|
+
const airport = 'EZE';
|
|
462
|
+
const role = 'PILOT';
|
|
463
|
+
|
|
464
|
+
const params = createQueryBuilder<AirportPersonnel>(
|
|
465
|
+
tableName,
|
|
466
|
+
client,
|
|
467
|
+
airportPersonnelModel,
|
|
468
|
+
undefined,
|
|
469
|
+
'AirportPersonnel'
|
|
470
|
+
)
|
|
471
|
+
.where((attr, op) =>
|
|
472
|
+
role
|
|
473
|
+
? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
|
|
474
|
+
: op.eq(attr.airport, airport)
|
|
475
|
+
)
|
|
476
|
+
.useIndex('GSI1')
|
|
477
|
+
.dbParams();
|
|
478
|
+
|
|
479
|
+
// Both airport (GSI1PK) and role (GSI1SK) become key conditions
|
|
480
|
+
expect(params.KeyConditionExpression).toContain('#GSI1PK');
|
|
481
|
+
expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
|
|
482
|
+
// _type still gets enforced as a filter — this is the fix
|
|
483
|
+
expect(params.FilterExpression).toBe('#_type = :_type');
|
|
484
|
+
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('does not collide with user attribute placeholders', () => {
|
|
488
|
+
// Confirm the auto-injected names/values don't clash with user-supplied ones
|
|
489
|
+
const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
|
|
490
|
+
tableName,
|
|
491
|
+
client,
|
|
492
|
+
airportPersonnelModel,
|
|
493
|
+
undefined,
|
|
494
|
+
'AirportPersonnel'
|
|
495
|
+
)
|
|
496
|
+
.where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
|
|
497
|
+
.useIndex('GSI1')
|
|
498
|
+
.dbParams();
|
|
499
|
+
|
|
500
|
+
expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
|
|
501
|
+
expect(params.ExpressionAttributeNames?.['#status']).toBe('status');
|
|
502
|
+
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
503
|
+
});
|
|
504
|
+
});
|
|
@@ -24,6 +24,7 @@ type QueryState<Model> = {
|
|
|
24
24
|
consistentReadEnabled?: boolean;
|
|
25
25
|
exclusiveStartKey?: Record<string, any>;
|
|
26
26
|
logger?: DynamoDBLogger;
|
|
27
|
+
entityType?: string;
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -60,11 +61,32 @@ function getKeyNameForAttribute(
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
|
-
* Apply key template to transform attribute value to key value
|
|
64
|
-
*
|
|
64
|
+
* Apply key template to transform an attribute value to its key value.
|
|
65
|
+
*
|
|
66
|
+
* For multi-variable templates (e.g. "RES#${category}#${code}") we can only fill
|
|
67
|
+
* the variable matching `fieldName`. For `beginsWith` queries we truncate the
|
|
68
|
+
* result at the first remaining `${...}` placeholder so the prefix is usable.
|
|
69
|
+
* For other operators an exact match is required, so we throw a clear error.
|
|
65
70
|
*/
|
|
66
|
-
function applyKeyTemplate(
|
|
67
|
-
|
|
71
|
+
function applyKeyTemplate(
|
|
72
|
+
template: string,
|
|
73
|
+
fieldName: string,
|
|
74
|
+
fieldValue: any,
|
|
75
|
+
keyOperator?: string
|
|
76
|
+
): string {
|
|
77
|
+
const substituted = template.replace(`\${${fieldName}}`, String(fieldValue));
|
|
78
|
+
const nextPlaceholder = substituted.indexOf('${');
|
|
79
|
+
if (nextPlaceholder === -1) return substituted;
|
|
80
|
+
|
|
81
|
+
if (keyOperator === 'beginsWith') {
|
|
82
|
+
return substituted.slice(0, nextPlaceholder);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Cannot use operator "${keyOperator ?? 'unknown'}" on key template "${template}" ` +
|
|
87
|
+
`with only "${fieldName}" provided — the template still contains unfilled ` +
|
|
88
|
+
`variable(s). Use beginsWith for prefix queries on composite keys.`
|
|
89
|
+
);
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
/**
|
|
@@ -116,7 +138,12 @@ function separateConditions(
|
|
|
116
138
|
if (cond.values) {
|
|
117
139
|
for (const [valuePlaceholder, value] of Object.entries(cond.values)) {
|
|
118
140
|
// Apply the key template to transform the value
|
|
119
|
-
const transformedValue = applyKeyTemplate(
|
|
141
|
+
const transformedValue = applyKeyTemplate(
|
|
142
|
+
keyInfo.template,
|
|
143
|
+
fieldName,
|
|
144
|
+
value,
|
|
145
|
+
cond.keyOperator
|
|
146
|
+
);
|
|
120
147
|
newValues[valuePlaceholder] = transformedValue;
|
|
121
148
|
}
|
|
122
149
|
}
|
|
@@ -239,17 +266,29 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
239
266
|
// Build KeyConditionExpression (simple AND)
|
|
240
267
|
const keyExpr = buildSimpleAndExpression(keyConditions);
|
|
241
268
|
|
|
269
|
+
// Auto-inject an entity-type filter so query() only returns items
|
|
270
|
+
// belonging to this entity (single-table designs share PKs across
|
|
271
|
+
// entities, especially on GSIs).
|
|
272
|
+
const allFilters = [...filterConditions];
|
|
273
|
+
if (state.entityType) {
|
|
274
|
+
allFilters.push({
|
|
275
|
+
expression: '#_type = :_type',
|
|
276
|
+
names: { '#_type': '_type' },
|
|
277
|
+
values: { ':_type': state.entityType },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
242
281
|
// Build FilterExpression (can be complex with OR, NOT, etc.)
|
|
243
282
|
let filterExpr = { expression: '', names: {}, values: {} };
|
|
244
|
-
if (
|
|
245
|
-
if (
|
|
246
|
-
filterExpr = buildExpression(
|
|
283
|
+
if (allFilters.length > 0) {
|
|
284
|
+
if (allFilters.length === 1 && allFilters[0]) {
|
|
285
|
+
filterExpr = buildExpression(allFilters[0]);
|
|
247
286
|
} else {
|
|
248
287
|
// Multiple filter conditions - combine with AND
|
|
249
288
|
filterExpr = buildExpression({
|
|
250
289
|
expression: '',
|
|
251
290
|
operator: 'AND',
|
|
252
|
-
children:
|
|
291
|
+
children: allFilters,
|
|
253
292
|
});
|
|
254
293
|
}
|
|
255
294
|
}
|
|
@@ -322,7 +361,8 @@ export function createQueryBuilder<Model>(
|
|
|
322
361
|
tableName: string,
|
|
323
362
|
client: DynamoDBClient,
|
|
324
363
|
model?: ModelDefinition,
|
|
325
|
-
logger?: DynamoDBLogger
|
|
364
|
+
logger?: DynamoDBLogger,
|
|
365
|
+
entityType?: string
|
|
326
366
|
): QueryBuilder<Model> {
|
|
327
367
|
return {
|
|
328
368
|
where(fn) {
|
|
@@ -346,6 +386,7 @@ export function createQueryBuilder<Model>(
|
|
|
346
386
|
model,
|
|
347
387
|
condition,
|
|
348
388
|
logger,
|
|
389
|
+
entityType,
|
|
349
390
|
};
|
|
350
391
|
|
|
351
392
|
return createQueryExecutor(initialState);
|
|
@@ -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' };
|