From 1758df980c3386049295401192149216410aa55a Mon Sep 17 00:00:00 2001 From: Seth Orell Date: Wed, 8 Nov 2023 19:12:33 -0700 Subject: [PATCH] Change update to partial --- README.md | 29 +++++ lib/keyValueRepository.ts | 57 +--------- package.json | 2 +- test/update.int.test.ts | 45 +++++++- test/updatePartial.int.test.ts | 186 --------------------------------- 5 files changed, 77 insertions(+), 242 deletions(-) delete mode 100644 test/updatePartial.int.test.ts diff --git a/README.md b/README.md index 41231bf5..ed2185de 100644 --- a/README.md +++ b/README.md @@ -100,4 +100,33 @@ await getAllItems(); await myRepo.remove(id); ``` +## Update + +- Requires `dynamodb:UpdateItem` +- Honors revision check; it will only update if the revision on disk is the one you are updating. Will return a `409` if the revision has changed underneath you. +- Will perform a partial update if you don't pass in all properties. Think of this as a "patch" vs. a replacement update. The `key` and `revision` properties are always required. +- Returns the entire entity, including both new and unchanged properties + +```js +const person = await myRepo.create({ + name: 'Joe', + age: 28, + favoriteColor: 'blue', +}); +// Full item update +person.favoriteColor = 'teal'; +const newPerson1 = await myRepo.update(person); +console.log(newPerson1.favoriteColor); // 'teal' + +// Partial update +const partial = { + favoriteColor: 'aquamarine', + key: person.key, + revision: newPerson1.revision, +}; +const newPerson2 = await myRepo.update(partial); +console.log(newPerson2.favoriteColor); // 'aquamarine' +console.log(newPerson2.age); // 28 +``` + // More Coming Soon... diff --git a/lib/keyValueRepository.ts b/lib/keyValueRepository.ts index a49b761f..613241b5 100644 --- a/lib/keyValueRepository.ts +++ b/lib/keyValueRepository.ts @@ -4,7 +4,6 @@ import { DynamoDBDocumentClient, GetCommand, PutCommand, - PutCommandInput, ScanCommand, ScanCommandInput, ScanCommandOutput, @@ -117,49 +116,7 @@ class KeyValueRepository { return itemToSave; } - async update(item: any) { - this.validateRequiredProperties(item); - const { revision: previousRevision } = item; - const itemToSave = setRepositoryModifiedProperties(item); - const putParams: PutCommandInput = { - TableName: this.tableName, - Item: itemToSave, - ConditionExpression: 'attribute_exists(#key) AND #revision = :prevRev', - ExpressionAttributeNames: { - '#key': this.keyName, - '#revision': 'revision', - }, - ExpressionAttributeValues: { - ':prevRev': previousRevision, - }, - ReturnValuesOnConditionCheckFailure: 'ALL_OLD', - }; - try { - await this.docClient.send(new PutCommand(putParams)); - } catch (err: any) { - if (err.name === 'ConditionalCheckFailedException') { - const { Item } = err; - if (isNotFoundConflict(Item)) { - throw NotFound(); - } - if ( - isRevisionConflict({ - expectedRevision: previousRevision, - actualRevision: Item?.revision?.N, - }) - ) { - throw Conflict( - `Conflict: Item in DB has revision [${Item?.revision?.N}]. You are using revision [${previousRevision}]`, - ); - } - } - throw err; - } - - return itemToSave; - } - - async updatePartial(item: any): Promise> { + async update(item: any): Promise> { this.validateRequiredProperties(item); const updateInput = this.buildUpdateCommandInput(item); let result: UpdateCommandOutput; @@ -189,7 +146,7 @@ class KeyValueRepository { private buildUpdateCommandInput(item: any): UpdateCommandInput { const { revision: previousRevision } = item; - const itemToSave = setRepositoryModifiedPropertiesForPartialUpdate(item); + const itemToSave = setRepositoryModifiedPropertiesForUpdate(item); const key = createDynamoDbKey({ keyName: this.keyName, keyValue: itemToSave[this.keyName] }); const updateInput: UpdateCommandInput = { TableName: this.tableName, @@ -230,15 +187,7 @@ const isRevisionConflict = (input: { expectedRevision: number; actualRevision: n return expectedRevision !== actualRevision; }; -const setRepositoryModifiedProperties = (item: any) => { - const returnItem = { ...item }; - returnItem.updatedAt = new Date().toISOString(); - returnItem.revision = item.revision + 1; - - return returnItem; -}; - -const setRepositoryModifiedPropertiesForPartialUpdate = (item: any) => { +const setRepositoryModifiedPropertiesForUpdate = (item: any) => { const returnItem = { ...item }; delete returnItem.createdAt; returnItem.updatedAt = new Date().toISOString(); diff --git a/package.json b/package.json index 91595629..ff3bac5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@setho/dynamodb-repository", - "version": "0.8.7", + "version": "0.9.0", "description": "DynamoDB repository for hash-key and hash-key/range indexed tables. Designed for Lambda use. Handles nice-to-haves like created and updated timestamps and default id creation.", "license": "MIT", "repository": "setho/dynamodb-repository", diff --git a/test/update.int.test.ts b/test/update.int.test.ts index 560b5b69..260b4cb3 100644 --- a/test/update.int.test.ts +++ b/test/update.int.test.ts @@ -85,14 +85,32 @@ describe('When updating an item', () => { const repo = new KeyValueRepository({ tableName: TableName, keyName: KeyName, documentClient }); const newField1 = faker.lorem.slug(); + // ACT + await repo.update({ ...item, field1: newField1 }); + + // ASSERT + const itemInDb = await repo.get(key); + expect(itemInDb.field1).toEqual(newField1); + }); + + it('should return the entire object, including unchanged properties', async () => { + // ARRANGE + const item = createTestKeyValueItem(); + const key = await insertHashKeyItem(item); + testKeys.push(key); + const repo = new KeyValueRepository({ tableName: TableName, keyName: KeyName, documentClient }); + const newField1 = faker.lorem.slug(); + // ACT const result = await repo.update({ ...item, field1: newField1 }); // ASSERT + expect(result.map1).toEqual(item.map1); + expect(result.field1).not.toEqual(item.field1); expect(result.field1).toEqual(newField1); }); - it.skip('should maintain original createdAt value', async () => { + it('should maintain original createdAt value', async () => { // ARRANGE const item = createTestKeyValueItem(); const originalCreatedAt = item.createdAt; @@ -158,4 +176,29 @@ describe('When updating an item', () => { await expect(updateAction()).rejects.toThrow(`${item.revision}`); }); }); + + describe('with a subset of properties', () => { + it('should return the entire object with the updated properties', async () => { + // ARRANGE + const item = createTestKeyValueItem(); + const key = await insertHashKeyItem(item); + testKeys.push(key); + const itemWithoutField1: any = { ...item }; + delete itemWithoutField1.field1; + const newMap = { field2: 'affirmative' }; + itemWithoutField1.map1 = newMap; + const repo = new KeyValueRepository({ + tableName: TableName, + keyName: KeyName, + documentClient, + }); + + // ACT + const result = await repo.update(itemWithoutField1); + + // ASSERT + expect(result.map1).toEqual(newMap); + expect(result.field1).toEqual(item.field1); + }); + }); }); diff --git a/test/updatePartial.int.test.ts b/test/updatePartial.int.test.ts deleted file mode 100644 index a5c138ac..00000000 --- a/test/updatePartial.int.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { faker } from '@faker-js/faker'; - -import { - removeHashKeyItem, - createTestKeyValueItem, - insertHashKeyItem, -} from './integrationTestUtils'; -import KeyValueRepository from '../lib/keyValueRepository'; -import getDocumentClient from './documentClient'; - -const TableName = 'HashKeyTestDB'; -const KeyName = 'key'; - -describe('When partially updating an item', () => { - const testKeys: string[] = []; - const documentClient = getDocumentClient(); - - afterAll(async () => { - const promises = testKeys.map(async (testKey) => removeHashKeyItem(testKey)); - await Promise.all(promises); - }); - - describe('and item does not have property of [keyName]', () => { - it('should return a 400 (Bad Request)', async () => { - // ARRANGE - const itemWithNoKey = {}; - const repo = new KeyValueRepository({ - tableName: TableName, - keyName: KeyName, - documentClient, - }); - - // ACT - const updateAction = async () => repo.updatePartial(itemWithNoKey); - - // ASSERT - await expect(updateAction()).rejects.toHaveProperty('statusCode', 400); - await expect(updateAction()).rejects.toThrow(/key/); - }); - }); - - describe('and item does not have property of revision', () => { - it('should return a 400 (Bad Request)', async () => { - // ARRANGE - const itemWithNoRevision: any = createTestKeyValueItem(); - delete itemWithNoRevision.revision; - const repo = new KeyValueRepository({ - tableName: TableName, - keyName: KeyName, - documentClient, - }); - - // ACT - const updateAction = async () => repo.updatePartial(itemWithNoRevision); - - // ASSERT - await expect(updateAction()).rejects.toHaveProperty('statusCode', 400); - await expect(updateAction()).rejects.toThrow(/revision/); - }); - }); - - describe('and item does not exist in db', () => { - it('should return a 404 (Not Found)', async () => { - // ARRANGE - const item = createTestKeyValueItem(); - const repo = new KeyValueRepository({ - tableName: TableName, - keyName: KeyName, - documentClient, - }); - - // ACT - const updateAction = async () => repo.updatePartial(item); - - // ASSERT - await expect(updateAction()).rejects.toHaveProperty('statusCode', 404); - }); - }); - - it('should update the item in db', async () => { - // ARRANGE - const item = createTestKeyValueItem(); - const key = await insertHashKeyItem(item); - testKeys.push(key); - const repo = new KeyValueRepository({ tableName: TableName, keyName: KeyName, documentClient }); - const newField1 = faker.lorem.slug(); - - // ACT - const result = await repo.updatePartial({ ...item, field1: newField1 }); - - // ASSERT - expect(result.field1).toEqual(newField1); - }); - - it('should maintain original createdAt value', async () => { - // ARRANGE - const item = createTestKeyValueItem(); - const originalCreatedAt = item.createdAt; - const key = await insertHashKeyItem(item); - testKeys.push(key); - const repo = new KeyValueRepository({ tableName: TableName, keyName: KeyName, documentClient }); - item.createdAt = faker.date.past().toISOString(); - - // ACT - const result = await repo.updatePartial(item); - - // ASSERT - expect(result.createdAt).toEqual(originalCreatedAt); - }); - - it('should change original updatedAt value', async () => { - // ARRANGE - const item = createTestKeyValueItem(); - const key = await insertHashKeyItem(item); - testKeys.push(key); - const repo = new KeyValueRepository({ tableName: TableName, keyName: KeyName, documentClient }); - - // ACT - const result = await repo.updatePartial(item); - - // ASSERT - expect(result.updatedAt).not.toEqual(item.updatedAt); - }); - - it('should update revision value', async () => { - // ARRANGE - const item = createTestKeyValueItem(); - const key = await insertHashKeyItem(item); - testKeys.push(key); - const repo = new KeyValueRepository({ tableName: TableName, keyName: KeyName, documentClient }); - - // ACT - const result = await repo.updatePartial(item); - - // ASSERT - expect(result.revision).toEqual(item.revision + 1); - }); - - describe('and revision is off', () => { - it('should throw 409 (Conflict)', async () => { - // ARRANGE - const item = createTestKeyValueItem(); - const oldRevision = item.revision - 1; - const key = await insertHashKeyItem(item); - testKeys.push(key); - const repo = new KeyValueRepository({ - tableName: TableName, - keyName: KeyName, - documentClient, - }); - item.revision = oldRevision; - // ACT - const updateAction = () => repo.updatePartial(item); - - // ASSERT - await expect(updateAction()).rejects.toHaveProperty('statusCode', 409); - await expect(updateAction()).rejects.toThrow(`${oldRevision}`); - await expect(updateAction()).rejects.toThrow(`${item.revision}`); - }); - }); - - describe('with a subset of properties', () => { - it('should return the entire object with the updated properties', async () => { - // ARRANGE - const item = createTestKeyValueItem(); - const key = await insertHashKeyItem(item); - testKeys.push(key); - const itemWithoutField1: any = { ...item }; - delete itemWithoutField1.field1; - const newMap = { field2: 'affirmative' }; - itemWithoutField1.map1 = newMap; - const repo = new KeyValueRepository({ - tableName: TableName, - keyName: KeyName, - documentClient, - }); - - // ACT - const result = await repo.updatePartial(itemWithoutField1); - - // ASSERT - expect(result.map1).toEqual(newMap); - expect(result.field1).toEqual(item.field1); - }); - }); -});