@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
|
@@ -14,6 +14,34 @@ export declare const resolveTemplate: (template: string, data: Record<string, an
|
|
|
14
14
|
* Resolves all keys or indexes for a model
|
|
15
15
|
*/
|
|
16
16
|
export declare const resolveKeys: <M extends ModelDefinition>(model: M, input: Record<string, any>, type?: "key" | "index" | "both") => Record<string, string>;
|
|
17
|
+
/**
|
|
18
|
+
* Result of analyzing how a partial update affects a model's secondary index keys.
|
|
19
|
+
*
|
|
20
|
+
* `actions` maps index-attribute name → freshly resolved template value, ready to SET.
|
|
21
|
+
* `missing` lists indexes whose template references at least one updated field but
|
|
22
|
+
* cannot be fully resolved from `keyVars + updates`; those entries identify the
|
|
23
|
+
* variables the caller must additionally provide.
|
|
24
|
+
*/
|
|
25
|
+
export type IndexUpdateAnalysis = {
|
|
26
|
+
actions: Record<string, string>;
|
|
27
|
+
missing: {
|
|
28
|
+
index: string;
|
|
29
|
+
template: string;
|
|
30
|
+
missing: string[];
|
|
31
|
+
}[];
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Determines which secondary-index keys must be recomputed given a partial update.
|
|
35
|
+
*
|
|
36
|
+
* An index is "affected" iff at least one of its template variables appears in
|
|
37
|
+
* `updates`. For each affected index we attempt to resolve the template from the
|
|
38
|
+
* union of `keyVars` (template vars carried in the primary key, e.g. `id`) and
|
|
39
|
+
* `updates` (the user's `.set()` payload). If any template variable is missing
|
|
40
|
+
* from that union, the index is reported in `missing` instead of `actions` —
|
|
41
|
+
* recomputing it would require reading the existing item from DynamoDB, which
|
|
42
|
+
* the builder intentionally does not do.
|
|
43
|
+
*/
|
|
44
|
+
export declare const computeIndexUpdates: <M extends ModelDefinition>(model: M, keyVars: Record<string, any>, updates: Record<string, any>) => IndexUpdateAnalysis;
|
|
17
45
|
/**
|
|
18
46
|
* Applies default and generated values to validated input
|
|
19
47
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model-utils.d.ts","sourceRoot":"","sources":["../../src/utils/model-utils.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAiB,eAAe,EAAE,MAAM,cAAc,CAAC;AAG1E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,UAAU,MAAM,KAAG,MAAM,EAG5D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAG,MAoB7E,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,eAAe,EACnD,OAAO,CAAC,EACR,OAAO,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC1B,OAAM,KAAK,GAAG,OAAO,GAAG,MAAc,KACrC,MAAM,CAAC,MAAM,EAAE,MAAM,CAWvB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,SAAS,eAAe,EACzD,OAAO,CAAC,EACR,eAAe,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAClC,UAAU;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,KACrD,UAAU,CAAC,CAAC,CAiCd,CAAC;AAOF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,SAAS,KAAG,CAAC,GAAG,CAAC,EAAE,GAAG,SAoB1E,CAAC"}
|
|
1
|
+
{"version":3,"file":"model-utils.d.ts","sourceRoot":"","sources":["../../src/utils/model-utils.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAiB,eAAe,EAAE,MAAM,cAAc,CAAC;AAG1E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,UAAU,MAAM,KAAG,MAAM,EAG5D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAG,MAoB7E,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,SAAS,eAAe,EACnD,OAAO,CAAC,EACR,OAAO,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC1B,OAAM,KAAK,GAAG,OAAO,GAAG,MAAc,KACrC,MAAM,CAAC,MAAM,EAAE,MAAM,CAWvB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAC;CACnE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,SAAS,eAAe,EAC3D,OAAO,CAAC,EACR,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC5B,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAC3B,mBAwBF,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,SAAS,eAAe,EACzD,OAAO,CAAC,EACR,eAAe,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAClC,UAAU;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,KACrD,UAAU,CAAC,CAAC,CAiCd,CAAC;AAOF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,SAAS,KAAG,CAAC,GAAG,CAAC,EAAE,GAAG,SAoB1E,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.stripInternalKeys = exports.applyPostDefaults = exports.resolveKeys = exports.resolveTemplate = exports.extractTemplateVars = void 0;
|
|
4
|
+
exports.stripInternalKeys = exports.applyPostDefaults = exports.computeIndexUpdates = exports.resolveKeys = exports.resolveTemplate = exports.extractTemplateVars = void 0;
|
|
5
5
|
const ulid_1 = require("ulid");
|
|
6
6
|
/**
|
|
7
7
|
* Extracts all variable names from a template string
|
|
@@ -53,6 +53,39 @@ const resolveKeys = (model, input, type = 'key') => {
|
|
|
53
53
|
return out;
|
|
54
54
|
};
|
|
55
55
|
exports.resolveKeys = resolveKeys;
|
|
56
|
+
/**
|
|
57
|
+
* Determines which secondary-index keys must be recomputed given a partial update.
|
|
58
|
+
*
|
|
59
|
+
* An index is "affected" iff at least one of its template variables appears in
|
|
60
|
+
* `updates`. For each affected index we attempt to resolve the template from the
|
|
61
|
+
* union of `keyVars` (template vars carried in the primary key, e.g. `id`) and
|
|
62
|
+
* `updates` (the user's `.set()` payload). If any template variable is missing
|
|
63
|
+
* from that union, the index is reported in `missing` instead of `actions` —
|
|
64
|
+
* recomputing it would require reading the existing item from DynamoDB, which
|
|
65
|
+
* the builder intentionally does not do.
|
|
66
|
+
*/
|
|
67
|
+
const computeIndexUpdates = (model, keyVars, updates) => {
|
|
68
|
+
const actions = {};
|
|
69
|
+
const missing = [];
|
|
70
|
+
if (!model.index)
|
|
71
|
+
return { actions, missing };
|
|
72
|
+
const updateKeys = new Set(Object.keys(updates));
|
|
73
|
+
const combined = { ...keyVars, ...updates };
|
|
74
|
+
for (const [indexName, indexDef] of Object.entries(model.index)) {
|
|
75
|
+
const templateVars = (0, exports.extractTemplateVars)(indexDef.value);
|
|
76
|
+
const isAffected = templateVars.some((v) => updateKeys.has(v));
|
|
77
|
+
if (!isAffected)
|
|
78
|
+
continue;
|
|
79
|
+
const missingVars = templateVars.filter((v) => combined[v] === undefined);
|
|
80
|
+
if (missingVars.length > 0) {
|
|
81
|
+
missing.push({ index: indexName, template: indexDef.value, missing: missingVars });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
actions[indexName] = (0, exports.resolveTemplate)(indexDef.value, combined);
|
|
85
|
+
}
|
|
86
|
+
return { actions, missing };
|
|
87
|
+
};
|
|
88
|
+
exports.computeIndexUpdates = computeIndexUpdates;
|
|
56
89
|
/**
|
|
57
90
|
* Applies default and generated values to validated input
|
|
58
91
|
*/
|
package/package.json
CHANGED
|
@@ -227,6 +227,126 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
227
227
|
expect(builder2.dbParams().ExclusiveStartKey).toEqual(startKey);
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
|
+
|
|
231
|
+
describe('iterate method', () => {
|
|
232
|
+
test('walks every page transparently and yields items in order', async () => {
|
|
233
|
+
const page1 = [
|
|
234
|
+
{ pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
|
|
235
|
+
{ pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
|
|
236
|
+
];
|
|
237
|
+
const page2 = [
|
|
238
|
+
{ pk: 'USER#3', sk: 'USER#3', name: 'User 3' },
|
|
239
|
+
{ pk: 'USER#4', sk: 'USER#4', name: 'User 4' },
|
|
240
|
+
];
|
|
241
|
+
const page3 = [{ pk: 'USER#5', sk: 'USER#5', name: 'User 5' }];
|
|
242
|
+
|
|
243
|
+
ddbMock
|
|
244
|
+
.on(QueryCommand)
|
|
245
|
+
.resolvesOnce({ Items: page1, LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' } })
|
|
246
|
+
.resolvesOnce({ Items: page2, LastEvaluatedKey: { pk: 'USER#4', sk: 'USER#4' } })
|
|
247
|
+
.resolvesOnce({ Items: page3 });
|
|
248
|
+
|
|
249
|
+
const collected: TestModel[] = [];
|
|
250
|
+
for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
251
|
+
.where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
|
|
252
|
+
.iterate()) {
|
|
253
|
+
collected.push(item);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
expect(collected).toHaveLength(5);
|
|
257
|
+
expect(collected.map((u) => u.name)).toEqual([
|
|
258
|
+
'User 1',
|
|
259
|
+
'User 2',
|
|
260
|
+
'User 3',
|
|
261
|
+
'User 4',
|
|
262
|
+
'User 5',
|
|
263
|
+
]);
|
|
264
|
+
expect(ddbMock.calls()).toHaveLength(3);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('forwards ExclusiveStartKey from each response into the next call', async () => {
|
|
268
|
+
const cursor1 = { pk: 'USER#2', sk: 'USER#2' };
|
|
269
|
+
const cursor2 = { pk: 'USER#4', sk: 'USER#4' };
|
|
270
|
+
|
|
271
|
+
ddbMock
|
|
272
|
+
.on(QueryCommand)
|
|
273
|
+
.resolvesOnce({ Items: [{ pk: 'USER#1', sk: 'USER#1' }], LastEvaluatedKey: cursor1 })
|
|
274
|
+
.resolvesOnce({ Items: [{ pk: 'USER#3', sk: 'USER#3' }], LastEvaluatedKey: cursor2 })
|
|
275
|
+
.resolvesOnce({ Items: [{ pk: 'USER#5', sk: 'USER#5' }] });
|
|
276
|
+
|
|
277
|
+
const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
278
|
+
.where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
|
|
279
|
+
.iterate();
|
|
280
|
+
|
|
281
|
+
// Consume the iterator
|
|
282
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
283
|
+
for await (const _ of iterator) {
|
|
284
|
+
// drain
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const calls = ddbMock.calls();
|
|
288
|
+
expect(calls).toHaveLength(3);
|
|
289
|
+
expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toBeUndefined();
|
|
290
|
+
expect((calls[1]!.args[0].input as any).ExclusiveStartKey).toEqual(cursor1);
|
|
291
|
+
expect((calls[2]!.args[0].input as any).ExclusiveStartKey).toEqual(cursor2);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('starts from the user-provided cursor on the first call', async () => {
|
|
295
|
+
const startKey = { pk: 'USER#10', sk: 'USER#10' };
|
|
296
|
+
|
|
297
|
+
ddbMock.on(QueryCommand).resolves({ Items: [{ pk: 'USER#11', sk: 'USER#11' }] });
|
|
298
|
+
|
|
299
|
+
const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
300
|
+
.where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
|
|
301
|
+
.startFrom(startKey)
|
|
302
|
+
.iterate();
|
|
303
|
+
|
|
304
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
305
|
+
for await (const _ of iterator) {
|
|
306
|
+
// drain
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const calls = ddbMock.calls();
|
|
310
|
+
expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toEqual(startKey);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('break out of the loop stops further DynamoDB calls', async () => {
|
|
314
|
+
ddbMock
|
|
315
|
+
.on(QueryCommand)
|
|
316
|
+
.resolvesOnce({
|
|
317
|
+
Items: [
|
|
318
|
+
{ pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
|
|
319
|
+
{ pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
|
|
320
|
+
],
|
|
321
|
+
LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' },
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const collected: TestModel[] = [];
|
|
325
|
+
for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
326
|
+
.where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
|
|
327
|
+
.iterate()) {
|
|
328
|
+
collected.push(item);
|
|
329
|
+
if (collected.length >= 1) break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
expect(collected).toHaveLength(1);
|
|
333
|
+
expect(ddbMock.calls()).toHaveLength(1);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('handles an empty result without making extra calls', async () => {
|
|
337
|
+
ddbMock.on(QueryCommand).resolves({ Items: [] });
|
|
338
|
+
|
|
339
|
+
const collected: TestModel[] = [];
|
|
340
|
+
for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
341
|
+
.where((attr, op) => op.beginsWith(attr.pk, 'NONE#'))
|
|
342
|
+
.iterate()) {
|
|
343
|
+
collected.push(item);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
expect(collected).toEqual([]);
|
|
347
|
+
expect(ddbMock.calls()).toHaveLength(1);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
230
350
|
});
|
|
231
351
|
|
|
232
352
|
describe('QueryBuilder - GSI key recognition', () => {
|
|
@@ -298,3 +418,207 @@ describe('QueryBuilder - GSI key recognition', () => {
|
|
|
298
418
|
expect(params.FilterExpression).toBeUndefined();
|
|
299
419
|
});
|
|
300
420
|
});
|
|
421
|
+
|
|
422
|
+
describe('QueryBuilder - multi-variable key templates', () => {
|
|
423
|
+
const client = new DynamoDBClient({});
|
|
424
|
+
const tableName = 'TestTable';
|
|
425
|
+
|
|
426
|
+
interface AirportResource {
|
|
427
|
+
id: string;
|
|
428
|
+
airport: string;
|
|
429
|
+
category: string;
|
|
430
|
+
code: string;
|
|
431
|
+
status: string;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const airportResourceModel: ModelDefinition = {
|
|
435
|
+
key: {
|
|
436
|
+
PK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
|
|
437
|
+
SK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
|
|
438
|
+
},
|
|
439
|
+
index: {
|
|
440
|
+
GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
|
|
441
|
+
GSI1SK: { type: String, value: 'RES#${category}#${code}' },
|
|
442
|
+
},
|
|
443
|
+
attributes: {
|
|
444
|
+
id: { type: String, required: true },
|
|
445
|
+
airport: { type: String, required: true },
|
|
446
|
+
category: { type: String, required: true },
|
|
447
|
+
code: { type: String, required: true },
|
|
448
|
+
status: { type: String, required: true },
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
test('beginsWith on attribute that is part of multi-var template should truncate at the first unfilled variable', () => {
|
|
453
|
+
const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
|
|
454
|
+
.where((attr, op) =>
|
|
455
|
+
op.and(op.eq(attr.airport, 'EZE'), op.beginsWith(attr.category, 'GPU'))
|
|
456
|
+
)
|
|
457
|
+
.useIndex('GSI1')
|
|
458
|
+
.dbParams();
|
|
459
|
+
|
|
460
|
+
expect(params.KeyConditionExpression).toBeDefined();
|
|
461
|
+
expect(params.KeyConditionExpression).toContain('#GSI1PK');
|
|
462
|
+
expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
|
|
463
|
+
expect(params.IndexName).toBe('GSI1');
|
|
464
|
+
|
|
465
|
+
const values = params.ExpressionAttributeValues ?? {};
|
|
466
|
+
expect(Object.values(values)).toContain('AIRPORT#EZE');
|
|
467
|
+
// Should be the prefix up to the next unfilled var, NOT 'RES#GPU#${code}'
|
|
468
|
+
expect(Object.values(values)).toContain('RES#GPU#');
|
|
469
|
+
expect(Object.values(values).every((v) => !String(v).includes('${'))).toBe(true);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test('eq on attribute with multi-var template should throw a clear error', () => {
|
|
473
|
+
expect(() =>
|
|
474
|
+
createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
|
|
475
|
+
.where((attr, op) => op.eq(attr.category, 'GPU'))
|
|
476
|
+
.useIndex('GSI1')
|
|
477
|
+
.dbParams()
|
|
478
|
+
).toThrow(/template/i);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('beginsWith on a single-var template still works (no truncation needed)', () => {
|
|
482
|
+
const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
|
|
483
|
+
.where((attr, op) => op.beginsWith(attr.airport, 'EZ'))
|
|
484
|
+
.useIndex('GSI1')
|
|
485
|
+
.dbParams();
|
|
486
|
+
|
|
487
|
+
const values = params.ExpressionAttributeValues ?? {};
|
|
488
|
+
expect(Object.values(values)).toContain('AIRPORT#EZ');
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe('QueryBuilder - entity type auto filter', () => {
|
|
493
|
+
const client = new DynamoDBClient({});
|
|
494
|
+
const tableName = 'TestTable';
|
|
495
|
+
|
|
496
|
+
interface AirportPersonnel {
|
|
497
|
+
id: string;
|
|
498
|
+
airport: string;
|
|
499
|
+
role: string;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Mirrors the reported scenario: PK is entity-specific, GSI1 is shared
|
|
503
|
+
// across multiple entities (Airport, AirportPersonnel, etc.)
|
|
504
|
+
const airportPersonnelModel: ModelDefinition = {
|
|
505
|
+
key: {
|
|
506
|
+
PK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
|
|
507
|
+
SK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
|
|
508
|
+
},
|
|
509
|
+
index: {
|
|
510
|
+
GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
|
|
511
|
+
GSI1SK: { type: String, value: 'PERSONNEL#${role}' },
|
|
512
|
+
},
|
|
513
|
+
attributes: {
|
|
514
|
+
id: { type: String, required: true },
|
|
515
|
+
airport: { type: String, required: true },
|
|
516
|
+
role: { type: String, required: true },
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
test('does NOT add _type filter when entityType is not provided (back-compat)', () => {
|
|
521
|
+
const params = createQueryBuilder<AirportPersonnel>(
|
|
522
|
+
tableName,
|
|
523
|
+
client,
|
|
524
|
+
airportPersonnelModel
|
|
525
|
+
)
|
|
526
|
+
.where((attr, op) => op.eq(attr.airport, 'EZE'))
|
|
527
|
+
.useIndex('GSI1')
|
|
528
|
+
.dbParams();
|
|
529
|
+
|
|
530
|
+
expect(params.FilterExpression).toBeUndefined();
|
|
531
|
+
const names = params.ExpressionAttributeNames ?? {};
|
|
532
|
+
expect(names['#_type']).toBeUndefined();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('adds _type filter when entityType is provided and there are no other filters', () => {
|
|
536
|
+
const params = createQueryBuilder<AirportPersonnel>(
|
|
537
|
+
tableName,
|
|
538
|
+
client,
|
|
539
|
+
airportPersonnelModel,
|
|
540
|
+
undefined,
|
|
541
|
+
'AirportPersonnel'
|
|
542
|
+
)
|
|
543
|
+
.where((attr, op) => op.eq(attr.airport, 'EZE'))
|
|
544
|
+
.useIndex('GSI1')
|
|
545
|
+
.dbParams();
|
|
546
|
+
|
|
547
|
+
// Key cond goes to KeyConditionExpression, _type goes to FilterExpression
|
|
548
|
+
expect(params.KeyConditionExpression).toBeDefined();
|
|
549
|
+
expect(params.KeyConditionExpression).toContain('#GSI1PK');
|
|
550
|
+
expect(params.FilterExpression).toBe('#_type = :_type');
|
|
551
|
+
expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
|
|
552
|
+
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test('combines _type filter with a user-provided non-key filter via AND', () => {
|
|
556
|
+
const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
|
|
557
|
+
tableName,
|
|
558
|
+
client,
|
|
559
|
+
airportPersonnelModel,
|
|
560
|
+
undefined,
|
|
561
|
+
'AirportPersonnel'
|
|
562
|
+
)
|
|
563
|
+
.where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
|
|
564
|
+
.useIndex('GSI1')
|
|
565
|
+
.dbParams();
|
|
566
|
+
|
|
567
|
+
expect(params.KeyConditionExpression).toContain('#GSI1PK');
|
|
568
|
+
expect(params.FilterExpression).toBeDefined();
|
|
569
|
+
expect(params.FilterExpression).toContain('#status');
|
|
570
|
+
expect(params.FilterExpression).toContain('#_type = :_type');
|
|
571
|
+
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test('repro of reported bug: GSI1 query with conditional beginsWith still filters by _type', () => {
|
|
575
|
+
// The exact shape from the user report:
|
|
576
|
+
// .where((attr, op) =>
|
|
577
|
+
// role
|
|
578
|
+
// ? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
|
|
579
|
+
// : op.eq(attr.airport, airport)
|
|
580
|
+
// )
|
|
581
|
+
const airport = 'EZE';
|
|
582
|
+
const role = 'PILOT';
|
|
583
|
+
|
|
584
|
+
const params = createQueryBuilder<AirportPersonnel>(
|
|
585
|
+
tableName,
|
|
586
|
+
client,
|
|
587
|
+
airportPersonnelModel,
|
|
588
|
+
undefined,
|
|
589
|
+
'AirportPersonnel'
|
|
590
|
+
)
|
|
591
|
+
.where((attr, op) =>
|
|
592
|
+
role
|
|
593
|
+
? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
|
|
594
|
+
: op.eq(attr.airport, airport)
|
|
595
|
+
)
|
|
596
|
+
.useIndex('GSI1')
|
|
597
|
+
.dbParams();
|
|
598
|
+
|
|
599
|
+
// Both airport (GSI1PK) and role (GSI1SK) become key conditions
|
|
600
|
+
expect(params.KeyConditionExpression).toContain('#GSI1PK');
|
|
601
|
+
expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
|
|
602
|
+
// _type still gets enforced as a filter — this is the fix
|
|
603
|
+
expect(params.FilterExpression).toBe('#_type = :_type');
|
|
604
|
+
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('does not collide with user attribute placeholders', () => {
|
|
608
|
+
// Confirm the auto-injected names/values don't clash with user-supplied ones
|
|
609
|
+
const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
|
|
610
|
+
tableName,
|
|
611
|
+
client,
|
|
612
|
+
airportPersonnelModel,
|
|
613
|
+
undefined,
|
|
614
|
+
'AirportPersonnel'
|
|
615
|
+
)
|
|
616
|
+
.where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
|
|
617
|
+
.useIndex('GSI1')
|
|
618
|
+
.dbParams();
|
|
619
|
+
|
|
620
|
+
expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
|
|
621
|
+
expect(params.ExpressionAttributeNames?.['#status']).toBe('status');
|
|
622
|
+
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
623
|
+
});
|
|
624
|
+
});
|
|
@@ -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
|
}
|
|
@@ -294,6 +333,14 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
294
333
|
};
|
|
295
334
|
},
|
|
296
335
|
|
|
336
|
+
/**
|
|
337
|
+
* ⚠️ Returns only the FIRST page of results. DynamoDB caps each Query
|
|
338
|
+
* response at ~1MB (or `Limit` if set). If the matching set is larger,
|
|
339
|
+
* the remaining items are silently dropped.
|
|
340
|
+
*
|
|
341
|
+
* Use {@link executeWithPagination} when you need to drive pagination
|
|
342
|
+
* yourself, or {@link iterate} to walk every matching item lazily.
|
|
343
|
+
*/
|
|
297
344
|
async execute(): Promise<Model[]> {
|
|
298
345
|
const params = this.dbParams();
|
|
299
346
|
const result = await state.client.send(new QueryCommand(params));
|
|
@@ -312,6 +359,20 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
312
359
|
scannedCount: result.ScannedCount,
|
|
313
360
|
};
|
|
314
361
|
},
|
|
362
|
+
|
|
363
|
+
async *iterate(): AsyncIterableIterator<Model> {
|
|
364
|
+
const baseParams = this.dbParams();
|
|
365
|
+
let cursor: Record<string, any> | undefined = baseParams.ExclusiveStartKey;
|
|
366
|
+
do {
|
|
367
|
+
const params = { ...baseParams, ExclusiveStartKey: cursor };
|
|
368
|
+
const result = await state.client.send(new QueryCommand(params));
|
|
369
|
+
state.logger?.log('QueryCommand', params, result);
|
|
370
|
+
for (const item of (result.Items ?? []) as unknown as Model[]) {
|
|
371
|
+
yield item;
|
|
372
|
+
}
|
|
373
|
+
cursor = result.LastEvaluatedKey;
|
|
374
|
+
} while (cursor);
|
|
375
|
+
},
|
|
315
376
|
};
|
|
316
377
|
}
|
|
317
378
|
|
|
@@ -322,7 +383,8 @@ export function createQueryBuilder<Model>(
|
|
|
322
383
|
tableName: string,
|
|
323
384
|
client: DynamoDBClient,
|
|
324
385
|
model?: ModelDefinition,
|
|
325
|
-
logger?: DynamoDBLogger
|
|
386
|
+
logger?: DynamoDBLogger,
|
|
387
|
+
entityType?: string
|
|
326
388
|
): QueryBuilder<Model> {
|
|
327
389
|
return {
|
|
328
390
|
where(fn) {
|
|
@@ -346,6 +408,7 @@ export function createQueryBuilder<Model>(
|
|
|
346
408
|
model,
|
|
347
409
|
condition,
|
|
348
410
|
logger,
|
|
411
|
+
entityType,
|
|
349
412
|
};
|
|
350
413
|
|
|
351
414
|
return createQueryExecutor(initialState);
|
|
@@ -74,6 +74,23 @@ export interface QueryExecutor<Model> extends ExecutableBuilder<Model[]> {
|
|
|
74
74
|
* Executes the query and returns result with pagination metadata
|
|
75
75
|
*/
|
|
76
76
|
executeWithPagination(): Promise<QueryResult<Model>>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns an async iterator that paginates internally and yields one item at
|
|
80
|
+
* a time. Memory stays at one page; you can `break` out of the loop early.
|
|
81
|
+
*
|
|
82
|
+
* `Limit` set via `.limit()` is forwarded to DynamoDB as a *per-request*
|
|
83
|
+
* cap, not a total cap. To cap the total, count yourself inside the loop:
|
|
84
|
+
*
|
|
85
|
+
* ```ts
|
|
86
|
+
* let n = 0;
|
|
87
|
+
* for await (const item of builder.iterate()) {
|
|
88
|
+
* if (n++ >= 1000) break;
|
|
89
|
+
* process(item);
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
iterate(): AsyncIterableIterator<Model>;
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
/**
|