@ftschopp/dynatable-core 1.4.1 → 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,10 @@
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
+
1
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)
2
9
 
3
10
 
@@ -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;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"}
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"}
@@ -190,6 +190,25 @@ function createQueryExecutor(state) {
190
190
  }
191
191
  // Separate key conditions from filter conditions
192
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
+ }
193
212
  // Build KeyConditionExpression (simple AND)
194
213
  const keyExpr = buildSimpleAndExpression(keyConditions);
195
214
  // Auto-inject an entity-type filter so query() only returns items
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftschopp/dynatable-core",
3
- "version": "1.4.1",
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",
@@ -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)
@@ -71,7 +72,7 @@ describe('QueryBuilder - Pagination', () => {
71
72
 
72
73
  test('should not include ExclusiveStartKey if not set', () => {
73
74
  const params = createQueryBuilder<TestModel>(tableName, client, testModel)
74
- .where((attr, op) => op.eq(attr.pk, 'USER#alice'))
75
+ .where((attr, op) => op.eq(attr.username, 'alice'))
75
76
  .dbParams();
76
77
 
77
78
  expect(params.ExclusiveStartKey).toBeUndefined();
@@ -81,10 +82,10 @@ describe('QueryBuilder - Pagination', () => {
81
82
  describe('executeWithPagination method', () => {
82
83
  test('should return items and lastEvaluatedKey', async () => {
83
84
  const mockItems = [
84
- { pk: 'USER#alice', sk: 'USER#alice', name: 'Alice' },
85
- { 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' },
86
87
  ];
87
- const mockLastKey = { pk: 'USER#bob', sk: 'USER#bob' };
88
+ const mockLastKey = { PK: 'USER#bob', SK: 'USER#bob' };
88
89
 
89
90
  ddbMock.on(QueryCommand).resolves({
90
91
  Items: mockItems,
@@ -94,7 +95,7 @@ describe('QueryBuilder - Pagination', () => {
94
95
  });
95
96
 
96
97
  const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
97
- .where((attr, op) => op.eq(attr.pk, 'USER#alice'))
98
+ .where((attr, op) => op.eq(attr.username, 'alice'))
98
99
  .limit(2)
99
100
  .executeWithPagination();
100
101
 
@@ -105,7 +106,7 @@ describe('QueryBuilder - Pagination', () => {
105
106
  });
106
107
 
107
108
  test('should return undefined lastEvaluatedKey when no more results', async () => {
108
- const mockItems = [{ pk: 'USER#alice', sk: 'USER#alice', name: 'Alice' }];
109
+ const mockItems = [{ PK: 'USER#alice', SK: 'USER#alice', name: 'Alice' }];
109
110
 
110
111
  ddbMock.on(QueryCommand).resolves({
111
112
  Items: mockItems,
@@ -114,7 +115,7 @@ describe('QueryBuilder - Pagination', () => {
114
115
  });
115
116
 
116
117
  const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
117
- .where((attr, op) => op.eq(attr.pk, 'USER#alice'))
118
+ .where((attr, op) => op.eq(attr.username, 'alice'))
118
119
  .executeWithPagination();
119
120
 
120
121
  expect(result.items).toEqual(mockItems);
@@ -129,7 +130,7 @@ describe('QueryBuilder - Pagination', () => {
129
130
  });
130
131
 
131
132
  const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
132
- .where((attr, op) => op.eq(attr.pk, 'USER#nonexistent'))
133
+ .where((attr, op) => op.eq(attr.username, 'nonexistent'))
133
134
  .executeWithPagination();
134
135
 
135
136
  expect(result.items).toEqual([]);
@@ -143,20 +144,20 @@ describe('QueryBuilder - Pagination', () => {
143
144
  test('should support manual pagination loop', async () => {
144
145
  // First page
145
146
  const page1Items = [
146
- { pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
147
- { 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' },
148
149
  ];
149
- const page1LastKey = { pk: 'USER#2', sk: 'USER#2' };
150
+ const page1LastKey = { PK: 'USER#2', SK: 'USER#2' };
150
151
 
151
152
  // Second page
152
153
  const page2Items = [
153
- { pk: 'USER#3', sk: 'USER#3', name: 'User 3' },
154
- { 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' },
155
156
  ];
156
- const page2LastKey = { pk: 'USER#4', sk: 'USER#4' };
157
+ const page2LastKey = { PK: 'USER#4', SK: 'USER#4' };
157
158
 
158
159
  // Third page (last)
159
- const page3Items = [{ pk: 'USER#5', sk: 'USER#5', name: 'User 5' }];
160
+ const page3Items = [{ PK: 'USER#5', SK: 'USER#5', name: 'User 5' }];
160
161
 
161
162
  ddbMock
162
163
  .on(QueryCommand)
@@ -183,7 +184,7 @@ describe('QueryBuilder - Pagination', () => {
183
184
 
184
185
  // Page 1
185
186
  const result1 = await createQueryBuilder<TestModel>(tableName, client, testModel)
186
- .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
187
+ .where((attr, op) => op.beginsWith(attr.username, ''))
187
188
  .limit(2)
188
189
  .executeWithPagination();
189
190
 
@@ -193,7 +194,7 @@ describe('QueryBuilder - Pagination', () => {
193
194
 
194
195
  // Page 2
195
196
  const result2 = await createQueryBuilder<TestModel>(tableName, client, testModel)
196
- .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
197
+ .where((attr, op) => op.beginsWith(attr.username, ''))
197
198
  .limit(2)
198
199
  .startFrom(lastKey)
199
200
  .executeWithPagination();
@@ -204,7 +205,7 @@ describe('QueryBuilder - Pagination', () => {
204
205
 
205
206
  // Page 3
206
207
  const result3 = await createQueryBuilder<TestModel>(tableName, client, testModel)
207
- .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
208
+ .where((attr, op) => op.beginsWith(attr.username, ''))
208
209
  .limit(2)
209
210
  .startFrom(lastKey)
210
211
  .executeWithPagination();
@@ -220,13 +221,77 @@ describe('QueryBuilder - Pagination', () => {
220
221
  });
221
222
  });
222
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
+
223
288
  describe('Immutability', () => {
224
289
  test('should create new builder instance when using startFrom', () => {
225
290
  const builder1 = createQueryBuilder<TestModel>(tableName, client, testModel).where(
226
- (attr, op) => op.eq(attr.pk, 'USER#alice')
291
+ (attr, op) => op.eq(attr.username, 'alice')
227
292
  );
228
293
 
229
- const startKey = { pk: 'USER#alice', sk: 'USER#alice' };
294
+ const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
230
295
  const builder2 = builder1.startFrom(startKey);
231
296
 
232
297
  expect(builder1.dbParams().ExclusiveStartKey).toBeUndefined();
@@ -237,24 +302,24 @@ describe('QueryBuilder - Pagination', () => {
237
302
  describe('iterate method', () => {
238
303
  test('walks every page transparently and yields items in order', async () => {
239
304
  const page1 = [
240
- { pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
241
- { 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' },
242
307
  ];
243
308
  const page2 = [
244
- { pk: 'USER#3', sk: 'USER#3', name: 'User 3' },
245
- { 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' },
246
311
  ];
247
- const page3 = [{ pk: 'USER#5', sk: 'USER#5', name: 'User 5' }];
312
+ const page3 = [{ PK: 'USER#5', SK: 'USER#5', name: 'User 5' }];
248
313
 
249
314
  ddbMock
250
315
  .on(QueryCommand)
251
- .resolvesOnce({ Items: page1, LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' } })
252
- .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' } })
253
318
  .resolvesOnce({ Items: page3 });
254
319
 
255
320
  const collected: TestModel[] = [];
256
321
  for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
257
- .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
322
+ .where((attr, op) => op.beginsWith(attr.username, ''))
258
323
  .iterate()) {
259
324
  collected.push(item);
260
325
  }
@@ -271,17 +336,17 @@ describe('QueryBuilder - Pagination', () => {
271
336
  });
272
337
 
273
338
  test('forwards ExclusiveStartKey from each response into the next call', async () => {
274
- const cursor1 = { pk: 'USER#2', sk: 'USER#2' };
275
- 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' };
276
341
 
277
342
  ddbMock
278
343
  .on(QueryCommand)
279
- .resolvesOnce({ Items: [{ pk: 'USER#1', sk: 'USER#1' }], LastEvaluatedKey: cursor1 })
280
- .resolvesOnce({ Items: [{ pk: 'USER#3', sk: 'USER#3' }], LastEvaluatedKey: cursor2 })
281
- .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' }] });
282
347
 
283
348
  const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
284
- .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
349
+ .where((attr, op) => op.beginsWith(attr.username, ''))
285
350
  .iterate();
286
351
 
287
352
  // Consume the iterator
@@ -298,12 +363,12 @@ describe('QueryBuilder - Pagination', () => {
298
363
  });
299
364
 
300
365
  test('starts from the user-provided cursor on the first call', async () => {
301
- const startKey = { pk: 'USER#10', sk: 'USER#10' };
366
+ const startKey = { PK: 'USER#10', SK: 'USER#10' };
302
367
 
303
- ddbMock.on(QueryCommand).resolves({ Items: [{ pk: 'USER#11', sk: 'USER#11' }] });
368
+ ddbMock.on(QueryCommand).resolves({ Items: [{ PK: 'USER#11', SK: 'USER#11' }] });
304
369
 
305
370
  const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
306
- .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
371
+ .where((attr, op) => op.beginsWith(attr.username, ''))
307
372
  .startFrom(startKey)
308
373
  .iterate();
309
374
 
@@ -321,15 +386,15 @@ describe('QueryBuilder - Pagination', () => {
321
386
  .on(QueryCommand)
322
387
  .resolvesOnce({
323
388
  Items: [
324
- { pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
325
- { 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' },
326
391
  ],
327
- LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' },
392
+ LastEvaluatedKey: { PK: 'USER#2', SK: 'USER#2' },
328
393
  });
329
394
 
330
395
  const collected: TestModel[] = [];
331
396
  for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
332
- .where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
397
+ .where((attr, op) => op.beginsWith(attr.username, ''))
333
398
  .iterate()) {
334
399
  collected.push(item);
335
400
  if (collected.length >= 1) break;
@@ -344,7 +409,7 @@ describe('QueryBuilder - Pagination', () => {
344
409
 
345
410
  const collected: TestModel[] = [];
346
411
  for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
347
- .where((attr, op) => op.beginsWith(attr.pk, 'NONE#'))
412
+ .where((attr, op) => op.eq(attr.username, 'nonexistent'))
348
413
  .iterate()) {
349
414
  collected.push(item);
350
415
  }
@@ -634,8 +699,9 @@ describe('QueryBuilder - projection placeholders', () => {
634
699
  const tableName = 'TestTable';
635
700
 
636
701
  interface UserModel {
637
- pk: string;
638
- sk: string;
702
+ PK: string;
703
+ SK: string;
704
+ username: string;
639
705
  name?: string;
640
706
  status?: string;
641
707
  age?: number;
@@ -658,7 +724,7 @@ describe('QueryBuilder - projection placeholders', () => {
658
724
 
659
725
  test('projects reserved DynamoDB words via #-placeholders', () => {
660
726
  const params = createQueryBuilder<UserModel>(tableName, client, userModel)
661
- .where((attr, op) => op.eq(attr.pk, 'USER#alice'))
727
+ .where((attr, op) => op.eq(attr.username, 'alice'))
662
728
  .select(['name', 'status', 'type'])
663
729
  .dbParams();
664
730
 
@@ -673,11 +739,7 @@ describe('QueryBuilder - projection placeholders', () => {
673
739
  });
674
740
 
675
741
  test('merges projection names with key/filter names without clobbering', () => {
676
- const params = createQueryBuilder<UserModel & { username: string }>(
677
- tableName,
678
- client,
679
- userModel
680
- )
742
+ const params = createQueryBuilder<UserModel>(tableName, client, userModel)
681
743
  .where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.gt(attr.age, 18)))
682
744
  .select(['name', 'status'])
683
745
  .dbParams();
@@ -696,7 +758,7 @@ describe('QueryBuilder - projection placeholders', () => {
696
758
 
697
759
  test('shares the same placeholder when an attribute is both projected and filtered (no key duplication)', () => {
698
760
  const params = createQueryBuilder<UserModel>(tableName, client, userModel)
699
- .where((attr, op) => op.and(op.eq(attr.pk, 'USER#alice'), op.eq(attr.status, 'active')))
761
+ .where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.eq(attr.status, 'active')))
700
762
  .select(['name', 'status'])
701
763
  .dbParams();
702
764
 
@@ -264,6 +264,31 @@ function createQueryExecutor<Model>(state: QueryState<Model>): QueryExecutor<Mod
264
264
  state.indexName
265
265
  );
266
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
+
267
292
  // Build KeyConditionExpression (simple AND)
268
293
  const keyExpr = buildSimpleAndExpression(keyConditions);
269
294