Kristian Barrett
Kristian Barrett

Reputation: 3742

AWS Dynamodb document client UpdateCommand

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

Answers (2)

Vincent AUDIBERT
Vincent AUDIBERT

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

Kristian Barrett
Kristian Barrett

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

Related Questions