node.js - Clientul de documente AWS DynamoDB comanda UpdateCommand


UpdateCommand în documentația AWS DynamoDB nu este foarte bine documentat și este greu de utilizat.

Am vrut să folosesc comanda update pentru a lua pur și simplu un obiect Javascript și a introduce toate cheile din acesta pe obiectul care corespunde cheii primare.

Privind InsertCommand, m-am gândit că ar fi simplu astfel:

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);
  }

Dar se pare că acest lucru eșuează.

Răspuns #1

Acum, când mă uitam în jur, documentația era greu de găsit, dar am reușit să o împăc în mai multe surse diferite.

Am creat o funcție care va lua un obiect și apoi va suprascrie toate acele proprietăți pe obiectul referențiat cu cheia primară. Sper că acest lucru poate fi de ajutor altora care au dificultăți în a înțelege cum funcționează UpdateCommand.

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

Răspunsul #2

A trebuit să construiesc pe baza răspunsului grozav al lui @kristian-barrett pentru a gestiona actualizările parțiale ale intrărilor înrudite din DynamoDB.

Problema era că o actualizare primită precum {foo: {bar: "valoare actualizată" }} suprascria toate atributele foo și nu doar pe cel foo.bar.

ex în ddb:

{
  "foo": {
    "bar": "old value",
    "baz": "other value"
  }
}

ar fi fost actualizat astfel:

{
  "foo": {
    "bar": "updated value" // no more foo.baz
  }
}

Practic, ceea ce este necesar este o construcție recursivă a expresiei DynamoDB atunci când se analizează actualizarea parțială primită, deci

Iată codul.

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