@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,966 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { Table } from '../../src/table';
3
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
4
+
5
+ /**
6
+ * DynamoDB Schema for Instagram Clone
7
+ *
8
+ * Single Table Design with the following access patterns:
9
+ * - Get User by username
10
+ * - Get Photo by username and photoId
11
+ * - List Photos by User (reverse chronological)
12
+ * - Like a Photo (enforce uniqueness per user)
13
+ * - List Likes for a Photo (chronological by likeId)
14
+ * - Comment on a Photo
15
+ * - List Comments for a Photo
16
+ * - Follow a User
17
+ * - List Followers of a User
18
+ * - List Users followed by a User
19
+ */
20
+
21
+ const InstagramSchema = {
22
+ format: 'dynatable:1.0.0',
23
+ version: '1.0.0',
24
+ indexes: {
25
+ primary: { hash: 'PK', sort: 'SK' },
26
+ gs1: { hash: 'GSI1PK', sort: 'GSI1SK' },
27
+ },
28
+ models: {
29
+ User: {
30
+ key: {
31
+ PK: { type: String, value: 'USER#${username}' },
32
+ SK: { type: String, value: 'USER#${username}' },
33
+ },
34
+ attributes: {
35
+ username: { type: String, required: true },
36
+ name: { type: String, required: true },
37
+ followerCount: { type: Number, default: 0 },
38
+ followingCount: { type: Number, default: 0 },
39
+ },
40
+ },
41
+
42
+ Photo: {
43
+ key: {
44
+ PK: { type: String, value: 'UP#${username}' },
45
+ SK: { type: String, value: 'PHOTO#${photoId}' },
46
+ },
47
+ attributes: {
48
+ username: { type: String, required: true },
49
+ photoId: { type: String, generate: 'ulid' },
50
+ url: { type: String, required: true },
51
+ likesCount: { type: Number, default: 0 },
52
+ commentCount: { type: Number, default: 0 },
53
+ },
54
+ },
55
+
56
+ Like: {
57
+ key: {
58
+ PK: { type: String, value: 'PL#${photoId}' },
59
+ SK: { type: String, value: 'LIKE#${likingUsername}' },
60
+ },
61
+ index: {
62
+ gs1pk: { type: String, value: 'PL#${photoId}' },
63
+ gs1sk: { type: String, value: 'LIKE#${likeId}' },
64
+ },
65
+ attributes: {
66
+ photoId: { type: String, required: true },
67
+ likingUsername: { type: String, required: true },
68
+ likeId: { type: String, generate: 'ulid', required: true },
69
+ },
70
+ },
71
+
72
+ Comment: {
73
+ key: {
74
+ PK: { type: String, value: 'PC#${photoId}' },
75
+ SK: { type: String, value: 'COMMENT#${commentId}' },
76
+ },
77
+ attributes: {
78
+ photoId: { type: String, required: true },
79
+ commentId: { type: String, generate: 'ulid', required: true },
80
+ commentingUsername: { type: String, required: true },
81
+ content: { type: String, required: true },
82
+ },
83
+ },
84
+
85
+ Follow: {
86
+ key: {
87
+ PK: { type: String, value: 'FOLLOW#${followedUsername}' },
88
+ SK: { type: String, value: 'FOLLOW#${followingUsername}' },
89
+ },
90
+ index: {
91
+ gs1pk: { type: String, value: 'FOLLOW#${followingUsername}' },
92
+ gs1sk: { type: String, value: 'FOLLOW#${followedUsername}' },
93
+ },
94
+ attributes: {
95
+ followedUsername: { type: String, required: true },
96
+ followingUsername: { type: String, required: true },
97
+ },
98
+ },
99
+ },
100
+ params: {
101
+ isoDates: true,
102
+ timestamps: true,
103
+ },
104
+ } as const;
105
+
106
+ /**
107
+ * Access Patterns Summary:
108
+ *
109
+ * 1. User Operations:
110
+ * - Create User: PutItem with PK=USER#{username}, SK=USER#{username}
111
+ * - Get User: GetItem with PK=USER#{username}, SK=USER#{username}
112
+ *
113
+ * 2. Photo Operations:
114
+ * - Create Photo: PutItem with PK=UP#{username}, SK=PHOTO#{photoId}
115
+ * - Get Photo: GetItem with PK=UP#{username}, SK=PHOTO#{photoId}
116
+ * - List User Photos: Query with PK=UP#{username}, SK begins_with "PHOTO#"
117
+ *
118
+ * 3. Like Operations:
119
+ * - Like Photo: TransactWrite - Put Like + Update Photo.likesCount
120
+ * - PK=PL#{photoId}, SK=LIKE#{likingUsername}
121
+ * - List Likes (chronological): Query GSI1 with GSI1PK=PL#{photoId}
122
+ *
123
+ * 4. Comment Operations:
124
+ * - Comment on Photo: TransactWrite - Put Comment + Update Photo.commentCount
125
+ * - PK=PC#{photoId}, SK=COMMENT#{commentId}
126
+ * - List Comments: Query with PK=PC#{photoId}, SK begins_with "COMMENT#"
127
+ *
128
+ * 5. Follow Operations:
129
+ * - Follow User: TransactWrite - Put Follow + Update followerCount + Update followingCount
130
+ * - PK=FOLLOW#{followedUsername}, SK=FOLLOW#{followingUsername}
131
+ * - List Followers: Query with PK=FOLLOW#{username}, then BatchGetItem for User details
132
+ * - List Following: Query GSI1 with GSI1PK=FOLLOW#{username}, then BatchGetItem for User details
133
+ */
134
+
135
+ const table = new Table({
136
+ name: 'InstagramClone',
137
+ client: new DynamoDBClient({}),
138
+ schema: InstagramSchema,
139
+ });
140
+
141
+ jest.mock('ulid', () => ({
142
+ ulid: jest.fn(() => '01K16ZP43BRX67DG50SHGZ11DS'),
143
+ }));
144
+ describe('Should test dbParams function builder', () => {
145
+ test('Get User by Username', async () => {
146
+ const params = await table.entities.User.get({
147
+ username: 'juanca',
148
+ }).dbParams();
149
+
150
+ expect(params).toEqual({
151
+ TableName: 'InstagramClone',
152
+ Key: {
153
+ PK: 'USER#juanca',
154
+ SK: 'USER#juanca',
155
+ },
156
+ });
157
+ });
158
+ test('should fail when trying to get user without required username', async () => {
159
+ expect(() => {
160
+ table.entities.User.get({} as any);
161
+ }).toThrow(
162
+ '[User] Missing required key field(s) for get(): username. Required fields: username'
163
+ );
164
+ });
165
+
166
+ test('Create User uniqueness', async () => {
167
+ const params = await table.entities.User.put({
168
+ name: 'Juan Carlos Bondi',
169
+ username: 'juanca',
170
+ })
171
+ .ifNotExists()
172
+ .dbParams();
173
+
174
+ expect(params).toMatchObject({
175
+ TableName: 'InstagramClone',
176
+ Item: expect.objectContaining({
177
+ username: 'juanca',
178
+ name: 'Juan Carlos Bondi',
179
+ followerCount: 0,
180
+ followingCount: 0,
181
+ PK: 'USER#juanca',
182
+ SK: 'USER#juanca',
183
+ createdAt: expect.any(String),
184
+ updatedAt: expect.any(String),
185
+ }),
186
+ ConditionExpression: '(attribute_not_exists(#PK)) AND (attribute_not_exists(#SK))',
187
+ ExpressionAttributeNames: {
188
+ '#PK': 'PK',
189
+ '#SK': 'SK',
190
+ },
191
+ });
192
+ });
193
+ test('Upsert User', async () => {
194
+ const params = await table.entities.User.put({
195
+ name: 'Juan Carlos Bondi',
196
+ username: 'juanca',
197
+ }).dbParams();
198
+
199
+ expect(params).toMatchObject({
200
+ TableName: 'InstagramClone',
201
+ Item: expect.objectContaining({
202
+ username: 'juanca',
203
+ name: 'Juan Carlos Bondi',
204
+ followerCount: 0,
205
+ followingCount: 0,
206
+ PK: 'USER#juanca',
207
+ SK: 'USER#juanca',
208
+ createdAt: expect.any(String),
209
+ updatedAt: expect.any(String),
210
+ }),
211
+ });
212
+ expect(params.ConditionExpression).toBeUndefined();
213
+ });
214
+
215
+ test('Create Photo', async () => {
216
+ const params = await table.entities.Photo.put({
217
+ username: 'juanca',
218
+ url: 'https://photos.app.goo.gl/abcd1234',
219
+ }).dbParams();
220
+
221
+ expect(params).toMatchObject({
222
+ TableName: 'InstagramClone',
223
+ Item: expect.objectContaining({
224
+ commentCount: 0,
225
+ likesCount: 0,
226
+ photoId: '01K16ZP43BRX67DG50SHGZ11DS',
227
+ PK: 'UP#juanca',
228
+ SK: 'PHOTO#01K16ZP43BRX67DG50SHGZ11DS',
229
+ url: 'https://photos.app.goo.gl/abcd1234',
230
+ username: 'juanca',
231
+ createdAt: expect.any(String),
232
+ updatedAt: expect.any(String),
233
+ }),
234
+ });
235
+ });
236
+
237
+ test('Get Photo', async () => {
238
+ const params = await table.entities.Photo.get({
239
+ username: 'juanca',
240
+ photoId: '01K16ZP43BRX67DG50SHGZ11DS',
241
+ }).dbParams();
242
+
243
+ expect(params).toEqual({
244
+ Key: { PK: 'UP#juanca', SK: 'PHOTO#01K16ZP43BRX67DG50SHGZ11DS' },
245
+ TableName: 'InstagramClone',
246
+ });
247
+ });
248
+
249
+ test('Query user Photos - basic', async () => {
250
+ const params = await table.entities.Photo.query()
251
+ .where((attr, op) => op.eq(attr.username, 'juanca'))
252
+ .dbParams();
253
+
254
+ // After query builder fix: username -> pk, and value gets template applied
255
+ expect(params).toEqual({
256
+ TableName: 'InstagramClone',
257
+ KeyConditionExpression: '#PK = :username_0',
258
+ ExpressionAttributeNames: {
259
+ '#PK': 'PK',
260
+ },
261
+ ExpressionAttributeValues: {
262
+ ':username_0': 'UP#juanca', // Template applied: UP#${username}
263
+ },
264
+ });
265
+
266
+ expect(params.TableName).toBe('InstagramClone');
267
+ expect(params.KeyConditionExpression).toMatch(/#PK = :username_\d+/);
268
+ expect(params.ExpressionAttributeNames).toEqual({
269
+ '#PK': 'PK',
270
+ });
271
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('UP#juanca');
272
+ });
273
+
274
+ test('Query user Photos - with filter using AND', async () => {
275
+ const params = await table.entities.Photo.query()
276
+ .where((attr, op) => op.and(op.eq(attr.username, 'juanca'), op.gt(attr.likesCount, 0)))
277
+ .dbParams();
278
+
279
+ expect(params).toEqual({
280
+ TableName: 'InstagramClone',
281
+ KeyConditionExpression: '#PK = :username_0',
282
+ FilterExpression: '#likesCount > :likesCount_1',
283
+ ExpressionAttributeNames: {
284
+ '#likesCount': 'likesCount',
285
+ '#PK': 'PK',
286
+ },
287
+ ExpressionAttributeValues: {
288
+ ':likesCount_1': 0,
289
+ ':username_0': 'UP#juanca',
290
+ },
291
+ });
292
+
293
+ expect(params.TableName).toBe('InstagramClone');
294
+ expect(params.KeyConditionExpression).toMatch(/#PK = :username_\d+/);
295
+ expect(params.FilterExpression).toMatch(/#likesCount > :likesCount_\d+/);
296
+ expect(params.ExpressionAttributeNames).toEqual({
297
+ '#PK': 'PK',
298
+ '#likesCount': 'likesCount',
299
+ });
300
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('UP#juanca');
301
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(0);
302
+ });
303
+
304
+ test('Query user Photos - with limit and sort order', async () => {
305
+ const params = await table.entities.Photo.query()
306
+ .where((attr, op) => op.eq(attr.username, 'juanca'))
307
+ .limit(10)
308
+ .scanIndexForward(false)
309
+ .dbParams();
310
+
311
+ expect(params).toEqual({
312
+ TableName: 'InstagramClone',
313
+ KeyConditionExpression: '#PK = :username_0',
314
+ ExpressionAttributeNames: {
315
+ '#PK': 'PK',
316
+ },
317
+ ExpressionAttributeValues: {
318
+ ':username_0': 'UP#juanca',
319
+ },
320
+ Limit: 10,
321
+ ScanIndexForward: false,
322
+ });
323
+
324
+ expect(params.TableName).toBe('InstagramClone');
325
+ expect(params.KeyConditionExpression).toMatch(/#PK = :username_\d+/);
326
+ expect(params.Limit).toBe(10);
327
+ expect(params.ScanIndexForward).toBe(false);
328
+ });
329
+
330
+ test('Query with projection', async () => {
331
+ const params = await table.entities.Photo.query()
332
+ .where((attr, op) => op.eq(attr.username, 'juanca'))
333
+ .select(['photoId', 'url', 'likesCount'])
334
+ .dbParams();
335
+
336
+ expect(params.TableName).toBe('InstagramClone');
337
+ expect(params.ProjectionExpression).toBe('photoId, url, likesCount');
338
+ });
339
+
340
+ test('Query Likes by photoId', async () => {
341
+ const params = await table.entities.Like.query()
342
+ .where((attr, op) => op.eq(attr.photoId, 'photo123'))
343
+ .dbParams();
344
+
345
+ expect(params).toEqual({
346
+ TableName: 'InstagramClone',
347
+ KeyConditionExpression: '#PK = :photoId_0',
348
+ ExpressionAttributeNames: {
349
+ '#PK': 'PK',
350
+ },
351
+ ExpressionAttributeValues: {
352
+ ':photoId_0': 'PL#photo123', // Template applied: PL#${photoId}
353
+ },
354
+ });
355
+ expect(params.TableName).toBe('InstagramClone');
356
+ expect(params.KeyConditionExpression).toMatch(/#PK = :photoId_\d+/);
357
+ expect(params.ExpressionAttributeNames).toEqual({ '#PK': 'PK' });
358
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('PL#photo123');
359
+ });
360
+
361
+ test('Query with OR filter expression', async () => {
362
+ const params = await table.entities.Photo.query()
363
+ .where((attr, op) =>
364
+ op.and(
365
+ op.eq(attr.username, 'juanca'),
366
+ op.or(op.gt(attr.likesCount, 100), op.gt(attr.commentCount, 50))
367
+ )
368
+ )
369
+ .dbParams();
370
+
371
+ expect(params.TableName).toBe('InstagramClone');
372
+ expect(params.KeyConditionExpression).toMatch(/#PK = :username_\d+/);
373
+ expect(params.FilterExpression).toMatch(
374
+ /\(#likesCount > :likesCount_\d+\) OR \(#commentCount > :commentCount_\d+\)/
375
+ );
376
+ expect(params.ExpressionAttributeNames).toEqual({
377
+ '#PK': 'PK',
378
+ '#likesCount': 'likesCount',
379
+ '#commentCount': 'commentCount',
380
+ });
381
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('UP#juanca');
382
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(100);
383
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(50);
384
+ });
385
+
386
+ test('Query with complex nested conditions', async () => {
387
+ const params = await table.entities.Photo.query()
388
+ .where((attr, op) =>
389
+ op.and(
390
+ op.eq(attr.username, 'juanca'),
391
+ op.or(
392
+ op.and(op.gt(attr.likesCount, 100), op.lt(attr.commentCount, 10)),
393
+ op.gt(attr.likesCount, 500)
394
+ )
395
+ )
396
+ )
397
+ .dbParams();
398
+
399
+ expect(params.TableName).toBe('InstagramClone');
400
+ expect(params.KeyConditionExpression).toMatch(/#PK = :username_\d+/);
401
+ expect(params.FilterExpression).toMatch(
402
+ /\(#likesCount > :likesCount_\d+\) AND \(#commentCount < :commentCount_\d+\)/
403
+ );
404
+ expect(params.FilterExpression).toMatch(/OR/);
405
+ expect(params.ExpressionAttributeNames).toEqual({
406
+ '#PK': 'PK',
407
+ '#likesCount': 'likesCount',
408
+ '#commentCount': 'commentCount',
409
+ });
410
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('UP#juanca');
411
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(100);
412
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(10);
413
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(500);
414
+ });
415
+
416
+ test('PUT with custom where condition using AND', async () => {
417
+ const params = await table.entities.User.put({
418
+ name: 'Juan Carlos Bondi',
419
+ username: 'juanca',
420
+ followerCount: 100,
421
+ })
422
+ .where((attr, op) => op.and(op.gt(attr.followerCount, 50), op.lt(attr.followerCount, 200)))
423
+ .dbParams();
424
+
425
+ expect(params.TableName).toBe('InstagramClone');
426
+ expect(params.Item).toMatchObject({
427
+ username: 'juanca',
428
+ name: 'Juan Carlos Bondi',
429
+ followerCount: 100,
430
+ followingCount: 0,
431
+ PK: 'USER#juanca',
432
+ SK: 'USER#juanca',
433
+ createdAt: expect.any(String),
434
+ updatedAt: expect.any(String),
435
+ });
436
+ expect(params.ConditionExpression).toMatch(
437
+ /\(#followerCount > :followerCount_\d+\) AND \(#followerCount < :followerCount_\d+\)/
438
+ );
439
+ expect(params.ExpressionAttributeNames).toEqual({
440
+ '#followerCount': 'followerCount',
441
+ });
442
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(50);
443
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(200);
444
+ });
445
+
446
+ test('PUT with OR condition', async () => {
447
+ const params = await table.entities.Photo.put({
448
+ username: 'juanca',
449
+ url: 'https://example.com/photo.jpg',
450
+ })
451
+ .where((attr, op) => op.or(op.eq(attr.likesCount, 0), op.eq(attr.commentCount, 0)))
452
+ .dbParams();
453
+
454
+ expect(params.TableName).toBe('InstagramClone');
455
+ expect(params.ConditionExpression).toMatch(
456
+ /\(#likesCount = :likesCount_\d+\) OR \(#commentCount = :commentCount_\d+\)/
457
+ );
458
+ expect(params.ExpressionAttributeNames).toEqual({
459
+ '#likesCount': 'likesCount',
460
+ '#commentCount': 'commentCount',
461
+ });
462
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(0);
463
+ });
464
+
465
+ test('PUT with ifNotExists and custom where condition', async () => {
466
+ const params = await table.entities.User.put({
467
+ name: 'Juan Carlos Bondi',
468
+ username: 'juanca',
469
+ followerCount: 100,
470
+ })
471
+ .ifNotExists()
472
+ .where((attr, op) => op.gt(attr.followerCount, 50))
473
+ .dbParams();
474
+
475
+ expect(params.TableName).toBe('InstagramClone');
476
+ // Should have both ifNotExists conditions AND custom where condition
477
+ expect(params.ConditionExpression).toMatch(/attribute_not_exists\(#PK\)/);
478
+ expect(params.ConditionExpression).toMatch(/attribute_not_exists\(#SK\)/);
479
+ expect(params.ConditionExpression).toMatch(/#followerCount > :followerCount_\d+/);
480
+ expect(params.ConditionExpression).toMatch(/AND/);
481
+ expect(params.ExpressionAttributeNames).toEqual({
482
+ '#PK': 'PK',
483
+ '#SK': 'SK',
484
+ '#followerCount': 'followerCount',
485
+ });
486
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(50);
487
+ });
488
+
489
+ // UPDATE tests
490
+ test('UPDATE User followerCount - increment', async () => {
491
+ const params = await table.entities.User.update({
492
+ username: 'juanca',
493
+ })
494
+ .add('followerCount', 1)
495
+ .dbParams();
496
+
497
+ expect(params.TableName).toBe('InstagramClone');
498
+ expect(params.Key).toEqual({
499
+ PK: 'USER#juanca',
500
+ SK: 'USER#juanca',
501
+ });
502
+ expect(params.UpdateExpression).toContain('ADD #followerCount :followerCount_0');
503
+ expect(params.UpdateExpression).toContain('SET #updatedAt = :updatedAt_ts');
504
+ expect(params.ExpressionAttributeNames).toMatchObject({
505
+ '#followerCount': 'followerCount',
506
+ '#updatedAt': 'updatedAt',
507
+ });
508
+ expect(params.ExpressionAttributeValues).toMatchObject({
509
+ ':followerCount_0': 1,
510
+ });
511
+ });
512
+
513
+ test('UPDATE User - set name and increment followerCount', async () => {
514
+ const params = await table.entities.User.update({
515
+ username: 'juanca',
516
+ })
517
+ .set('name', 'Juan Carlos Bondi Updated')
518
+ .add('followerCount', 5)
519
+ .returning('ALL_NEW')
520
+ .dbParams();
521
+
522
+ expect(params.TableName).toBe('InstagramClone');
523
+ expect(params.Key).toEqual({
524
+ PK: 'USER#juanca',
525
+ SK: 'USER#juanca',
526
+ });
527
+ expect(params.UpdateExpression).toContain('SET #name = :name_0');
528
+ expect(params.UpdateExpression).toContain('ADD #followerCount :followerCount_1');
529
+ expect(params.UpdateExpression).toContain('#updatedAt = :updatedAt_ts');
530
+ expect(params.ExpressionAttributeNames).toMatchObject({
531
+ '#name': 'name',
532
+ '#followerCount': 'followerCount',
533
+ '#updatedAt': 'updatedAt',
534
+ });
535
+ expect(params.ExpressionAttributeValues).toMatchObject({
536
+ ':name_0': 'Juan Carlos Bondi Updated',
537
+ ':followerCount_1': 5,
538
+ });
539
+ expect(params.ReturnValues).toBe('ALL_NEW');
540
+ });
541
+
542
+ test('UPDATE Photo likesCount - increment with condition', async () => {
543
+ const params = await table.entities.Photo.update({
544
+ username: 'juanca',
545
+ photoId: '01K16ZP43BRX67DG50SHGZ11DS',
546
+ })
547
+ .add('likesCount', 1)
548
+ .where((attr, op) => op.gte(attr.commentCount, 0))
549
+ .dbParams();
550
+
551
+ expect(params.TableName).toBe('InstagramClone');
552
+ expect(params.Key).toEqual({
553
+ PK: 'UP#juanca',
554
+ SK: 'PHOTO#01K16ZP43BRX67DG50SHGZ11DS',
555
+ });
556
+ expect(params.UpdateExpression).toContain('ADD #likesCount :likesCount_0');
557
+ expect(params.UpdateExpression).toContain('SET #updatedAt = :updatedAt_ts');
558
+ expect(params.ConditionExpression).toMatch(/#commentCount >= :commentCount_\d+/);
559
+ expect(params.ExpressionAttributeNames).toMatchObject({
560
+ '#likesCount': 'likesCount',
561
+ '#commentCount': 'commentCount',
562
+ '#updatedAt': 'updatedAt',
563
+ });
564
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(1);
565
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(0);
566
+ });
567
+
568
+ test('UPDATE Photo - increment both likes and comments', async () => {
569
+ const params = await table.entities.Photo.update({
570
+ username: 'juanca',
571
+ photoId: '01K16ZP43BRX67DG50SHGZ11DS',
572
+ })
573
+ .add('likesCount', 1)
574
+ .add('commentCount', 1)
575
+ .dbParams();
576
+
577
+ expect(params.TableName).toBe('InstagramClone');
578
+ expect(params.UpdateExpression).toContain(
579
+ 'ADD #likesCount :likesCount_0, #commentCount :commentCount_1'
580
+ );
581
+ expect(params.UpdateExpression).toContain('SET #updatedAt = :updatedAt_ts');
582
+ expect(params.ExpressionAttributeNames).toMatchObject({
583
+ '#likesCount': 'likesCount',
584
+ '#commentCount': 'commentCount',
585
+ '#updatedAt': 'updatedAt',
586
+ });
587
+ expect(params.ExpressionAttributeValues).toMatchObject({
588
+ ':likesCount_0': 1,
589
+ ':commentCount_1': 1,
590
+ });
591
+ });
592
+
593
+ test('UPDATE Comment - change content', async () => {
594
+ const params = await table.entities.Comment.update({
595
+ photoId: 'photo123',
596
+ commentId: 'comment456',
597
+ })
598
+ .set('content', 'Updated comment text')
599
+ .where((attr, op) => op.eq(attr.commentingUsername, 'juanca'))
600
+ .returning('UPDATED_NEW')
601
+ .dbParams();
602
+
603
+ expect(params.TableName).toBe('InstagramClone');
604
+ expect(params.Key).toEqual({
605
+ PK: 'PC#photo123',
606
+ SK: 'COMMENT#comment456',
607
+ });
608
+ expect(params.UpdateExpression).toContain('SET #content = :content_0');
609
+ expect(params.UpdateExpression).toContain('#updatedAt = :updatedAt_ts');
610
+ expect(params.ConditionExpression).toMatch(/#commentingUsername = :commentingUsername_\d+/);
611
+ expect(params.ReturnValues).toBe('UPDATED_NEW');
612
+ });
613
+
614
+ // DELETE tests
615
+ test('DELETE User by username', async () => {
616
+ const params = await table.entities.User.delete({
617
+ username: 'juanca',
618
+ }).dbParams();
619
+
620
+ expect(params.TableName).toBe('InstagramClone');
621
+ expect(params.Key).toEqual({
622
+ PK: 'USER#juanca',
623
+ SK: 'USER#juanca',
624
+ });
625
+ expect(params.ConditionExpression).toBeUndefined();
626
+ expect(params.ReturnValues).toBeUndefined();
627
+ });
628
+
629
+ test('DELETE Photo with condition', async () => {
630
+ const params = await table.entities.Photo.delete({
631
+ username: 'juanca',
632
+ photoId: '01K16ZP43BRX67DG50SHGZ11DS',
633
+ })
634
+ .where((attr, op) => op.eq(attr.likesCount, 0))
635
+ .dbParams();
636
+
637
+ expect(params.TableName).toBe('InstagramClone');
638
+ expect(params.Key).toEqual({
639
+ PK: 'UP#juanca',
640
+ SK: 'PHOTO#01K16ZP43BRX67DG50SHGZ11DS',
641
+ });
642
+ expect(params.ConditionExpression).toMatch(/#likesCount = :likesCount_\d+/);
643
+ expect(params.ExpressionAttributeNames).toEqual({
644
+ '#likesCount': 'likesCount',
645
+ });
646
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(0);
647
+ });
648
+
649
+ test('DELETE Photo only if no likes or comments', async () => {
650
+ const params = await table.entities.Photo.delete({
651
+ username: 'juanca',
652
+ photoId: '01K16ZP43BRX67DG50SHGZ11DS',
653
+ })
654
+ .where((attr, op) => op.and(op.eq(attr.likesCount, 0), op.eq(attr.commentCount, 0)))
655
+ .returning('ALL_OLD')
656
+ .dbParams();
657
+
658
+ expect(params.TableName).toBe('InstagramClone');
659
+ expect(params.ConditionExpression).toMatch(
660
+ /\(#likesCount = :likesCount_\d+\) AND \(#commentCount = :commentCount_\d+\)/
661
+ );
662
+ expect(params.ExpressionAttributeNames).toEqual({
663
+ '#likesCount': 'likesCount',
664
+ '#commentCount': 'commentCount',
665
+ });
666
+ expect(params.ReturnValues).toBe('ALL_OLD');
667
+ });
668
+
669
+ test('DELETE Comment by photoId and commentId', async () => {
670
+ const params = await table.entities.Comment.delete({
671
+ photoId: 'photo123',
672
+ commentId: 'comment456',
673
+ }).dbParams();
674
+
675
+ expect(params.TableName).toBe('InstagramClone');
676
+ expect(params.Key).toEqual({
677
+ PK: 'PC#photo123',
678
+ SK: 'COMMENT#comment456',
679
+ });
680
+ });
681
+
682
+ test('DELETE Like by photoId and likingUsername', async () => {
683
+ const params = await table.entities.Like.delete({
684
+ photoId: 'photo123',
685
+ likingUsername: 'juanca',
686
+ }).dbParams();
687
+
688
+ expect(params.TableName).toBe('InstagramClone');
689
+ expect(params.Key).toEqual({
690
+ PK: 'PL#photo123',
691
+ SK: 'LIKE#juanca',
692
+ });
693
+ });
694
+
695
+ test('DELETE Follow relationship', async () => {
696
+ const params = await table.entities.Follow.delete({
697
+ followedUsername: 'alice',
698
+ followingUsername: 'bob',
699
+ }).dbParams();
700
+
701
+ expect(params.TableName).toBe('InstagramClone');
702
+ expect(params.Key).toEqual({
703
+ PK: 'FOLLOW#alice',
704
+ SK: 'FOLLOW#bob',
705
+ });
706
+ });
707
+
708
+ test('should fail when trying to update user without required username', async () => {
709
+ expect(() => {
710
+ table.entities.User.update({} as any);
711
+ }).toThrow(
712
+ '[User] Missing required key field(s) for update(): username. Required fields: username'
713
+ );
714
+ });
715
+
716
+ test('should fail when trying to delete user without required username', async () => {
717
+ expect(() => {
718
+ table.entities.User.delete({} as any);
719
+ }).toThrow(
720
+ '[User] Missing required key field(s) for delete(): username. Required fields: username'
721
+ );
722
+ });
723
+
724
+ // SCAN tests
725
+ test('SCAN all Users', async () => {
726
+ const params = await table.entities.User.scan().dbParams();
727
+
728
+ expect(params.TableName).toBe('InstagramClone');
729
+ expect(params.FilterExpression).toBeUndefined();
730
+ });
731
+
732
+ test('SCAN Users with filter', async () => {
733
+ const params = await table.entities.User.scan()
734
+ .filter((attr, op) => op.gt(attr.followerCount, 100))
735
+ .dbParams();
736
+
737
+ expect(params.TableName).toBe('InstagramClone');
738
+ expect(params.FilterExpression).toMatch(/#followerCount > :followerCount_\d+/);
739
+ expect(params.ExpressionAttributeNames).toEqual({
740
+ '#followerCount': 'followerCount',
741
+ });
742
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(100);
743
+ });
744
+
745
+ test('SCAN Users with multiple filters', async () => {
746
+ const params = await table.entities.User.scan()
747
+ .filter((attr, op) => op.gt(attr.followerCount, 100))
748
+ .filter((attr, op) => op.gt(attr.followingCount, 50))
749
+ .dbParams();
750
+
751
+ expect(params.FilterExpression).toMatch(
752
+ /\(#followerCount > :followerCount_\d+\) AND \(#followingCount > :followingCount_\d+\)/
753
+ );
754
+ expect(params.ExpressionAttributeNames).toEqual({
755
+ '#followerCount': 'followerCount',
756
+ '#followingCount': 'followingCount',
757
+ });
758
+ });
759
+
760
+ test('SCAN Photos with projection', async () => {
761
+ const params = await table.entities.Photo.scan()
762
+ .select(['username', 'photoId', 'likesCount'])
763
+ .dbParams();
764
+
765
+ expect(params.ProjectionExpression).toBe('username, photoId, likesCount');
766
+ });
767
+
768
+ test('SCAN Photos with limit', async () => {
769
+ const params = await table.entities.Photo.scan()
770
+ .filter((attr, op) => op.gt(attr.likesCount, 0))
771
+ .limit(10)
772
+ .dbParams();
773
+
774
+ expect(params.Limit).toBe(10);
775
+ expect(params.FilterExpression).toMatch(/#likesCount > :likesCount_\d+/);
776
+ });
777
+
778
+ test('SCAN with IN operator', async () => {
779
+ const params = await table.entities.User.scan()
780
+ .filter((attr, op) => op.in(attr.username, ['alice', 'bob', 'charlie']))
781
+ .dbParams();
782
+
783
+ expect(params.FilterExpression).toContain('IN');
784
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('alice');
785
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('bob');
786
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain('charlie');
787
+ });
788
+
789
+ test('SCAN with size operator', async () => {
790
+ const params = await table.entities.User.scan()
791
+ .filter((attr, op) => op.size(attr.name).gt(5))
792
+ .dbParams();
793
+
794
+ expect(params.FilterExpression).toMatch(/size\(#name\) > :name_size_\d+/);
795
+ expect(Object.values(params.ExpressionAttributeValues || {})).toContain(5);
796
+ });
797
+
798
+ test('SCAN Comments with complex filter', async () => {
799
+ const params = await table.entities.Comment.scan()
800
+ .filter((attr, op) => op.and(op.exists(attr.content), op.size(attr.content).gt(10)))
801
+ .select(['photoId', 'commentId', 'content'])
802
+ .limit(50)
803
+ .dbParams();
804
+
805
+ expect(params.FilterExpression).toMatch(/attribute_exists\(#content\)/);
806
+ expect(params.FilterExpression).toMatch(/size\(#content\) > :content_size_\d+/);
807
+ expect(params.ProjectionExpression).toBe('photoId, commentId, content');
808
+ expect(params.Limit).toBe(50);
809
+ });
810
+
811
+ // Entity-level BatchGet tests
812
+ test('Entity BatchGet multiple Users', () => {
813
+ const params = table.entities.User.batchGet([
814
+ { username: 'alice' },
815
+ { username: 'bob' },
816
+ { username: 'charlie' },
817
+ ]).dbParams();
818
+
819
+ expect(params.RequestItems).toEqual({
820
+ InstagramClone: {
821
+ Keys: [
822
+ { PK: 'USER#alice', SK: 'USER#alice' },
823
+ { PK: 'USER#bob', SK: 'USER#bob' },
824
+ { PK: 'USER#charlie', SK: 'USER#charlie' },
825
+ ],
826
+ },
827
+ });
828
+ });
829
+
830
+ test('Entity BatchGet Users with projection', () => {
831
+ const params = table.entities.User.batchGet([{ username: 'alice' }, { username: 'bob' }])
832
+ .select(['username', 'name', 'followerCount'])
833
+ .dbParams();
834
+
835
+ expect(params.RequestItems).toEqual({
836
+ InstagramClone: {
837
+ Keys: [
838
+ { PK: 'USER#alice', SK: 'USER#alice' },
839
+ { PK: 'USER#bob', SK: 'USER#bob' },
840
+ ],
841
+ ProjectionExpression: 'username, name, followerCount',
842
+ },
843
+ });
844
+ });
845
+
846
+ test('Entity BatchGet Photos', () => {
847
+ const params = table.entities.Photo.batchGet([
848
+ { username: 'alice', photoId: 'photo1' },
849
+ { username: 'alice', photoId: 'photo2' },
850
+ { username: 'bob', photoId: 'photo3' },
851
+ ]).dbParams();
852
+
853
+ expect(params.RequestItems).toEqual({
854
+ InstagramClone: {
855
+ Keys: [
856
+ { PK: 'UP#alice', SK: 'PHOTO#photo1' },
857
+ { PK: 'UP#alice', SK: 'PHOTO#photo2' },
858
+ { PK: 'UP#bob', SK: 'PHOTO#photo3' },
859
+ ],
860
+ },
861
+ });
862
+ });
863
+
864
+ test('Entity BatchGet should fail with missing key fields', () => {
865
+ expect(() => {
866
+ table.entities.Photo.batchGet([
867
+ // @ts-expect-error - Missing photoId to test error handling
868
+ { username: 'alice' },
869
+ ]);
870
+ }).toThrow(
871
+ '[Photo] Missing required key field(s) for batchGet(): photoId. Required fields: username, photoId'
872
+ );
873
+ });
874
+
875
+ // Entity-level BatchWrite tests
876
+ test('Entity BatchWrite create multiple Users', () => {
877
+ const params = table.entities.User.batchWrite([
878
+ {
879
+ username: 'alice',
880
+ name: 'Alice Smith',
881
+ followerCount: 100,
882
+ followingCount: 50,
883
+ },
884
+ {
885
+ username: 'bob',
886
+ name: 'Bob Jones',
887
+ followerCount: 200,
888
+ followingCount: 75,
889
+ },
890
+ ]).dbParams();
891
+
892
+ expect(params.RequestItems?.InstagramClone).toHaveLength(2);
893
+ expect(params.RequestItems?.InstagramClone[0]?.PutRequest?.Item).toMatchObject({
894
+ PK: 'USER#alice',
895
+ SK: 'USER#alice',
896
+ username: 'alice',
897
+ name: 'Alice Smith',
898
+ followerCount: 100,
899
+ followingCount: 50,
900
+ createdAt: expect.any(String),
901
+ updatedAt: expect.any(String),
902
+ });
903
+ expect(params.RequestItems?.InstagramClone[1]?.PutRequest?.Item).toMatchObject({
904
+ PK: 'USER#bob',
905
+ SK: 'USER#bob',
906
+ username: 'bob',
907
+ name: 'Bob Jones',
908
+ followerCount: 200,
909
+ followingCount: 75,
910
+ createdAt: expect.any(String),
911
+ updatedAt: expect.any(String),
912
+ });
913
+ });
914
+
915
+ test('Entity BatchWrite create multiple Photos', () => {
916
+ const params = table.entities.Photo.batchWrite([
917
+ {
918
+ username: 'alice',
919
+ url: 'https://example.com/photo1.jpg',
920
+ },
921
+ {
922
+ username: 'alice',
923
+ url: 'https://example.com/photo2.jpg',
924
+ },
925
+ ]).dbParams();
926
+
927
+ expect(params.RequestItems).toBeDefined();
928
+ if (params.RequestItems?.InstagramClone) {
929
+ expect(params.RequestItems.InstagramClone).toHaveLength(2);
930
+ expect(params.RequestItems.InstagramClone[0]?.PutRequest?.Item).toMatchObject({
931
+ username: 'alice',
932
+ url: 'https://example.com/photo1.jpg',
933
+ photoId: '01K16ZP43BRX67DG50SHGZ11DS',
934
+ likesCount: 0,
935
+ commentCount: 0,
936
+ PK: 'UP#alice',
937
+ SK: 'PHOTO#01K16ZP43BRX67DG50SHGZ11DS',
938
+ createdAt: expect.any(String),
939
+ updatedAt: expect.any(String),
940
+ });
941
+ }
942
+ });
943
+
944
+ test('Entity BatchWrite with defaults applied', () => {
945
+ const params = table.entities.User.batchWrite([
946
+ {
947
+ username: 'charlie',
948
+ name: 'Charlie Brown',
949
+ },
950
+ ]).dbParams();
951
+
952
+ expect(params.RequestItems).toBeDefined();
953
+ if (params.RequestItems?.InstagramClone) {
954
+ expect(params.RequestItems.InstagramClone[0]?.PutRequest?.Item).toMatchObject({
955
+ username: 'charlie',
956
+ name: 'Charlie Brown',
957
+ followerCount: 0,
958
+ followingCount: 0,
959
+ PK: 'USER#charlie',
960
+ SK: 'USER#charlie',
961
+ createdAt: expect.any(String),
962
+ updatedAt: expect.any(String),
963
+ });
964
+ }
965
+ });
966
+ });