@ftschopp/dynatable-core 1.4.2 → 1.4.4
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 +14 -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/builders/update/create-update-builder.d.ts.map +1 -1
- package/dist/builders/update/create-update-builder.js +25 -0
- package/dist/core/types.d.ts +5 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/utils/model-utils.d.ts +9 -3
- package/dist/utils/model-utils.d.ts.map +1 -1
- package/dist/utils/model-utils.js +15 -7
- package/package.json +19 -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,416 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
-
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
4
|
-
import { mockClient } from 'aws-sdk-client-mock';
|
|
5
|
-
import { createScanBuilder } from './create-scan-builder';
|
|
6
|
-
|
|
7
|
-
const ddbMock = mockClient(DynamoDBClient);
|
|
8
|
-
|
|
9
|
-
describe('ScanBuilder', () => {
|
|
10
|
-
const client = new DynamoDBClient({});
|
|
11
|
-
const tableName = 'TestTable';
|
|
12
|
-
|
|
13
|
-
interface TestModel {
|
|
14
|
-
pk: string;
|
|
15
|
-
sk: string;
|
|
16
|
-
name?: string;
|
|
17
|
-
age?: number;
|
|
18
|
-
status?: string;
|
|
19
|
-
score?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('Basic scan operations', () => {
|
|
23
|
-
test('should build params for basic scan', () => {
|
|
24
|
-
const params = createScanBuilder<TestModel>(tableName, client).dbParams();
|
|
25
|
-
|
|
26
|
-
expect(params.TableName).toBe(tableName);
|
|
27
|
-
expect(params.FilterExpression).toBeUndefined();
|
|
28
|
-
expect(params.ProjectionExpression).toBeUndefined();
|
|
29
|
-
expect(params.Limit).toBeUndefined();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test('should build params with filter', () => {
|
|
33
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
34
|
-
.filter((attr, op) => op.eq(attr.status, 'active'))
|
|
35
|
-
.dbParams();
|
|
36
|
-
|
|
37
|
-
expect(params.TableName).toBe(tableName);
|
|
38
|
-
expect(params.FilterExpression).toMatch(/#status = :status_\d+/);
|
|
39
|
-
expect(params.ExpressionAttributeNames).toEqual({
|
|
40
|
-
'#status': 'status',
|
|
41
|
-
});
|
|
42
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain('active');
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('should build params with multiple filters using AND', () => {
|
|
46
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
47
|
-
.filter((attr, op) => op.gt(attr.age, 18))
|
|
48
|
-
.filter((attr, op) => op.eq(attr.status, 'active'))
|
|
49
|
-
.dbParams();
|
|
50
|
-
|
|
51
|
-
expect(params.TableName).toBe(tableName);
|
|
52
|
-
expect(params.FilterExpression).toMatch(/\(#age > :age_\d+\) AND \(#status = :status_\d+\)/);
|
|
53
|
-
expect(params.ExpressionAttributeNames).toEqual({
|
|
54
|
-
'#age': 'age',
|
|
55
|
-
'#status': 'status',
|
|
56
|
-
});
|
|
57
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain(18);
|
|
58
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain('active');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('should build params with OR filter', () => {
|
|
62
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
63
|
-
.filter((attr, op) => op.or(op.eq(attr.status, 'active'), op.eq(attr.status, 'pending')))
|
|
64
|
-
.dbParams();
|
|
65
|
-
|
|
66
|
-
expect(params.FilterExpression).toMatch(
|
|
67
|
-
/\(#status = :status_\d+\) OR \(#status = :status_\d+\)/
|
|
68
|
-
);
|
|
69
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain('active');
|
|
70
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain('pending');
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe('Projection', () => {
|
|
75
|
-
test('should build params with select', () => {
|
|
76
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
77
|
-
.select(['name', 'age', 'status'])
|
|
78
|
-
.dbParams();
|
|
79
|
-
|
|
80
|
-
expect(params.ProjectionExpression).toBe('#name, #age, #status');
|
|
81
|
-
expect(params.ExpressionAttributeNames).toEqual({
|
|
82
|
-
'#name': 'name',
|
|
83
|
-
'#age': 'age',
|
|
84
|
-
'#status': 'status',
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('should build params with select and filter, merging ExpressionAttributeNames', () => {
|
|
89
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
90
|
-
.select(['name', 'age'])
|
|
91
|
-
.filter((attr, op) => op.gt(attr.score, 50))
|
|
92
|
-
.dbParams();
|
|
93
|
-
|
|
94
|
-
expect(params.ProjectionExpression).toBe('#name, #age');
|
|
95
|
-
expect(params.FilterExpression).toMatch(/#score > :score_\d+/);
|
|
96
|
-
// Merged names from filter (#score) + projection (#name, #age)
|
|
97
|
-
expect(params.ExpressionAttributeNames).toEqual({
|
|
98
|
-
'#name': 'name',
|
|
99
|
-
'#age': 'age',
|
|
100
|
-
'#score': 'score',
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test('placeholders survive when projected attribute is also referenced in the filter (no key collision)', () => {
|
|
105
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
106
|
-
.select(['name', 'age'])
|
|
107
|
-
.filter((attr, op) => op.eq(attr.name, 'alice'))
|
|
108
|
-
.dbParams();
|
|
109
|
-
|
|
110
|
-
expect(params.ProjectionExpression).toBe('#name, #age');
|
|
111
|
-
// #name resolves to "name" — filter and projection share the placeholder.
|
|
112
|
-
expect(params.ExpressionAttributeNames!['#name']).toBe('name');
|
|
113
|
-
expect(params.ExpressionAttributeNames!['#age']).toBe('age');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('reserved DynamoDB words like "name", "date", "status", "type" can be projected', () => {
|
|
117
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
118
|
-
.select(['name', 'status'])
|
|
119
|
-
.dbParams();
|
|
120
|
-
|
|
121
|
-
expect(params.ProjectionExpression).toBe('#name, #status');
|
|
122
|
-
expect(params.ExpressionAttributeNames).toEqual({
|
|
123
|
-
'#name': 'name',
|
|
124
|
-
'#status': 'status',
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe('Limit', () => {
|
|
130
|
-
test('should build params with limit', () => {
|
|
131
|
-
const params = createScanBuilder<TestModel>(tableName, client).limit(100).dbParams();
|
|
132
|
-
|
|
133
|
-
expect(params.Limit).toBe(100);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test('should build params with limit and filter', () => {
|
|
137
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
138
|
-
.filter((attr, op) => op.eq(attr.status, 'active'))
|
|
139
|
-
.limit(50)
|
|
140
|
-
.dbParams();
|
|
141
|
-
|
|
142
|
-
expect(params.Limit).toBe(50);
|
|
143
|
-
expect(params.FilterExpression).toMatch(/#status = :status_\d+/);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe('Consistent Read', () => {
|
|
148
|
-
test('should build params with consistent read', () => {
|
|
149
|
-
const params = createScanBuilder<TestModel>(tableName, client).consistentRead().dbParams();
|
|
150
|
-
|
|
151
|
-
expect(params.ConsistentRead).toBe(true);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test('should not set ConsistentRead by default', () => {
|
|
155
|
-
const params = createScanBuilder<TestModel>(tableName, client).dbParams();
|
|
156
|
-
|
|
157
|
-
expect(params.ConsistentRead).toBeUndefined();
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe('Index Scan', () => {
|
|
162
|
-
test('should build params with index name', () => {
|
|
163
|
-
const params = createScanBuilder<TestModel>(tableName, client).usingIndex('GSI1').dbParams();
|
|
164
|
-
|
|
165
|
-
expect(params.IndexName).toBe('GSI1');
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test('should build params with index and filter', () => {
|
|
169
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
170
|
-
.usingIndex('GSI1')
|
|
171
|
-
.filter((attr, op) => op.gt(attr.score, 100))
|
|
172
|
-
.dbParams();
|
|
173
|
-
|
|
174
|
-
expect(params.IndexName).toBe('GSI1');
|
|
175
|
-
expect(params.FilterExpression).toMatch(/#score > :score_\d+/);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe('Pagination', () => {
|
|
180
|
-
test('should build params with ExclusiveStartKey', () => {
|
|
181
|
-
const startKey = { pk: 'USER#1', sk: 'USER#1' };
|
|
182
|
-
const params = createScanBuilder<TestModel>(tableName, client).startFrom(startKey).dbParams();
|
|
183
|
-
|
|
184
|
-
expect(params.ExclusiveStartKey).toEqual(startKey);
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
describe('Parallel Scan', () => {
|
|
189
|
-
test('should build params with segment configuration', () => {
|
|
190
|
-
const params = createScanBuilder<TestModel>(tableName, client).segment(0, 4).dbParams();
|
|
191
|
-
|
|
192
|
-
expect(params.Segment).toBe(0);
|
|
193
|
-
expect(params.TotalSegments).toBe(4);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test('should build params with multiple segments', () => {
|
|
197
|
-
const params1 = createScanBuilder<TestModel>(tableName, client).segment(0, 3).dbParams();
|
|
198
|
-
const params2 = createScanBuilder<TestModel>(tableName, client).segment(1, 3).dbParams();
|
|
199
|
-
const params3 = createScanBuilder<TestModel>(tableName, client).segment(2, 3).dbParams();
|
|
200
|
-
|
|
201
|
-
expect(params1.Segment).toBe(0);
|
|
202
|
-
expect(params1.TotalSegments).toBe(3);
|
|
203
|
-
expect(params2.Segment).toBe(1);
|
|
204
|
-
expect(params2.TotalSegments).toBe(3);
|
|
205
|
-
expect(params3.Segment).toBe(2);
|
|
206
|
-
expect(params3.TotalSegments).toBe(3);
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
describe('Complex scenarios', () => {
|
|
211
|
-
test('should build params with all features', () => {
|
|
212
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
213
|
-
.filter((attr, op) => op.and(op.gt(attr.age, 18), op.eq(attr.status, 'active')))
|
|
214
|
-
.select(['name', 'age', 'status'])
|
|
215
|
-
.limit(100)
|
|
216
|
-
.consistentRead()
|
|
217
|
-
.usingIndex('GSI1')
|
|
218
|
-
.dbParams();
|
|
219
|
-
|
|
220
|
-
expect(params.TableName).toBe(tableName);
|
|
221
|
-
expect(params.FilterExpression).toMatch(/\(#age > :age_\d+\) AND \(#status = :status_\d+\)/);
|
|
222
|
-
expect(params.ProjectionExpression).toBe('#name, #age, #status');
|
|
223
|
-
expect(params.Limit).toBe(100);
|
|
224
|
-
expect(params.ConsistentRead).toBe(true);
|
|
225
|
-
expect(params.IndexName).toBe('GSI1');
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test('should build params for parallel scan with filter and limit', () => {
|
|
229
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
230
|
-
.filter((attr, op) => op.exists(attr.name))
|
|
231
|
-
.limit(50)
|
|
232
|
-
.segment(1, 4)
|
|
233
|
-
.dbParams();
|
|
234
|
-
|
|
235
|
-
expect(params.FilterExpression).toMatch(/attribute_exists\(#name\)/);
|
|
236
|
-
expect(params.Limit).toBe(50);
|
|
237
|
-
expect(params.Segment).toBe(1);
|
|
238
|
-
expect(params.TotalSegments).toBe(4);
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
describe('Immutability', () => {
|
|
243
|
-
test('should create new builder instance on each method call', () => {
|
|
244
|
-
const builder1 = createScanBuilder<TestModel>(tableName, client);
|
|
245
|
-
const builder2 = builder1.filter((attr, op) => op.eq(attr.status, 'active'));
|
|
246
|
-
const builder3 = builder2.limit(50);
|
|
247
|
-
|
|
248
|
-
expect(builder1.dbParams().FilterExpression).toBeUndefined();
|
|
249
|
-
expect(builder1.dbParams().Limit).toBeUndefined();
|
|
250
|
-
|
|
251
|
-
expect(builder2.dbParams().FilterExpression).toMatch(/#status = :status_\d+/);
|
|
252
|
-
expect(builder2.dbParams().Limit).toBeUndefined();
|
|
253
|
-
|
|
254
|
-
expect(builder3.dbParams().FilterExpression).toMatch(/#status = :status_\d+/);
|
|
255
|
-
expect(builder3.dbParams().Limit).toBe(50);
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
describe('Advanced operators', () => {
|
|
260
|
-
test('should support contains operator', () => {
|
|
261
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
262
|
-
.filter((attr, op) => op.contains(attr.name, 'John'))
|
|
263
|
-
.dbParams();
|
|
264
|
-
|
|
265
|
-
expect(params.FilterExpression).toMatch(/contains\(#name, :name_\d+\)/);
|
|
266
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain('John');
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test('should support IN operator', () => {
|
|
270
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
271
|
-
.filter((attr, op) => op.in(attr.status, ['active', 'pending', 'approved']))
|
|
272
|
-
.dbParams();
|
|
273
|
-
|
|
274
|
-
expect(params.FilterExpression).toContain('IN');
|
|
275
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain('active');
|
|
276
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain('pending');
|
|
277
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain('approved');
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
test('should support size operator', () => {
|
|
281
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
282
|
-
.filter((attr, op) => op.size(attr.name).gt(10))
|
|
283
|
-
.dbParams();
|
|
284
|
-
|
|
285
|
-
expect(params.FilterExpression).toMatch(/size\(#name\) > :name_size_\d+/);
|
|
286
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain(10);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
test('should support between operator', () => {
|
|
290
|
-
const params = createScanBuilder<TestModel>(tableName, client)
|
|
291
|
-
.filter((attr, op) => op.between(attr.age, 18, 65))
|
|
292
|
-
.dbParams();
|
|
293
|
-
|
|
294
|
-
expect(params.FilterExpression).toMatch(/#age BETWEEN :age_low_\d+ AND :age_high_\d+/);
|
|
295
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain(18);
|
|
296
|
-
expect(Object.values(params.ExpressionAttributeValues || {})).toContain(65);
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
describe('executeWithPagination', () => {
|
|
301
|
-
beforeEach(() => {
|
|
302
|
-
ddbMock.reset();
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
test('returns items, lastEvaluatedKey and counts', async () => {
|
|
306
|
-
const items = [
|
|
307
|
-
{ pk: 'USER#alice', sk: 'USER#alice', name: 'Alice' },
|
|
308
|
-
{ pk: 'USER#bob', sk: 'USER#bob', name: 'Bob' },
|
|
309
|
-
];
|
|
310
|
-
const lastKey = { pk: 'USER#bob', sk: 'USER#bob' };
|
|
311
|
-
|
|
312
|
-
ddbMock.on(ScanCommand).resolves({
|
|
313
|
-
Items: items,
|
|
314
|
-
LastEvaluatedKey: lastKey,
|
|
315
|
-
Count: 2,
|
|
316
|
-
ScannedCount: 5,
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
const result = await createScanBuilder<TestModel>(tableName, client).executeWithPagination();
|
|
320
|
-
|
|
321
|
-
expect(result.items).toEqual(items);
|
|
322
|
-
expect(result.lastEvaluatedKey).toEqual(lastKey);
|
|
323
|
-
expect(result.count).toBe(2);
|
|
324
|
-
expect(result.scannedCount).toBe(5);
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
test('returns undefined lastEvaluatedKey on the final page', async () => {
|
|
328
|
-
ddbMock.on(ScanCommand).resolves({ Items: [], Count: 0, ScannedCount: 0 });
|
|
329
|
-
|
|
330
|
-
const result = await createScanBuilder<TestModel>(tableName, client).executeWithPagination();
|
|
331
|
-
|
|
332
|
-
expect(result.items).toEqual([]);
|
|
333
|
-
expect(result.lastEvaluatedKey).toBeUndefined();
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
describe('iterate', () => {
|
|
338
|
-
beforeEach(() => {
|
|
339
|
-
ddbMock.reset();
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
test('walks every page transparently and yields items in order', async () => {
|
|
343
|
-
const page1 = [
|
|
344
|
-
{ pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
|
|
345
|
-
{ pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
|
|
346
|
-
];
|
|
347
|
-
const page2 = [{ pk: 'USER#3', sk: 'USER#3', name: 'User 3' }];
|
|
348
|
-
|
|
349
|
-
ddbMock
|
|
350
|
-
.on(ScanCommand)
|
|
351
|
-
.resolvesOnce({ Items: page1, LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' } })
|
|
352
|
-
.resolvesOnce({ Items: page2 });
|
|
353
|
-
|
|
354
|
-
const collected: TestModel[] = [];
|
|
355
|
-
for await (const item of createScanBuilder<TestModel>(tableName, client).iterate()) {
|
|
356
|
-
collected.push(item);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
expect(collected.map((u) => u.name)).toEqual(['User 1', 'User 2', 'User 3']);
|
|
360
|
-
expect(ddbMock.calls()).toHaveLength(2);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
test('forwards LastEvaluatedKey from each response into the next call', async () => {
|
|
364
|
-
const cursor1 = { pk: 'USER#2', sk: 'USER#2' };
|
|
365
|
-
|
|
366
|
-
ddbMock
|
|
367
|
-
.on(ScanCommand)
|
|
368
|
-
.resolvesOnce({ Items: [{ pk: 'USER#1', sk: 'USER#1' }], LastEvaluatedKey: cursor1 })
|
|
369
|
-
.resolvesOnce({ Items: [{ pk: 'USER#3', sk: 'USER#3' }] });
|
|
370
|
-
|
|
371
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
372
|
-
for await (const _ of createScanBuilder<TestModel>(tableName, client).iterate()) {
|
|
373
|
-
// drain
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const calls = ddbMock.calls();
|
|
377
|
-
expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toBeUndefined();
|
|
378
|
-
expect((calls[1]!.args[0].input as any).ExclusiveStartKey).toEqual(cursor1);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test('starts from the user-provided cursor on the first call', async () => {
|
|
382
|
-
const startKey = { pk: 'USER#10', sk: 'USER#10' };
|
|
383
|
-
|
|
384
|
-
ddbMock.on(ScanCommand).resolves({ Items: [{ pk: 'USER#11', sk: 'USER#11' }] });
|
|
385
|
-
|
|
386
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
387
|
-
for await (const _ of createScanBuilder<TestModel>(tableName, client)
|
|
388
|
-
.startFrom(startKey)
|
|
389
|
-
.iterate()) {
|
|
390
|
-
// drain
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const calls = ddbMock.calls();
|
|
394
|
-
expect((calls[0]!.args[0].input as any).ExclusiveStartKey).toEqual(startKey);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
test('break out of the loop stops further DynamoDB calls', async () => {
|
|
398
|
-
ddbMock.on(ScanCommand).resolvesOnce({
|
|
399
|
-
Items: [
|
|
400
|
-
{ pk: 'USER#1', sk: 'USER#1' },
|
|
401
|
-
{ pk: 'USER#2', sk: 'USER#2' },
|
|
402
|
-
],
|
|
403
|
-
LastEvaluatedKey: { pk: 'USER#2', sk: 'USER#2' },
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
const collected: TestModel[] = [];
|
|
407
|
-
for await (const item of createScanBuilder<TestModel>(tableName, client).iterate()) {
|
|
408
|
-
collected.push(item);
|
|
409
|
-
if (collected.length >= 1) break;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
expect(collected).toHaveLength(1);
|
|
413
|
-
expect(ddbMock.calls()).toHaveLength(1);
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
});
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
-
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
4
|
-
import {
|
|
5
|
-
buildExpression,
|
|
6
|
-
AttrBuilder,
|
|
7
|
-
Condition,
|
|
8
|
-
createOpBuilder,
|
|
9
|
-
buildProjectionExpression,
|
|
10
|
-
} from '../shared';
|
|
11
|
-
import { ScanBuilder, ScanResult } from './types';
|
|
12
|
-
import { DynamoDBLogger } from '../../utils/dynamodb-logger';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Creates a ScanBuilder for scanning a table or index.
|
|
16
|
-
*/
|
|
17
|
-
export function createScanBuilder<Model>(
|
|
18
|
-
tableName: string,
|
|
19
|
-
client: DynamoDBClient,
|
|
20
|
-
filters: Condition[] = [],
|
|
21
|
-
projectionAttrs: (keyof Model)[] = [],
|
|
22
|
-
limitValue?: number,
|
|
23
|
-
isConsistentRead = false,
|
|
24
|
-
indexName?: string,
|
|
25
|
-
exclusiveStartKey?: Record<string, any>,
|
|
26
|
-
segmentConfig?: { segment: number; totalSegments: number },
|
|
27
|
-
logger?: DynamoDBLogger
|
|
28
|
-
): ScanBuilder<Model> {
|
|
29
|
-
const build = (): ScanBuilder<Model> => ({
|
|
30
|
-
filter(fn) {
|
|
31
|
-
const attrs = new Proxy({} as AttrBuilder<Model>, {
|
|
32
|
-
get(_, prop: string) {
|
|
33
|
-
return { name: prop };
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
const opBuilder = createOpBuilder();
|
|
37
|
-
const condition = fn(attrs, opBuilder);
|
|
38
|
-
return createScanBuilder(
|
|
39
|
-
tableName,
|
|
40
|
-
client,
|
|
41
|
-
[...filters, condition],
|
|
42
|
-
projectionAttrs,
|
|
43
|
-
limitValue,
|
|
44
|
-
isConsistentRead,
|
|
45
|
-
indexName,
|
|
46
|
-
exclusiveStartKey,
|
|
47
|
-
segmentConfig,
|
|
48
|
-
logger
|
|
49
|
-
);
|
|
50
|
-
},
|
|
51
|
-
|
|
52
|
-
select(attrs) {
|
|
53
|
-
return createScanBuilder(
|
|
54
|
-
tableName,
|
|
55
|
-
client,
|
|
56
|
-
filters,
|
|
57
|
-
attrs,
|
|
58
|
-
limitValue,
|
|
59
|
-
isConsistentRead,
|
|
60
|
-
indexName,
|
|
61
|
-
exclusiveStartKey,
|
|
62
|
-
segmentConfig,
|
|
63
|
-
logger
|
|
64
|
-
);
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
limit(count) {
|
|
68
|
-
return createScanBuilder(
|
|
69
|
-
tableName,
|
|
70
|
-
client,
|
|
71
|
-
filters,
|
|
72
|
-
projectionAttrs,
|
|
73
|
-
count,
|
|
74
|
-
isConsistentRead,
|
|
75
|
-
indexName,
|
|
76
|
-
exclusiveStartKey,
|
|
77
|
-
segmentConfig,
|
|
78
|
-
logger
|
|
79
|
-
);
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
consistentRead() {
|
|
83
|
-
return createScanBuilder(
|
|
84
|
-
tableName,
|
|
85
|
-
client,
|
|
86
|
-
filters,
|
|
87
|
-
projectionAttrs,
|
|
88
|
-
limitValue,
|
|
89
|
-
true,
|
|
90
|
-
indexName,
|
|
91
|
-
exclusiveStartKey,
|
|
92
|
-
segmentConfig,
|
|
93
|
-
logger
|
|
94
|
-
);
|
|
95
|
-
},
|
|
96
|
-
|
|
97
|
-
usingIndex(index) {
|
|
98
|
-
return createScanBuilder(
|
|
99
|
-
tableName,
|
|
100
|
-
client,
|
|
101
|
-
filters,
|
|
102
|
-
projectionAttrs,
|
|
103
|
-
limitValue,
|
|
104
|
-
isConsistentRead,
|
|
105
|
-
index,
|
|
106
|
-
exclusiveStartKey,
|
|
107
|
-
segmentConfig,
|
|
108
|
-
logger
|
|
109
|
-
);
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
startFrom(key) {
|
|
113
|
-
return createScanBuilder(
|
|
114
|
-
tableName,
|
|
115
|
-
client,
|
|
116
|
-
filters,
|
|
117
|
-
projectionAttrs,
|
|
118
|
-
limitValue,
|
|
119
|
-
isConsistentRead,
|
|
120
|
-
indexName,
|
|
121
|
-
key,
|
|
122
|
-
segmentConfig,
|
|
123
|
-
logger
|
|
124
|
-
);
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
segment(segmentNumber, totalSegments) {
|
|
128
|
-
return createScanBuilder(
|
|
129
|
-
tableName,
|
|
130
|
-
client,
|
|
131
|
-
filters,
|
|
132
|
-
projectionAttrs,
|
|
133
|
-
limitValue,
|
|
134
|
-
isConsistentRead,
|
|
135
|
-
indexName,
|
|
136
|
-
exclusiveStartKey,
|
|
137
|
-
{ segment: segmentNumber, totalSegments },
|
|
138
|
-
logger
|
|
139
|
-
);
|
|
140
|
-
},
|
|
141
|
-
|
|
142
|
-
dbParams() {
|
|
143
|
-
let filterExpression = '';
|
|
144
|
-
let expressionAttributeNames: Record<string, string> = {};
|
|
145
|
-
let expressionAttributeValues: Record<string, any> = {};
|
|
146
|
-
|
|
147
|
-
// Build FilterExpression from filters
|
|
148
|
-
if (filters.length > 0) {
|
|
149
|
-
const combinedFilter =
|
|
150
|
-
filters.length === 1 && filters[0]
|
|
151
|
-
? filters[0]
|
|
152
|
-
: {
|
|
153
|
-
expression: '',
|
|
154
|
-
operator: 'AND' as const,
|
|
155
|
-
children: filters,
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const result = buildExpression(combinedFilter);
|
|
159
|
-
filterExpression = result.expression;
|
|
160
|
-
expressionAttributeNames = result.names;
|
|
161
|
-
expressionAttributeValues = result.values;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Build ProjectionExpression with placeholders so reserved words
|
|
165
|
-
// (name, date, status, type, …) don't blow up at DynamoDB.
|
|
166
|
-
let projectionExpression = '';
|
|
167
|
-
if (projectionAttrs.length > 0) {
|
|
168
|
-
const proj = buildProjectionExpression(projectionAttrs.map((attr) => String(attr)));
|
|
169
|
-
projectionExpression = proj.ProjectionExpression;
|
|
170
|
-
// Idempotent merge: filter and projection placeholders both map
|
|
171
|
-
// `#name → name`, so collisions resolve to the same value.
|
|
172
|
-
expressionAttributeNames = {
|
|
173
|
-
...proj.ExpressionAttributeNames,
|
|
174
|
-
...expressionAttributeNames,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const params: any = {
|
|
179
|
-
TableName: tableName,
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
if (filterExpression) {
|
|
183
|
-
params.FilterExpression = filterExpression;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (Object.keys(expressionAttributeNames).length > 0) {
|
|
187
|
-
params.ExpressionAttributeNames = expressionAttributeNames;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (Object.keys(expressionAttributeValues).length > 0) {
|
|
191
|
-
params.ExpressionAttributeValues = expressionAttributeValues;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (projectionExpression) {
|
|
195
|
-
params.ProjectionExpression = projectionExpression;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (limitValue !== undefined) {
|
|
199
|
-
params.Limit = limitValue;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (isConsistentRead) {
|
|
203
|
-
params.ConsistentRead = true;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (indexName) {
|
|
207
|
-
params.IndexName = indexName;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (exclusiveStartKey) {
|
|
211
|
-
params.ExclusiveStartKey = exclusiveStartKey;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (segmentConfig) {
|
|
215
|
-
params.Segment = segmentConfig.segment;
|
|
216
|
-
params.TotalSegments = segmentConfig.totalSegments;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return params;
|
|
220
|
-
},
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* ⚠️ Returns only the FIRST page of results. DynamoDB caps each Scan
|
|
224
|
-
* response at ~1MB (or `Limit` if set). If the matching set is larger,
|
|
225
|
-
* the remaining items are silently dropped.
|
|
226
|
-
*
|
|
227
|
-
* Use {@link executeWithPagination} when you need to drive pagination
|
|
228
|
-
* yourself, or {@link iterate} to walk every matching item lazily.
|
|
229
|
-
*/
|
|
230
|
-
async execute() {
|
|
231
|
-
const params = build().dbParams();
|
|
232
|
-
const response = await client.send(new ScanCommand(params));
|
|
233
|
-
logger?.log('ScanCommand', params, response);
|
|
234
|
-
return (response.Items || []) as Model[];
|
|
235
|
-
},
|
|
236
|
-
|
|
237
|
-
async executeWithPagination(): Promise<ScanResult<Model>> {
|
|
238
|
-
const params = build().dbParams();
|
|
239
|
-
const response = await client.send(new ScanCommand(params));
|
|
240
|
-
logger?.log('ScanCommand', params, response);
|
|
241
|
-
return {
|
|
242
|
-
items: (response.Items ?? []) as Model[],
|
|
243
|
-
lastEvaluatedKey: response.LastEvaluatedKey,
|
|
244
|
-
count: response.Count,
|
|
245
|
-
scannedCount: response.ScannedCount,
|
|
246
|
-
};
|
|
247
|
-
},
|
|
248
|
-
|
|
249
|
-
async *iterate(): AsyncIterableIterator<Model> {
|
|
250
|
-
const baseParams = build().dbParams();
|
|
251
|
-
let cursor: Record<string, any> | undefined = baseParams.ExclusiveStartKey;
|
|
252
|
-
do {
|
|
253
|
-
const params = { ...baseParams, ExclusiveStartKey: cursor };
|
|
254
|
-
const response = await client.send(new ScanCommand(params));
|
|
255
|
-
logger?.log('ScanCommand', params, response);
|
|
256
|
-
for (const item of (response.Items ?? []) as Model[]) {
|
|
257
|
-
yield item;
|
|
258
|
-
}
|
|
259
|
-
cursor = response.LastEvaluatedKey;
|
|
260
|
-
} while (cursor);
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
return build();
|
|
265
|
-
}
|