@ftschopp/dynatable-core 1.4.0 → 1.4.2
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/batch-get/create-batch-get-builder.d.ts.map +1 -1
- package/dist/builders/batch-get/create-batch-get-builder.js +8 -3
- package/dist/builders/get/create-get-builder.d.ts.map +1 -1
- package/dist/builders/get/create-get-builder.js +2 -19
- package/dist/builders/query/create-query-builder.d.ts.map +1 -1
- package/dist/builders/query/create-query-builder.js +32 -3
- package/dist/builders/scan/create-scan-builder.d.ts.map +1 -1
- package/dist/builders/scan/create-scan-builder.js +10 -2
- package/dist/builders/shared/index.d.ts +1 -0
- package/dist/builders/shared/index.d.ts.map +1 -1
- package/dist/builders/shared/index.js +1 -0
- package/dist/builders/shared/projection.d.ts +15 -0
- package/dist/builders/shared/projection.d.ts.map +1 -0
- package/dist/builders/shared/projection.js +26 -0
- package/package.json +1 -1
- package/src/builders/batch-get/create-batch-get-builder.test.ts +30 -2
- package/src/builders/batch-get/create-batch-get-builder.ts +10 -3
- package/src/builders/get/create-get-builder.ts +1 -24
- package/src/builders/query/create-query-builder.test.ts +195 -49
- package/src/builders/query/create-query-builder.ts +43 -5
- package/src/builders/scan/create-scan-builder.test.ts +41 -6
- package/src/builders/scan/create-scan-builder.ts +17 -3
- package/src/builders/shared/index.ts +1 -0
- package/src/builders/shared/projection.ts +28 -0
- package/tests/integration/instagram-clone.integration.test.ts +23 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## @ftschopp/dynatable-core [1.4.2](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@1.4.1...@ftschopp/dynatable-core@1.4.2) (2026-05-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **core:** throw at dbParams() when Query has no partition-key condition ([#27](https://github.com/ftschopp/dynatable/issues/27)) ([2f93385](https://github.com/ftschopp/dynatable/commit/2f933850677956a198077421eaeb032372d50724)), closes [#8](https://github.com/ftschopp/dynatable/issues/8)
|
|
7
|
+
|
|
8
|
+
## @ftschopp/dynatable-core [1.4.1](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@1.4.0...@ftschopp/dynatable-core@1.4.1) (2026-05-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **core:** use ExpressionAttributeNames placeholders for projections ([#25](https://github.com/ftschopp/dynatable/issues/25)) ([0e14ebd](https://github.com/ftschopp/dynatable/commit/0e14ebd64cfc58eb2b2353fa7678239bdddb22a6)), closes [#5](https://github.com/ftschopp/dynatable/issues/5)
|
|
14
|
+
|
|
1
15
|
# @ftschopp/dynatable-core [1.4.0](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@1.3.0...@ftschopp/dynatable-core@1.4.0) (2026-05-08)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-batch-get-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/batch-get/create-batch-get-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"create-batch-get-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/batch-get/create-batch-get-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAO1D,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAE7D;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EACzC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,GAAG,EAAE,CAAA;CAAE,CAAC,EAC7C,MAAM,EAAE,cAAc,EACtB,OAAO,CAAC,EAAE;IACR,UAAU,CAAC,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC7B,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,EACD,MAAM,CAAC,EAAE,cAAc,GACtB,eAAe,CAAC,KAAK,CAAC,CAgFxB"}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createBatchGetBuilder = createBatchGetBuilder;
|
|
4
4
|
const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
|
|
5
|
+
const shared_1 = require("../shared");
|
|
5
6
|
/**
|
|
6
7
|
* Creates a BatchGetBuilder to retrieve multiple items by their keys.
|
|
7
8
|
*
|
|
@@ -36,12 +37,16 @@ function createBatchGetBuilder(requestItems, client, options, logger) {
|
|
|
36
37
|
* Build the underlying DynamoDB input parameters.
|
|
37
38
|
*/
|
|
38
39
|
dbParams() {
|
|
39
|
-
//
|
|
40
|
+
// Build the projection once (with #-placeholders so reserved words
|
|
41
|
+
// like name/date/status/type don't blow up at DynamoDB) and reuse
|
|
42
|
+
// it per-table.
|
|
43
|
+
const proj = projection.length > 0 ? (0, shared_1.buildProjectionExpression)(projection.map(String)) : undefined;
|
|
40
44
|
const enhancedRequestItems = Object.entries(requestItems).reduce((acc, [tableName, tableRequest]) => {
|
|
41
45
|
acc[tableName] = {
|
|
42
46
|
Keys: tableRequest.Keys,
|
|
43
|
-
...(
|
|
44
|
-
ProjectionExpression:
|
|
47
|
+
...(proj && {
|
|
48
|
+
ProjectionExpression: proj.ProjectionExpression,
|
|
49
|
+
ExpressionAttributeNames: proj.ExpressionAttributeNames,
|
|
45
50
|
}),
|
|
46
51
|
...(isConsistent && { ConsistentRead: true }),
|
|
47
52
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-get-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/get/create-get-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"create-get-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/get/create-get-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAG1D,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAE7D;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,KAAK,EAC9C,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACxB,MAAM,EAAE,cAAc,EACtB,OAAO,CAAC,EAAE;IACR,UAAU,CAAC,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC7B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,sBAAsB,CAAC,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;CACvD,EACD,MAAM,CAAC,EAAE,cAAc,GACtB,UAAU,CAAC,QAAQ,EAAE,KAAK,CAAC,CAgG7B"}
|
|
@@ -2,24 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createGetBuilder = createGetBuilder;
|
|
4
4
|
const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
|
|
5
|
-
|
|
6
|
-
* Builds a ProjectionExpression with proper ExpressionAttributeNames
|
|
7
|
-
* to handle reserved words and special characters.
|
|
8
|
-
*/
|
|
9
|
-
function buildProjectionExpression(attrs) {
|
|
10
|
-
const names = {};
|
|
11
|
-
const projectionParts = [];
|
|
12
|
-
attrs.forEach((attr) => {
|
|
13
|
-
// Use a placeholder for the attribute name to avoid reserved word conflicts
|
|
14
|
-
const placeholder = `#${attr}`;
|
|
15
|
-
names[placeholder] = attr;
|
|
16
|
-
projectionParts.push(placeholder);
|
|
17
|
-
});
|
|
18
|
-
return {
|
|
19
|
-
ProjectionExpression: projectionParts.join(', '),
|
|
20
|
-
ExpressionAttributeNames: names,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
5
|
+
const shared_1 = require("../shared");
|
|
23
6
|
/**
|
|
24
7
|
* Creates a GetBuilder to retrieve an item by its key.
|
|
25
8
|
*/
|
|
@@ -68,7 +51,7 @@ function createGetBuilder(tableName, key, client, options, logger) {
|
|
|
68
51
|
};
|
|
69
52
|
// Add projection with proper ExpressionAttributeNames
|
|
70
53
|
if (projection.length > 0) {
|
|
71
|
-
const projectionExpr = buildProjectionExpression(projection);
|
|
54
|
+
const projectionExpr = (0, shared_1.buildProjectionExpression)(projection);
|
|
72
55
|
params.ProjectionExpression = projectionExpr.ProjectionExpression;
|
|
73
56
|
params.ExpressionAttributeNames = projectionExpr.ExpressionAttributeNames;
|
|
74
57
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-query-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/query/create-query-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"create-query-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/query/create-query-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAM1D,OAAO,EAAE,YAAY,EAA8B,MAAM,SAAS,CAAC;AACnE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAsZ7D;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EACtC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,cAAc,EACtB,KAAK,CAAC,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,cAAc,EACvB,UAAU,CAAC,EAAE,MAAM,GAClB,YAAY,CAAC,KAAK,CAAC,CA6BrB"}
|
|
@@ -4,6 +4,7 @@ exports.createQueryBuilder = createQueryBuilder;
|
|
|
4
4
|
const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
|
|
5
5
|
const operators_1 = require("../shared/operators");
|
|
6
6
|
const conditions_1 = require("../shared/conditions");
|
|
7
|
+
const projection_1 = require("../shared/projection");
|
|
7
8
|
const model_utils_1 = require("../../utils/model-utils");
|
|
8
9
|
/**
|
|
9
10
|
* Maps a model attribute name to its corresponding DynamoDB key name (PK/SK)
|
|
@@ -189,6 +190,25 @@ function createQueryExecutor(state) {
|
|
|
189
190
|
}
|
|
190
191
|
// Separate key conditions from filter conditions
|
|
191
192
|
const { keyConditions, filterConditions } = separateConditions(state.condition, state.model, state.indexName);
|
|
193
|
+
// DynamoDB Query requires at least a partition-key condition. If
|
|
194
|
+
// separation didn't pick anything as a key condition, the caller is
|
|
195
|
+
// either filtering on non-key attributes (which means they want
|
|
196
|
+
// scan()) or referencing the wrong attribute name. Fail loudly here
|
|
197
|
+
// instead of letting the SDK reject the request at execute time.
|
|
198
|
+
if (keyConditions.length === 0) {
|
|
199
|
+
const keyTemplates = state.indexName
|
|
200
|
+
? Object.entries(state.model?.index ?? {})
|
|
201
|
+
.filter(([keyName]) => keyName.startsWith(state.indexName))
|
|
202
|
+
.map(([, def]) => def.value)
|
|
203
|
+
: Object.values(state.model?.key ?? {}).map((def) => def.value);
|
|
204
|
+
const templateVars = Array.from(new Set(keyTemplates.flatMap((tpl) => (0, model_utils_1.extractTemplateVars)(tpl))));
|
|
205
|
+
const indexHint = state.indexName ? ` on index "${state.indexName}"` : '';
|
|
206
|
+
const fieldsHint = templateVars.length > 0
|
|
207
|
+
? ` Expected a condition on one of: ${templateVars.join(', ')}.`
|
|
208
|
+
: '';
|
|
209
|
+
throw new Error(`Query requires a condition on the partition key${indexHint}.${fieldsHint} ` +
|
|
210
|
+
`For non-key filtering use scan() instead.`);
|
|
211
|
+
}
|
|
192
212
|
// Build KeyConditionExpression (simple AND)
|
|
193
213
|
const keyExpr = buildSimpleAndExpression(keyConditions);
|
|
194
214
|
// Auto-inject an entity-type filter so query() only returns items
|
|
@@ -226,6 +246,16 @@ function createQueryExecutor(state) {
|
|
|
226
246
|
...(keyExpr.values ?? {}),
|
|
227
247
|
...(filterExpr.values ?? {}),
|
|
228
248
|
};
|
|
249
|
+
// Build ProjectionExpression with placeholders so reserved words
|
|
250
|
+
// (name, date, status, type, …) don't blow up at DynamoDB.
|
|
251
|
+
// Idempotent merge: filter/key and projection placeholders both map
|
|
252
|
+
// `#name → name`, so collisions resolve to the same value.
|
|
253
|
+
let projectionExpression;
|
|
254
|
+
if (state.projection && state.projection.length > 0) {
|
|
255
|
+
const proj = (0, projection_1.buildProjectionExpression)(state.projection.map((a) => String(a)));
|
|
256
|
+
projectionExpression = proj.ProjectionExpression;
|
|
257
|
+
Object.assign(allNames, proj.ExpressionAttributeNames);
|
|
258
|
+
}
|
|
229
259
|
return {
|
|
230
260
|
TableName: state.tableName,
|
|
231
261
|
...(keyExpr.expression && {
|
|
@@ -245,9 +275,8 @@ function createQueryExecutor(state) {
|
|
|
245
275
|
...(state.scanForward !== undefined && {
|
|
246
276
|
ScanIndexForward: state.scanForward,
|
|
247
277
|
}),
|
|
248
|
-
...(
|
|
249
|
-
|
|
250
|
-
ProjectionExpression: state.projection.join(', '),
|
|
278
|
+
...(projectionExpression && {
|
|
279
|
+
ProjectionExpression: projectionExpression,
|
|
251
280
|
}),
|
|
252
281
|
...(state.consistentReadEnabled && { ConsistentRead: true }),
|
|
253
282
|
...(state.exclusiveStartKey && {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-scan-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/scan/create-scan-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE1D,OAAO,
|
|
1
|
+
{"version":3,"file":"create-scan-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/scan/create-scan-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE1D,OAAO,EAGL,SAAS,EAGV,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,WAAW,EAAc,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAE7D;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EACrC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,cAAc,EACtB,OAAO,GAAE,SAAS,EAAO,EACzB,eAAe,GAAE,CAAC,MAAM,KAAK,CAAC,EAAO,EACrC,UAAU,CAAC,EAAE,MAAM,EACnB,gBAAgB,UAAQ,EACxB,SAAS,CAAC,EAAE,MAAM,EAClB,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACvC,aAAa,CAAC,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,EAC1D,MAAM,CAAC,EAAE,cAAc,GACtB,WAAW,CAAC,KAAK,CAAC,CA6OpB"}
|
|
@@ -54,10 +54,18 @@ function createScanBuilder(tableName, client, filters = [], projectionAttrs = []
|
|
|
54
54
|
expressionAttributeNames = result.names;
|
|
55
55
|
expressionAttributeValues = result.values;
|
|
56
56
|
}
|
|
57
|
-
// Build ProjectionExpression
|
|
57
|
+
// Build ProjectionExpression with placeholders so reserved words
|
|
58
|
+
// (name, date, status, type, …) don't blow up at DynamoDB.
|
|
58
59
|
let projectionExpression = '';
|
|
59
60
|
if (projectionAttrs.length > 0) {
|
|
60
|
-
|
|
61
|
+
const proj = (0, shared_1.buildProjectionExpression)(projectionAttrs.map((attr) => String(attr)));
|
|
62
|
+
projectionExpression = proj.ProjectionExpression;
|
|
63
|
+
// Idempotent merge: filter and projection placeholders both map
|
|
64
|
+
// `#name → name`, so collisions resolve to the same value.
|
|
65
|
+
expressionAttributeNames = {
|
|
66
|
+
...proj.ExpressionAttributeNames,
|
|
67
|
+
...expressionAttributeNames,
|
|
68
|
+
};
|
|
61
69
|
}
|
|
62
70
|
const params = {
|
|
63
71
|
TableName: tableName,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/builders/shared/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/builders/shared/index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a `ProjectionExpression` together with the matching
|
|
3
|
+
* `ExpressionAttributeNames` map so that any attribute — including
|
|
4
|
+
* DynamoDB reserved words like `name`, `date`, `status`, `type`, `data`,
|
|
5
|
+
* `count`, `size`, `value`, `time`, `year`, `source`, … — is referenced
|
|
6
|
+
* via a `#placeholder`.
|
|
7
|
+
*
|
|
8
|
+
* Without this indirection DynamoDB rejects the request with
|
|
9
|
+
* `ValidationException: Attribute name is a reserved keyword`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildProjectionExpression(attrs: string[]): {
|
|
12
|
+
ProjectionExpression: string;
|
|
13
|
+
ExpressionAttributeNames: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=projection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../../../src/builders/shared/projection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG;IAC1D,oBAAoB,EAAE,MAAM,CAAC;IAC7B,wBAAwB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClD,CAcA"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildProjectionExpression = buildProjectionExpression;
|
|
4
|
+
/**
|
|
5
|
+
* Builds a `ProjectionExpression` together with the matching
|
|
6
|
+
* `ExpressionAttributeNames` map so that any attribute — including
|
|
7
|
+
* DynamoDB reserved words like `name`, `date`, `status`, `type`, `data`,
|
|
8
|
+
* `count`, `size`, `value`, `time`, `year`, `source`, … — is referenced
|
|
9
|
+
* via a `#placeholder`.
|
|
10
|
+
*
|
|
11
|
+
* Without this indirection DynamoDB rejects the request with
|
|
12
|
+
* `ValidationException: Attribute name is a reserved keyword`.
|
|
13
|
+
*/
|
|
14
|
+
function buildProjectionExpression(attrs) {
|
|
15
|
+
const names = {};
|
|
16
|
+
const projectionParts = [];
|
|
17
|
+
attrs.forEach((attr) => {
|
|
18
|
+
const placeholder = `#${attr}`;
|
|
19
|
+
names[placeholder] = attr;
|
|
20
|
+
projectionParts.push(placeholder);
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
ProjectionExpression: projectionParts.join(', '),
|
|
24
|
+
ExpressionAttributeNames: names,
|
|
25
|
+
};
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -64,7 +64,12 @@ describe('BatchGetBuilder', () => {
|
|
|
64
64
|
{ pk: 'USER#alice', sk: 'USER#alice' },
|
|
65
65
|
{ pk: 'USER#bob', sk: 'USER#bob' },
|
|
66
66
|
],
|
|
67
|
-
ProjectionExpression: 'username, name, followerCount',
|
|
67
|
+
ProjectionExpression: '#username, #name, #followerCount',
|
|
68
|
+
ExpressionAttributeNames: {
|
|
69
|
+
'#username': 'username',
|
|
70
|
+
'#name': 'name',
|
|
71
|
+
'#followerCount': 'followerCount',
|
|
72
|
+
},
|
|
68
73
|
},
|
|
69
74
|
},
|
|
70
75
|
});
|
|
@@ -155,11 +160,34 @@ describe('BatchGetBuilder', () => {
|
|
|
155
160
|
{ pk: 'USER#alice', sk: 'USER#alice' },
|
|
156
161
|
{ pk: 'USER#bob', sk: 'USER#bob' },
|
|
157
162
|
],
|
|
158
|
-
ProjectionExpression: 'username, name',
|
|
163
|
+
ProjectionExpression: '#username, #name',
|
|
164
|
+
ExpressionAttributeNames: {
|
|
165
|
+
'#username': 'username',
|
|
166
|
+
'#name': 'name',
|
|
167
|
+
},
|
|
159
168
|
ConsistentRead: true,
|
|
160
169
|
},
|
|
161
170
|
},
|
|
162
171
|
});
|
|
163
172
|
});
|
|
173
|
+
|
|
174
|
+
test('reserved DynamoDB words like "name", "date", "status" can be projected', () => {
|
|
175
|
+
const requestItems = {
|
|
176
|
+
Users: {
|
|
177
|
+
Keys: [{ pk: 'USER#alice', sk: 'USER#alice' }],
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const builder = createBatchGetBuilder<User>(requestItems, client).select(['name']);
|
|
182
|
+
const params = builder.dbParams();
|
|
183
|
+
|
|
184
|
+
expect(params.RequestItems!['Users']).toEqual({
|
|
185
|
+
Keys: [{ pk: 'USER#alice', sk: 'USER#alice' }],
|
|
186
|
+
ProjectionExpression: '#name',
|
|
187
|
+
ExpressionAttributeNames: {
|
|
188
|
+
'#name': 'name',
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
});
|
|
164
192
|
});
|
|
165
193
|
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
BatchGetCommandInput,
|
|
6
6
|
BatchGetCommandOutput,
|
|
7
7
|
} from '@aws-sdk/lib-dynamodb';
|
|
8
|
+
import { buildProjectionExpression } from '../shared';
|
|
8
9
|
import { BatchGetBuilder } from './types';
|
|
9
10
|
import { DynamoDBLogger } from '../../utils/dynamodb-logger';
|
|
10
11
|
|
|
@@ -63,13 +64,19 @@ export function createBatchGetBuilder<Model>(
|
|
|
63
64
|
* Build the underlying DynamoDB input parameters.
|
|
64
65
|
*/
|
|
65
66
|
dbParams(): BatchGetCommandInput {
|
|
66
|
-
//
|
|
67
|
+
// Build the projection once (with #-placeholders so reserved words
|
|
68
|
+
// like name/date/status/type don't blow up at DynamoDB) and reuse
|
|
69
|
+
// it per-table.
|
|
70
|
+
const proj =
|
|
71
|
+
projection.length > 0 ? buildProjectionExpression(projection.map(String)) : undefined;
|
|
72
|
+
|
|
67
73
|
const enhancedRequestItems = Object.entries(requestItems).reduce(
|
|
68
74
|
(acc, [tableName, tableRequest]) => {
|
|
69
75
|
acc[tableName] = {
|
|
70
76
|
Keys: tableRequest.Keys,
|
|
71
|
-
...(
|
|
72
|
-
ProjectionExpression:
|
|
77
|
+
...(proj && {
|
|
78
|
+
ProjectionExpression: proj.ProjectionExpression,
|
|
79
|
+
ExpressionAttributeNames: proj.ExpressionAttributeNames,
|
|
73
80
|
}),
|
|
74
81
|
...(isConsistent && { ConsistentRead: true }),
|
|
75
82
|
};
|
|
@@ -1,33 +1,10 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
3
|
import { GetCommand, GetCommandInput, GetCommandOutput } from '@aws-sdk/lib-dynamodb';
|
|
4
|
+
import { buildProjectionExpression } from '../shared';
|
|
4
5
|
import { GetBuilder } from './types';
|
|
5
6
|
import { DynamoDBLogger } from '../../utils/dynamodb-logger';
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
-
* Builds a ProjectionExpression with proper ExpressionAttributeNames
|
|
9
|
-
* to handle reserved words and special characters.
|
|
10
|
-
*/
|
|
11
|
-
function buildProjectionExpression(attrs: string[]): {
|
|
12
|
-
ProjectionExpression: string;
|
|
13
|
-
ExpressionAttributeNames: Record<string, string>;
|
|
14
|
-
} {
|
|
15
|
-
const names: Record<string, string> = {};
|
|
16
|
-
const projectionParts: string[] = [];
|
|
17
|
-
|
|
18
|
-
attrs.forEach((attr) => {
|
|
19
|
-
// Use a placeholder for the attribute name to avoid reserved word conflicts
|
|
20
|
-
const placeholder = `#${attr}`;
|
|
21
|
-
names[placeholder] = attr;
|
|
22
|
-
projectionParts.push(placeholder);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
return {
|
|
26
|
-
ProjectionExpression: projectionParts.join(', '),
|
|
27
|
-
ExpressionAttributeNames: names,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
8
|
/**
|
|
32
9
|
* Creates a GetBuilder to retrieve an item by its key.
|
|
33
10
|
*/
|
|
@@ -12,8 +12,9 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
12
12
|
const tableName = 'TestTable';
|
|
13
13
|
|
|
14
14
|
interface TestModel {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
PK: string;
|
|
16
|
+
SK: string;
|
|
17
|
+
username: string;
|
|
17
18
|
name?: string;
|
|
18
19
|
age?: number;
|
|
19
20
|
status?: string;
|
|
@@ -38,9 +39,9 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
38
39
|
|
|
39
40
|
describe('startFrom method', () => {
|
|
40
41
|
test('should add ExclusiveStartKey to query params', () => {
|
|
41
|
-
const startKey = {
|
|
42
|
+
const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
|
|
42
43
|
const params = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
43
|
-
.where((attr, op) => op.eq(attr.
|
|
44
|
+
.where((attr, op) => op.eq(attr.username, 'alice'))
|
|
44
45
|
.startFrom(startKey)
|
|
45
46
|
.dbParams();
|
|
46
47
|
|
|
@@ -48,9 +49,9 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
test('should work with all other query options', () => {
|
|
51
|
-
const startKey = {
|
|
52
|
+
const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
|
|
52
53
|
const params = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
53
|
-
.where((attr, op) => op.eq(attr.
|
|
54
|
+
.where((attr, op) => op.eq(attr.username, 'alice'))
|
|
54
55
|
.startFrom(startKey)
|
|
55
56
|
.limit(10)
|
|
56
57
|
.scanIndexForward(false)
|
|
@@ -60,12 +61,18 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
60
61
|
expect(params.ExclusiveStartKey).toEqual(startKey);
|
|
61
62
|
expect(params.Limit).toBe(10);
|
|
62
63
|
expect(params.ScanIndexForward).toBe(false);
|
|
63
|
-
expect(params.ProjectionExpression).toBe('name, age');
|
|
64
|
+
expect(params.ProjectionExpression).toBe('#name, #age');
|
|
65
|
+
expect(params.ExpressionAttributeNames).toEqual(
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
'#name': 'name',
|
|
68
|
+
'#age': 'age',
|
|
69
|
+
})
|
|
70
|
+
);
|
|
64
71
|
});
|
|
65
72
|
|
|
66
73
|
test('should not include ExclusiveStartKey if not set', () => {
|
|
67
74
|
const params = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
68
|
-
.where((attr, op) => op.eq(attr.
|
|
75
|
+
.where((attr, op) => op.eq(attr.username, 'alice'))
|
|
69
76
|
.dbParams();
|
|
70
77
|
|
|
71
78
|
expect(params.ExclusiveStartKey).toBeUndefined();
|
|
@@ -75,10 +82,10 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
75
82
|
describe('executeWithPagination method', () => {
|
|
76
83
|
test('should return items and lastEvaluatedKey', async () => {
|
|
77
84
|
const mockItems = [
|
|
78
|
-
{
|
|
79
|
-
{
|
|
85
|
+
{ PK: 'USER#alice', SK: 'USER#alice', name: 'Alice' },
|
|
86
|
+
{ PK: 'USER#bob', SK: 'USER#bob', name: 'Bob' },
|
|
80
87
|
];
|
|
81
|
-
const mockLastKey = {
|
|
88
|
+
const mockLastKey = { PK: 'USER#bob', SK: 'USER#bob' };
|
|
82
89
|
|
|
83
90
|
ddbMock.on(QueryCommand).resolves({
|
|
84
91
|
Items: mockItems,
|
|
@@ -88,7 +95,7 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
88
95
|
});
|
|
89
96
|
|
|
90
97
|
const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
91
|
-
.where((attr, op) => op.eq(attr.
|
|
98
|
+
.where((attr, op) => op.eq(attr.username, 'alice'))
|
|
92
99
|
.limit(2)
|
|
93
100
|
.executeWithPagination();
|
|
94
101
|
|
|
@@ -99,7 +106,7 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
99
106
|
});
|
|
100
107
|
|
|
101
108
|
test('should return undefined lastEvaluatedKey when no more results', async () => {
|
|
102
|
-
const mockItems = [{
|
|
109
|
+
const mockItems = [{ PK: 'USER#alice', SK: 'USER#alice', name: 'Alice' }];
|
|
103
110
|
|
|
104
111
|
ddbMock.on(QueryCommand).resolves({
|
|
105
112
|
Items: mockItems,
|
|
@@ -108,7 +115,7 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
108
115
|
});
|
|
109
116
|
|
|
110
117
|
const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
111
|
-
.where((attr, op) => op.eq(attr.
|
|
118
|
+
.where((attr, op) => op.eq(attr.username, 'alice'))
|
|
112
119
|
.executeWithPagination();
|
|
113
120
|
|
|
114
121
|
expect(result.items).toEqual(mockItems);
|
|
@@ -123,7 +130,7 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
123
130
|
});
|
|
124
131
|
|
|
125
132
|
const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
126
|
-
.where((attr, op) => op.eq(attr.
|
|
133
|
+
.where((attr, op) => op.eq(attr.username, 'nonexistent'))
|
|
127
134
|
.executeWithPagination();
|
|
128
135
|
|
|
129
136
|
expect(result.items).toEqual([]);
|
|
@@ -137,20 +144,20 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
137
144
|
test('should support manual pagination loop', async () => {
|
|
138
145
|
// First page
|
|
139
146
|
const page1Items = [
|
|
140
|
-
{
|
|
141
|
-
{
|
|
147
|
+
{ PK: 'USER#1', SK: 'USER#1', name: 'User 1' },
|
|
148
|
+
{ PK: 'USER#2', SK: 'USER#2', name: 'User 2' },
|
|
142
149
|
];
|
|
143
|
-
const page1LastKey = {
|
|
150
|
+
const page1LastKey = { PK: 'USER#2', SK: 'USER#2' };
|
|
144
151
|
|
|
145
152
|
// Second page
|
|
146
153
|
const page2Items = [
|
|
147
|
-
{
|
|
148
|
-
{
|
|
154
|
+
{ PK: 'USER#3', SK: 'USER#3', name: 'User 3' },
|
|
155
|
+
{ PK: 'USER#4', SK: 'USER#4', name: 'User 4' },
|
|
149
156
|
];
|
|
150
|
-
const page2LastKey = {
|
|
157
|
+
const page2LastKey = { PK: 'USER#4', SK: 'USER#4' };
|
|
151
158
|
|
|
152
159
|
// Third page (last)
|
|
153
|
-
const page3Items = [{
|
|
160
|
+
const page3Items = [{ PK: 'USER#5', SK: 'USER#5', name: 'User 5' }];
|
|
154
161
|
|
|
155
162
|
ddbMock
|
|
156
163
|
.on(QueryCommand)
|
|
@@ -177,7 +184,7 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
177
184
|
|
|
178
185
|
// Page 1
|
|
179
186
|
const result1 = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
180
|
-
.where((attr, op) => op.beginsWith(attr.
|
|
187
|
+
.where((attr, op) => op.beginsWith(attr.username, ''))
|
|
181
188
|
.limit(2)
|
|
182
189
|
.executeWithPagination();
|
|
183
190
|
|
|
@@ -187,7 +194,7 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
187
194
|
|
|
188
195
|
// Page 2
|
|
189
196
|
const result2 = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
190
|
-
.where((attr, op) => op.beginsWith(attr.
|
|
197
|
+
.where((attr, op) => op.beginsWith(attr.username, ''))
|
|
191
198
|
.limit(2)
|
|
192
199
|
.startFrom(lastKey)
|
|
193
200
|
.executeWithPagination();
|
|
@@ -198,7 +205,7 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
198
205
|
|
|
199
206
|
// Page 3
|
|
200
207
|
const result3 = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
201
|
-
.where((attr, op) => op.beginsWith(attr.
|
|
208
|
+
.where((attr, op) => op.beginsWith(attr.username, ''))
|
|
202
209
|
.limit(2)
|
|
203
210
|
.startFrom(lastKey)
|
|
204
211
|
.executeWithPagination();
|
|
@@ -214,13 +221,77 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
214
221
|
});
|
|
215
222
|
});
|
|
216
223
|
|
|
224
|
+
describe('requires a partition-key condition', () => {
|
|
225
|
+
test('throws when where() only references non-key attributes', () => {
|
|
226
|
+
const builder = createQueryBuilder<TestModel>(tableName, client, testModel).where(
|
|
227
|
+
(attr, op) => op.eq(attr.status, 'active')
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(() => builder.dbParams()).toThrow(/partition key/i);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('error message lists the available key field names from the model', () => {
|
|
234
|
+
const builder = createQueryBuilder<TestModel>(tableName, client, testModel).where(
|
|
235
|
+
(attr, op) => op.eq(attr.status, 'active')
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(() => builder.dbParams()).toThrow(/username/);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('error message points users at scan() as the alternative', () => {
|
|
242
|
+
const builder = createQueryBuilder<TestModel>(tableName, client, testModel).where(
|
|
243
|
+
(attr, op) => op.eq(attr.status, 'active')
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
expect(() => builder.dbParams()).toThrow(/scan\(\)/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('error message references the index name when querying a GSI', () => {
|
|
250
|
+
const modelWithIndex: ModelDefinition = {
|
|
251
|
+
key: {
|
|
252
|
+
PK: { type: String, value: 'USER#${username}' },
|
|
253
|
+
SK: { type: String, value: 'USER#${username}' },
|
|
254
|
+
},
|
|
255
|
+
index: {
|
|
256
|
+
GSI1PK: { type: String, value: 'EMAIL#${email}' },
|
|
257
|
+
GSI1SK: { type: String, value: 'USER#${username}' },
|
|
258
|
+
},
|
|
259
|
+
attributes: {
|
|
260
|
+
username: { type: String, required: true },
|
|
261
|
+
email: { type: String, required: true },
|
|
262
|
+
status: { type: String },
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const builder = createQueryBuilder<TestModel & { email: string }>(
|
|
267
|
+
tableName,
|
|
268
|
+
client,
|
|
269
|
+
modelWithIndex
|
|
270
|
+
)
|
|
271
|
+
.where((attr, op) => op.eq(attr.status, 'active'))
|
|
272
|
+
.useIndex('GSI1');
|
|
273
|
+
|
|
274
|
+
expect(() => builder.dbParams()).toThrow(/GSI1/);
|
|
275
|
+
// The hint should mention the GSI's template vars, not the table's.
|
|
276
|
+
expect(() => builder.dbParams()).toThrow(/email/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('does NOT throw when the where condition matches a key template variable', () => {
|
|
280
|
+
const builder = createQueryBuilder<TestModel>(tableName, client, testModel).where(
|
|
281
|
+
(attr, op) => op.eq(attr.username, 'alice')
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
expect(() => builder.dbParams()).not.toThrow();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
217
288
|
describe('Immutability', () => {
|
|
218
289
|
test('should create new builder instance when using startFrom', () => {
|
|
219
290
|
const builder1 = createQueryBuilder<TestModel>(tableName, client, testModel).where(
|
|
220
|
-
(attr, op) => op.eq(attr.
|
|
291
|
+
(attr, op) => op.eq(attr.username, 'alice')
|
|
221
292
|
);
|
|
222
293
|
|
|
223
|
-
const startKey = {
|
|
294
|
+
const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
|
|
224
295
|
const builder2 = builder1.startFrom(startKey);
|
|
225
296
|
|
|
226
297
|
expect(builder1.dbParams().ExclusiveStartKey).toBeUndefined();
|
|
@@ -231,24 +302,24 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
231
302
|
describe('iterate method', () => {
|
|
232
303
|
test('walks every page transparently and yields items in order', async () => {
|
|
233
304
|
const page1 = [
|
|
234
|
-
{
|
|
235
|
-
{
|
|
305
|
+
{ PK: 'USER#1', SK: 'USER#1', name: 'User 1' },
|
|
306
|
+
{ PK: 'USER#2', SK: 'USER#2', name: 'User 2' },
|
|
236
307
|
];
|
|
237
308
|
const page2 = [
|
|
238
|
-
{
|
|
239
|
-
{
|
|
309
|
+
{ PK: 'USER#3', SK: 'USER#3', name: 'User 3' },
|
|
310
|
+
{ PK: 'USER#4', SK: 'USER#4', name: 'User 4' },
|
|
240
311
|
];
|
|
241
|
-
const page3 = [{
|
|
312
|
+
const page3 = [{ PK: 'USER#5', SK: 'USER#5', name: 'User 5' }];
|
|
242
313
|
|
|
243
314
|
ddbMock
|
|
244
315
|
.on(QueryCommand)
|
|
245
|
-
.resolvesOnce({ Items: page1, LastEvaluatedKey: {
|
|
246
|
-
.resolvesOnce({ Items: page2, LastEvaluatedKey: {
|
|
316
|
+
.resolvesOnce({ Items: page1, LastEvaluatedKey: { PK: 'USER#2', SK: 'USER#2' } })
|
|
317
|
+
.resolvesOnce({ Items: page2, LastEvaluatedKey: { PK: 'USER#4', SK: 'USER#4' } })
|
|
247
318
|
.resolvesOnce({ Items: page3 });
|
|
248
319
|
|
|
249
320
|
const collected: TestModel[] = [];
|
|
250
321
|
for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
251
|
-
.where((attr, op) => op.beginsWith(attr.
|
|
322
|
+
.where((attr, op) => op.beginsWith(attr.username, ''))
|
|
252
323
|
.iterate()) {
|
|
253
324
|
collected.push(item);
|
|
254
325
|
}
|
|
@@ -265,17 +336,17 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
265
336
|
});
|
|
266
337
|
|
|
267
338
|
test('forwards ExclusiveStartKey from each response into the next call', async () => {
|
|
268
|
-
const cursor1 = {
|
|
269
|
-
const cursor2 = {
|
|
339
|
+
const cursor1 = { PK: 'USER#2', SK: 'USER#2' };
|
|
340
|
+
const cursor2 = { PK: 'USER#4', SK: 'USER#4' };
|
|
270
341
|
|
|
271
342
|
ddbMock
|
|
272
343
|
.on(QueryCommand)
|
|
273
|
-
.resolvesOnce({ Items: [{
|
|
274
|
-
.resolvesOnce({ Items: [{
|
|
275
|
-
.resolvesOnce({ Items: [{
|
|
344
|
+
.resolvesOnce({ Items: [{ PK: 'USER#1', SK: 'USER#1' }], LastEvaluatedKey: cursor1 })
|
|
345
|
+
.resolvesOnce({ Items: [{ PK: 'USER#3', SK: 'USER#3' }], LastEvaluatedKey: cursor2 })
|
|
346
|
+
.resolvesOnce({ Items: [{ PK: 'USER#5', SK: 'USER#5' }] });
|
|
276
347
|
|
|
277
348
|
const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
278
|
-
.where((attr, op) => op.beginsWith(attr.
|
|
349
|
+
.where((attr, op) => op.beginsWith(attr.username, ''))
|
|
279
350
|
.iterate();
|
|
280
351
|
|
|
281
352
|
// Consume the iterator
|
|
@@ -292,12 +363,12 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
292
363
|
});
|
|
293
364
|
|
|
294
365
|
test('starts from the user-provided cursor on the first call', async () => {
|
|
295
|
-
const startKey = {
|
|
366
|
+
const startKey = { PK: 'USER#10', SK: 'USER#10' };
|
|
296
367
|
|
|
297
|
-
ddbMock.on(QueryCommand).resolves({ Items: [{
|
|
368
|
+
ddbMock.on(QueryCommand).resolves({ Items: [{ PK: 'USER#11', SK: 'USER#11' }] });
|
|
298
369
|
|
|
299
370
|
const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
300
|
-
.where((attr, op) => op.beginsWith(attr.
|
|
371
|
+
.where((attr, op) => op.beginsWith(attr.username, ''))
|
|
301
372
|
.startFrom(startKey)
|
|
302
373
|
.iterate();
|
|
303
374
|
|
|
@@ -315,15 +386,15 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
315
386
|
.on(QueryCommand)
|
|
316
387
|
.resolvesOnce({
|
|
317
388
|
Items: [
|
|
318
|
-
{
|
|
319
|
-
{
|
|
389
|
+
{ PK: 'USER#1', SK: 'USER#1', name: 'User 1' },
|
|
390
|
+
{ PK: 'USER#2', SK: 'USER#2', name: 'User 2' },
|
|
320
391
|
],
|
|
321
|
-
LastEvaluatedKey: {
|
|
392
|
+
LastEvaluatedKey: { PK: 'USER#2', SK: 'USER#2' },
|
|
322
393
|
});
|
|
323
394
|
|
|
324
395
|
const collected: TestModel[] = [];
|
|
325
396
|
for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
326
|
-
.where((attr, op) => op.beginsWith(attr.
|
|
397
|
+
.where((attr, op) => op.beginsWith(attr.username, ''))
|
|
327
398
|
.iterate()) {
|
|
328
399
|
collected.push(item);
|
|
329
400
|
if (collected.length >= 1) break;
|
|
@@ -338,7 +409,7 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
338
409
|
|
|
339
410
|
const collected: TestModel[] = [];
|
|
340
411
|
for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
341
|
-
.where((attr, op) => op.
|
|
412
|
+
.where((attr, op) => op.eq(attr.username, 'nonexistent'))
|
|
342
413
|
.iterate()) {
|
|
343
414
|
collected.push(item);
|
|
344
415
|
}
|
|
@@ -622,3 +693,78 @@ describe('QueryBuilder - entity type auto filter', () => {
|
|
|
622
693
|
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
623
694
|
});
|
|
624
695
|
});
|
|
696
|
+
|
|
697
|
+
describe('QueryBuilder - projection placeholders', () => {
|
|
698
|
+
const client = new DynamoDBClient({});
|
|
699
|
+
const tableName = 'TestTable';
|
|
700
|
+
|
|
701
|
+
interface UserModel {
|
|
702
|
+
PK: string;
|
|
703
|
+
SK: string;
|
|
704
|
+
username: string;
|
|
705
|
+
name?: string;
|
|
706
|
+
status?: string;
|
|
707
|
+
age?: number;
|
|
708
|
+
type?: string;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const userModel: ModelDefinition = {
|
|
712
|
+
key: {
|
|
713
|
+
PK: { type: String, value: 'USER#${username}' },
|
|
714
|
+
SK: { type: String, value: 'USER#${username}' },
|
|
715
|
+
},
|
|
716
|
+
attributes: {
|
|
717
|
+
username: { type: String, required: true },
|
|
718
|
+
name: { type: String },
|
|
719
|
+
status: { type: String },
|
|
720
|
+
age: { type: Number },
|
|
721
|
+
type: { type: String },
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
test('projects reserved DynamoDB words via #-placeholders', () => {
|
|
726
|
+
const params = createQueryBuilder<UserModel>(tableName, client, userModel)
|
|
727
|
+
.where((attr, op) => op.eq(attr.username, 'alice'))
|
|
728
|
+
.select(['name', 'status', 'type'])
|
|
729
|
+
.dbParams();
|
|
730
|
+
|
|
731
|
+
expect(params.ProjectionExpression).toBe('#name, #status, #type');
|
|
732
|
+
expect(params.ExpressionAttributeNames).toEqual(
|
|
733
|
+
expect.objectContaining({
|
|
734
|
+
'#name': 'name',
|
|
735
|
+
'#status': 'status',
|
|
736
|
+
'#type': 'type',
|
|
737
|
+
})
|
|
738
|
+
);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test('merges projection names with key/filter names without clobbering', () => {
|
|
742
|
+
const params = createQueryBuilder<UserModel>(tableName, client, userModel)
|
|
743
|
+
.where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.gt(attr.age, 18)))
|
|
744
|
+
.select(['name', 'status'])
|
|
745
|
+
.dbParams();
|
|
746
|
+
|
|
747
|
+
// Projection placeholders (#name, #status) merged with key (#PK from
|
|
748
|
+
// the username→PK template rewrite) and filter (#age) — none clobbered.
|
|
749
|
+
expect(params.ExpressionAttributeNames).toEqual(
|
|
750
|
+
expect.objectContaining({
|
|
751
|
+
'#name': 'name',
|
|
752
|
+
'#status': 'status',
|
|
753
|
+
'#PK': 'PK',
|
|
754
|
+
'#age': 'age',
|
|
755
|
+
})
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test('shares the same placeholder when an attribute is both projected and filtered (no key duplication)', () => {
|
|
760
|
+
const params = createQueryBuilder<UserModel>(tableName, client, userModel)
|
|
761
|
+
.where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.eq(attr.status, 'active')))
|
|
762
|
+
.select(['name', 'status'])
|
|
763
|
+
.dbParams();
|
|
764
|
+
|
|
765
|
+
// The map has #status exactly once, mapping to "status".
|
|
766
|
+
expect(params.ExpressionAttributeNames!['#status']).toBe('status');
|
|
767
|
+
expect(params.ProjectionExpression).toBe('#name, #status');
|
|
768
|
+
expect(params.FilterExpression).toMatch(/#status = :status_\d+/);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
@@ -4,6 +4,7 @@ import { QueryCommand, QueryCommandInput } from '@aws-sdk/lib-dynamodb';
|
|
|
4
4
|
import { Condition, AttrBuilder, AttrRef } from '../shared';
|
|
5
5
|
import { createOpBuilder } from '../shared/operators';
|
|
6
6
|
import { buildExpression } from '../shared/conditions';
|
|
7
|
+
import { buildProjectionExpression } from '../shared/projection';
|
|
7
8
|
import { QueryBuilder, QueryExecutor, QueryResult } from './types';
|
|
8
9
|
import { ModelDefinition } from '../../core/types';
|
|
9
10
|
import { extractTemplateVars } from '../../utils/model-utils';
|
|
@@ -263,6 +264,31 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
263
264
|
state.indexName
|
|
264
265
|
);
|
|
265
266
|
|
|
267
|
+
// DynamoDB Query requires at least a partition-key condition. If
|
|
268
|
+
// separation didn't pick anything as a key condition, the caller is
|
|
269
|
+
// either filtering on non-key attributes (which means they want
|
|
270
|
+
// scan()) or referencing the wrong attribute name. Fail loudly here
|
|
271
|
+
// instead of letting the SDK reject the request at execute time.
|
|
272
|
+
if (keyConditions.length === 0) {
|
|
273
|
+
const keyTemplates = state.indexName
|
|
274
|
+
? Object.entries(state.model?.index ?? {})
|
|
275
|
+
.filter(([keyName]) => keyName.startsWith(state.indexName!))
|
|
276
|
+
.map(([, def]) => def.value)
|
|
277
|
+
: Object.values(state.model?.key ?? {}).map((def) => def.value);
|
|
278
|
+
const templateVars = Array.from(
|
|
279
|
+
new Set(keyTemplates.flatMap((tpl) => extractTemplateVars(tpl)))
|
|
280
|
+
);
|
|
281
|
+
const indexHint = state.indexName ? ` on index "${state.indexName}"` : '';
|
|
282
|
+
const fieldsHint =
|
|
283
|
+
templateVars.length > 0
|
|
284
|
+
? ` Expected a condition on one of: ${templateVars.join(', ')}.`
|
|
285
|
+
: '';
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Query requires a condition on the partition key${indexHint}.${fieldsHint} ` +
|
|
288
|
+
`For non-key filtering use scan() instead.`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
266
292
|
// Build KeyConditionExpression (simple AND)
|
|
267
293
|
const keyExpr = buildSimpleAndExpression(keyConditions);
|
|
268
294
|
|
|
@@ -294,7 +320,7 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
294
320
|
}
|
|
295
321
|
|
|
296
322
|
// Merge attribute names and values from both expressions
|
|
297
|
-
const allNames = {
|
|
323
|
+
const allNames: Record<string, string> = {
|
|
298
324
|
...(keyExpr.names ?? {}),
|
|
299
325
|
...(filterExpr.names ?? {}),
|
|
300
326
|
};
|
|
@@ -303,6 +329,19 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
303
329
|
...(filterExpr.values ?? {}),
|
|
304
330
|
};
|
|
305
331
|
|
|
332
|
+
// Build ProjectionExpression with placeholders so reserved words
|
|
333
|
+
// (name, date, status, type, …) don't blow up at DynamoDB.
|
|
334
|
+
// Idempotent merge: filter/key and projection placeholders both map
|
|
335
|
+
// `#name → name`, so collisions resolve to the same value.
|
|
336
|
+
let projectionExpression: string | undefined;
|
|
337
|
+
if (state.projection && state.projection.length > 0) {
|
|
338
|
+
const proj = buildProjectionExpression(
|
|
339
|
+
(state.projection as (keyof Model)[]).map((a) => String(a))
|
|
340
|
+
);
|
|
341
|
+
projectionExpression = proj.ProjectionExpression;
|
|
342
|
+
Object.assign(allNames, proj.ExpressionAttributeNames);
|
|
343
|
+
}
|
|
344
|
+
|
|
306
345
|
return {
|
|
307
346
|
TableName: state.tableName,
|
|
308
347
|
...(keyExpr.expression && {
|
|
@@ -322,10 +361,9 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
322
361
|
...(state.scanForward !== undefined && {
|
|
323
362
|
ScanIndexForward: state.scanForward,
|
|
324
363
|
}),
|
|
325
|
-
...(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}),
|
|
364
|
+
...(projectionExpression && {
|
|
365
|
+
ProjectionExpression: projectionExpression,
|
|
366
|
+
}),
|
|
329
367
|
...(state.consistentReadEnabled && { ConsistentRead: true }),
|
|
330
368
|
...(state.exclusiveStartKey && {
|
|
331
369
|
ExclusiveStartKey: state.exclusiveStartKey,
|
|
@@ -77,17 +77,52 @@ describe('ScanBuilder', () => {
|
|
|
77
77
|
.select(['name', 'age', 'status'])
|
|
78
78
|
.dbParams();
|
|
79
79
|
|
|
80
|
-
expect(params.ProjectionExpression).toBe('name, age, status');
|
|
80
|
+
expect(params.ProjectionExpression).toBe('#name, #age, #status');
|
|
81
|
+
expect(params.ExpressionAttributeNames).toEqual({
|
|
82
|
+
'#name': 'name',
|
|
83
|
+
'#age': 'age',
|
|
84
|
+
'#status': 'status',
|
|
85
|
+
});
|
|
81
86
|
});
|
|
82
87
|
|
|
83
|
-
test('should build params with select and filter', () => {
|
|
88
|
+
test('should build params with select and filter, merging ExpressionAttributeNames', () => {
|
|
84
89
|
const params = createScanBuilder<TestModel>(tableName, client)
|
|
85
90
|
.select(['name', 'age'])
|
|
86
|
-
.filter((attr, op) => op.gt(attr.
|
|
91
|
+
.filter((attr, op) => op.gt(attr.score, 50))
|
|
87
92
|
.dbParams();
|
|
88
93
|
|
|
89
|
-
expect(params.ProjectionExpression).toBe('name, age');
|
|
90
|
-
expect(params.FilterExpression).toMatch(/#
|
|
94
|
+
expect(params.ProjectionExpression).toBe('#name, #age');
|
|
95
|
+
expect(params.FilterExpression).toMatch(/#score > :score_\d+/);
|
|
96
|
+
// Merged names from filter (#score) + projection (#name, #age)
|
|
97
|
+
expect(params.ExpressionAttributeNames).toEqual({
|
|
98
|
+
'#name': 'name',
|
|
99
|
+
'#age': 'age',
|
|
100
|
+
'#score': 'score',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('placeholders survive when projected attribute is also referenced in the filter (no key collision)', () => {
|
|
105
|
+
const params = createScanBuilder<TestModel>(tableName, client)
|
|
106
|
+
.select(['name', 'age'])
|
|
107
|
+
.filter((attr, op) => op.eq(attr.name, 'alice'))
|
|
108
|
+
.dbParams();
|
|
109
|
+
|
|
110
|
+
expect(params.ProjectionExpression).toBe('#name, #age');
|
|
111
|
+
// #name resolves to "name" — filter and projection share the placeholder.
|
|
112
|
+
expect(params.ExpressionAttributeNames!['#name']).toBe('name');
|
|
113
|
+
expect(params.ExpressionAttributeNames!['#age']).toBe('age');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('reserved DynamoDB words like "name", "date", "status", "type" can be projected', () => {
|
|
117
|
+
const params = createScanBuilder<TestModel>(tableName, client)
|
|
118
|
+
.select(['name', 'status'])
|
|
119
|
+
.dbParams();
|
|
120
|
+
|
|
121
|
+
expect(params.ProjectionExpression).toBe('#name, #status');
|
|
122
|
+
expect(params.ExpressionAttributeNames).toEqual({
|
|
123
|
+
'#name': 'name',
|
|
124
|
+
'#status': 'status',
|
|
125
|
+
});
|
|
91
126
|
});
|
|
92
127
|
});
|
|
93
128
|
|
|
@@ -184,7 +219,7 @@ describe('ScanBuilder', () => {
|
|
|
184
219
|
|
|
185
220
|
expect(params.TableName).toBe(tableName);
|
|
186
221
|
expect(params.FilterExpression).toMatch(/\(#age > :age_\d+\) AND \(#status = :status_\d+\)/);
|
|
187
|
-
expect(params.ProjectionExpression).toBe('name, age, status');
|
|
222
|
+
expect(params.ProjectionExpression).toBe('#name, #age, #status');
|
|
188
223
|
expect(params.Limit).toBe(100);
|
|
189
224
|
expect(params.ConsistentRead).toBe(true);
|
|
190
225
|
expect(params.IndexName).toBe('GSI1');
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
3
|
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildExpression,
|
|
6
|
+
AttrBuilder,
|
|
7
|
+
Condition,
|
|
8
|
+
createOpBuilder,
|
|
9
|
+
buildProjectionExpression,
|
|
10
|
+
} from '../shared';
|
|
5
11
|
import { ScanBuilder, ScanResult } from './types';
|
|
6
12
|
import { DynamoDBLogger } from '../../utils/dynamodb-logger';
|
|
7
13
|
|
|
@@ -155,10 +161,18 @@ export function createScanBuilder<Model>(
|
|
|
155
161
|
expressionAttributeValues = result.values;
|
|
156
162
|
}
|
|
157
163
|
|
|
158
|
-
// Build ProjectionExpression
|
|
164
|
+
// Build ProjectionExpression with placeholders so reserved words
|
|
165
|
+
// (name, date, status, type, …) don't blow up at DynamoDB.
|
|
159
166
|
let projectionExpression = '';
|
|
160
167
|
if (projectionAttrs.length > 0) {
|
|
161
|
-
|
|
168
|
+
const proj = buildProjectionExpression(projectionAttrs.map((attr) => String(attr)));
|
|
169
|
+
projectionExpression = proj.ProjectionExpression;
|
|
170
|
+
// Idempotent merge: filter and projection placeholders both map
|
|
171
|
+
// `#name → name`, so collisions resolve to the same value.
|
|
172
|
+
expressionAttributeNames = {
|
|
173
|
+
...proj.ExpressionAttributeNames,
|
|
174
|
+
...expressionAttributeNames,
|
|
175
|
+
};
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
const params: any = {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a `ProjectionExpression` together with the matching
|
|
3
|
+
* `ExpressionAttributeNames` map so that any attribute — including
|
|
4
|
+
* DynamoDB reserved words like `name`, `date`, `status`, `type`, `data`,
|
|
5
|
+
* `count`, `size`, `value`, `time`, `year`, `source`, … — is referenced
|
|
6
|
+
* via a `#placeholder`.
|
|
7
|
+
*
|
|
8
|
+
* Without this indirection DynamoDB rejects the request with
|
|
9
|
+
* `ValidationException: Attribute name is a reserved keyword`.
|
|
10
|
+
*/
|
|
11
|
+
export function buildProjectionExpression(attrs: string[]): {
|
|
12
|
+
ProjectionExpression: string;
|
|
13
|
+
ExpressionAttributeNames: Record<string, string>;
|
|
14
|
+
} {
|
|
15
|
+
const names: Record<string, string> = {};
|
|
16
|
+
const projectionParts: string[] = [];
|
|
17
|
+
|
|
18
|
+
attrs.forEach((attr) => {
|
|
19
|
+
const placeholder = `#${attr}`;
|
|
20
|
+
names[placeholder] = attr;
|
|
21
|
+
projectionParts.push(placeholder);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
ProjectionExpression: projectionParts.join(', '),
|
|
26
|
+
ExpressionAttributeNames: names,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -346,7 +346,14 @@ describe('Should test dbParams function builder', () => {
|
|
|
346
346
|
.dbParams();
|
|
347
347
|
|
|
348
348
|
expect(params.TableName).toBe('InstagramClone');
|
|
349
|
-
expect(params.ProjectionExpression).toBe('photoId, url, likesCount');
|
|
349
|
+
expect(params.ProjectionExpression).toBe('#photoId, #url, #likesCount');
|
|
350
|
+
expect(params.ExpressionAttributeNames).toEqual(
|
|
351
|
+
expect.objectContaining({
|
|
352
|
+
'#photoId': 'photoId',
|
|
353
|
+
'#url': 'url',
|
|
354
|
+
'#likesCount': 'likesCount',
|
|
355
|
+
})
|
|
356
|
+
);
|
|
350
357
|
});
|
|
351
358
|
|
|
352
359
|
test('Query Likes by photoId', async () => {
|
|
@@ -786,9 +793,16 @@ describe('Should test dbParams function builder', () => {
|
|
|
786
793
|
.select(['username', 'photoId', 'likesCount'])
|
|
787
794
|
.dbParams();
|
|
788
795
|
|
|
789
|
-
expect(params.ProjectionExpression).toBe('username, photoId, likesCount');
|
|
796
|
+
expect(params.ProjectionExpression).toBe('#username, #photoId, #likesCount');
|
|
790
797
|
expect(params.FilterExpression).toBe('#_type = :_type');
|
|
791
798
|
expect(params.ExpressionAttributeValues).toMatchObject({ ':_type': 'Photo' });
|
|
799
|
+
expect(params.ExpressionAttributeNames).toEqual(
|
|
800
|
+
expect.objectContaining({
|
|
801
|
+
'#username': 'username',
|
|
802
|
+
'#photoId': 'photoId',
|
|
803
|
+
'#likesCount': 'likesCount',
|
|
804
|
+
})
|
|
805
|
+
);
|
|
792
806
|
});
|
|
793
807
|
|
|
794
808
|
test('SCAN Photos with limit', async () => {
|
|
@@ -838,7 +852,7 @@ describe('Should test dbParams function builder', () => {
|
|
|
838
852
|
expect(params.FilterExpression).toMatch(/size\(#content\) > :content_size_\d+/);
|
|
839
853
|
expect(params.FilterExpression).toMatch(/#_type = :_type/);
|
|
840
854
|
expect(params.ExpressionAttributeValues).toMatchObject({ ':_type': 'Comment' });
|
|
841
|
-
expect(params.ProjectionExpression).toBe('photoId, commentId, content');
|
|
855
|
+
expect(params.ProjectionExpression).toBe('#photoId, #commentId, #content');
|
|
842
856
|
expect(params.Limit).toBe(50);
|
|
843
857
|
});
|
|
844
858
|
|
|
@@ -872,7 +886,12 @@ describe('Should test dbParams function builder', () => {
|
|
|
872
886
|
{ PK: 'USER#alice', SK: 'USER#alice' },
|
|
873
887
|
{ PK: 'USER#bob', SK: 'USER#bob' },
|
|
874
888
|
],
|
|
875
|
-
ProjectionExpression: 'username, name, followerCount',
|
|
889
|
+
ProjectionExpression: '#username, #name, #followerCount',
|
|
890
|
+
ExpressionAttributeNames: {
|
|
891
|
+
'#username': 'username',
|
|
892
|
+
'#name': 'name',
|
|
893
|
+
'#followerCount': 'followerCount',
|
|
894
|
+
},
|
|
876
895
|
},
|
|
877
896
|
});
|
|
878
897
|
});
|