@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 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;AAM1D,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,CA0ExB"}
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
- // Apply projection and consistentRead to all tables
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
- ...(projection.length > 0 && {
44
- ProjectionExpression: projection.join(', '),
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;AAE1D,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AA0B7D;;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"}
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;AAK1D,OAAO,EAAE,YAAY,EAA8B,MAAM,SAAS,CAAC;AACnE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAiX7D;;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"}
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
- ...(state.projection &&
249
- state.projection.length > 0 && {
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,EAAgC,SAAS,EAAmB,MAAM,WAAW,CAAC;AACrF,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,CAqOpB"}
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
- projectionExpression = projectionAttrs.map((attr) => String(attr)).join(', ');
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,4 +1,5 @@
1
1
  export * from './types';
2
2
  export * from './operators';
3
3
  export * from './conditions';
4
+ export * from './projection';
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -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"}
@@ -17,3 +17,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./types"), exports);
18
18
  __exportStar(require("./operators"), exports);
19
19
  __exportStar(require("./conditions"), exports);
20
+ __exportStar(require("./projection"), exports);
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftschopp/dynatable-core",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Core library for DynamoDB single table design",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- // Apply projection and consistentRead to all tables
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
- ...(projection.length > 0 && {
72
- ProjectionExpression: projection.join(', '),
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
- ...(state.projection &&
326
- state.projection.length > 0 && {
327
- ProjectionExpression: state.projection.join(', '),
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.age, 18))
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(/#age > :age_\d+/);
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 { buildExpression, AttrBuilder, Condition, createOpBuilder } from '../shared';
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
- projectionExpression = projectionAttrs.map((attr) => String(attr)).join(', ');
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 = {
@@ -1,3 +1,4 @@
1
1
  export * from './types';
2
2
  export * from './operators';
3
3
  export * from './conditions';
4
+ export * from './projection';
@@ -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
  });