Reputation: 3742
The UpdateCommand
in the AWS DynamoDB documentation is not very well documented and hard to use.
I wanted to use the update command to just take a Javascript object and insert all of the keys from that onto the object matching the primary key.
Looking at the InsertCommand
I thought it would be simple like this:
async updateItem(tableName: string, primaryKeyName: string, id: string, item: { [key: string]: any }) {
const input: UpdateCommandInput = {
Key: {
[primaryKeyName]: id,
},
AttributeUpdates: item,
TableName: tableName,
};
const command = new UpdateCommand(input);
return await this.client.send(command);
}
But this seems to be failing.
Upvotes: 7
Views: 12801
Reputation: 356
I had to build on top of @kristian-barrett's great answer to handle partial updates on nested dynamodb entries.
The issue was that an incoming update like {foo: {bar: "updated value" }}
would overwrite all foo
attribute and not only foo.bar
one.
ex in ddb:
{
"foo": {
"bar": "old value",
"baz": "other value"
}
}
would have been updated as such:
{
"foo": {
"bar": "updated value" // no more foo.baz
}
}
Basically what is needed is a recursive build of the dynamodb expression when parsing the incoming partial update, so
Here is the code.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb"
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"
/** The AWS DynamoDB service client object. */
export const ddbClient = new DynamoDBClient({})
const marshallOptions = {
// Whether to automatically convert empty strings, blobs, and sets to `null`.
convertEmptyValues: false, // false, by default.
// Whether to remove undefined values while marshalling.
removeUndefinedValues: true, // false, by default.
// Whether to convert typeof object to map attribute.
convertClassInstanceToMap: false, // false, by default.
}
const unmarshallOptions = {
// Whether to return numbers as a string instead of converting them to native JavaScript numbers.
wrapNumbers: false, // false, by default.
}
const translateConfig = { marshallOptions, unmarshallOptions }
/** The DynamoDB Document client. */
export const ddbDocClient = DynamoDBDocumentClient.from(ddbClient, translateConfig)
// eslint-disable-next-line no-secrets/no-secrets
// Code adapted from : https://stackoverflow.com/questions/68358472/aws-dynamodb-document-client-updatecommand
export interface CreateDDBUpdateExpressionProps {
/**
* Item representing the partial update on an object
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
item: any
/**
* Root describing the context of the item, relative to the top-parent update object
*/
root: {
/**
* Path is the concatenation of attributes leading to item
* It is used for placeholder and local alias in expressions
*
* ex: { foo: { bar: { the item }}} will have as path: foobar
* Will appear in expression: SET #foo.#foobar = :foobar
*/
path: string
/**
* Alias is the cumulative concatenation of paths leading to item
* It is used for building the alias from top-parent update object
*
* ex: { foo: { bar: { the item }}} will have as:
* - local alias: #foobar
* - parent alias to the item: #foo
* - full alias as parent + local = #foo.#foobar
*/
alias: string
}
}
/**
* updateExpression: The actual update state, example: SET #alias = :placeholder
* expressionAttribute: The value to insert in placeholder example: :placeholder = value
* expressionAttributeNames: Are the aliases properties to avoid clashes: #alias = key
*/
export interface DDBUpdateExpression {
updateExpression: string
expressionAttributeValues: { [key: string]: unknown }
expressionAttributeNames: { [key: string]: string }
}
/**
* Recursive translation of a update object to an unfinished DynamoDB expression
*
* @param param0 a CreateDDBUpdateExpressionProps
* @returns an unfinished DDBUpdateExpression (lacking 'SET' in expression)
*/
function createDDBUpdateExpression({ item, root }: CreateDDBUpdateExpressionProps): DDBUpdateExpression {
const rootPath = root.path ? `${root.path}` : "" // rootPath to be added for all keys of item
const rootAlias = root.alias ? `${root.alias}.` : ""
const filteredItem = { ...item } // unsure if still usefull besides removing non-enumerable properties
const updateExpressionArr: string[] = []
const expressionAttributeValues: { [key: string]: unknown } = {}
const expressionAttributeNames: { [key: string]: string } = {}
Object.keys(filteredItem).forEach((key) => {
// build the full path to attribute being inspected
const fullKey = `${rootPath}${key}`
if (typeof filteredItem[key] === "object") {
// need to recurse on each key as it can be an object representing a partial update.
// https://stackoverflow.com/questions/51911927/update-nested-map-dynamodb
const {
updateExpression: nestedExpression,
expressionAttributeValues: nestedValues,
expressionAttributeNames: nestedNames,
} = createDDBUpdateExpression({
item: filteredItem[key],
root: {
path: `${rootPath}${key}`,
alias: `${rootAlias}#${rootPath}${key}`,
},
})
updateExpressionArr.push(nestedExpression)
Object.assign(expressionAttributeValues, nestedValues)
Object.assign(expressionAttributeNames, nestedNames)
expressionAttributeNames[`#${fullKey}`] = key
return
}
if (typeof filteredItem[key] === "function") {
// bail out, methods should not be there anyway nor appear in update expression
return
}
// leaf case where key points to a simple primitive type (ie no object nor function)
const placeholder = `:${fullKey}`
const alias = `#${fullKey}`
updateExpressionArr.push(`${rootAlias}${alias} = ${placeholder}`)
expressionAttributeValues[placeholder] = item[key]
expressionAttributeNames[alias] = key
})
const updateExpression = updateExpressionArr.join(", ")
return { updateExpression, expressionAttributeValues, expressionAttributeNames }
}
/**
* We alias properties to be sure we can insert reserved names.
*
* @param item a js object representing a partial update
* @param primaryKeyName the name of property considered as primary key (to remove from update expression)
* @returns necessary expression properties to update a DynamoDB item
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createDDBUpdateExpressions(item: any, primaryKeyName?: string): DDBUpdateExpression {
const filteredItem = { ...item }
if (primaryKeyName) {
delete filteredItem[primaryKeyName] // remove primary key to forbid id update
}
const { updateExpression, expressionAttributeValues, expressionAttributeNames } = createDDBUpdateExpression({
item: filteredItem,
root: { path: "", alias: "" },
})
const updateExpressionSet = `SET ${updateExpression}`
return { updateExpression: updateExpressionSet, expressionAttributeValues, expressionAttributeNames }
}
```
Upvotes: 2
Reputation: 3742
Now when I was looking around the documentation was hard to come by but I managed to piece it together over several different sources.
I have created a function that will take an object and then just overwrite with all those properties on the object referenced with primary key. I hope this can be of help to others having a hard time figuring out how UpdateCommand
works.
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
UpdateCommandInput,
UpdateCommand,
DynamoDBDocumentClient,
} from '@aws-sdk/lib-dynamodb';
export class DynamoDBService {
private client: DynamoDBDocumentClient;
constructor() {
const client = new DynamoDBClient({
region: config.get(<<your_region>>),
});
const marshallOptions = {
// Whether to automatically convert empty strings, blobs, and sets to `null`.
convertEmptyValues: false, // false, by default.
// Whether to remove undefined values while marshalling.
removeUndefinedValues: false, // false, by default.
// Whether to convert typeof object to map attribute.
convertClassInstanceToMap: true, // false, by default.
};
this.client = DynamoDBDocumentClient.from(client, { marshallOptions });
}
/**
* Takes a javascript object and transforms it into update expressions on the dynamodb object.
*
* It translates all of the actions to SET which will overwrite any attribute that is already there.
* It works best with simple types but can also serialise arrays and objects.
*
* https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/dynamodb-example-dynamodb-utilities.html
*
* @param tableName Name of the table to update on
* @param primaryKeyName The primary key name to update on
* @param id The primary key value
* @param item The item to update
*/
async upsertProperties(tableName: string, primaryKeyName: string, id: string, item: { [key: string]: any }) {
const updatedItem = this.removePrimaryKey(primaryKeyName, item);
const { updateExpression, expressionAttribute, expressionAttributeNames } =
this.createUpdateExpressions(updatedItem);
const input: UpdateCommandInput = {
Key: {
[primaryKeyName]: id,
},
UpdateExpression: `SET ${updateExpression.join(', ')}`,
ExpressionAttributeValues: expressionAttribute,
ExpressionAttributeNames: expressionAttributeNames,
TableName: tableName,
};
const command = new UpdateCommand(input);
return await this.client.send(command);
}
/**
* We alias properties to be sure we can insert reserved names (status fx).
*
* It is a bit complicated:
* updateExpression: The actual update state, example: SET #alias = :placeholder
* expressionAttribute: The value to insert in placeholder example: :placeholder = value
* expressionAttributeNames: Are the aliases properties to avoid clashe: #alias = key
*
* So in the end it ties together and both the inserted value and alias are fixed.
*/
private createUpdateExpressions(item: { [key: string]: any }) {
const updateExpression: string[] = [];
const expressionAttribute: { [key: string]: any } = {};
const expressionAttributeNames: { [key: string]: any } = {};
Object.keys(item).map((key) => {
const placeholder = `:p${key}`;
const alias = `#a${key}`;
updateExpression.push(`${alias} = ${placeholder}`);
expressionAttribute[placeholder] = item[key];
expressionAttributeNames[alias] = key;
});
return { updateExpression, expressionAttribute, expressionAttributeNames };
}
/**
* Remove primary key from the object or else we cannot make an
* update statement because the primary key would be overwritten
* which violates the insert.
*
* Copy to new object to ensure we don't manipulate the reference.
*/
private removePrimaryKey(primaryKeyName: string, item: { [key: string]: any }) {
const itemWithoutPrimaryKey = { ...item };
delete itemWithoutPrimaryKey[primaryKeyName];
return itemWithoutPrimaryKey;
}
}
Upvotes: 9