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 11 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
96 changes: 96 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,99 @@ 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 existingEntry = (
await this.props.doc.get({
TableName: this.props.counterTableName,
Key: this.props.counterTableKey,
})
).Item

const counter: number | undefined =
existingEntry?.[this.props.attributeName]

let untrackedEntryPutCommandInput: PutCommandInput | undefined = undefined
if (counter === undefined) {
nextCounter = existingEntry
? this.props.initialValue + 1
: this.props.initialValue
} else {
nextCounter = counter + 1
}

if (counter === undefined && existingEntry) {
untrackedEntryPutCommandInput = {
TableName: this.props.tableName,
Item: {
...existingEntry,
[this.props.attributeName]: this.props.initialValue,
},
}
}

const Item = {
...item,
...this.props.counterTableKey,
[this.props.attributeName]: nextCounter,
}

const puts: PutCommandInput[] = [
{
ConditionExpression: 'attribute_not_exists(#counter)',
ExpressionAttributeNames: {
'#counter': this.props.attributeName,
},
Item,
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,
TableName: this.props.counterTableName,
},
]
if (untrackedEntryPutCommandInput) puts.push(untrackedEntryPutCommandInput)
dakota002 marked this conversation as resolved.
Show resolved Hide resolved

return { puts, nextCounter }
}
}
207 changes: 196 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,145 @@ 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 latestItem = (
await doc.get({
TableName: 'widgets',
Key: { widgetID },
})
).Item
const latestVersionItem = (
await doc.get({
TableName: 'widgetHistory',
Key: { widgetID, version: newVersion },
})
).Item

// Ensure the latest version in the couter table matches the version in the main table
expect(latestItem).toStrictEqual(latestVersionItem)

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

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

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,
})
await doc.put({
TableName: 'widgetHistory',
Item: initialItem,
})

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

// Ensure the latest version in the couter table matches the version in the main table
expect(latestItem).toStrictEqual(latestVersionItem)

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

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

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,
})
await doc.put({
TableName: 'widgetHistory',
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