@ftschopp/dynatable-core 1.2.4 → 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 +14 -0
- package/README.md +1 -3
- package/dist/builders/batch-get/create-batch-get-builder.d.ts.map +1 -1
- package/dist/builders/batch-get/create-batch-get-builder.js +4 -8
- package/dist/builders/batch-get/types.d.ts +2 -3
- package/dist/builders/batch-get/types.d.ts.map +1 -1
- 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/core/types.d.ts +0 -1
- package/dist/core/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/batch-get/README.md +9 -64
- package/src/builders/batch-get/create-batch-get-builder.ts +5 -10
- package/src/builders/batch-get/types.ts +2 -3
- 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/core/types.test.ts +0 -2
- package/src/core/types.ts +0 -1
- 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 -4
- package/tests/integration/pagination-timestamps.integration.test.ts +1 -2
- package/tests/integration/transactions.integration.test.ts +0 -1
|
@@ -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' };
|
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
3
|
import { UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
4
4
|
import { buildExpression, AttrBuilder, Condition, createOpBuilder, AttrRef } from '../shared';
|
|
5
|
-
import { UpdateBuilder, UpdateAction } from './types';
|
|
5
|
+
import { UpdateBuilder, UpdateAction, IndexContext } from './types';
|
|
6
6
|
import { DynamoDBLogger } from '../../utils/dynamodb-logger';
|
|
7
|
+
import { computeIndexUpdates } from '../../utils/model-utils';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Creates an UpdateBuilder for an item key and table.
|
|
11
|
+
*
|
|
12
|
+
* When `indexContext` is provided, fields written via `.set()` that participate
|
|
13
|
+
* in any secondary-index template are detected and the affected index keys are
|
|
14
|
+
* recomputed automatically and included in the SET expression. If a template
|
|
15
|
+
* cannot be fully resolved from the primary-key vars plus the updates, building
|
|
16
|
+
* the params throws — the caller must include the missing fields in `.set()`.
|
|
10
17
|
*/
|
|
11
18
|
export function createUpdateBuilder<Model>(
|
|
12
19
|
tableName: string,
|
|
@@ -22,7 +29,9 @@ export function createUpdateBuilder<Model>(
|
|
|
22
29
|
returnMode: 'NONE' | 'ALL_OLD' | 'ALL_NEW' | 'UPDATED_OLD' | 'UPDATED_NEW' = 'NONE',
|
|
23
30
|
valueCounter = 0,
|
|
24
31
|
enableTimestamps = false,
|
|
25
|
-
logger?: DynamoDBLogger
|
|
32
|
+
logger?: DynamoDBLogger,
|
|
33
|
+
indexContext?: IndexContext,
|
|
34
|
+
setInputs: Record<string, any> = {}
|
|
26
35
|
): UpdateBuilder<Model> {
|
|
27
36
|
const conditions = [...prevConditions];
|
|
28
37
|
|
|
@@ -55,27 +64,25 @@ export function createUpdateBuilder<Model>(
|
|
|
55
64
|
returnMode,
|
|
56
65
|
valueCounter,
|
|
57
66
|
enableTimestamps,
|
|
58
|
-
logger
|
|
67
|
+
logger,
|
|
68
|
+
indexContext,
|
|
69
|
+
setInputs
|
|
59
70
|
);
|
|
60
71
|
},
|
|
61
72
|
|
|
62
73
|
set(attrOrUpdates: keyof Model | AttrRef | Partial<Model>, value?: any) {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
74
|
+
// When no value is provided and the first arg is a plain object,
|
|
75
|
+
// treat it as a Partial<Model>. The AttrRef overload always passes
|
|
76
|
+
// a value, so it's handled by the single-update path below.
|
|
66
77
|
if (
|
|
67
78
|
value === undefined &&
|
|
68
79
|
typeof attrOrUpdates === 'object' &&
|
|
69
|
-
attrOrUpdates !== null
|
|
70
|
-
!(
|
|
71
|
-
Object.keys(attrOrUpdates).length === 1 &&
|
|
72
|
-
'name' in attrOrUpdates &&
|
|
73
|
-
typeof (attrOrUpdates as any).name === 'string'
|
|
74
|
-
)
|
|
80
|
+
attrOrUpdates !== null
|
|
75
81
|
) {
|
|
76
82
|
// Multiple updates case
|
|
77
83
|
const updates = attrOrUpdates as Partial<Model>;
|
|
78
84
|
const newActions: UpdateAction[] = [];
|
|
85
|
+
const newSetInputs = { ...setInputs };
|
|
79
86
|
|
|
80
87
|
for (const [attr, val] of Object.entries(updates)) {
|
|
81
88
|
const attrName = attr;
|
|
@@ -85,6 +92,7 @@ export function createUpdateBuilder<Model>(
|
|
|
85
92
|
names: { [`#${attrName}`]: attrName },
|
|
86
93
|
values: { [`:${valueName}`]: val },
|
|
87
94
|
});
|
|
95
|
+
newSetInputs[attrName] = val;
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
return createUpdateBuilder(
|
|
@@ -96,7 +104,9 @@ export function createUpdateBuilder<Model>(
|
|
|
96
104
|
returnMode,
|
|
97
105
|
valueCounter,
|
|
98
106
|
enableTimestamps,
|
|
99
|
-
logger
|
|
107
|
+
logger,
|
|
108
|
+
indexContext,
|
|
109
|
+
newSetInputs
|
|
100
110
|
);
|
|
101
111
|
}
|
|
102
112
|
|
|
@@ -117,7 +127,9 @@ export function createUpdateBuilder<Model>(
|
|
|
117
127
|
returnMode,
|
|
118
128
|
valueCounter,
|
|
119
129
|
enableTimestamps,
|
|
120
|
-
logger
|
|
130
|
+
logger,
|
|
131
|
+
indexContext,
|
|
132
|
+
{ ...setInputs, [attrName]: value }
|
|
121
133
|
);
|
|
122
134
|
},
|
|
123
135
|
|
|
@@ -136,7 +148,9 @@ export function createUpdateBuilder<Model>(
|
|
|
136
148
|
returnMode,
|
|
137
149
|
valueCounter,
|
|
138
150
|
enableTimestamps,
|
|
139
|
-
logger
|
|
151
|
+
logger,
|
|
152
|
+
indexContext,
|
|
153
|
+
setInputs
|
|
140
154
|
);
|
|
141
155
|
},
|
|
142
156
|
|
|
@@ -157,7 +171,9 @@ export function createUpdateBuilder<Model>(
|
|
|
157
171
|
returnMode,
|
|
158
172
|
valueCounter,
|
|
159
173
|
enableTimestamps,
|
|
160
|
-
logger
|
|
174
|
+
logger,
|
|
175
|
+
indexContext,
|
|
176
|
+
setInputs
|
|
161
177
|
);
|
|
162
178
|
},
|
|
163
179
|
|
|
@@ -178,7 +194,9 @@ export function createUpdateBuilder<Model>(
|
|
|
178
194
|
returnMode,
|
|
179
195
|
valueCounter,
|
|
180
196
|
enableTimestamps,
|
|
181
|
-
logger
|
|
197
|
+
logger,
|
|
198
|
+
indexContext,
|
|
199
|
+
setInputs
|
|
182
200
|
);
|
|
183
201
|
},
|
|
184
202
|
|
|
@@ -192,7 +210,9 @@ export function createUpdateBuilder<Model>(
|
|
|
192
210
|
mode,
|
|
193
211
|
valueCounter,
|
|
194
212
|
enableTimestamps,
|
|
195
|
-
logger
|
|
213
|
+
logger,
|
|
214
|
+
indexContext,
|
|
215
|
+
setInputs
|
|
196
216
|
);
|
|
197
217
|
},
|
|
198
218
|
|
|
@@ -203,7 +223,40 @@ export function createUpdateBuilder<Model>(
|
|
|
203
223
|
const allValues: Record<string, any> = {};
|
|
204
224
|
|
|
205
225
|
// Clone updateActions to avoid mutation
|
|
206
|
-
const actionsToProcess = { ...updateActions };
|
|
226
|
+
const actionsToProcess = { ...updateActions, set: [...updateActions.set] };
|
|
227
|
+
|
|
228
|
+
// Auto-recompute secondary-index keys whose templates depend on any
|
|
229
|
+
// updated field. If a template can't be fully resolved from the primary
|
|
230
|
+
// key vars + the .set() payload, we throw — recomputing from the
|
|
231
|
+
// existing item would require an extra read the builder won't do.
|
|
232
|
+
if (indexContext) {
|
|
233
|
+
const { actions: idxActions, missing } = computeIndexUpdates(
|
|
234
|
+
indexContext.model,
|
|
235
|
+
indexContext.keyVars,
|
|
236
|
+
setInputs
|
|
237
|
+
);
|
|
238
|
+
if (missing.length > 0) {
|
|
239
|
+
const details = missing
|
|
240
|
+
.map(
|
|
241
|
+
(m) =>
|
|
242
|
+
` - ${m.index} ("${m.template}"): missing ${m.missing.join(', ')}`
|
|
243
|
+
)
|
|
244
|
+
.join('\n');
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Update touches fields that participate in secondary index templates, ` +
|
|
247
|
+
`but the templates cannot be fully resolved from the update payload. ` +
|
|
248
|
+
`Include the missing fields in .set():\n${details}`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
for (const [indexName, resolved] of Object.entries(idxActions)) {
|
|
252
|
+
const valueName = getUniqueValueName(indexName);
|
|
253
|
+
actionsToProcess.set.push({
|
|
254
|
+
expression: `#${indexName} = :${valueName}`,
|
|
255
|
+
names: { [`#${indexName}`]: indexName },
|
|
256
|
+
values: { [`:${valueName}`]: resolved },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
207
260
|
|
|
208
261
|
// Add updatedAt timestamp if enabled
|
|
209
262
|
if (enableTimestamps) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { UpdateCommandInput } from '@aws-sdk/lib-dynamodb';
|
|
2
|
+
import { ModelDefinition } from '@/core/types';
|
|
2
3
|
import { OperationBuilder, AttrRef } from '../shared';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -10,6 +11,19 @@ export type UpdateAction = {
|
|
|
10
11
|
values?: Record<string, any>;
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Context that lets the update builder auto-recompute secondary-index keys
|
|
16
|
+
* when a `.set()` touches a field that participates in their templates.
|
|
17
|
+
*
|
|
18
|
+
* `keyVars` carries the original template-variable values used to build the
|
|
19
|
+
* primary key (e.g. `{ id: '123' }` from `update({ id: '123' })`) so they can
|
|
20
|
+
* be combined with the user's `.set()` payload when resolving index templates.
|
|
21
|
+
*/
|
|
22
|
+
export type IndexContext = {
|
|
23
|
+
model: ModelDefinition;
|
|
24
|
+
keyVars: Record<string, any>;
|
|
25
|
+
};
|
|
26
|
+
|
|
13
27
|
/**
|
|
14
28
|
* Builder interface for DynamoDB UpdateItem operations
|
|
15
29
|
*/
|
package/src/core/types.test.ts
CHANGED
|
@@ -287,12 +287,10 @@ describe('Schema Definition Validation', () => {
|
|
|
287
287
|
},
|
|
288
288
|
},
|
|
289
289
|
params: {
|
|
290
|
-
isoDates: true,
|
|
291
290
|
timestamps: true,
|
|
292
291
|
},
|
|
293
292
|
} as const satisfies SchemaDefinition;
|
|
294
293
|
|
|
295
|
-
expect(schemaWithParams.params?.isoDates).toBe(true);
|
|
296
294
|
expect(schemaWithParams.params?.timestamps).toBe(true);
|
|
297
295
|
});
|
|
298
296
|
|
package/src/core/types.ts
CHANGED
|
@@ -85,9 +85,10 @@ export const createEntityAPI = <Model extends ModelDefinition>(
|
|
|
85
85
|
},
|
|
86
86
|
|
|
87
87
|
query() {
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
88
|
+
// Auto-filter by entity type so query() only returns items for this
|
|
89
|
+
// entity. Without this, queries on shared GSIs return items from other
|
|
90
|
+
// entities that share the same partition key.
|
|
91
|
+
return createQueryBuilder<InferModel<Model>>(tableName, client, model, logger, modelName);
|
|
91
92
|
},
|
|
92
93
|
|
|
93
94
|
scan() {
|
|
@@ -130,7 +131,10 @@ export const createEntityAPI = <Model extends ModelDefinition>(
|
|
|
130
131
|
'NONE',
|
|
131
132
|
0,
|
|
132
133
|
timestamps,
|
|
133
|
-
logger
|
|
134
|
+
logger,
|
|
135
|
+
// Carry the original template-vars-bearing key (e.g. { username: 'jane' })
|
|
136
|
+
// so the builder can recompute index keys when .set() touches their template fields.
|
|
137
|
+
{ model, keyVars: key as Record<string, unknown> }
|
|
134
138
|
);
|
|
135
139
|
|
|
136
140
|
return withMiddleware(builder, createCleanKeysMiddleware(cleanInternalKeys));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { applyPostDefaults, stripInternalKeys } from './model-utils';
|
|
2
|
+
import { applyPostDefaults, computeIndexUpdates, stripInternalKeys } from './model-utils';
|
|
3
3
|
import { ModelDefinition } from '../core/types';
|
|
4
4
|
|
|
5
5
|
describe('applyPostDefaults - Timestamps', () => {
|
|
@@ -360,3 +360,93 @@ describe('stripInternalKeys', () => {
|
|
|
360
360
|
});
|
|
361
361
|
});
|
|
362
362
|
});
|
|
363
|
+
|
|
364
|
+
describe('computeIndexUpdates', () => {
|
|
365
|
+
const personnelModel: ModelDefinition = {
|
|
366
|
+
key: {
|
|
367
|
+
PK: { type: String, value: 'PERSON#${id}' },
|
|
368
|
+
SK: { type: String, value: 'PROFILE' },
|
|
369
|
+
},
|
|
370
|
+
index: {
|
|
371
|
+
GSI1PK: { type: String, value: 'AIRPORT#${airportId}' },
|
|
372
|
+
GSI1SK: { type: String, value: 'PERSON#${lastName}#${firstName}' },
|
|
373
|
+
},
|
|
374
|
+
attributes: {
|
|
375
|
+
id: { type: String, required: true },
|
|
376
|
+
airportId: { type: String, required: true },
|
|
377
|
+
firstName: { type: String, required: true },
|
|
378
|
+
lastName: { type: String, required: true },
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
test('returns empty result when model has no indexes', () => {
|
|
383
|
+
const noIndexModel: ModelDefinition = {
|
|
384
|
+
key: {
|
|
385
|
+
PK: { type: String, value: 'USER#${id}' },
|
|
386
|
+
SK: { type: String, value: 'USER#${id}' },
|
|
387
|
+
},
|
|
388
|
+
attributes: { id: { type: String, required: true } },
|
|
389
|
+
};
|
|
390
|
+
const result = computeIndexUpdates(noIndexModel, { id: '1' }, { name: 'X' });
|
|
391
|
+
expect(result).toEqual({ actions: {}, missing: [] });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('skips indexes whose template references no updated field', () => {
|
|
395
|
+
// Updating only "role" — none of the index templates mention role.
|
|
396
|
+
const result = computeIndexUpdates(
|
|
397
|
+
personnelModel,
|
|
398
|
+
{ id: '1' },
|
|
399
|
+
{ role: 'pilot' }
|
|
400
|
+
);
|
|
401
|
+
expect(result).toEqual({ actions: {}, missing: [] });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('recomputes an affected index when all template vars resolve', () => {
|
|
405
|
+
const result = computeIndexUpdates(
|
|
406
|
+
personnelModel,
|
|
407
|
+
{ id: '1' },
|
|
408
|
+
{ firstName: 'Ada', lastName: 'Lovelace' }
|
|
409
|
+
);
|
|
410
|
+
expect(result.actions).toEqual({ GSI1SK: 'PERSON#Lovelace#Ada' });
|
|
411
|
+
expect(result.missing).toEqual([]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('reports missing template vars instead of throwing', () => {
|
|
415
|
+
const result = computeIndexUpdates(
|
|
416
|
+
personnelModel,
|
|
417
|
+
{ id: '1' },
|
|
418
|
+
{ lastName: 'Lovelace' }
|
|
419
|
+
);
|
|
420
|
+
expect(result.actions).toEqual({});
|
|
421
|
+
expect(result.missing).toEqual([
|
|
422
|
+
{
|
|
423
|
+
index: 'GSI1SK',
|
|
424
|
+
template: 'PERSON#${lastName}#${firstName}',
|
|
425
|
+
missing: ['firstName'],
|
|
426
|
+
},
|
|
427
|
+
]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('uses keyVars to resolve fields not present in updates', () => {
|
|
431
|
+
// airportId is not in updates but in keyVars — GSI1PK should still resolve.
|
|
432
|
+
const result = computeIndexUpdates(
|
|
433
|
+
personnelModel,
|
|
434
|
+
{ id: '1', airportId: 'EZE' },
|
|
435
|
+
{ airportId: 'AEP' } // changing it
|
|
436
|
+
);
|
|
437
|
+
expect(result.actions).toEqual({ GSI1PK: 'AIRPORT#AEP' });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('handles multiple affected indexes independently', () => {
|
|
441
|
+
const result = computeIndexUpdates(
|
|
442
|
+
personnelModel,
|
|
443
|
+
{ id: '1' },
|
|
444
|
+
{ airportId: 'AEP', firstName: 'Ada', lastName: 'Lovelace' }
|
|
445
|
+
);
|
|
446
|
+
expect(result.actions).toEqual({
|
|
447
|
+
GSI1PK: 'AIRPORT#AEP',
|
|
448
|
+
GSI1SK: 'PERSON#Lovelace#Ada',
|
|
449
|
+
});
|
|
450
|
+
expect(result.missing).toEqual([]);
|
|
451
|
+
});
|
|
452
|
+
});
|
package/src/utils/model-utils.ts
CHANGED
|
@@ -59,6 +59,60 @@ export const resolveKeys = <M extends ModelDefinition>(
|
|
|
59
59
|
return out;
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Result of analyzing how a partial update affects a model's secondary index keys.
|
|
64
|
+
*
|
|
65
|
+
* `actions` maps index-attribute name → freshly resolved template value, ready to SET.
|
|
66
|
+
* `missing` lists indexes whose template references at least one updated field but
|
|
67
|
+
* cannot be fully resolved from `keyVars + updates`; those entries identify the
|
|
68
|
+
* variables the caller must additionally provide.
|
|
69
|
+
*/
|
|
70
|
+
export type IndexUpdateAnalysis = {
|
|
71
|
+
actions: Record<string, string>;
|
|
72
|
+
missing: { index: string; template: string; missing: string[] }[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Determines which secondary-index keys must be recomputed given a partial update.
|
|
77
|
+
*
|
|
78
|
+
* An index is "affected" iff at least one of its template variables appears in
|
|
79
|
+
* `updates`. For each affected index we attempt to resolve the template from the
|
|
80
|
+
* union of `keyVars` (template vars carried in the primary key, e.g. `id`) and
|
|
81
|
+
* `updates` (the user's `.set()` payload). If any template variable is missing
|
|
82
|
+
* from that union, the index is reported in `missing` instead of `actions` —
|
|
83
|
+
* recomputing it would require reading the existing item from DynamoDB, which
|
|
84
|
+
* the builder intentionally does not do.
|
|
85
|
+
*/
|
|
86
|
+
export const computeIndexUpdates = <M extends ModelDefinition>(
|
|
87
|
+
model: M,
|
|
88
|
+
keyVars: Record<string, any>,
|
|
89
|
+
updates: Record<string, any>
|
|
90
|
+
): IndexUpdateAnalysis => {
|
|
91
|
+
const actions: Record<string, string> = {};
|
|
92
|
+
const missing: { index: string; template: string; missing: string[] }[] = [];
|
|
93
|
+
|
|
94
|
+
if (!model.index) return { actions, missing };
|
|
95
|
+
|
|
96
|
+
const updateKeys = new Set(Object.keys(updates));
|
|
97
|
+
const combined: Record<string, any> = { ...keyVars, ...updates };
|
|
98
|
+
|
|
99
|
+
for (const [indexName, indexDef] of Object.entries(model.index)) {
|
|
100
|
+
const templateVars = extractTemplateVars(indexDef.value);
|
|
101
|
+
const isAffected = templateVars.some((v) => updateKeys.has(v));
|
|
102
|
+
if (!isAffected) continue;
|
|
103
|
+
|
|
104
|
+
const missingVars = templateVars.filter((v) => combined[v] === undefined);
|
|
105
|
+
if (missingVars.length > 0) {
|
|
106
|
+
missing.push({ index: indexName, template: indexDef.value, missing: missingVars });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
actions[indexName] = resolveTemplate(indexDef.value, combined);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { actions, missing };
|
|
114
|
+
};
|
|
115
|
+
|
|
62
116
|
/**
|
|
63
117
|
* Applies default and generated values to validated input
|
|
64
118
|
*/
|