@ftschopp/dynatable-core 1.4.2 → 1.4.4

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