@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
|
@@ -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
|
@@ -1,57 +1,29 @@
|
|
|
1
1
|
# BatchGet Builder
|
|
2
2
|
|
|
3
|
-
The `BatchGetBuilder`
|
|
3
|
+
The `BatchGetBuilder` retrieves multiple items by key in a single DynamoDB `BatchGetItem` request. It is consumed via the entity API (`table.entities.<Entity>.batchGet([...])`), and `execute()` returns a flat `Model[]`.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- Retrieve multiple items by their keys
|
|
8
8
|
- Supports attribute projection (select only specific fields)
|
|
9
9
|
- Supports consistent read
|
|
10
|
-
- Can retrieve items from multiple tables in a single request
|
|
11
10
|
|
|
12
11
|
## Basic Usage
|
|
13
12
|
|
|
14
13
|
```typescript
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
const users = await table.entities.User.batchGet([
|
|
15
|
+
{ username: 'alice' },
|
|
16
|
+
{ username: 'bob' },
|
|
17
|
+
{ username: 'charlie' },
|
|
18
|
+
]).execute();
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// Retrieve multiple users
|
|
21
|
-
const result = await createBatchGetBuilder(
|
|
22
|
-
{
|
|
23
|
-
Users: {
|
|
24
|
-
Keys: [
|
|
25
|
-
{ pk: 'USER#alice', sk: 'USER#alice' },
|
|
26
|
-
{ pk: 'USER#bob', sk: 'USER#bob' },
|
|
27
|
-
{ pk: 'USER#charlie', sk: 'USER#charlie' },
|
|
28
|
-
],
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
client
|
|
32
|
-
).execute();
|
|
20
|
+
users.forEach((user) => console.log(user.username));
|
|
33
21
|
```
|
|
34
22
|
|
|
35
23
|
## With Attribute Projection
|
|
36
24
|
|
|
37
25
|
```typescript
|
|
38
|
-
|
|
39
|
-
username: string;
|
|
40
|
-
name: string;
|
|
41
|
-
followerCount: number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const result = await createBatchGetBuilder<User>(
|
|
45
|
-
{
|
|
46
|
-
Users: {
|
|
47
|
-
Keys: [
|
|
48
|
-
{ pk: 'USER#alice', sk: 'USER#alice' },
|
|
49
|
-
{ pk: 'USER#bob', sk: 'USER#bob' },
|
|
50
|
-
],
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
client
|
|
54
|
-
)
|
|
26
|
+
const users = await table.entities.User.batchGet([{ username: 'alice' }, { username: 'bob' }])
|
|
55
27
|
.select(['username', 'name', 'followerCount'])
|
|
56
28
|
.execute();
|
|
57
29
|
```
|
|
@@ -59,38 +31,11 @@ const result = await createBatchGetBuilder<User>(
|
|
|
59
31
|
## With Consistent Read
|
|
60
32
|
|
|
61
33
|
```typescript
|
|
62
|
-
const
|
|
63
|
-
{
|
|
64
|
-
Users: {
|
|
65
|
-
Keys: [
|
|
66
|
-
{ pk: 'USER#alice', sk: 'USER#alice' },
|
|
67
|
-
{ pk: 'USER#bob', sk: 'USER#bob' },
|
|
68
|
-
],
|
|
69
|
-
},
|
|
70
|
-
},
|
|
71
|
-
client
|
|
72
|
-
)
|
|
34
|
+
const users = await table.entities.User.batchGet([{ username: 'alice' }, { username: 'bob' }])
|
|
73
35
|
.consistentRead()
|
|
74
36
|
.execute();
|
|
75
37
|
```
|
|
76
38
|
|
|
77
|
-
## Using the Table Class
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
// Retrieve multiple users using the Table class
|
|
81
|
-
const result = await table
|
|
82
|
-
.batchGet({
|
|
83
|
-
InstagramClone: {
|
|
84
|
-
Keys: [
|
|
85
|
-
{ pk: 'USER#alice', sk: 'USER#alice' },
|
|
86
|
-
{ pk: 'USER#bob', sk: 'USER#bob' },
|
|
87
|
-
],
|
|
88
|
-
},
|
|
89
|
-
})
|
|
90
|
-
.select(['username', 'name'])
|
|
91
|
-
.execute();
|
|
92
|
-
```
|
|
93
|
-
|
|
94
39
|
## DynamoDB Limitations
|
|
95
40
|
|
|
96
41
|
- Maximum 100 items per request
|
|
@@ -84,23 +84,18 @@ export function createBatchGetBuilder<Model>(
|
|
|
84
84
|
},
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
* Execute the BatchGetItem command and return the items.
|
|
87
|
+
* Execute the BatchGetItem command and return the items as a flat array.
|
|
88
88
|
*/
|
|
89
|
-
async execute(): Promise<
|
|
89
|
+
async execute(): Promise<Model[]> {
|
|
90
90
|
const params = this.dbParams();
|
|
91
91
|
const result: BatchGetCommandOutput = await client.send(new BatchGetCommand(params));
|
|
92
92
|
logger?.log('BatchGetCommand', params, result);
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (result.Responses) {
|
|
98
|
-
for (const [tableName, items] of Object.entries(result.Responses)) {
|
|
99
|
-
responses[tableName] = items as unknown as Model[];
|
|
100
|
-
}
|
|
94
|
+
if (!result.Responses) {
|
|
95
|
+
return [];
|
|
101
96
|
}
|
|
102
97
|
|
|
103
|
-
return
|
|
98
|
+
return Object.values(result.Responses).flat() as unknown as Model[];
|
|
104
99
|
},
|
|
105
100
|
};
|
|
106
101
|
}
|
|
@@ -22,8 +22,7 @@ export type BatchGetBuilder<Model> = {
|
|
|
22
22
|
dbParams(): BatchGetItemCommandInput;
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Execute the BatchGetItem command and return the items.
|
|
26
|
-
* Returns an object mapping table names to arrays of items.
|
|
25
|
+
* Execute the BatchGetItem command and return the items as a flat array.
|
|
27
26
|
*/
|
|
28
|
-
execute(): Promise<
|
|
27
|
+
execute(): Promise<Model[]>;
|
|
29
28
|
};
|
|
@@ -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
|
};
|