@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.
Files changed (197) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +17 -0
  3. package/dist/builders/batch-get/create-batch-get-builder.d.ts +18 -0
  4. package/dist/builders/batch-get/create-batch-get-builder.d.ts.map +1 -0
  5. package/dist/builders/batch-get/create-batch-get-builder.js +71 -0
  6. package/dist/builders/batch-get/index.d.ts +3 -0
  7. package/dist/builders/batch-get/index.d.ts.map +1 -0
  8. package/dist/builders/batch-get/index.js +18 -0
  9. package/dist/builders/batch-get/types.d.ts +25 -0
  10. package/dist/builders/batch-get/types.d.ts.map +1 -0
  11. package/dist/builders/batch-get/types.js +2 -0
  12. package/dist/builders/batch-write/create-batch-write-builder.d.ts +12 -0
  13. package/dist/builders/batch-write/create-batch-write-builder.d.ts.map +1 -0
  14. package/dist/builders/batch-write/create-batch-write-builder.js +35 -0
  15. package/dist/builders/batch-write/index.d.ts +3 -0
  16. package/dist/builders/batch-write/index.d.ts.map +1 -0
  17. package/dist/builders/batch-write/index.js +18 -0
  18. package/dist/builders/batch-write/types.d.ts +30 -0
  19. package/dist/builders/batch-write/types.d.ts.map +1 -0
  20. package/dist/builders/batch-write/types.js +2 -0
  21. package/dist/builders/delete/create-delete-builder.d.ts +9 -0
  22. package/dist/builders/delete/create-delete-builder.d.ts.map +1 -0
  23. package/dist/builders/delete/create-delete-builder.js +72 -0
  24. package/dist/builders/delete/index.d.ts +3 -0
  25. package/dist/builders/delete/index.d.ts.map +1 -0
  26. package/dist/builders/delete/index.js +18 -0
  27. package/dist/builders/delete/types.d.ts +11 -0
  28. package/dist/builders/delete/types.d.ts.map +1 -0
  29. package/dist/builders/delete/types.js +2 -0
  30. package/dist/builders/get/create-get-builder.d.ts +12 -0
  31. package/dist/builders/get/create-get-builder.d.ts.map +1 -0
  32. package/dist/builders/get/create-get-builder.js +95 -0
  33. package/dist/builders/get/index.d.ts +3 -0
  34. package/dist/builders/get/index.d.ts.map +1 -0
  35. package/dist/builders/get/index.js +18 -0
  36. package/dist/builders/get/types.d.ts +28 -0
  37. package/dist/builders/get/types.d.ts.map +1 -0
  38. package/dist/builders/get/types.js +2 -0
  39. package/dist/builders/index.d.ts +12 -0
  40. package/dist/builders/index.d.ts.map +1 -0
  41. package/dist/builders/index.js +29 -0
  42. package/dist/builders/put/create-put-builder.d.ts +9 -0
  43. package/dist/builders/put/create-put-builder.d.ts.map +1 -0
  44. package/dist/builders/put/create-put-builder.js +104 -0
  45. package/dist/builders/put/index.d.ts +3 -0
  46. package/dist/builders/put/index.d.ts.map +1 -0
  47. package/dist/builders/put/index.js +18 -0
  48. package/dist/builders/put/types.d.ts +17 -0
  49. package/dist/builders/put/types.d.ts.map +1 -0
  50. package/dist/builders/put/types.js +2 -0
  51. package/dist/builders/query/create-query-builder.d.ts +9 -0
  52. package/dist/builders/query/create-query-builder.d.ts.map +1 -0
  53. package/dist/builders/query/create-query-builder.js +283 -0
  54. package/dist/builders/query/index.d.ts +3 -0
  55. package/dist/builders/query/index.d.ts.map +1 -0
  56. package/dist/builders/query/index.js +18 -0
  57. package/dist/builders/query/types.d.ts +79 -0
  58. package/dist/builders/query/types.d.ts.map +1 -0
  59. package/dist/builders/query/types.js +2 -0
  60. package/dist/builders/scan/create-scan-builder.d.ts +12 -0
  61. package/dist/builders/scan/create-scan-builder.d.ts.map +1 -0
  62. package/dist/builders/scan/create-scan-builder.js +103 -0
  63. package/dist/builders/scan/index.d.ts +3 -0
  64. package/dist/builders/scan/index.d.ts.map +1 -0
  65. package/dist/builders/scan/index.js +18 -0
  66. package/dist/builders/scan/types.d.ts +43 -0
  67. package/dist/builders/scan/types.d.ts.map +1 -0
  68. package/dist/builders/scan/types.js +2 -0
  69. package/dist/builders/shared/conditions.d.ts +18 -0
  70. package/dist/builders/shared/conditions.d.ts.map +1 -0
  71. package/dist/builders/shared/conditions.js +48 -0
  72. package/dist/builders/shared/index.d.ts +4 -0
  73. package/dist/builders/shared/index.d.ts.map +1 -0
  74. package/dist/builders/shared/index.js +19 -0
  75. package/dist/builders/shared/operators.d.ts +12 -0
  76. package/dist/builders/shared/operators.d.ts.map +1 -0
  77. package/dist/builders/shared/operators.js +197 -0
  78. package/dist/builders/shared/types.d.ts +77 -0
  79. package/dist/builders/shared/types.d.ts.map +1 -0
  80. package/dist/builders/shared/types.js +6 -0
  81. package/dist/builders/transact-get/create-transact-get-builder.d.ts +18 -0
  82. package/dist/builders/transact-get/create-transact-get-builder.d.ts.map +1 -0
  83. package/dist/builders/transact-get/create-transact-get-builder.js +60 -0
  84. package/dist/builders/transact-get/index.d.ts +3 -0
  85. package/dist/builders/transact-get/index.d.ts.map +1 -0
  86. package/dist/builders/transact-get/index.js +18 -0
  87. package/dist/builders/transact-get/types.d.ts +28 -0
  88. package/dist/builders/transact-get/types.d.ts.map +1 -0
  89. package/dist/builders/transact-get/types.js +2 -0
  90. package/dist/builders/transact-write/create-transact-write-builder.d.ts +18 -0
  91. package/dist/builders/transact-write/create-transact-write-builder.d.ts.map +1 -0
  92. package/dist/builders/transact-write/create-transact-write-builder.js +97 -0
  93. package/dist/builders/transact-write/index.d.ts +3 -0
  94. package/dist/builders/transact-write/index.d.ts.map +1 -0
  95. package/dist/builders/transact-write/index.js +18 -0
  96. package/dist/builders/transact-write/types.d.ts +34 -0
  97. package/dist/builders/transact-write/types.d.ts.map +1 -0
  98. package/dist/builders/transact-write/types.js +2 -0
  99. package/dist/builders/update/create-update-builder.d.ts +14 -0
  100. package/dist/builders/update/create-update-builder.d.ts.map +1 -0
  101. package/dist/builders/update/create-update-builder.js +180 -0
  102. package/dist/builders/update/index.d.ts +3 -0
  103. package/dist/builders/update/index.d.ts.map +1 -0
  104. package/dist/builders/update/index.js +18 -0
  105. package/dist/builders/update/types.d.ts +35 -0
  106. package/dist/builders/update/types.d.ts.map +1 -0
  107. package/dist/builders/update/types.js +2 -0
  108. package/dist/core/types.d.ts +224 -0
  109. package/dist/core/types.d.ts.map +1 -0
  110. package/dist/core/types.js +3 -0
  111. package/dist/entity.d.ts +73 -0
  112. package/dist/entity.d.ts.map +1 -0
  113. package/dist/entity.js +161 -0
  114. package/dist/index.d.ts +4 -0
  115. package/dist/index.d.ts.map +1 -0
  116. package/dist/index.js +20 -0
  117. package/dist/table.d.ts +70 -0
  118. package/dist/table.d.ts.map +1 -0
  119. package/dist/table.js +69 -0
  120. package/dist/utils/dynamodb-logger.d.ts +118 -0
  121. package/dist/utils/dynamodb-logger.d.ts.map +1 -0
  122. package/dist/utils/dynamodb-logger.js +125 -0
  123. package/dist/utils/model-utils.d.ts +24 -0
  124. package/dist/utils/model-utils.d.ts.map +1 -0
  125. package/dist/utils/model-utils.js +88 -0
  126. package/dist/utils/zod-utils.d.ts +13 -0
  127. package/dist/utils/zod-utils.d.ts.map +1 -0
  128. package/dist/utils/zod-utils.js +35 -0
  129. package/eslint.config.mjs +4 -0
  130. package/jest.config.js +11 -0
  131. package/package.json +36 -0
  132. package/src/builders/README.md +272 -0
  133. package/src/builders/batch-get/README.md +98 -0
  134. package/src/builders/batch-get/create-batch-get-builder.test.ts +165 -0
  135. package/src/builders/batch-get/create-batch-get-builder.ts +106 -0
  136. package/src/builders/batch-get/index.ts +2 -0
  137. package/src/builders/batch-get/types.ts +29 -0
  138. package/src/builders/batch-write/README.md +204 -0
  139. package/src/builders/batch-write/create-batch-write-builder.test.ts +173 -0
  140. package/src/builders/batch-write/create-batch-write-builder.ts +49 -0
  141. package/src/builders/batch-write/index.ts +2 -0
  142. package/src/builders/batch-write/types.ts +33 -0
  143. package/src/builders/delete/create-delete-builder.test.ts +294 -0
  144. package/src/builders/delete/create-delete-builder.ts +100 -0
  145. package/src/builders/delete/index.ts +2 -0
  146. package/src/builders/delete/types.ts +11 -0
  147. package/src/builders/get/create-get-builder.test.ts +272 -0
  148. package/src/builders/get/create-get-builder.ts +140 -0
  149. package/src/builders/get/index.ts +2 -0
  150. package/src/builders/get/types.ts +30 -0
  151. package/src/builders/index.ts +14 -0
  152. package/src/builders/put/create-put-builder.test.ts +213 -0
  153. package/src/builders/put/create-put-builder.ts +151 -0
  154. package/src/builders/put/index.ts +2 -0
  155. package/src/builders/put/types.ts +18 -0
  156. package/src/builders/query/create-query-builder.test.ts +230 -0
  157. package/src/builders/query/create-query-builder.ts +353 -0
  158. package/src/builders/query/index.ts +2 -0
  159. package/src/builders/query/types.ts +95 -0
  160. package/src/builders/scan/create-scan-builder.test.ts +260 -0
  161. package/src/builders/scan/create-scan-builder.ts +217 -0
  162. package/src/builders/scan/index.ts +2 -0
  163. package/src/builders/scan/types.ts +49 -0
  164. package/src/builders/shared/conditions.ts +58 -0
  165. package/src/builders/shared/index.ts +3 -0
  166. package/src/builders/shared/operators.test.ts +270 -0
  167. package/src/builders/shared/operators.ts +200 -0
  168. package/src/builders/shared/types.ts +100 -0
  169. package/src/builders/transact-get/README.md +167 -0
  170. package/src/builders/transact-get/create-transact-get-builder.test.ts +239 -0
  171. package/src/builders/transact-get/create-transact-get-builder.ts +67 -0
  172. package/src/builders/transact-get/index.ts +2 -0
  173. package/src/builders/transact-get/types.ts +31 -0
  174. package/src/builders/transact-write/README.md +166 -0
  175. package/src/builders/transact-write/create-transact-write-builder.test.ts +288 -0
  176. package/src/builders/transact-write/create-transact-write-builder.ts +118 -0
  177. package/src/builders/transact-write/index.ts +2 -0
  178. package/src/builders/transact-write/types.ts +33 -0
  179. package/src/builders/update/create-update-builder.test.ts +333 -0
  180. package/src/builders/update/create-update-builder.ts +284 -0
  181. package/src/builders/update/index.ts +2 -0
  182. package/src/builders/update/types.ts +42 -0
  183. package/src/core/types.test.ts +506 -0
  184. package/src/core/types.ts +290 -0
  185. package/src/entity.ts +337 -0
  186. package/src/index.ts +22 -0
  187. package/src/table.ts +109 -0
  188. package/src/utils/dynamodb-logger.test.ts +246 -0
  189. package/src/utils/dynamodb-logger.ts +175 -0
  190. package/src/utils/model-utils.test.ts +232 -0
  191. package/src/utils/model-utils.ts +101 -0
  192. package/src/utils/zod-utils.test.ts +272 -0
  193. package/src/utils/zod-utils.ts +36 -0
  194. package/tests/integration/instagram-clone.integration.test.ts +966 -0
  195. package/tests/integration/pagination-timestamps.integration.test.ts +375 -0
  196. package/tests/integration/transactions.integration.test.ts +529 -0
  197. 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,2 @@
1
+ export * from './types';
2
+ export * from './create-query-builder';
@@ -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
+ }