@ftschopp/dynatable-core 1.4.2 → 1.4.3

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/builders/query/create-query-builder.d.ts.map +1 -1
  3. package/dist/builders/query/create-query-builder.js +19 -2
  4. package/dist/core/types.d.ts +5 -0
  5. package/dist/core/types.d.ts.map +1 -1
  6. package/package.json +11 -2
  7. package/eslint.config.mjs +0 -4
  8. package/jest.config.js +0 -11
  9. package/src/builders/README.md +0 -339
  10. package/src/builders/batch-get/README.md +0 -43
  11. package/src/builders/batch-get/create-batch-get-builder.test.ts +0 -193
  12. package/src/builders/batch-get/create-batch-get-builder.ts +0 -108
  13. package/src/builders/batch-get/index.ts +0 -2
  14. package/src/builders/batch-get/types.ts +0 -28
  15. package/src/builders/batch-write/README.md +0 -204
  16. package/src/builders/batch-write/create-batch-write-builder.test.ts +0 -173
  17. package/src/builders/batch-write/create-batch-write-builder.ts +0 -49
  18. package/src/builders/batch-write/index.ts +0 -2
  19. package/src/builders/batch-write/types.ts +0 -33
  20. package/src/builders/delete/create-delete-builder.test.ts +0 -294
  21. package/src/builders/delete/create-delete-builder.ts +0 -100
  22. package/src/builders/delete/index.ts +0 -2
  23. package/src/builders/delete/types.ts +0 -17
  24. package/src/builders/get/create-get-builder.test.ts +0 -272
  25. package/src/builders/get/create-get-builder.ts +0 -117
  26. package/src/builders/get/index.ts +0 -2
  27. package/src/builders/get/types.ts +0 -39
  28. package/src/builders/index.ts +0 -14
  29. package/src/builders/put/create-put-builder.test.ts +0 -213
  30. package/src/builders/put/create-put-builder.ts +0 -160
  31. package/src/builders/put/index.ts +0 -2
  32. package/src/builders/put/types.ts +0 -24
  33. package/src/builders/query/create-query-builder.test.ts +0 -770
  34. package/src/builders/query/create-query-builder.ts +0 -455
  35. package/src/builders/query/index.ts +0 -2
  36. package/src/builders/query/types.ts +0 -109
  37. package/src/builders/scan/create-scan-builder.test.ts +0 -416
  38. package/src/builders/scan/create-scan-builder.ts +0 -265
  39. package/src/builders/scan/index.ts +0 -2
  40. package/src/builders/scan/types.ts +0 -88
  41. package/src/builders/shared/conditions.ts +0 -58
  42. package/src/builders/shared/index.ts +0 -4
  43. package/src/builders/shared/operators.test.ts +0 -270
  44. package/src/builders/shared/operators.ts +0 -208
  45. package/src/builders/shared/projection.ts +0 -28
  46. package/src/builders/shared/types.ts +0 -101
  47. package/src/builders/transact-get/README.md +0 -167
  48. package/src/builders/transact-get/create-transact-get-builder.test.ts +0 -239
  49. package/src/builders/transact-get/create-transact-get-builder.ts +0 -67
  50. package/src/builders/transact-get/index.ts +0 -2
  51. package/src/builders/transact-get/types.ts +0 -31
  52. package/src/builders/transact-write/README.md +0 -202
  53. package/src/builders/transact-write/create-transact-write-builder.test.ts +0 -288
  54. package/src/builders/transact-write/create-transact-write-builder.ts +0 -124
  55. package/src/builders/transact-write/index.ts +0 -2
  56. package/src/builders/transact-write/types.ts +0 -71
  57. package/src/builders/update/create-update-builder.test.ts +0 -573
  58. package/src/builders/update/create-update-builder.ts +0 -375
  59. package/src/builders/update/index.ts +0 -2
  60. package/src/builders/update/types.ts +0 -63
  61. package/src/core/types.test.ts +0 -641
  62. package/src/core/types.ts +0 -366
  63. package/src/entity/create-entity-api.ts +0 -224
  64. package/src/entity/index.ts +0 -19
  65. package/src/entity/middleware/factories.ts +0 -15
  66. package/src/entity/middleware/types.ts +0 -7
  67. package/src/entity/middleware/with-middleware.ts +0 -37
  68. package/src/entity/types.ts +0 -79
  69. package/src/entity/validation/key-validation.ts +0 -34
  70. package/src/index.ts +0 -23
  71. package/src/table.ts +0 -116
  72. package/src/utils/dynamodb-logger.test.ts +0 -246
  73. package/src/utils/dynamodb-logger.ts +0 -175
  74. package/src/utils/model-utils.test.ts +0 -452
  75. package/src/utils/model-utils.ts +0 -188
  76. package/src/utils/zod-utils.test.ts +0 -363
  77. package/src/utils/zod-utils.ts +0 -56
  78. package/tests/integration/instagram-clone.integration.test.ts +0 -1019
  79. package/tests/integration/pagination-timestamps.integration.test.ts +0 -374
  80. package/tests/integration/transactions.integration.test.ts +0 -528
  81. package/tsconfig.json +0 -12
@@ -1,770 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
3
- import { QueryCommand } from '@aws-sdk/lib-dynamodb';
4
- import { mockClient } from 'aws-sdk-client-mock';
5
- import { createQueryBuilder } from './create-query-builder';
6
- import { ModelDefinition } from '../../core/types';
7
-
8
- const ddbMock = mockClient(DynamoDBClient);
9
-
10
- describe('QueryBuilder - Pagination', () => {
11
- const client = new DynamoDBClient({});
12
- const tableName = 'TestTable';
13
-
14
- interface TestModel {
15
- PK: string;
16
- SK: string;
17
- username: string;
18
- name?: string;
19
- age?: number;
20
- status?: string;
21
- }
22
-
23
- const testModel: ModelDefinition = {
24
- key: {
25
- PK: { type: String, value: 'USER#${username}' },
26
- SK: { type: String, value: 'USER#${username}' },
27
- },
28
- attributes: {
29
- username: { type: String, required: true },
30
- name: { type: String },
31
- age: { type: Number },
32
- status: { type: String },
33
- },
34
- };
35
-
36
- beforeEach(() => {
37
- ddbMock.reset();
38
- });
39
-
40
- describe('startFrom method', () => {
41
- test('should add ExclusiveStartKey to query params', () => {
42
- const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
43
- const params = createQueryBuilder<TestModel>(tableName, client, testModel)
44
- .where((attr, op) => op.eq(attr.username, 'alice'))
45
- .startFrom(startKey)
46
- .dbParams();
47
-
48
- expect(params.ExclusiveStartKey).toEqual(startKey);
49
- });
50
-
51
- test('should work with all other query options', () => {
52
- const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
53
- const params = createQueryBuilder<TestModel>(tableName, client, testModel)
54
- .where((attr, op) => op.eq(attr.username, 'alice'))
55
- .startFrom(startKey)
56
- .limit(10)
57
- .scanIndexForward(false)
58
- .select(['name', 'age'])
59
- .dbParams();
60
-
61
- expect(params.ExclusiveStartKey).toEqual(startKey);
62
- expect(params.Limit).toBe(10);
63
- expect(params.ScanIndexForward).toBe(false);
64
- expect(params.ProjectionExpression).toBe('#name, #age');
65
- expect(params.ExpressionAttributeNames).toEqual(
66
- expect.objectContaining({
67
- '#name': 'name',
68
- '#age': 'age',
69
- })
70
- );
71
- });
72
-
73
- test('should not include ExclusiveStartKey if not set', () => {
74
- const params = createQueryBuilder<TestModel>(tableName, client, testModel)
75
- .where((attr, op) => op.eq(attr.username, 'alice'))
76
- .dbParams();
77
-
78
- expect(params.ExclusiveStartKey).toBeUndefined();
79
- });
80
- });
81
-
82
- describe('executeWithPagination method', () => {
83
- test('should return items and lastEvaluatedKey', async () => {
84
- const mockItems = [
85
- { PK: 'USER#alice', SK: 'USER#alice', name: 'Alice' },
86
- { PK: 'USER#bob', SK: 'USER#bob', name: 'Bob' },
87
- ];
88
- const mockLastKey = { PK: 'USER#bob', SK: 'USER#bob' };
89
-
90
- ddbMock.on(QueryCommand).resolves({
91
- Items: mockItems,
92
- LastEvaluatedKey: mockLastKey,
93
- Count: 2,
94
- ScannedCount: 2,
95
- });
96
-
97
- const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
98
- .where((attr, op) => op.eq(attr.username, 'alice'))
99
- .limit(2)
100
- .executeWithPagination();
101
-
102
- expect(result.items).toEqual(mockItems);
103
- expect(result.lastEvaluatedKey).toEqual(mockLastKey);
104
- expect(result.count).toBe(2);
105
- expect(result.scannedCount).toBe(2);
106
- });
107
-
108
- test('should return undefined lastEvaluatedKey when no more results', async () => {
109
- const mockItems = [{ PK: 'USER#alice', SK: 'USER#alice', name: 'Alice' }];
110
-
111
- ddbMock.on(QueryCommand).resolves({
112
- Items: mockItems,
113
- Count: 1,
114
- ScannedCount: 1,
115
- });
116
-
117
- const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
118
- .where((attr, op) => op.eq(attr.username, 'alice'))
119
- .executeWithPagination();
120
-
121
- expect(result.items).toEqual(mockItems);
122
- expect(result.lastEvaluatedKey).toBeUndefined();
123
- });
124
-
125
- test('should handle empty results', async () => {
126
- ddbMock.on(QueryCommand).resolves({
127
- Items: [],
128
- Count: 0,
129
- ScannedCount: 0,
130
- });
131
-
132
- const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
133
- .where((attr, op) => op.eq(attr.username, 'nonexistent'))
134
- .executeWithPagination();
135
-
136
- expect(result.items).toEqual([]);
137
- expect(result.lastEvaluatedKey).toBeUndefined();
138
- expect(result.count).toBe(0);
139
- expect(result.scannedCount).toBe(0);
140
- });
141
- });
142
-
143
- describe('Pagination workflow', () => {
144
- test('should support manual pagination loop', async () => {
145
- // First page
146
- const page1Items = [
147
- { PK: 'USER#1', SK: 'USER#1', name: 'User 1' },
148
- { PK: 'USER#2', SK: 'USER#2', name: 'User 2' },
149
- ];
150
- const page1LastKey = { PK: 'USER#2', SK: 'USER#2' };
151
-
152
- // Second page
153
- const page2Items = [
154
- { PK: 'USER#3', SK: 'USER#3', name: 'User 3' },
155
- { PK: 'USER#4', SK: 'USER#4', name: 'User 4' },
156
- ];
157
- const page2LastKey = { PK: 'USER#4', SK: 'USER#4' };
158
-
159
- // Third page (last)
160
- const page3Items = [{ PK: 'USER#5', SK: 'USER#5', name: 'User 5' }];
161
-
162
- ddbMock
163
- .on(QueryCommand)
164
- .resolvesOnce({
165
- Items: page1Items,
166
- LastEvaluatedKey: page1LastKey,
167
- Count: 2,
168
- ScannedCount: 2,
169
- })
170
- .resolvesOnce({
171
- Items: page2Items,
172
- LastEvaluatedKey: page2LastKey,
173
- Count: 2,
174
- ScannedCount: 2,
175
- })
176
- .resolvesOnce({
177
- Items: page3Items,
178
- Count: 1,
179
- ScannedCount: 1,
180
- });
181
-
182
- const allItems: any[] = [];
183
- let lastKey: any = undefined;
184
-
185
- // Page 1
186
- const result1 = await createQueryBuilder<TestModel>(tableName, client, testModel)
187
- .where((attr, op) => op.beginsWith(attr.username, ''))
188
- .limit(2)
189
- .executeWithPagination();
190
-
191
- allItems.push(...result1.items);
192
- lastKey = result1.lastEvaluatedKey;
193
- expect(lastKey).toBeDefined();
194
-
195
- // Page 2
196
- const result2 = await createQueryBuilder<TestModel>(tableName, client, testModel)
197
- .where((attr, op) => op.beginsWith(attr.username, ''))
198
- .limit(2)
199
- .startFrom(lastKey)
200
- .executeWithPagination();
201
-
202
- allItems.push(...result2.items);
203
- lastKey = result2.lastEvaluatedKey;
204
- expect(lastKey).toBeDefined();
205
-
206
- // Page 3
207
- const result3 = await createQueryBuilder<TestModel>(tableName, client, testModel)
208
- .where((attr, op) => op.beginsWith(attr.username, ''))
209
- .limit(2)
210
- .startFrom(lastKey)
211
- .executeWithPagination();
212
-
213
- allItems.push(...result3.items);
214
- lastKey = result3.lastEvaluatedKey;
215
- expect(lastKey).toBeUndefined();
216
-
217
- // Verify all items collected
218
- expect(allItems).toHaveLength(5);
219
- expect(allItems[0].name).toBe('User 1');
220
- expect(allItems[4].name).toBe('User 5');
221
- });
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
-
288
- describe('Immutability', () => {
289
- test('should create new builder instance when using startFrom', () => {
290
- const builder1 = createQueryBuilder<TestModel>(tableName, client, testModel).where(
291
- (attr, op) => op.eq(attr.username, 'alice')
292
- );
293
-
294
- const startKey = { PK: 'USER#alice', SK: 'USER#alice' };
295
- const builder2 = builder1.startFrom(startKey);
296
-
297
- expect(builder1.dbParams().ExclusiveStartKey).toBeUndefined();
298
- expect(builder2.dbParams().ExclusiveStartKey).toEqual(startKey);
299
- });
300
- });
301
-
302
- describe('iterate method', () => {
303
- test('walks every page transparently and yields items in order', async () => {
304
- const page1 = [
305
- { PK: 'USER#1', SK: 'USER#1', name: 'User 1' },
306
- { PK: 'USER#2', SK: 'USER#2', name: 'User 2' },
307
- ];
308
- const page2 = [
309
- { PK: 'USER#3', SK: 'USER#3', name: 'User 3' },
310
- { PK: 'USER#4', SK: 'USER#4', name: 'User 4' },
311
- ];
312
- const page3 = [{ PK: 'USER#5', SK: 'USER#5', name: 'User 5' }];
313
-
314
- ddbMock
315
- .on(QueryCommand)
316
- .resolvesOnce({ Items: page1, LastEvaluatedKey: { PK: 'USER#2', SK: 'USER#2' } })
317
- .resolvesOnce({ Items: page2, LastEvaluatedKey: { PK: 'USER#4', SK: 'USER#4' } })
318
- .resolvesOnce({ Items: page3 });
319
-
320
- const collected: TestModel[] = [];
321
- for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
322
- .where((attr, op) => op.beginsWith(attr.username, ''))
323
- .iterate()) {
324
- collected.push(item);
325
- }
326
-
327
- expect(collected).toHaveLength(5);
328
- expect(collected.map((u) => u.name)).toEqual([
329
- 'User 1',
330
- 'User 2',
331
- 'User 3',
332
- 'User 4',
333
- 'User 5',
334
- ]);
335
- expect(ddbMock.calls()).toHaveLength(3);
336
- });
337
-
338
- test('forwards ExclusiveStartKey from each response into the next call', async () => {
339
- const cursor1 = { PK: 'USER#2', SK: 'USER#2' };
340
- const cursor2 = { PK: 'USER#4', SK: 'USER#4' };
341
-
342
- ddbMock
343
- .on(QueryCommand)
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' }] });
347
-
348
- const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
349
- .where((attr, op) => op.beginsWith(attr.username, ''))
350
- .iterate();
351
-
352
- // Consume the iterator
353
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
354
- for await (const _ of iterator) {
355
- // drain
356
- }
357
-
358
- const calls = ddbMock.calls();
359
- expect(calls).toHaveLength(3);
360
- expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toBeUndefined();
361
- expect((calls[1]!.args[0].input as any).ExclusiveStartKey).toEqual(cursor1);
362
- expect((calls[2]!.args[0].input as any).ExclusiveStartKey).toEqual(cursor2);
363
- });
364
-
365
- test('starts from the user-provided cursor on the first call', async () => {
366
- const startKey = { PK: 'USER#10', SK: 'USER#10' };
367
-
368
- ddbMock.on(QueryCommand).resolves({ Items: [{ PK: 'USER#11', SK: 'USER#11' }] });
369
-
370
- const iterator = createQueryBuilder<TestModel>(tableName, client, testModel)
371
- .where((attr, op) => op.beginsWith(attr.username, ''))
372
- .startFrom(startKey)
373
- .iterate();
374
-
375
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
376
- for await (const _ of iterator) {
377
- // drain
378
- }
379
-
380
- const calls = ddbMock.calls();
381
- expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toEqual(startKey);
382
- });
383
-
384
- test('break out of the loop stops further DynamoDB calls', async () => {
385
- ddbMock
386
- .on(QueryCommand)
387
- .resolvesOnce({
388
- Items: [
389
- { PK: 'USER#1', SK: 'USER#1', name: 'User 1' },
390
- { PK: 'USER#2', SK: 'USER#2', name: 'User 2' },
391
- ],
392
- LastEvaluatedKey: { PK: 'USER#2', SK: 'USER#2' },
393
- });
394
-
395
- const collected: TestModel[] = [];
396
- for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
397
- .where((attr, op) => op.beginsWith(attr.username, ''))
398
- .iterate()) {
399
- collected.push(item);
400
- if (collected.length >= 1) break;
401
- }
402
-
403
- expect(collected).toHaveLength(1);
404
- expect(ddbMock.calls()).toHaveLength(1);
405
- });
406
-
407
- test('handles an empty result without making extra calls', async () => {
408
- ddbMock.on(QueryCommand).resolves({ Items: [] });
409
-
410
- const collected: TestModel[] = [];
411
- for await (const item of createQueryBuilder<TestModel>(tableName, client, testModel)
412
- .where((attr, op) => op.eq(attr.username, 'nonexistent'))
413
- .iterate()) {
414
- collected.push(item);
415
- }
416
-
417
- expect(collected).toEqual([]);
418
- expect(ddbMock.calls()).toHaveLength(1);
419
- });
420
- });
421
- });
422
-
423
- describe('QueryBuilder - GSI key recognition', () => {
424
- const client = new DynamoDBClient({});
425
- const tableName = 'TestTable';
426
-
427
- interface UserModel {
428
- id: string;
429
- email: string;
430
- name?: string;
431
- }
432
-
433
- const modelWithIndex: ModelDefinition = {
434
- key: {
435
- PK: { type: String, value: 'USER#${id}' },
436
- SK: { type: String, value: 'USER#${id}' },
437
- },
438
- index: {
439
- GSI1PK: { type: String, value: 'EMAIL#${email}' },
440
- GSI1SK: { type: String, value: 'EMAIL#${email}' },
441
- },
442
- attributes: {
443
- id: { type: String, required: true },
444
- email: { type: String, required: true },
445
- name: { type: String },
446
- },
447
- };
448
-
449
- test('should place GSI index attribute into KeyConditionExpression when useIndex is set', () => {
450
- const params = createQueryBuilder<UserModel>(tableName, client, modelWithIndex)
451
- .where((attr, op) => op.eq(attr.email, 'alice@example.com'))
452
- .useIndex('GSI1')
453
- .dbParams();
454
-
455
- // email should be recognized as a key field via model.index and placed in KeyConditionExpression
456
- expect(params.KeyConditionExpression).toBeDefined();
457
- expect(params.KeyConditionExpression).toContain('#GSI1PK');
458
- expect(params.ExpressionAttributeValues).toBeDefined();
459
- // The value should have the template applied
460
- expect(Object.values(params.ExpressionAttributeValues!)).toContain('EMAIL#alice@example.com');
461
- // It should NOT be in FilterExpression
462
- expect(params.FilterExpression).toBeUndefined();
463
- expect(params.IndexName).toBe('GSI1');
464
- });
465
-
466
- test('should place GSI attribute into FilterExpression when NO index is specified', () => {
467
- const params = createQueryBuilder<UserModel>(tableName, client, modelWithIndex)
468
- .where((attr, op) => op.and(op.eq(attr.id, '123'), op.eq(attr.email, 'alice@example.com')))
469
- .dbParams();
470
-
471
- // id maps to PK -> KeyConditionExpression
472
- expect(params.KeyConditionExpression).toBeDefined();
473
- expect(params.KeyConditionExpression).toContain('#PK');
474
- // email is NOT a primary key field, so without useIndex it goes to filter
475
- expect(params.FilterExpression).toBeDefined();
476
- expect(params.FilterExpression).toContain('#email');
477
- });
478
-
479
- test('should handle both primary key and GSI key fields together with useIndex', () => {
480
- const params = createQueryBuilder<UserModel>(tableName, client, modelWithIndex)
481
- .where((attr, op) => op.and(op.eq(attr.email, 'alice@example.com'), op.eq(attr.id, '123')))
482
- .useIndex('GSI1')
483
- .dbParams();
484
-
485
- // Both should be key conditions: email via index, id via primary key
486
- expect(params.KeyConditionExpression).toBeDefined();
487
- expect(params.KeyConditionExpression).toContain('#GSI1PK');
488
- expect(params.KeyConditionExpression).toContain('#PK');
489
- expect(params.FilterExpression).toBeUndefined();
490
- });
491
- });
492
-
493
- describe('QueryBuilder - multi-variable key templates', () => {
494
- const client = new DynamoDBClient({});
495
- const tableName = 'TestTable';
496
-
497
- interface AirportResource {
498
- id: string;
499
- airport: string;
500
- category: string;
501
- code: string;
502
- status: string;
503
- }
504
-
505
- const airportResourceModel: ModelDefinition = {
506
- key: {
507
- PK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
508
- SK: { type: String, value: 'AIRPORT_RESOURCE#${id}' },
509
- },
510
- index: {
511
- GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
512
- GSI1SK: { type: String, value: 'RES#${category}#${code}' },
513
- },
514
- attributes: {
515
- id: { type: String, required: true },
516
- airport: { type: String, required: true },
517
- category: { type: String, required: true },
518
- code: { type: String, required: true },
519
- status: { type: String, required: true },
520
- },
521
- };
522
-
523
- test('beginsWith on attribute that is part of multi-var template should truncate at the first unfilled variable', () => {
524
- const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
525
- .where((attr, op) =>
526
- op.and(op.eq(attr.airport, 'EZE'), op.beginsWith(attr.category, 'GPU'))
527
- )
528
- .useIndex('GSI1')
529
- .dbParams();
530
-
531
- expect(params.KeyConditionExpression).toBeDefined();
532
- expect(params.KeyConditionExpression).toContain('#GSI1PK');
533
- expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
534
- expect(params.IndexName).toBe('GSI1');
535
-
536
- const values = params.ExpressionAttributeValues ?? {};
537
- expect(Object.values(values)).toContain('AIRPORT#EZE');
538
- // Should be the prefix up to the next unfilled var, NOT 'RES#GPU#${code}'
539
- expect(Object.values(values)).toContain('RES#GPU#');
540
- expect(Object.values(values).every((v) => !String(v).includes('${'))).toBe(true);
541
- });
542
-
543
- test('eq on attribute with multi-var template should throw a clear error', () => {
544
- expect(() =>
545
- createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
546
- .where((attr, op) => op.eq(attr.category, 'GPU'))
547
- .useIndex('GSI1')
548
- .dbParams()
549
- ).toThrow(/template/i);
550
- });
551
-
552
- test('beginsWith on a single-var template still works (no truncation needed)', () => {
553
- const params = createQueryBuilder<AirportResource>(tableName, client, airportResourceModel)
554
- .where((attr, op) => op.beginsWith(attr.airport, 'EZ'))
555
- .useIndex('GSI1')
556
- .dbParams();
557
-
558
- const values = params.ExpressionAttributeValues ?? {};
559
- expect(Object.values(values)).toContain('AIRPORT#EZ');
560
- });
561
- });
562
-
563
- describe('QueryBuilder - entity type auto filter', () => {
564
- const client = new DynamoDBClient({});
565
- const tableName = 'TestTable';
566
-
567
- interface AirportPersonnel {
568
- id: string;
569
- airport: string;
570
- role: string;
571
- }
572
-
573
- // Mirrors the reported scenario: PK is entity-specific, GSI1 is shared
574
- // across multiple entities (Airport, AirportPersonnel, etc.)
575
- const airportPersonnelModel: ModelDefinition = {
576
- key: {
577
- PK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
578
- SK: { type: String, value: 'AIRPORT_PERSONNEL#${id}' },
579
- },
580
- index: {
581
- GSI1PK: { type: String, value: 'AIRPORT#${airport}' },
582
- GSI1SK: { type: String, value: 'PERSONNEL#${role}' },
583
- },
584
- attributes: {
585
- id: { type: String, required: true },
586
- airport: { type: String, required: true },
587
- role: { type: String, required: true },
588
- },
589
- };
590
-
591
- test('does NOT add _type filter when entityType is not provided (back-compat)', () => {
592
- const params = createQueryBuilder<AirportPersonnel>(
593
- tableName,
594
- client,
595
- airportPersonnelModel
596
- )
597
- .where((attr, op) => op.eq(attr.airport, 'EZE'))
598
- .useIndex('GSI1')
599
- .dbParams();
600
-
601
- expect(params.FilterExpression).toBeUndefined();
602
- const names = params.ExpressionAttributeNames ?? {};
603
- expect(names['#_type']).toBeUndefined();
604
- });
605
-
606
- test('adds _type filter when entityType is provided and there are no other filters', () => {
607
- const params = createQueryBuilder<AirportPersonnel>(
608
- tableName,
609
- client,
610
- airportPersonnelModel,
611
- undefined,
612
- 'AirportPersonnel'
613
- )
614
- .where((attr, op) => op.eq(attr.airport, 'EZE'))
615
- .useIndex('GSI1')
616
- .dbParams();
617
-
618
- // Key cond goes to KeyConditionExpression, _type goes to FilterExpression
619
- expect(params.KeyConditionExpression).toBeDefined();
620
- expect(params.KeyConditionExpression).toContain('#GSI1PK');
621
- expect(params.FilterExpression).toBe('#_type = :_type');
622
- expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
623
- expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
624
- });
625
-
626
- test('combines _type filter with a user-provided non-key filter via AND', () => {
627
- const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
628
- tableName,
629
- client,
630
- airportPersonnelModel,
631
- undefined,
632
- 'AirportPersonnel'
633
- )
634
- .where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
635
- .useIndex('GSI1')
636
- .dbParams();
637
-
638
- expect(params.KeyConditionExpression).toContain('#GSI1PK');
639
- expect(params.FilterExpression).toBeDefined();
640
- expect(params.FilterExpression).toContain('#status');
641
- expect(params.FilterExpression).toContain('#_type = :_type');
642
- expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
643
- });
644
-
645
- test('repro of reported bug: GSI1 query with conditional beginsWith still filters by _type', () => {
646
- // The exact shape from the user report:
647
- // .where((attr, op) =>
648
- // role
649
- // ? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
650
- // : op.eq(attr.airport, airport)
651
- // )
652
- const airport = 'EZE';
653
- const role = 'PILOT';
654
-
655
- const params = createQueryBuilder<AirportPersonnel>(
656
- tableName,
657
- client,
658
- airportPersonnelModel,
659
- undefined,
660
- 'AirportPersonnel'
661
- )
662
- .where((attr, op) =>
663
- role
664
- ? op.and(op.eq(attr.airport, airport), op.beginsWith(attr.role, role))
665
- : op.eq(attr.airport, airport)
666
- )
667
- .useIndex('GSI1')
668
- .dbParams();
669
-
670
- // Both airport (GSI1PK) and role (GSI1SK) become key conditions
671
- expect(params.KeyConditionExpression).toContain('#GSI1PK');
672
- expect(params.KeyConditionExpression).toContain('begins_with(#GSI1SK');
673
- // _type still gets enforced as a filter — this is the fix
674
- expect(params.FilterExpression).toBe('#_type = :_type');
675
- expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
676
- });
677
-
678
- test('does not collide with user attribute placeholders', () => {
679
- // Confirm the auto-injected names/values don't clash with user-supplied ones
680
- const params = createQueryBuilder<AirportPersonnel & { status?: string }>(
681
- tableName,
682
- client,
683
- airportPersonnelModel,
684
- undefined,
685
- 'AirportPersonnel'
686
- )
687
- .where((attr, op) => op.and(op.eq(attr.airport, 'EZE'), op.eq(attr.status, 'active')))
688
- .useIndex('GSI1')
689
- .dbParams();
690
-
691
- expect(params.ExpressionAttributeNames?.['#_type']).toBe('_type');
692
- expect(params.ExpressionAttributeNames?.['#status']).toBe('status');
693
- expect(params.ExpressionAttributeValues?.[':_type']).toBe('AirportPersonnel');
694
- });
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
- });