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 }
}
```