@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 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;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;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
- ...(state.projection &&
249
- state.projection.length > 0 && {
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,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.2",
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
  */
@@ -12,8 +12,9 @@ describe('QueryBuilder - Pagination', () => {
12
12
  const tableName = 'TestTable';
13
13
 
14
14
  interface TestModel {
15
- pk: string;
16
- sk: string;
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 = { pk: 'USER#alice', sk: 'USER#alice' };
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.pk, 'USER#alice'))
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 = { pk: 'USER#alice', sk: 'USER#alice' };
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.pk, 'USER#alice'))
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.pk, 'USER#alice'))
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
- { pk: 'USER#alice', sk: 'USER#alice', name: 'Alice' },
79
- { pk: 'USER#bob', sk: 'USER#bob', name: 'Bob' },
85
+ { PK: 'USER#alice', SK: 'USER#alice', name: 'Alice' },
86
+ { PK: 'USER#bob', SK: 'USER#bob', name: 'Bob' },
80
87
  ];
81
- const mockLastKey = { pk: 'USER#bob', sk: 'USER#bob' };
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.pk, 'USER#alice'))
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 = [{ pk: 'USER#alice', sk: 'USER#alice', name: 'Alice' }];
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.pk, 'USER#alice'))
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.pk, 'USER#nonexistent'))
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
- { pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
141
- { pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
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 = { pk: 'USER#2', sk: 'USER#2' };
150
+ const page1LastKey = { PK: 'USER#2', SK: 'USER#2' };
144
151
 
145
152
  // Second page
146
153
  const page2Items = [
147
- { pk: 'USER#3', sk: 'USER#3', name: 'User 3' },
148
- { pk: 'USER#4', sk: 'USER#4', name: 'User 4' },
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 = { pk: 'USER#4', sk: 'USER#4' };
157
+ const page2LastKey = { PK: 'USER#4', SK: 'USER#4' };
151
158
 
152
159
  // Third page (last)
153
- const page3Items = [{ pk: 'USER#5', sk: 'USER#5', name: 'User 5' }];
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.pk, 'USER#'))
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.pk, 'USER#'))
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.pk, 'USER#'))
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.pk, 'USER#alice')
291
+ (attr, op) => op.eq(attr.username, 'alice')
221
292
  );
222
293
 
223
- const startKey = { pk: 'USER#alice', sk: 'USER#alice' };
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
- { pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
235
- { pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
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
- { pk: 'USER#3', sk: 'USER#3', name: 'User 3' },
239
- { pk: 'USER#4', sk: 'USER#4', name: 'User 4' },
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 = [{ pk: 'USER#5', sk: 'USER#5', name: 'User 5' }];
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: { pk: 'USER#2', sk: 'USER#2' } })
246
- .resolvesOnce({ Items: page2, LastEvaluatedKey: { pk: 'USER#4', sk: 'USER#4' } })
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.pk, 'USER#'))
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 = { pk: 'USER#2', sk: 'USER#2' };
269
- const cursor2 = { pk: 'USER#4', sk: 'USER#4' };
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: [{ pk: 'USER#1', sk: 'USER#1' }], LastEvaluatedKey: cursor1 })
274
- .resolvesOnce({ Items: [{ pk: 'USER#3', sk: 'USER#3' }], LastEvaluatedKey: cursor2 })
275
- .resolvesOnce({ Items: [{ pk: 'USER#5', sk: 'USER#5' }] });
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.pk, 'USER#'))
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 = { pk: 'USER#10', sk: 'USER#10' };
366
+ const startKey = { PK: 'USER#10', SK: 'USER#10' };
296
367
 
297
- ddbMock.on(QueryCommand).resolves({ Items: [{ pk: 'USER#11', sk: 'USER#11' }] });
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.pk, 'USER#'))
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
- { pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
319
- { pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
389
+ { PK: 'USER#1', SK: 'USER#1', name: 'User 1' },
390
+ { PK: 'USER#2', SK: 'USER#2', name: 'User 2' },
320
391
  ],
321
- LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' },
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.pk, 'USER#'))
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.beginsWith(attr.pk, 'NONE#'))
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
- ...(state.projection &&
326
- state.projection.length > 0 && {
327
- ProjectionExpression: state.projection.join(', '),
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.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
  });