@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;
|
|
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
|
@@ -12,8 +12,9 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
12
12
|
const tableName = 'TestTable';
|
|
13
13
|
|
|
14
14
|
interface TestModel {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
PK: string;
|
|
16
|
+
SK: string;
|
|
17
|
+
username: string;
|
|
17
18
|
name?: string;
|
|
18
19
|
age?: number;
|
|
19
20
|
status?: string;
|
|
@@ -38,9 +39,9 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
38
39
|
|
|
39
40
|
describe('startFrom method', () => {
|
|
40
41
|
test('should add ExclusiveStartKey to query params', () => {
|
|
41
|
-
const startKey = {
|
|
42
|
+
const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
|
|
42
43
|
const params = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
43
|
-
.where((attr, op) => op.eq(attr.
|
|
44
|
+
.where((attr, op) => op.eq(attr.username, 'alice'))
|
|
44
45
|
.startFrom(startKey)
|
|
45
46
|
.dbParams();
|
|
46
47
|
|
|
@@ -48,9 +49,9 @@ describe('QueryBuilder - Pagination', () => {
|
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
test('should work with all other query options', () => {
|
|
51
|
-
const startKey = {
|
|
52
|
+
const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
|
|
52
53
|
const params = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
53
|
-
.where((attr, op) => op.eq(attr.
|
|
54
|
+
.where((attr, op) => op.eq(attr.username, 'alice'))
|
|
54
55
|
.startFrom(startKey)
|
|
55
56
|
.limit(10)
|
|
56
57
|
.scanIndexForward(false)
|
|
@@ -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.
|
|
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
|
-
{
|
|
85
|
-
{
|
|
85
|
+
{ PK: 'USER#alice', SK: 'USER#alice', name: 'Alice' },
|
|
86
|
+
{ PK: 'USER#bob', SK: 'USER#bob', name: 'Bob' },
|
|
86
87
|
];
|
|
87
|
-
const mockLastKey = {
|
|
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.
|
|
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 = [{
|
|
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.
|
|
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.
|
|
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
|
-
{
|
|
147
|
-
{
|
|
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 = {
|
|
150
|
+
const page1LastKey = { PK: 'USER#2', SK: 'USER#2' };
|
|
150
151
|
|
|
151
152
|
// Second page
|
|
152
153
|
const page2Items = [
|
|
153
|
-
{
|
|
154
|
-
{
|
|
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 = {
|
|
157
|
+
const page2LastKey = { PK: 'USER#4', SK: 'USER#4' };
|
|
157
158
|
|
|
158
159
|
// Third page (last)
|
|
159
|
-
const page3Items = [{
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
291
|
+
(attr, op) => op.eq(attr.username, 'alice')
|
|
227
292
|
);
|
|
228
293
|
|
|
229
|
-
const startKey = {
|
|
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
|
-
{
|
|
241
|
-
{
|
|
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
|
-
{
|
|
245
|
-
{
|
|
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 = [{
|
|
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: {
|
|
252
|
-
.resolvesOnce({ Items: page2, LastEvaluatedKey: {
|
|
316
|
+
.resolvesOnce({ Items: page1, LastEvaluatedKey: { PK: 'USER#2', SK: 'USER#2' } })
|
|
317
|
+
.resolvesOnce({ Items: page2, LastEvaluatedKey: { PK: 'USER#4', SK: 'USER#4' } })
|
|
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.
|
|
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 = {
|
|
275
|
-
const cursor2 = {
|
|
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: [{
|
|
280
|
-
.resolvesOnce({ Items: [{
|
|
281
|
-
.resolvesOnce({ Items: [{
|
|
344
|
+
.resolvesOnce({ Items: [{ PK: 'USER#1', SK: 'USER#1' }], LastEvaluatedKey: cursor1 })
|
|
345
|
+
.resolvesOnce({ Items: [{ PK: 'USER#3', SK: 'USER#3' }], LastEvaluatedKey: cursor2 })
|
|
346
|
+
.resolvesOnce({ Items: [{ PK: 'USER#5', SK: 'USER#5' }] });
|
|
282
347
|
|
|
283
348
|
const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
284
|
-
.where((attr, op) => op.beginsWith(attr.
|
|
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 = {
|
|
366
|
+
const startKey = { PK: 'USER#10', SK: 'USER#10' };
|
|
302
367
|
|
|
303
|
-
ddbMock.on(QueryCommand).resolves({ Items: [{
|
|
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.
|
|
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
|
-
{
|
|
325
|
-
{
|
|
389
|
+
{ PK: 'USER#1', SK: 'USER#1', name: 'User 1' },
|
|
390
|
+
{ PK: 'USER#2', SK: 'USER#2', name: 'User 2' },
|
|
326
391
|
],
|
|
327
|
-
LastEvaluatedKey: {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
638
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|