@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.
- package/CHANGELOG.md +7 -0
- package/dist/builders/query/create-query-builder.d.ts.map +1 -1
- package/dist/builders/query/create-query-builder.js +19 -2
- package/dist/core/types.d.ts +5 -0
- package/dist/core/types.d.ts.map +1 -1
- package/package.json +11 -2
- package/eslint.config.mjs +0 -4
- package/jest.config.js +0 -11
- package/src/builders/README.md +0 -339
- package/src/builders/batch-get/README.md +0 -43
- package/src/builders/batch-get/create-batch-get-builder.test.ts +0 -193
- package/src/builders/batch-get/create-batch-get-builder.ts +0 -108
- package/src/builders/batch-get/index.ts +0 -2
- package/src/builders/batch-get/types.ts +0 -28
- package/src/builders/batch-write/README.md +0 -204
- package/src/builders/batch-write/create-batch-write-builder.test.ts +0 -173
- package/src/builders/batch-write/create-batch-write-builder.ts +0 -49
- package/src/builders/batch-write/index.ts +0 -2
- package/src/builders/batch-write/types.ts +0 -33
- package/src/builders/delete/create-delete-builder.test.ts +0 -294
- package/src/builders/delete/create-delete-builder.ts +0 -100
- package/src/builders/delete/index.ts +0 -2
- package/src/builders/delete/types.ts +0 -17
- package/src/builders/get/create-get-builder.test.ts +0 -272
- package/src/builders/get/create-get-builder.ts +0 -117
- package/src/builders/get/index.ts +0 -2
- package/src/builders/get/types.ts +0 -39
- package/src/builders/index.ts +0 -14
- package/src/builders/put/create-put-builder.test.ts +0 -213
- package/src/builders/put/create-put-builder.ts +0 -160
- package/src/builders/put/index.ts +0 -2
- package/src/builders/put/types.ts +0 -24
- package/src/builders/query/create-query-builder.test.ts +0 -770
- package/src/builders/query/create-query-builder.ts +0 -455
- package/src/builders/query/index.ts +0 -2
- package/src/builders/query/types.ts +0 -109
- package/src/builders/scan/create-scan-builder.test.ts +0 -416
- package/src/builders/scan/create-scan-builder.ts +0 -265
- package/src/builders/scan/index.ts +0 -2
- package/src/builders/scan/types.ts +0 -88
- package/src/builders/shared/conditions.ts +0 -58
- package/src/builders/shared/index.ts +0 -4
- package/src/builders/shared/operators.test.ts +0 -270
- package/src/builders/shared/operators.ts +0 -208
- package/src/builders/shared/projection.ts +0 -28
- package/src/builders/shared/types.ts +0 -101
- package/src/builders/transact-get/README.md +0 -167
- package/src/builders/transact-get/create-transact-get-builder.test.ts +0 -239
- package/src/builders/transact-get/create-transact-get-builder.ts +0 -67
- package/src/builders/transact-get/index.ts +0 -2
- package/src/builders/transact-get/types.ts +0 -31
- package/src/builders/transact-write/README.md +0 -202
- package/src/builders/transact-write/create-transact-write-builder.test.ts +0 -288
- package/src/builders/transact-write/create-transact-write-builder.ts +0 -124
- package/src/builders/transact-write/index.ts +0 -2
- package/src/builders/transact-write/types.ts +0 -71
- package/src/builders/update/create-update-builder.test.ts +0 -573
- package/src/builders/update/create-update-builder.ts +0 -375
- package/src/builders/update/index.ts +0 -2
- package/src/builders/update/types.ts +0 -63
- package/src/core/types.test.ts +0 -641
- package/src/core/types.ts +0 -366
- package/src/entity/create-entity-api.ts +0 -224
- package/src/entity/index.ts +0 -19
- package/src/entity/middleware/factories.ts +0 -15
- package/src/entity/middleware/types.ts +0 -7
- package/src/entity/middleware/with-middleware.ts +0 -37
- package/src/entity/types.ts +0 -79
- package/src/entity/validation/key-validation.ts +0 -34
- package/src/index.ts +0 -23
- package/src/table.ts +0 -116
- package/src/utils/dynamodb-logger.test.ts +0 -246
- package/src/utils/dynamodb-logger.ts +0 -175
- package/src/utils/model-utils.test.ts +0 -452
- package/src/utils/model-utils.ts +0 -188
- package/src/utils/zod-utils.test.ts +0 -363
- package/src/utils/zod-utils.ts +0 -56
- package/tests/integration/instagram-clone.integration.test.ts +0 -1019
- package/tests/integration/pagination-timestamps.integration.test.ts +0 -374
- package/tests/integration/transactions.integration.test.ts +0 -528
- 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
|
-
});
|