Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DynamoDBHistoryAutoIncrement class for version control in DynamoDB #20

Merged
merged 18 commits into from
Nov 5, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions jest-dynamodb-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ const config = {
KeySchema: [{ AttributeName: 'widgetID', KeyType: 'HASH' }],
TableName: 'widgets',
},
{
BillingMode: 'PAY_PER_REQUEST',
AttributeDefinitions: [
{ AttributeName: 'widgetID', AttributeType: 'N' },
{ AttributeName: 'version', AttributeType: 'N' },
],
KeySchema: [
{ AttributeName: 'widgetID', KeyType: 'HASH' },
{ AttributeName: 'version', KeyType: 'RANGE' },
],
TableName: 'widgetHistory',
},
],
installerConfig: {
installPath: './dynamodb_local_latest',
Expand Down
85 changes: 85 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,88 @@ export class DynamoDBAutoIncrement extends BaseDynamoDBAutoIncrement {
return { puts, nextCounter }
}
}

/**
* Update a history table with an auto-incrementing attribute value in DynamoDB
*
* @example
* ```
* import { DynamoDB } from '@aws-sdk/client-dynamodb'
* import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
* import { DynamoDBHistoryAutoIncrement } from '@nasa-gcn/dynamodb-autoincrement'
*
* const client = new DynamoDB({})
* const doc = DynamoDBDocument.from(client)
*
* const autoIncrementHistory = DynamoDBHistoryAutoIncrement({
* doc,
* counterTableName: 'widgets', // The table storing the current item
* counterTableKey: {
* widgetID: 42 // ID of the item to be updated
* },
* attributeName: 'version',
* tableName: 'widgetsHistory', // The table storing the history of items in
* initialValue: 1,
* })
*
* const latestVersionNumber = await autoIncrementHistory.put({
* widgetName: 'A new name for this item',
* costDollars: 199.99,
* })
* ```
*/
export class DynamoDBHistoryAutoIncrement extends BaseDynamoDBAutoIncrement {
protected async next(item: Record<string, NativeAttributeValue>) {
let nextCounter

const existingItem = (
await this.props.doc.get({
TableName: this.props.counterTableName,
Key: this.props.counterTableKey,
})
).Item

const counter: number | undefined = existingItem?.[this.props.attributeName]
dakota002 marked this conversation as resolved.
Show resolved Hide resolved

if (counter === undefined) {
nextCounter = this.props.initialValue

// Existing item didn't have a version, so give it one
if (existingItem) {
existingItem[this.props.attributeName] = nextCounter
dakota002 marked this conversation as resolved.
Show resolved Hide resolved
nextCounter += 1
}
} else {
nextCounter = counter + 1
}

const puts: PutCommandInput[] = [
{
ConditionExpression: 'attribute_not_exists(#counter)',
ExpressionAttributeNames: {
'#counter': this.props.attributeName,
},
Item: existingItem,
TableName: this.props.tableName,
},
{
ConditionExpression:
'attribute_not_exists(#counter) OR #counter <> :counter',
ExpressionAttributeNames: {
'#counter': this.props.attributeName,
},
ExpressionAttributeValues: {
':counter': nextCounter,
},
dakota002 marked this conversation as resolved.
Show resolved Hide resolved
Item: {
...item,
...this.props.counterTableKey,
[this.props.attributeName]: nextCounter,
},
TableName: this.props.counterTableName,
},
]

return { puts, nextCounter }
}
}
168 changes: 157 additions & 11 deletions src/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {
} from '@aws-sdk/client-dynamodb'
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb'
import type { DynamoDBAutoIncrementProps } from '.'
import { DynamoDBAutoIncrement } from '.'
import { DynamoDBAutoIncrement, DynamoDBHistoryAutoIncrement } from '.'

let doc: DynamoDBDocument
let autoincrement: DynamoDBAutoIncrement
let autoincrementVersion: DynamoDBHistoryAutoIncrement
let autoincrementDangerously: DynamoDBAutoIncrement
const N = 20

Expand All @@ -31,6 +32,17 @@ beforeAll(async () => {
initialValue: 1,
}
autoincrement = new DynamoDBAutoIncrement(options)
const versioningOptions: DynamoDBAutoIncrementProps = {
doc,
counterTableName: 'widgets',
counterTableKey: {
widgetID: 1,
},
attributeName: 'version',
tableName: 'widgetHistory',
initialValue: 1,
}
autoincrementVersion = new DynamoDBHistoryAutoIncrement(versioningOptions)
autoincrementDangerously = new DynamoDBAutoIncrement({
...options,
dangerously: true,
Expand All @@ -43,18 +55,49 @@ afterEach(async () => {
[
{ TableName: 'autoincrement', KeyAttributeName: 'tableName' },
{ TableName: 'widgets', KeyAttributeName: 'widgetID' },
].map(
async ({ TableName, KeyAttributeName }) =>
await Promise.all(
((await doc.scan({ TableName })).Items ?? []).map(
async ({ [KeyAttributeName]: KeyValue }) =>
await doc.delete({
TableName,
Key: { [KeyAttributeName]: KeyValue },
})
]
.map(
async ({ TableName, KeyAttributeName }) =>
await Promise.all(
((await doc.scan({ TableName })).Items ?? []).map(
async ({ [KeyAttributeName]: KeyValue }) =>
await doc.delete({
TableName,
Key: { [KeyAttributeName]: KeyValue },
})
)
)
)
.concat(
...[
{
TableName: 'widgetHistory',
PartitionKeyAttributeName: 'widgetID',
SortKeyAttributeName: 'version',
},
].map(
async ({
TableName,
PartitionKeyAttributeName,
SortKeyAttributeName,
}) =>
await Promise.all(
((await doc.scan({ TableName })).Items ?? []).map(
async ({
[PartitionKeyAttributeName]: KeyValue,
[SortKeyAttributeName]: SortKeyValue,
}) =>
await doc.delete({
TableName,
Key: {
[PartitionKeyAttributeName]: KeyValue,
[SortKeyAttributeName]: SortKeyValue,
},
})
)
)
)
)
)
dakota002 marked this conversation as resolved.
Show resolved Hide resolved
)
})

Expand Down Expand Up @@ -119,3 +162,106 @@ describe('dynamoDBAutoIncrement dangerously', () => {
).rejects.toThrow(ConditionalCheckFailedException)
})
})

describe('autoincrementVersion', () => {
test('increments version on put when attributeName field is not defined on item', async () => {
// Insert initial table item
const widgetID = 1
await doc.put({
TableName: 'widgets',
Item: {
widgetID,
name: 'Handy Widget',
description: 'Does something',
},
})

// Create new version
const newVersion = await autoincrementVersion.put({
name: 'Handy Widget',
description: 'Does Everything!',
})
expect(newVersion).toBe(2)

const historyItems = (
await doc.query({
TableName: 'widgetHistory',
KeyConditionExpression: 'widgetID = :widgetID',
ExpressionAttributeValues: {
':widgetID': widgetID,
},
})
).Items

expect(historyItems?.length).toBe(1)
})

test('increments version on put when attributeName field is defined on item', async () => {
// Insert initial table item
const widgetID = 1
const initialItem = {
widgetID,
name: 'Handy Widget',
description: 'Does something',
version: 1,
}
await doc.put({
TableName: 'widgets',
Item: initialItem,
})

// Create new version
const newVersion = await autoincrementVersion.put({
name: 'Handy Widget',
description: 'Does Everything!',
})
expect(newVersion).toBe(2)

const historyItems = (
await doc.query({
TableName: 'widgetHistory',
KeyConditionExpression: 'widgetID = :widgetID',
ExpressionAttributeValues: {
':widgetID': widgetID,
},
})
).Items

expect(historyItems?.length).toBe(1)
})

test('increments version correctly if tracked field is included in the item on update', async () => {
// Insert initial table item
const widgetID = 1
const initialItem = {
widgetID,
name: 'Handy Widget',
description: 'Does something',
version: 1,
}
await doc.put({
TableName: 'widgets',
Item: initialItem,
})

// Create new version
const newVersion = await autoincrementVersion.put({
name: 'Handy Widget',
description: 'Does Everything!',
version: 3,
})
expect(newVersion).toBe(2)
const latestItem = (
await doc.get({
TableName: 'widgets',
Key: { widgetID },
})
).Item
expect(latestItem).toStrictEqual({
widgetID,
name: 'Handy Widget',
description: 'Does Everything!',
version: 2,
})
})
})
Loading