@ftschopp/dynatable-core 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/builders/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 +13 -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 +85 -1
- package/src/builders/query/create-query-builder.ts +18 -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,10 @@
|
|
|
1
|
+
## @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)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **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)
|
|
7
|
+
|
|
1
8
|
# @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
9
|
|
|
3
10
|
|
|
@@ -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;AA6X7D;;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)
|
|
@@ -226,6 +227,16 @@ function createQueryExecutor(state) {
|
|
|
226
227
|
...(keyExpr.values ?? {}),
|
|
227
228
|
...(filterExpr.values ?? {}),
|
|
228
229
|
};
|
|
230
|
+
// Build ProjectionExpression with placeholders so reserved words
|
|
231
|
+
// (name, date, status, type, …) don't blow up at DynamoDB.
|
|
232
|
+
// Idempotent merge: filter/key and projection placeholders both map
|
|
233
|
+
// `#name → name`, so collisions resolve to the same value.
|
|
234
|
+
let projectionExpression;
|
|
235
|
+
if (state.projection && state.projection.length > 0) {
|
|
236
|
+
const proj = (0, projection_1.buildProjectionExpression)(state.projection.map((a) => String(a)));
|
|
237
|
+
projectionExpression = proj.ProjectionExpression;
|
|
238
|
+
Object.assign(allNames, proj.ExpressionAttributeNames);
|
|
239
|
+
}
|
|
229
240
|
return {
|
|
230
241
|
TableName: state.tableName,
|
|
231
242
|
...(keyExpr.expression && {
|
|
@@ -245,9 +256,8 @@ function createQueryExecutor(state) {
|
|
|
245
256
|
...(state.scanForward !== undefined && {
|
|
246
257
|
ScanIndexForward: state.scanForward,
|
|
247
258
|
}),
|
|
248
|
-
...(
|
|
249
|
-
|
|
250
|
-
ProjectionExpression: state.projection.join(', '),
|
|
259
|
+
...(projectionExpression && {
|
|
260
|
+
ProjectionExpression: projectionExpression,
|
|
251
261
|
}),
|
|
252
262
|
...(state.consistentReadEnabled && { ConsistentRead: true }),
|
|
253
263
|
...(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
|
*/
|
|
@@ -60,7 +60,13 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
60
60
|
expect(params.ExclusiveStartKey).toEqual(startKey);
|
|
61
61
|
expect(params.Limit).toBe(10);
|
|
62
62
|
expect(params.ScanIndexForward).toBe(false);
|
|
63
|
-
expect(params.ProjectionExpression).toBe('name, age');
|
|
63
|
+
expect(params.ProjectionExpression).toBe('#name, #age');
|
|
64
|
+
expect(params.ExpressionAttributeNames).toEqual(
|
|
65
|
+
expect.objectContaining({
|
|
66
|
+
'#name': 'name',
|
|
67
|
+
'#age': 'age',
|
|
68
|
+
})
|
|
69
|
+
);
|
|
64
70
|
});
|
|
65
71
|
|
|
66
72
|
test('should not include ExclusiveStartKey if not set', () => {
|
|
@@ -622,3 +628,81 @@ describe('QueryBuilder - entity type auto filter', () => {
|
|
|
622
628
|
expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
|
|
623
629
|
});
|
|
624
630
|
});
|
|
631
|
+
|
|
632
|
+
describe('QueryBuilder - projection placeholders', () => {
|
|
633
|
+
const client = new DynamoDBClient({});
|
|
634
|
+
const tableName = 'TestTable';
|
|
635
|
+
|
|
636
|
+
interface UserModel {
|
|
637
|
+
pk: string;
|
|
638
|
+
sk: string;
|
|
639
|
+
name?: string;
|
|
640
|
+
status?: string;
|
|
641
|
+
age?: number;
|
|
642
|
+
type?: string;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const userModel: ModelDefinition = {
|
|
646
|
+
key: {
|
|
647
|
+
PK: { type: String, value: 'USER#${username}' },
|
|
648
|
+
SK: { type: String, value: 'USER#${username}' },
|
|
649
|
+
},
|
|
650
|
+
attributes: {
|
|
651
|
+
username: { type: String, required: true },
|
|
652
|
+
name: { type: String },
|
|
653
|
+
status: { type: String },
|
|
654
|
+
age: { type: Number },
|
|
655
|
+
type: { type: String },
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
test('projects reserved DynamoDB words via #-placeholders', () => {
|
|
660
|
+
const params = createQueryBuilder<UserModel>(tableName, client, userModel)
|
|
661
|
+
.where((attr, op) => op.eq(attr.pk, 'USER#alice'))
|
|
662
|
+
.select(['name', 'status', 'type'])
|
|
663
|
+
.dbParams();
|
|
664
|
+
|
|
665
|
+
expect(params.ProjectionExpression).toBe('#name, #status, #type');
|
|
666
|
+
expect(params.ExpressionAttributeNames).toEqual(
|
|
667
|
+
expect.objectContaining({
|
|
668
|
+
'#name': 'name',
|
|
669
|
+
'#status': 'status',
|
|
670
|
+
'#type': 'type',
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test('merges projection names with key/filter names without clobbering', () => {
|
|
676
|
+
const params = createQueryBuilder<UserModel & { username: string }>(
|
|
677
|
+
tableName,
|
|
678
|
+
client,
|
|
679
|
+
userModel
|
|
680
|
+
)
|
|
681
|
+
.where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.gt(attr.age, 18)))
|
|
682
|
+
.select(['name', 'status'])
|
|
683
|
+
.dbParams();
|
|
684
|
+
|
|
685
|
+
// Projection placeholders (#name, #status) merged with key (#PK from
|
|
686
|
+
// the username→PK template rewrite) and filter (#age) — none clobbered.
|
|
687
|
+
expect(params.ExpressionAttributeNames).toEqual(
|
|
688
|
+
expect.objectContaining({
|
|
689
|
+
'#name': 'name',
|
|
690
|
+
'#status': 'status',
|
|
691
|
+
'#PK': 'PK',
|
|
692
|
+
'#age': 'age',
|
|
693
|
+
})
|
|
694
|
+
);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test('shares the same placeholder when an attribute is both projected and filtered (no key duplication)', () => {
|
|
698
|
+
const params = createQueryBuilder<UserModel>(tableName, client, userModel)
|
|
699
|
+
.where((attr, op) => op.and(op.eq(attr.pk, 'USER#alice'), op.eq(attr.status, 'active')))
|
|
700
|
+
.select(['name', 'status'])
|
|
701
|
+
.dbParams();
|
|
702
|
+
|
|
703
|
+
// The map has #status exactly once, mapping to "status".
|
|
704
|
+
expect(params.ExpressionAttributeNames!['#status']).toBe('status');
|
|
705
|
+
expect(params.ProjectionExpression).toBe('#name, #status');
|
|
706
|
+
expect(params.FilterExpression).toMatch(/#status = :status_\d+/);
|
|
707
|
+
});
|
|
708
|
+
});
|
|
@@ -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';
|
|
@@ -294,7 +295,7 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
294
295
|
}
|
|
295
296
|
|
|
296
297
|
// Merge attribute names and values from both expressions
|
|
297
|
-
const allNames = {
|
|
298
|
+
const allNames: Record<string, string> = {
|
|
298
299
|
...(keyExpr.names ?? {}),
|
|
299
300
|
...(filterExpr.names ?? {}),
|
|
300
301
|
};
|
|
@@ -303,6 +304,19 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
303
304
|
...(filterExpr.values ?? {}),
|
|
304
305
|
};
|
|
305
306
|
|
|
307
|
+
// Build ProjectionExpression with placeholders so reserved words
|
|
308
|
+
// (name, date, status, type, …) don't blow up at DynamoDB.
|
|
309
|
+
// Idempotent merge: filter/key and projection placeholders both map
|
|
310
|
+
// `#name → name`, so collisions resolve to the same value.
|
|
311
|
+
let projectionExpression: string | undefined;
|
|
312
|
+
if (state.projection && state.projection.length > 0) {
|
|
313
|
+
const proj = buildProjectionExpression(
|
|
314
|
+
(state.projection as (keyof Model)[]).map((a) => String(a))
|
|
315
|
+
);
|
|
316
|
+
projectionExpression = proj.ProjectionExpression;
|
|
317
|
+
Object.assign(allNames, proj.ExpressionAttributeNames);
|
|
318
|
+
}
|
|
319
|
+
|
|
306
320
|
return {
|
|
307
321
|
TableName: state.tableName,
|
|
308
322
|
...(keyExpr.expression && {
|
|
@@ -322,10 +336,9 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
|
|
|
322
336
|
...(state.scanForward !== undefined && {
|
|
323
337
|
ScanIndexForward: state.scanForward,
|
|
324
338
|
}),
|
|
325
|
-
...(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}),
|
|
339
|
+
...(projectionExpression && {
|
|
340
|
+
ProjectionExpression: projectionExpression,
|
|
341
|
+
}),
|
|
329
342
|
...(state.consistentReadEnabled && { ConsistentRead: true }),
|
|
330
343
|
...(state.exclusiveStartKey && {
|
|
331
344
|
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
|
});
|