@ftschopp/dynatable-core 1.4.2 → 1.4.3

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 (81) hide show
  1. package/CHANGELOG.md +7 -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/core/types.d.ts +5 -0
  5. package/dist/core/types.d.ts.map +1 -1
  6. package/package.json +11 -2
  7. package/eslint.config.mjs +0 -4
  8. package/jest.config.js +0 -11
  9. package/src/builders/README.md +0 -339
  10. package/src/builders/batch-get/README.md +0 -43
  11. package/src/builders/batch-get/create-batch-get-builder.test.ts +0 -193
  12. package/src/builders/batch-get/create-batch-get-builder.ts +0 -108
  13. package/src/builders/batch-get/index.ts +0 -2
  14. package/src/builders/batch-get/types.ts +0 -28
  15. package/src/builders/batch-write/README.md +0 -204
  16. package/src/builders/batch-write/create-batch-write-builder.test.ts +0 -173
  17. package/src/builders/batch-write/create-batch-write-builder.ts +0 -49
  18. package/src/builders/batch-write/index.ts +0 -2
  19. package/src/builders/batch-write/types.ts +0 -33
  20. package/src/builders/delete/create-delete-builder.test.ts +0 -294
  21. package/src/builders/delete/create-delete-builder.ts +0 -100
  22. package/src/builders/delete/index.ts +0 -2
  23. package/src/builders/delete/types.ts +0 -17
  24. package/src/builders/get/create-get-builder.test.ts +0 -272
  25. package/src/builders/get/create-get-builder.ts +0 -117
  26. package/src/builders/get/index.ts +0 -2
  27. package/src/builders/get/types.ts +0 -39
  28. package/src/builders/index.ts +0 -14
  29. package/src/builders/put/create-put-builder.test.ts +0 -213
  30. package/src/builders/put/create-put-builder.ts +0 -160
  31. package/src/builders/put/index.ts +0 -2
  32. package/src/builders/put/types.ts +0 -24
  33. package/src/builders/query/create-query-builder.test.ts +0 -770
  34. package/src/builders/query/create-query-builder.ts +0 -455
  35. package/src/builders/query/index.ts +0 -2
  36. package/src/builders/query/types.ts +0 -109
  37. package/src/builders/scan/create-scan-builder.test.ts +0 -416
  38. package/src/builders/scan/create-scan-builder.ts +0 -265
  39. package/src/builders/scan/index.ts +0 -2
  40. package/src/builders/scan/types.ts +0 -88
  41. package/src/builders/shared/conditions.ts +0 -58
  42. package/src/builders/shared/index.ts +0 -4
  43. package/src/builders/shared/operators.test.ts +0 -270
  44. package/src/builders/shared/operators.ts +0 -208
  45. package/src/builders/shared/projection.ts +0 -28
  46. package/src/builders/shared/types.ts +0 -101
  47. package/src/builders/transact-get/README.md +0 -167
  48. package/src/builders/transact-get/create-transact-get-builder.test.ts +0 -239
  49. package/src/builders/transact-get/create-transact-get-builder.ts +0 -67
  50. package/src/builders/transact-get/index.ts +0 -2
  51. package/src/builders/transact-get/types.ts +0 -31
  52. package/src/builders/transact-write/README.md +0 -202
  53. package/src/builders/transact-write/create-transact-write-builder.test.ts +0 -288
  54. package/src/builders/transact-write/create-transact-write-builder.ts +0 -124
  55. package/src/builders/transact-write/index.ts +0 -2
  56. package/src/builders/transact-write/types.ts +0 -71
  57. package/src/builders/update/create-update-builder.test.ts +0 -573
  58. package/src/builders/update/create-update-builder.ts +0 -375
  59. package/src/builders/update/index.ts +0 -2
  60. package/src/builders/update/types.ts +0 -63
  61. package/src/core/types.test.ts +0 -641
  62. package/src/core/types.ts +0 -366
  63. package/src/entity/create-entity-api.ts +0 -224
  64. package/src/entity/index.ts +0 -19
  65. package/src/entity/middleware/factories.ts +0 -15
  66. package/src/entity/middleware/types.ts +0 -7
  67. package/src/entity/middleware/with-middleware.ts +0 -37
  68. package/src/entity/types.ts +0 -79
  69. package/src/entity/validation/key-validation.ts +0 -34
  70. package/src/index.ts +0 -23
  71. package/src/table.ts +0 -116
  72. package/src/utils/dynamodb-logger.test.ts +0 -246
  73. package/src/utils/dynamodb-logger.ts +0 -175
  74. package/src/utils/model-utils.test.ts +0 -452
  75. package/src/utils/model-utils.ts +0 -188
  76. package/src/utils/zod-utils.test.ts +0 -363
  77. package/src/utils/zod-utils.ts +0 -56
  78. package/tests/integration/instagram-clone.integration.test.ts +0 -1019
  79. package/tests/integration/pagination-timestamps.integration.test.ts +0 -374
  80. package/tests/integration/transactions.integration.test.ts +0 -528
  81. 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
- });