Skip to content

Expressions: Update builder

Eugene Lazutkin edited this page Apr 23, 2026 · 1 revision

Expressions: Update builder

buildUpdate constructs a DynamoDB UpdateExpression with attribute-name aliasing, attribute-value placeholders, and atomic array operations.

See also (AWS JS SDK v3): UpdateCommand · Update expressions · Expression attribute names · Expression attribute values. Vocabulary: Concepts.

Patches: the write-side counterpart of projection

A patch is to writes what projection is to reads. On the read side, projection asks DynamoDB to return only the attributes you actually need; on the write side, a patch asks DynamoDB to modify only the attributes that actually changed. Same philosophy, opposite direction — and the same three wins: less capacity consumed, less network bandwidth used, less work for DynamoDB to do.

Concretely: when a caller holds a projection of an item, mutates a handful of fields on the client, and wants to persist those changes, do not serialize the whole (partial) item into a PutItem — that would overwrite the attributes you didn't read with whatever your client happens to have (or didn't have). Instead, build an UpdateExpression that SETs the changed fields, REMOVEs the ones you explicitly want cleared, and leaves everything else alone. The resulting wire payload is proportional to the change, not to the item size.

Patches also enable DB-side invariant checks with no round-trip. A partial update that touches a subset of fields leaves open the question "is the resulting item still valid?" Instead of reading the item back to the client to verify, attach a ConditionExpression — or register a checkConsistency hook — and DynamoDB rejects the update atomically if the invariant is violated. See Adapter: Transaction auto-upgrade for the pattern.

Additive builders: how params flows

Every builder in dynamodb-toolkit/expressions mutates a params object in place and returns the same object. The design is deliberately additive: a caller builds up a full DynamoDB Command input by running one builder after another against the same params — each call adds its own expression field plus the supporting name/value aliases.

  • Simple case — pass {} (or nothing), use the returned object directly.
  • Typical case — start with the mandatory SDK fields (TableName, Key, Item, IndexName, …), pass through each builder you need, call cleanParams at the end, hand the result to the SDK.

buildUpdate fits in the middle of that pipeline:

import {buildUpdate, buildCondition, cleanParams} from 'dynamodb-toolkit/expressions';
import {UpdateCommand} from '@aws-sdk/lib-dynamodb';

let params = {TableName: 'planets', Key: {name: 'Hoth'}};  // mandatory SDK fields
params = buildUpdate({climate: 'cold'}, {}, params);        // adds UpdateExpression, names, values
params = buildCondition(                                    // AND-merges into ConditionExpression
  [{path: 'revision', op: '=', value: 3}], params
);
params = cleanParams(params);                               // strips any unused name/value
await docClient.send(new UpdateCommand(params));

Each builder picks up the running #/: counters from whatever is already on params — calling buildUpdate then buildCondition produces a coherent, collision-free expression regardless of order. See Concepts → Additive params for the full picture.

This is not a fluent chain (.a().b()) — it's a passed-through mutable bag. There is no step-based "chain" to interrupt; you can call builders zero, one, or many times on the same params, and they all append.

Basic usage

import {buildUpdate} from 'dynamodb-toolkit/expressions';

const params = buildUpdate(
  {climate: 'cold', diameter: 7200},
  {delete: ['old_field'], separator: '.', arrayOps: [{op: 'append', path: 'tags', values: ['frozen']}]}
);
// params.UpdateExpression: "SET #upk0 = :upv0, #upk1 = :upv1, #upk2 = list_append(if_not_exists(#upk2, :upv3), :upv4) REMOVE #upk5"
// params.ExpressionAttributeNames / ExpressionAttributeValues populated

Signature

function buildUpdate<T extends object>(
  patch: Record<string, unknown>,
  options?: UpdateOptions,
  params?: T
): T & {UpdateExpression: string};

interface UpdateOptions {
  delete?: string[];        // paths to REMOVE
  separator?: string;       // path separator, default '.'
  arrayOps?: ArrayOp[];     // atomic array operations
}

buildUpdate mutates and returns params (or creates {} when omitted). Existing ExpressionAttributeNames / ExpressionAttributeValues are preserved — new aliases get unique counters (#upk0, #upk1, …, :upv0, :upv1, …) continuing from whatever is already in the map.

Dotted paths

Pure-digit segments become array indices:

buildUpdate({'config.thresholds.0': 100, 'tags.2': 'archived'});
// SET #upk0.#upk1[0] = :upv0, #upk2[2] = :upv1

Use a non-default separator if your keys contain .:

buildUpdate({'a.b.c': 'flat'}, {separator: '/'});
// SET #upk0 = :upv0  (treats the whole string as one segment)

Array operations

op DDB expression Notes
append SET path = list_append(if_not_exists(path, :empty), :values) Add to tail; values is an array
prepend SET path = list_append(:values, if_not_exists(path, :empty)) Add to head
setAtIndex SET path[i] = :value Absolute-index write
removeAtIndex REMOVE path[i] DDB leaves a hole — no shift
add ADD path :value Atomic numeric increment, or Set add
buildUpdate({}, {arrayOps: [
  {op: 'append', path: 'moons', values: ['A', 'B']},
  {op: 'setAtIndex', path: 'moons', index: 5, value: 'F'},
  {op: 'removeAtIndex', path: 'tags', index: 2},
  {op: 'add', path: 'visit_count', value: 1}
]});

Not shipped (deferred)

  • splice (remove + shift), reorder (swap), insertAtIndex (shift up). DynamoDB has no atomic primitive for these; they would require a read-modify-write loop (concurrency-unsafe) or a TransactWriteItems read+write pair with preconditions. Build it in your own consistency hook if you need it.

cleanParams — always at the end

The builders allocate every name/value placeholder a rich input might need. Conditional paths (an empty patch, a condition builder with no clauses, a field that was if_not_exists-guarded on a path that never fired) can leave entries in ExpressionAttributeNames / ExpressionAttributeValues that don't appear in any expression field. DynamoDB rejects unused entries at request time.

cleanParams(params) scans the five expression fields (KeyConditionExpression, ConditionExpression, UpdateExpression, ProjectionExpression, FilterExpression) and drops every name/value alias that isn't referenced. It's idempotent; safe to call on any params.

import {buildUpdate, cleanParams} from 'dynamodb-toolkit/expressions';

let params = buildUpdate({climate: 'cold'}, {}, {TableName: 'planets', Key: {name: 'Hoth'}});
params = cleanParams(params);
// Unused aliases (if any) removed; params ready for UpdateCommand

The Adapter calls cleanParams automatically inside every make* / CRUD / mass method (see src/adapter/adapter.js). Call it yourself at the end of any builder pipeline you assemble by hand.

End-to-end: caller → builder → SDK

Full example — build a conditional update from a plain patch, ship it via the SDK, no Adapter:

import {UpdateCommand} from '@aws-sdk/lib-dynamodb';
import {buildUpdate, buildCondition, cleanParams} from 'dynamodb-toolkit/expressions';

// 1. Mandatory SDK fields (TableName, Key)
let params = {TableName: 'planets', Key: {name: 'Hoth'}};

// 2. Build the mutation — SET climate, remove old_field
params = buildUpdate({climate: 'cold'}, {delete: ['old_field']}, params);

// 3. Guard: only apply the update if revision is still 3 (optimistic lock)
params = buildCondition(
  [{path: 'revision', op: '=', value: 3}],
  params
);

// 4. Bump revision as part of the same atomic update
params = buildUpdate({}, {arrayOps: [{op: 'add', path: 'revision', value: 1}]}, params);

// 5. Drop any unused aliases and ship
params = cleanParams(params);
await docClient.send(new UpdateCommand(params));

Steps 2–4 could happen in any order; builders only read the running counters and append. This is the "additive builders" contract — params is an accumulator, not a fluent chain.

Clone this wiki locally