@ftschopp/dynatable-core 1.0.0
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 +62 -0
- package/README.md +17 -0
- package/dist/builders/batch-get/create-batch-get-builder.d.ts +18 -0
- package/dist/builders/batch-get/create-batch-get-builder.d.ts.map +1 -0
- package/dist/builders/batch-get/create-batch-get-builder.js +71 -0
- package/dist/builders/batch-get/index.d.ts +3 -0
- package/dist/builders/batch-get/index.d.ts.map +1 -0
- package/dist/builders/batch-get/index.js +18 -0
- package/dist/builders/batch-get/types.d.ts +25 -0
- package/dist/builders/batch-get/types.d.ts.map +1 -0
- package/dist/builders/batch-get/types.js +2 -0
- package/dist/builders/batch-write/create-batch-write-builder.d.ts +12 -0
- package/dist/builders/batch-write/create-batch-write-builder.d.ts.map +1 -0
- package/dist/builders/batch-write/create-batch-write-builder.js +35 -0
- package/dist/builders/batch-write/index.d.ts +3 -0
- package/dist/builders/batch-write/index.d.ts.map +1 -0
- package/dist/builders/batch-write/index.js +18 -0
- package/dist/builders/batch-write/types.d.ts +30 -0
- package/dist/builders/batch-write/types.d.ts.map +1 -0
- package/dist/builders/batch-write/types.js +2 -0
- package/dist/builders/delete/create-delete-builder.d.ts +9 -0
- package/dist/builders/delete/create-delete-builder.d.ts.map +1 -0
- package/dist/builders/delete/create-delete-builder.js +72 -0
- package/dist/builders/delete/index.d.ts +3 -0
- package/dist/builders/delete/index.d.ts.map +1 -0
- package/dist/builders/delete/index.js +18 -0
- package/dist/builders/delete/types.d.ts +11 -0
- package/dist/builders/delete/types.d.ts.map +1 -0
- package/dist/builders/delete/types.js +2 -0
- package/dist/builders/get/create-get-builder.d.ts +12 -0
- package/dist/builders/get/create-get-builder.d.ts.map +1 -0
- package/dist/builders/get/create-get-builder.js +95 -0
- package/dist/builders/get/index.d.ts +3 -0
- package/dist/builders/get/index.d.ts.map +1 -0
- package/dist/builders/get/index.js +18 -0
- package/dist/builders/get/types.d.ts +28 -0
- package/dist/builders/get/types.d.ts.map +1 -0
- package/dist/builders/get/types.js +2 -0
- package/dist/builders/index.d.ts +12 -0
- package/dist/builders/index.d.ts.map +1 -0
- package/dist/builders/index.js +29 -0
- package/dist/builders/put/create-put-builder.d.ts +9 -0
- package/dist/builders/put/create-put-builder.d.ts.map +1 -0
- package/dist/builders/put/create-put-builder.js +104 -0
- package/dist/builders/put/index.d.ts +3 -0
- package/dist/builders/put/index.d.ts.map +1 -0
- package/dist/builders/put/index.js +18 -0
- package/dist/builders/put/types.d.ts +17 -0
- package/dist/builders/put/types.d.ts.map +1 -0
- package/dist/builders/put/types.js +2 -0
- package/dist/builders/query/create-query-builder.d.ts +9 -0
- package/dist/builders/query/create-query-builder.d.ts.map +1 -0
- package/dist/builders/query/create-query-builder.js +283 -0
- package/dist/builders/query/index.d.ts +3 -0
- package/dist/builders/query/index.d.ts.map +1 -0
- package/dist/builders/query/index.js +18 -0
- package/dist/builders/query/types.d.ts +79 -0
- package/dist/builders/query/types.d.ts.map +1 -0
- package/dist/builders/query/types.js +2 -0
- package/dist/builders/scan/create-scan-builder.d.ts +12 -0
- package/dist/builders/scan/create-scan-builder.d.ts.map +1 -0
- package/dist/builders/scan/create-scan-builder.js +103 -0
- package/dist/builders/scan/index.d.ts +3 -0
- package/dist/builders/scan/index.d.ts.map +1 -0
- package/dist/builders/scan/index.js +18 -0
- package/dist/builders/scan/types.d.ts +43 -0
- package/dist/builders/scan/types.d.ts.map +1 -0
- package/dist/builders/scan/types.js +2 -0
- package/dist/builders/shared/conditions.d.ts +18 -0
- package/dist/builders/shared/conditions.d.ts.map +1 -0
- package/dist/builders/shared/conditions.js +48 -0
- package/dist/builders/shared/index.d.ts +4 -0
- package/dist/builders/shared/index.d.ts.map +1 -0
- package/dist/builders/shared/index.js +19 -0
- package/dist/builders/shared/operators.d.ts +12 -0
- package/dist/builders/shared/operators.d.ts.map +1 -0
- package/dist/builders/shared/operators.js +197 -0
- package/dist/builders/shared/types.d.ts +77 -0
- package/dist/builders/shared/types.d.ts.map +1 -0
- package/dist/builders/shared/types.js +6 -0
- package/dist/builders/transact-get/create-transact-get-builder.d.ts +18 -0
- package/dist/builders/transact-get/create-transact-get-builder.d.ts.map +1 -0
- package/dist/builders/transact-get/create-transact-get-builder.js +60 -0
- package/dist/builders/transact-get/index.d.ts +3 -0
- package/dist/builders/transact-get/index.d.ts.map +1 -0
- package/dist/builders/transact-get/index.js +18 -0
- package/dist/builders/transact-get/types.d.ts +28 -0
- package/dist/builders/transact-get/types.d.ts.map +1 -0
- package/dist/builders/transact-get/types.js +2 -0
- package/dist/builders/transact-write/create-transact-write-builder.d.ts +18 -0
- package/dist/builders/transact-write/create-transact-write-builder.d.ts.map +1 -0
- package/dist/builders/transact-write/create-transact-write-builder.js +97 -0
- package/dist/builders/transact-write/index.d.ts +3 -0
- package/dist/builders/transact-write/index.d.ts.map +1 -0
- package/dist/builders/transact-write/index.js +18 -0
- package/dist/builders/transact-write/types.d.ts +34 -0
- package/dist/builders/transact-write/types.d.ts.map +1 -0
- package/dist/builders/transact-write/types.js +2 -0
- package/dist/builders/update/create-update-builder.d.ts +14 -0
- package/dist/builders/update/create-update-builder.d.ts.map +1 -0
- package/dist/builders/update/create-update-builder.js +180 -0
- package/dist/builders/update/index.d.ts +3 -0
- package/dist/builders/update/index.d.ts.map +1 -0
- package/dist/builders/update/index.js +18 -0
- package/dist/builders/update/types.d.ts +35 -0
- package/dist/builders/update/types.d.ts.map +1 -0
- package/dist/builders/update/types.js +2 -0
- package/dist/core/types.d.ts +224 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +3 -0
- package/dist/entity.d.ts +73 -0
- package/dist/entity.d.ts.map +1 -0
- package/dist/entity.js +161 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/table.d.ts +70 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/table.js +69 -0
- package/dist/utils/dynamodb-logger.d.ts +118 -0
- package/dist/utils/dynamodb-logger.d.ts.map +1 -0
- package/dist/utils/dynamodb-logger.js +125 -0
- package/dist/utils/model-utils.d.ts +24 -0
- package/dist/utils/model-utils.d.ts.map +1 -0
- package/dist/utils/model-utils.js +88 -0
- package/dist/utils/zod-utils.d.ts +13 -0
- package/dist/utils/zod-utils.d.ts.map +1 -0
- package/dist/utils/zod-utils.js +35 -0
- package/eslint.config.mjs +4 -0
- package/jest.config.js +11 -0
- package/package.json +36 -0
- package/src/builders/README.md +272 -0
- package/src/builders/batch-get/README.md +98 -0
- package/src/builders/batch-get/create-batch-get-builder.test.ts +165 -0
- package/src/builders/batch-get/create-batch-get-builder.ts +106 -0
- package/src/builders/batch-get/index.ts +2 -0
- package/src/builders/batch-get/types.ts +29 -0
- package/src/builders/batch-write/README.md +204 -0
- package/src/builders/batch-write/create-batch-write-builder.test.ts +173 -0
- package/src/builders/batch-write/create-batch-write-builder.ts +49 -0
- package/src/builders/batch-write/index.ts +2 -0
- package/src/builders/batch-write/types.ts +33 -0
- package/src/builders/delete/create-delete-builder.test.ts +294 -0
- package/src/builders/delete/create-delete-builder.ts +100 -0
- package/src/builders/delete/index.ts +2 -0
- package/src/builders/delete/types.ts +11 -0
- package/src/builders/get/create-get-builder.test.ts +272 -0
- package/src/builders/get/create-get-builder.ts +140 -0
- package/src/builders/get/index.ts +2 -0
- package/src/builders/get/types.ts +30 -0
- package/src/builders/index.ts +14 -0
- package/src/builders/put/create-put-builder.test.ts +213 -0
- package/src/builders/put/create-put-builder.ts +151 -0
- package/src/builders/put/index.ts +2 -0
- package/src/builders/put/types.ts +18 -0
- package/src/builders/query/create-query-builder.test.ts +230 -0
- package/src/builders/query/create-query-builder.ts +353 -0
- package/src/builders/query/index.ts +2 -0
- package/src/builders/query/types.ts +95 -0
- package/src/builders/scan/create-scan-builder.test.ts +260 -0
- package/src/builders/scan/create-scan-builder.ts +217 -0
- package/src/builders/scan/index.ts +2 -0
- package/src/builders/scan/types.ts +49 -0
- package/src/builders/shared/conditions.ts +58 -0
- package/src/builders/shared/index.ts +3 -0
- package/src/builders/shared/operators.test.ts +270 -0
- package/src/builders/shared/operators.ts +200 -0
- package/src/builders/shared/types.ts +100 -0
- package/src/builders/transact-get/README.md +167 -0
- package/src/builders/transact-get/create-transact-get-builder.test.ts +239 -0
- package/src/builders/transact-get/create-transact-get-builder.ts +67 -0
- package/src/builders/transact-get/index.ts +2 -0
- package/src/builders/transact-get/types.ts +31 -0
- package/src/builders/transact-write/README.md +166 -0
- package/src/builders/transact-write/create-transact-write-builder.test.ts +288 -0
- package/src/builders/transact-write/create-transact-write-builder.ts +118 -0
- package/src/builders/transact-write/index.ts +2 -0
- package/src/builders/transact-write/types.ts +33 -0
- package/src/builders/update/create-update-builder.test.ts +333 -0
- package/src/builders/update/create-update-builder.ts +284 -0
- package/src/builders/update/index.ts +2 -0
- package/src/builders/update/types.ts +42 -0
- package/src/core/types.test.ts +506 -0
- package/src/core/types.ts +290 -0
- package/src/entity.ts +337 -0
- package/src/index.ts +22 -0
- package/src/table.ts +109 -0
- package/src/utils/dynamodb-logger.test.ts +246 -0
- package/src/utils/dynamodb-logger.ts +175 -0
- package/src/utils/model-utils.test.ts +232 -0
- package/src/utils/model-utils.ts +101 -0
- package/src/utils/zod-utils.test.ts +272 -0
- package/src/utils/zod-utils.ts +36 -0
- package/tests/integration/instagram-clone.integration.test.ts +966 -0
- package/tests/integration/pagination-timestamps.integration.test.ts +375 -0
- package/tests/integration/transactions.integration.test.ts +529 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
name?: string;
|
|
18
|
+
age?: number;
|
|
19
|
+
status?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const testModel: ModelDefinition = {
|
|
23
|
+
key: {
|
|
24
|
+
PK: { type: String, value: 'USER#${username}' },
|
|
25
|
+
SK: { type: String, value: 'USER#${username}' },
|
|
26
|
+
},
|
|
27
|
+
attributes: {
|
|
28
|
+
username: { type: String, required: true },
|
|
29
|
+
name: { type: String },
|
|
30
|
+
age: { type: Number },
|
|
31
|
+
status: { type: String },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
ddbMock.reset();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('startFrom method', () => {
|
|
40
|
+
test('should add ExclusiveStartKey to query params', () => {
|
|
41
|
+
const startKey = { pk: 'USER#alice', sk: 'USER#alice' };
|
|
42
|
+
const params = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
43
|
+
.where((attr, op) => op.eq(attr.pk, 'USER#alice'))
|
|
44
|
+
.startFrom(startKey)
|
|
45
|
+
.dbParams();
|
|
46
|
+
|
|
47
|
+
expect(params.ExclusiveStartKey).toEqual(startKey);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('should work with all other query options', () => {
|
|
51
|
+
const startKey = { pk: 'USER#alice', sk: 'USER#alice' };
|
|
52
|
+
const params = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
53
|
+
.where((attr, op) => op.eq(attr.pk, 'USER#alice'))
|
|
54
|
+
.startFrom(startKey)
|
|
55
|
+
.limit(10)
|
|
56
|
+
.scanIndexForward(false)
|
|
57
|
+
.select(['name', 'age'])
|
|
58
|
+
.dbParams();
|
|
59
|
+
|
|
60
|
+
expect(params.ExclusiveStartKey).toEqual(startKey);
|
|
61
|
+
expect(params.Limit).toBe(10);
|
|
62
|
+
expect(params.ScanIndexForward).toBe(false);
|
|
63
|
+
expect(params.ProjectionExpression).toBe('name, age');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('should not include ExclusiveStartKey if not set', () => {
|
|
67
|
+
const params = createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
68
|
+
.where((attr, op) => op.eq(attr.pk, 'USER#alice'))
|
|
69
|
+
.dbParams();
|
|
70
|
+
|
|
71
|
+
expect(params.ExclusiveStartKey).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('executeWithPagination method', () => {
|
|
76
|
+
test('should return items and lastEvaluatedKey', async () => {
|
|
77
|
+
const mockItems = [
|
|
78
|
+
{ pk: 'USER#alice', sk: 'USER#alice', name: 'Alice' },
|
|
79
|
+
{ pk: 'USER#bob', sk: 'USER#bob', name: 'Bob' },
|
|
80
|
+
];
|
|
81
|
+
const mockLastKey = { pk: 'USER#bob', sk: 'USER#bob' };
|
|
82
|
+
|
|
83
|
+
ddbMock.on(QueryCommand).resolves({
|
|
84
|
+
Items: mockItems,
|
|
85
|
+
LastEvaluatedKey: mockLastKey,
|
|
86
|
+
Count: 2,
|
|
87
|
+
ScannedCount: 2,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
91
|
+
.where((attr, op) => op.eq(attr.pk, 'USER#alice'))
|
|
92
|
+
.limit(2)
|
|
93
|
+
.executeWithPagination();
|
|
94
|
+
|
|
95
|
+
expect(result.items).toEqual(mockItems);
|
|
96
|
+
expect(result.lastEvaluatedKey).toEqual(mockLastKey);
|
|
97
|
+
expect(result.count).toBe(2);
|
|
98
|
+
expect(result.scannedCount).toBe(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('should return undefined lastEvaluatedKey when no more results', async () => {
|
|
102
|
+
const mockItems = [{ pk: 'USER#alice', sk: 'USER#alice', name: 'Alice' }];
|
|
103
|
+
|
|
104
|
+
ddbMock.on(QueryCommand).resolves({
|
|
105
|
+
Items: mockItems,
|
|
106
|
+
Count: 1,
|
|
107
|
+
ScannedCount: 1,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
111
|
+
.where((attr, op) => op.eq(attr.pk, 'USER#alice'))
|
|
112
|
+
.executeWithPagination();
|
|
113
|
+
|
|
114
|
+
expect(result.items).toEqual(mockItems);
|
|
115
|
+
expect(result.lastEvaluatedKey).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('should handle empty results', async () => {
|
|
119
|
+
ddbMock.on(QueryCommand).resolves({
|
|
120
|
+
Items: [],
|
|
121
|
+
Count: 0,
|
|
122
|
+
ScannedCount: 0,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
126
|
+
.where((attr, op) => op.eq(attr.pk, 'USER#nonexistent'))
|
|
127
|
+
.executeWithPagination();
|
|
128
|
+
|
|
129
|
+
expect(result.items).toEqual([]);
|
|
130
|
+
expect(result.lastEvaluatedKey).toBeUndefined();
|
|
131
|
+
expect(result.count).toBe(0);
|
|
132
|
+
expect(result.scannedCount).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('Pagination workflow', () => {
|
|
137
|
+
test('should support manual pagination loop', async () => {
|
|
138
|
+
// First page
|
|
139
|
+
const page1Items = [
|
|
140
|
+
{ pk: 'USER#1', sk: 'USER#1', name: 'User 1' },
|
|
141
|
+
{ pk: 'USER#2', sk: 'USER#2', name: 'User 2' },
|
|
142
|
+
];
|
|
143
|
+
const page1LastKey = { pk: 'USER#2', sk: 'USER#2' };
|
|
144
|
+
|
|
145
|
+
// Second page
|
|
146
|
+
const page2Items = [
|
|
147
|
+
{ pk: 'USER#3', sk: 'USER#3', name: 'User 3' },
|
|
148
|
+
{ pk: 'USER#4', sk: 'USER#4', name: 'User 4' },
|
|
149
|
+
];
|
|
150
|
+
const page2LastKey = { pk: 'USER#4', sk: 'USER#4' };
|
|
151
|
+
|
|
152
|
+
// Third page (last)
|
|
153
|
+
const page3Items = [{ pk: 'USER#5', sk: 'USER#5', name: 'User 5' }];
|
|
154
|
+
|
|
155
|
+
ddbMock
|
|
156
|
+
.on(QueryCommand)
|
|
157
|
+
.resolvesOnce({
|
|
158
|
+
Items: page1Items,
|
|
159
|
+
LastEvaluatedKey: page1LastKey,
|
|
160
|
+
Count: 2,
|
|
161
|
+
ScannedCount: 2,
|
|
162
|
+
})
|
|
163
|
+
.resolvesOnce({
|
|
164
|
+
Items: page2Items,
|
|
165
|
+
LastEvaluatedKey: page2LastKey,
|
|
166
|
+
Count: 2,
|
|
167
|
+
ScannedCount: 2,
|
|
168
|
+
})
|
|
169
|
+
.resolvesOnce({
|
|
170
|
+
Items: page3Items,
|
|
171
|
+
Count: 1,
|
|
172
|
+
ScannedCount: 1,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const allItems: any[] = [];
|
|
176
|
+
let lastKey: any = undefined;
|
|
177
|
+
|
|
178
|
+
// Page 1
|
|
179
|
+
const result1 = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
180
|
+
.where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
|
|
181
|
+
.limit(2)
|
|
182
|
+
.executeWithPagination();
|
|
183
|
+
|
|
184
|
+
allItems.push(...result1.items);
|
|
185
|
+
lastKey = result1.lastEvaluatedKey;
|
|
186
|
+
expect(lastKey).toBeDefined();
|
|
187
|
+
|
|
188
|
+
// Page 2
|
|
189
|
+
const result2 = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
190
|
+
.where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
|
|
191
|
+
.limit(2)
|
|
192
|
+
.startFrom(lastKey)
|
|
193
|
+
.executeWithPagination();
|
|
194
|
+
|
|
195
|
+
allItems.push(...result2.items);
|
|
196
|
+
lastKey = result2.lastEvaluatedKey;
|
|
197
|
+
expect(lastKey).toBeDefined();
|
|
198
|
+
|
|
199
|
+
// Page 3
|
|
200
|
+
const result3 = await createQueryBuilder<TestModel>(tableName, client, testModel)
|
|
201
|
+
.where((attr, op) => op.beginsWith(attr.pk, 'USER#'))
|
|
202
|
+
.limit(2)
|
|
203
|
+
.startFrom(lastKey)
|
|
204
|
+
.executeWithPagination();
|
|
205
|
+
|
|
206
|
+
allItems.push(...result3.items);
|
|
207
|
+
lastKey = result3.lastEvaluatedKey;
|
|
208
|
+
expect(lastKey).toBeUndefined();
|
|
209
|
+
|
|
210
|
+
// Verify all items collected
|
|
211
|
+
expect(allItems).toHaveLength(5);
|
|
212
|
+
expect(allItems[0].name).toBe('User 1');
|
|
213
|
+
expect(allItems[4].name).toBe('User 5');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('Immutability', () => {
|
|
218
|
+
test('should create new builder instance when using startFrom', () => {
|
|
219
|
+
const builder1 = createQueryBuilder<TestModel>(tableName, client, testModel).where(
|
|
220
|
+
(attr, op) => op.eq(attr.pk, 'USER#alice')
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const startKey = { pk: 'USER#alice', sk: 'USER#alice' };
|
|
224
|
+
const builder2 = builder1.startFrom(startKey);
|
|
225
|
+
|
|
226
|
+
expect(builder1.dbParams().ExclusiveStartKey).toBeUndefined();
|
|
227
|
+
expect(builder2.dbParams().ExclusiveStartKey).toEqual(startKey);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
3
|
+
import { QueryCommand, QueryCommandInput } from '@aws-sdk/lib-dynamodb';
|
|
4
|
+
import { Condition, AttrBuilder, AttrRef } from '../shared';
|
|
5
|
+
import { createOpBuilder } from '../shared/operators';
|
|
6
|
+
import { buildExpression } from '../shared/conditions';
|
|
7
|
+
import { QueryBuilder, QueryExecutor, QueryResult } from './types';
|
|
8
|
+
import { ModelDefinition } from '../../core/types';
|
|
9
|
+
import { extractTemplateVars } from '../../utils/model-utils';
|
|
10
|
+
import { DynamoDBLogger } from '../../utils/dynamodb-logger';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Internal state for the query builder
|
|
14
|
+
*/
|
|
15
|
+
type QueryState<Model> = {
|
|
16
|
+
tableName: string;
|
|
17
|
+
client: DynamoDBClient;
|
|
18
|
+
model?: ModelDefinition;
|
|
19
|
+
condition?: Condition;
|
|
20
|
+
indexName?: string;
|
|
21
|
+
limitValue?: number;
|
|
22
|
+
scanForward?: boolean;
|
|
23
|
+
projection?: (keyof Model)[];
|
|
24
|
+
consistentReadEnabled?: boolean;
|
|
25
|
+
exclusiveStartKey?: Record<string, any>;
|
|
26
|
+
logger?: DynamoDBLogger;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Determines if a field is a key field (used in pk/sk templates)
|
|
31
|
+
*/
|
|
32
|
+
function isKeyField(fieldName: string, model?: ModelDefinition): boolean {
|
|
33
|
+
if (!model?.key) return false;
|
|
34
|
+
|
|
35
|
+
const keyEntries = Object.entries(model.key);
|
|
36
|
+
for (const [, keyDef] of keyEntries) {
|
|
37
|
+
const templateVars = extractTemplateVars(keyDef.value);
|
|
38
|
+
if (templateVars.includes(fieldName)) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Maps a model attribute name to its corresponding DynamoDB key name (PK/SK)
|
|
47
|
+
* Also returns the key template for value transformation
|
|
48
|
+
*/
|
|
49
|
+
function getKeyNameForAttribute(
|
|
50
|
+
fieldName: string,
|
|
51
|
+
model?: ModelDefinition
|
|
52
|
+
): { keyName: string; template: string } | null {
|
|
53
|
+
if (!model?.key) return null;
|
|
54
|
+
|
|
55
|
+
for (const [keyName, keyDef] of Object.entries(model.key)) {
|
|
56
|
+
const templateVars = extractTemplateVars(keyDef.value);
|
|
57
|
+
if (templateVars.includes(fieldName)) {
|
|
58
|
+
return { keyName, template: keyDef.value }; // Returns "PK" and "UP#${username}"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Apply key template to transform attribute value to key value
|
|
66
|
+
* e.g., applyKeyTemplate("UP#${username}", "username", "johndoe") -> "UP#johndoe"
|
|
67
|
+
*/
|
|
68
|
+
function applyKeyTemplate(template: string, fieldName: string, fieldValue: any): string {
|
|
69
|
+
return template.replace(`\${${fieldName}}`, fieldValue);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Separates a condition tree into key conditions and filter conditions
|
|
74
|
+
* Key conditions are rewritten to use actual DynamoDB key names (PK/SK)
|
|
75
|
+
*/
|
|
76
|
+
function separateConditions(
|
|
77
|
+
condition: Condition,
|
|
78
|
+
model?: ModelDefinition
|
|
79
|
+
): { keyConditions: Condition[]; filterConditions: Condition[] } {
|
|
80
|
+
const keyConditions: Condition[] = [];
|
|
81
|
+
const filterConditions: Condition[] = [];
|
|
82
|
+
|
|
83
|
+
function traverse(cond: Condition) {
|
|
84
|
+
// If it's a combinator
|
|
85
|
+
if (cond.operator && cond.children) {
|
|
86
|
+
// For KeyConditionExpression, we can only use AND at the top level
|
|
87
|
+
if (cond.operator === 'AND') {
|
|
88
|
+
cond.children.forEach((child) => {
|
|
89
|
+
// Check if this is a leaf condition or another combinator
|
|
90
|
+
if (child.children) {
|
|
91
|
+
// Nested combinator - goes to filter
|
|
92
|
+
filterConditions.push(child);
|
|
93
|
+
} else {
|
|
94
|
+
traverse(child);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
// OR at any level must go to filter
|
|
99
|
+
filterConditions.push(cond);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// It's a leaf condition - check if it's a key field
|
|
105
|
+
// Extract field name from the expression (e.g., "#username" -> "username")
|
|
106
|
+
const fieldMatch = cond.expression.match(/#(\w+)/);
|
|
107
|
+
if (fieldMatch && fieldMatch[1]) {
|
|
108
|
+
const fieldName = fieldMatch[1];
|
|
109
|
+
const keyInfo = getKeyNameForAttribute(fieldName, model);
|
|
110
|
+
|
|
111
|
+
if (keyInfo) {
|
|
112
|
+
// This is a key field - rewrite the condition to use the actual key name
|
|
113
|
+
// and apply the template to the value
|
|
114
|
+
|
|
115
|
+
// Find the value placeholder in the condition and transform it
|
|
116
|
+
const newValues: Record<string, any> = {};
|
|
117
|
+
if (cond.values) {
|
|
118
|
+
for (const [valuePlaceholder, value] of Object.entries(cond.values)) {
|
|
119
|
+
// Apply the key template to transform the value
|
|
120
|
+
const transformedValue = applyKeyTemplate(keyInfo.template, fieldName, value);
|
|
121
|
+
newValues[valuePlaceholder] = transformedValue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rewrittenCond = {
|
|
126
|
+
...cond,
|
|
127
|
+
expression: cond.expression.replace(
|
|
128
|
+
new RegExp(`#${fieldName}\\b`, 'g'),
|
|
129
|
+
`#${keyInfo.keyName}`
|
|
130
|
+
),
|
|
131
|
+
names: {
|
|
132
|
+
[`#${keyInfo.keyName}`]: keyInfo.keyName,
|
|
133
|
+
},
|
|
134
|
+
values: newValues,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
keyConditions.push(rewrittenCond);
|
|
138
|
+
} else {
|
|
139
|
+
filterConditions.push(cond);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Can't determine field, put in filter to be safe
|
|
143
|
+
filterConditions.push(cond);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
traverse(condition);
|
|
148
|
+
|
|
149
|
+
return { keyConditions, filterConditions };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Builds a simple AND expression from multiple conditions
|
|
154
|
+
*/
|
|
155
|
+
function buildSimpleAndExpression(conditions: Condition[]): {
|
|
156
|
+
expression: string;
|
|
157
|
+
names: Record<string, string>;
|
|
158
|
+
values: Record<string, any>;
|
|
159
|
+
} {
|
|
160
|
+
if (conditions.length === 0) {
|
|
161
|
+
return { expression: '', names: {}, values: {} };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const expressions: string[] = [];
|
|
165
|
+
const names: Record<string, string> = {};
|
|
166
|
+
const values: Record<string, any> = {};
|
|
167
|
+
|
|
168
|
+
conditions.forEach((cond) => {
|
|
169
|
+
expressions.push(cond.expression);
|
|
170
|
+
Object.assign(names, cond.names || {});
|
|
171
|
+
Object.assign(values, cond.values || {});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
expression: expressions.join(' AND '),
|
|
176
|
+
names,
|
|
177
|
+
values,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Creates the query executor with all query methods
|
|
183
|
+
*/
|
|
184
|
+
function createQueryExecutor<Model, M extends ModelDefinition = any>(
|
|
185
|
+
state: QueryState<Model>
|
|
186
|
+
): QueryExecutor<Model, M> {
|
|
187
|
+
return {
|
|
188
|
+
limit(count) {
|
|
189
|
+
return createQueryExecutor({
|
|
190
|
+
...state,
|
|
191
|
+
limitValue: count,
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
scanIndexForward(forward) {
|
|
196
|
+
return createQueryExecutor({
|
|
197
|
+
...state,
|
|
198
|
+
scanForward: forward,
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
useIndex(indexName) {
|
|
203
|
+
return createQueryExecutor({
|
|
204
|
+
...state,
|
|
205
|
+
indexName,
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
select(attrs) {
|
|
210
|
+
return createQueryExecutor({
|
|
211
|
+
...state,
|
|
212
|
+
projection: attrs,
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
consistentRead() {
|
|
217
|
+
return createQueryExecutor({
|
|
218
|
+
...state,
|
|
219
|
+
consistentReadEnabled: true,
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
startFrom(key) {
|
|
224
|
+
return createQueryExecutor({
|
|
225
|
+
...state,
|
|
226
|
+
exclusiveStartKey: key,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
dbParams(): QueryCommandInput {
|
|
231
|
+
if (!state.condition) {
|
|
232
|
+
throw new Error('No where condition specified');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Separate key conditions from filter conditions
|
|
236
|
+
const { keyConditions, filterConditions } = separateConditions(state.condition, state.model);
|
|
237
|
+
|
|
238
|
+
// Build KeyConditionExpression (simple AND)
|
|
239
|
+
const keyExpr = buildSimpleAndExpression(keyConditions);
|
|
240
|
+
|
|
241
|
+
// Build FilterExpression (can be complex with OR, NOT, etc.)
|
|
242
|
+
let filterExpr = { expression: '', names: {}, values: {} };
|
|
243
|
+
if (filterConditions.length > 0) {
|
|
244
|
+
if (filterConditions.length === 1 && filterConditions[0]) {
|
|
245
|
+
filterExpr = buildExpression(filterConditions[0]);
|
|
246
|
+
} else {
|
|
247
|
+
// Multiple filter conditions - combine with AND
|
|
248
|
+
filterExpr = buildExpression({
|
|
249
|
+
expression: '',
|
|
250
|
+
operator: 'AND',
|
|
251
|
+
children: filterConditions,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Merge attribute names and values from both expressions
|
|
257
|
+
const allNames = {
|
|
258
|
+
...(keyExpr.names ?? {}),
|
|
259
|
+
...(filterExpr.names ?? {}),
|
|
260
|
+
};
|
|
261
|
+
const allValues = {
|
|
262
|
+
...(keyExpr.values ?? {}),
|
|
263
|
+
...(filterExpr.values ?? {}),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
TableName: state.tableName,
|
|
268
|
+
...(keyExpr.expression && {
|
|
269
|
+
KeyConditionExpression: keyExpr.expression,
|
|
270
|
+
}),
|
|
271
|
+
...(filterExpr.expression && {
|
|
272
|
+
FilterExpression: filterExpr.expression,
|
|
273
|
+
}),
|
|
274
|
+
...(Object.keys(allNames).length && {
|
|
275
|
+
ExpressionAttributeNames: allNames,
|
|
276
|
+
}),
|
|
277
|
+
...(Object.keys(allValues).length && {
|
|
278
|
+
ExpressionAttributeValues: allValues,
|
|
279
|
+
}),
|
|
280
|
+
...(state.indexName && { IndexName: state.indexName }),
|
|
281
|
+
...(state.limitValue && { Limit: state.limitValue }),
|
|
282
|
+
...(state.scanForward !== undefined && {
|
|
283
|
+
ScanIndexForward: state.scanForward,
|
|
284
|
+
}),
|
|
285
|
+
...(state.projection &&
|
|
286
|
+
state.projection.length > 0 && {
|
|
287
|
+
ProjectionExpression: state.projection.join(', '),
|
|
288
|
+
}),
|
|
289
|
+
...(state.consistentReadEnabled && { ConsistentRead: true }),
|
|
290
|
+
...(state.exclusiveStartKey && {
|
|
291
|
+
ExclusiveStartKey: state.exclusiveStartKey,
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
async execute(): Promise<Model[]> {
|
|
297
|
+
const params = this.dbParams();
|
|
298
|
+
const result = await state.client.send(new QueryCommand(params));
|
|
299
|
+
state.logger?.log('QueryCommand', params, result);
|
|
300
|
+
return (result.Items ?? []) as unknown as Model[];
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
async executeWithPagination(): Promise<QueryResult<Model>> {
|
|
304
|
+
const params = this.dbParams();
|
|
305
|
+
const result = await state.client.send(new QueryCommand(params));
|
|
306
|
+
state.logger?.log('QueryCommand', params, result);
|
|
307
|
+
return {
|
|
308
|
+
items: (result.Items ?? []) as unknown as Model[],
|
|
309
|
+
lastEvaluatedKey: result.LastEvaluatedKey,
|
|
310
|
+
count: result.Count,
|
|
311
|
+
scannedCount: result.ScannedCount,
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Creates a QueryBuilder for a table
|
|
319
|
+
*/
|
|
320
|
+
export function createQueryBuilder<Model, M extends ModelDefinition = any>(
|
|
321
|
+
tableName: string,
|
|
322
|
+
client: DynamoDBClient,
|
|
323
|
+
model?: M,
|
|
324
|
+
logger?: DynamoDBLogger
|
|
325
|
+
): QueryBuilder<Model, M> {
|
|
326
|
+
return {
|
|
327
|
+
where(fn) {
|
|
328
|
+
// Create attribute builder proxy
|
|
329
|
+
const attrProxy = new Proxy({} as AttrBuilder<Model>, {
|
|
330
|
+
get(_, prop: string): AttrRef {
|
|
331
|
+
return { name: prop };
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Create a scoped opBuilder for this query to avoid global state
|
|
336
|
+
const opBuilder = createOpBuilder();
|
|
337
|
+
|
|
338
|
+
// Call the user's function to get the condition tree
|
|
339
|
+
const condition = fn(attrProxy, opBuilder);
|
|
340
|
+
|
|
341
|
+
// Create initial state with the condition
|
|
342
|
+
const initialState: QueryState<Model> = {
|
|
343
|
+
tableName,
|
|
344
|
+
client,
|
|
345
|
+
model,
|
|
346
|
+
condition,
|
|
347
|
+
logger,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
return createQueryExecutor(initialState);
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { ExecutableBuilder, AttrBuilder, OpBuilder, Condition } from '../shared';
|
|
3
|
+
import { ModelDefinition } from '../../core/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Query result with pagination support
|
|
7
|
+
*/
|
|
8
|
+
export interface QueryResult<Model> {
|
|
9
|
+
/**
|
|
10
|
+
* The items returned by the query
|
|
11
|
+
*/
|
|
12
|
+
items: Model[];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The LastEvaluatedKey for pagination (if more results available)
|
|
16
|
+
*/
|
|
17
|
+
lastEvaluatedKey?: Record<string, any>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Number of items examined before applying filters
|
|
21
|
+
*/
|
|
22
|
+
count?: number;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Number of items returned after applying filters
|
|
26
|
+
*/
|
|
27
|
+
scannedCount?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Query executor with additional query-specific options
|
|
32
|
+
*/
|
|
33
|
+
export interface QueryExecutor<Model, M extends ModelDefinition = any> extends ExecutableBuilder<
|
|
34
|
+
Model[]
|
|
35
|
+
> {
|
|
36
|
+
/**
|
|
37
|
+
* Limit the number of items to return
|
|
38
|
+
*/
|
|
39
|
+
limit(count: number): QueryExecutor<Model, M>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Scan index forward (ascending) or backward (descending)
|
|
43
|
+
*/
|
|
44
|
+
scanIndexForward(forward: boolean): QueryExecutor<Model, M>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Use a secondary index
|
|
48
|
+
*/
|
|
49
|
+
useIndex(indexName: string): QueryExecutor<Model, M>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Select specific attributes to return
|
|
53
|
+
*/
|
|
54
|
+
select(attrs: (keyof Model)[]): QueryExecutor<Model, M>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Use consistent read
|
|
58
|
+
*/
|
|
59
|
+
consistentRead(): QueryExecutor<Model, M>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Start query from a specific key (for pagination)
|
|
63
|
+
*/
|
|
64
|
+
startFrom(key: Record<string, any>): QueryExecutor<Model, M>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns the raw DynamoDB query parameters
|
|
68
|
+
*/
|
|
69
|
+
dbParams(): any;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Executes the query and returns matching items
|
|
73
|
+
*/
|
|
74
|
+
execute(): Promise<Model[]>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Executes the query and returns result with pagination metadata
|
|
78
|
+
*/
|
|
79
|
+
executeWithPagination(): Promise<QueryResult<Model>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Main Query Builder interface with type-safe where clause
|
|
84
|
+
*/
|
|
85
|
+
export interface QueryBuilder<Model, M extends ModelDefinition = any> {
|
|
86
|
+
/**
|
|
87
|
+
* Build a condition expression using attributes and operators
|
|
88
|
+
* Usage: .where((attr, op) => op.eq(attr.username, 'juanca'))
|
|
89
|
+
* Usage: .where((attr, op) => op.and(
|
|
90
|
+
* op.eq(attr.username, 'juanca'),
|
|
91
|
+
* op.gt(attr.age, 18)
|
|
92
|
+
* ))
|
|
93
|
+
*/
|
|
94
|
+
where(fn: (attr: AttrBuilder<Model>, op: OpBuilder) => Condition): QueryExecutor<Model, M>;
|
|
95
|
+
}
|