Skip to content

Recipe: Querying subtrees with buildKey

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

Recipe: Querying subtrees with buildKey

The SQL-equivalent question this answers: hierarchical lookups against a tree of rows. SELECT * FROM rentals WHERE state = 'TX' (every descendant of TX). SELECT * FROM rentals WHERE state = 'TX' AND facility = 'Dallas' (the Dallas row and its vehicles — self + children). SELECT * FROM rentals WHERE state = 'TX' AND facility LIKE 'Dal%' (facilities starting with "Dal"). In SQL you use materialized-path columns plus LIKE (or a recursive CTE); in DynamoDB it's the structural-key sort-attribute plus begins_with in the KeyConditionExpression.

Pattern: declare composite keyFields + a structuralKey. Call adapter.buildKey(values, options?) to emit the right KeyConditionExpression for the three natural subtree shapes — children-only (default), self + descendants ({self: true}), and narrow-prefix ({partial: 'Dal'}). Pipe the resulting params into getListByParams / cascade / any mass-op.

Why DynamoDB makes you compose this by hand

DynamoDB's KeyConditionExpression only accepts the partition-key equality plus one optional clause on the sort key — =, range comparisons (<, <=, >, >=), BETWEEN, or begins_with. That's the whole vocabulary. No JOIN, no CTE, no multi-clause filter in the KCE itself.

The workaround, pioneered by the single-table-design community, is to encode the hierarchy in the sort key itself: state|facility|vehicleVin. Once the hierarchy lives in the key string, the "descendants of X" query is a begins_with, a "narrow prefix" query is a longer begins_with, and DynamoDB plans the Query against the primary index directly (no FilterExpression, no post-fetch filtering).

Building the right prefix by hand is mechanical but error-prone:

  • Zero-pad {type: 'number'} components per their width so lexicographic order matches numeric order.
  • Get the separator right, consistently (| on writes, | on reads, same character).
  • Distinguish "children of X" (trailing separator in the prefix) from "self + children" (no trailing separator) correctly.
  • Emit BOTH the partition-key equality AND the sort-key prefix clause in the same KCE — DynamoDB rejects either alone on a composite key.

adapter.buildKey(values, options?, params?) does all of that for you given the Adapter's declared keyFields + structuralKey. Three options cover the three shapes.

The pattern

import {Adapter} from 'dynamodb-toolkit';

const rentals = new Adapter({
  client: docClient,
  table: 'rentals',

  keyFields: ['state', 'facility', {name: 'vin', type: 'string'}],
  structuralKey: '_sk',                                        // joins with '|' — default separator
  technicalPrefix: '_',

  typeLabels: ['state', 'facility', 'vehicle'],
  typeField: 'kind'
});

Items look like:

// State-tier row:     {state: 'TX', _sk: 'TX',                 kind: 'state',   ...}
// Facility-tier row:  {state: 'TX', _sk: 'TX|Dallas',          kind: 'facility', facility: 'Dallas', ...}
// Vehicle-tier row:   {state: 'TX', _sk: 'TX|Dallas|1HGC…',   kind: 'vehicle',  facility: 'Dallas', vin: '1HGC…', ...}

The built-in prepare step composes _sk automatically — everything that follows reads from it.

Shape 1 — children-only (default)

"Vehicles under Dallas" — but not the Dallas facility row itself, and not anything else:

const params = rentals.buildKey({state: 'TX', facility: 'Dallas'});
params.TableName = rentals.table;

const result = await rentals.getListByParams(params, {limit: 20});
// result.data: [{vin, ...}, {vin, ...}, ...] — vehicles only

Emitted on the wire:

KeyConditionExpression: #pk = :pk AND begins_with(#sk, :sk)
  :pk = 'TX'
  :sk = 'TX|Dallas|'        ← trailing separator excludes the facility row itself

Use this when you have a clean "list the children, not the parent" access pattern — most tier-by-tier drill-down UI fits here.

Sugar: getListUnder(partialKey, options). Equivalent to getListByParams(buildKey(partialKey), options). Saves one line for the common case:

const vehicles = await rentals.getListUnder(
  {state: 'TX', facility: 'Dallas'},
  {limit: 20}
);

For the {self} / {partial} shapes below, compose buildKey + getListByParams directly — getListUnder is only the default.

Shape 2 — self + descendants ({self: true})

"The Dallas facility and everything under it":

const params = rentals.buildKey(
  {state: 'TX', facility: 'Dallas'},
  {self: true}
);
params.TableName = rentals.table;

const result = await rentals.getListByParams(params);
// result.data: [{kind: 'facility', ...}, {kind: 'vehicle', ...}, {kind: 'vehicle', ...}, ...]

Emitted on the wire:

KeyConditionExpression: #pk = :pk AND begins_with(#sk, :sk)
  :pk = 'TX'
  :sk = 'TX|Dallas'          ← NO trailing separator — matches 'TX|Dallas' AND 'TX|Dallas|…'

The implicit invariant. {self: true} works because no sibling at the parent's tier has a name that starts with 'Dallas' except 'Dallas' itself. If 'Dallas' and 'Dallasville' coexisted as facilities, begins_with(_sk, 'TX|Dallas') would return both plus their vehicles — surprising.

How to make the invariant hold:

  • Use immutable surrogate IDs at each tier (UUIDs, numeric IDs with zero padding) — distinct values can't be prefixes of each other if they're the same length or use content-free encoding.
  • If you must use human-readable names, enforce a name-uniqueness-plus-reservation check at insert time: no new facility name may be a prefix of an existing one, and vice versa. This is application-layer discipline; the toolkit can't enforce it at the DDB level.
  • typeLabels prefix-collision is checked at adapter construction — the toolkit rejects e.g. typeLabels: ['state', 'facility', 'facil'] because 'facil' is a prefix of 'facility'. The value-level invariant is harder: the toolkit can't enforce that no two facility names prefix-collide without reading every row.

When the invariant can't be guaranteed: use two Queries (kind = facility AND state = TX AND facility = Dallas for the row + buildKey({state, facility}) for children) and concatenate client-side. Clean, 2× the RCU.

Use cases for {self}.

  • UI "drill-in" views where the parent header + its children list in the same page.
  • Cascade operations that need to include the root (the toolkit's deleteAllUnder / moveAllUnder do this internally on the self-phase — without {self: true} in their pagination shape).
  • Data exports where every row under a scope counts, including the scope row itself.

Shape 3 — narrow prefix ({partial: 'Dal'})

"Facilities under TX whose name starts with 'Dal'" — Dallas, Dalton, Dalhart, …:

const params = rentals.buildKey(
  {state: 'TX'},
  {partial: 'Dal'}
);
params.TableName = rentals.table;

const result = await rentals.getListByParams(params);
// result.data: the facility rows (and anything under them) whose name starts with 'Dal'

Emitted on the wire:

KeyConditionExpression: #pk = :pk AND begins_with(#sk, :sk)
  :pk = 'TX'
  :sk = 'TX|Dal'             ← narrows the next tier by the partial prefix

Use cases:

  • Autocomplete / typeahead against the next tier's name.
  • Browsing "everything with a name starting with X" — ZIP codes, SKU prefixes, product codes.
  • Quick filters where the tier's values have a natural prefix hierarchy.

Precedence when combined with {self}: partial wins — they target different key ranges and can't be merged into a single begins_with. If you need both ("self + children, narrowed"), do it in two Queries.

Partial is a string, not a value. The partial string replaces the next tier's supplied value — you don't supply the next-tier field in values when using {partial}. The toolkit throws if you try: buildKey({state: 'TX', facility: 'Dallas'}, {partial: 'Ma'}) is ambiguous and rejected.

What buildKey does not cover

GSI-targeted queries — deferred

The toolkit's buildKey targets the base table only. Calling buildKey({...}, {indexName: 'by-kind'}) throws today — declarative GSI-key composition is future work.

For GSI-targeted KeyConditionExpression, drop to the lower-level buildKeyCondition(input, params?) primitive:

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

const params = buildKeyCondition({
  name: 'createdAt',                 // GSI sort-key attribute
  value: '2026-04-01',
  kind: 'prefix',                    // begins_with
  pkName: 'status',                  // GSI partition-key attribute
  pkValue: 'active'                  // GSI partition-key value
});
params.TableName = rentals.table;
params.IndexName = 'by-status-createdAt';

const result = await rentals.getListByParams(params);

The primitive takes a fully-prepared value string — you're responsible for composing the sort-key shape (no keyFields awareness, no zero-padding). Fine when the GSI schema is simple; inconvenient when the GSI also has a composite sort key.

When this becomes painful enough to matter, file a request — declarative GSI buildKey is on the roadmap.

Range conditions

buildKey only emits = or begins_with. For <, <=, >, >=, BETWEEN on the sort key (e.g. "events between timestamps"), drop to buildKeyCondition with kind: 'exact' for the partition key alone, then add the range condition by hand or via adapter.applyFilter (which promotes valid range clauses to the KCE automatically).

Composition

buildKey returns a params object with the KeyConditionExpression + attribute maps populated. It pairs naturally with any mass-op that takes Query/Scan params:

const params = rentals.buildKey({state: 'TX', facility: 'Dallas'});
params.TableName = rentals.table;

// Read a page:
const page = await rentals.getListByParams(params, {limit: 20});

// Delete everything under Dallas (non-cascade — just the children):
const deleted = await rentals.deleteListByParams(params, {maxItems: 5000});

// Clone everything under Dallas to Austin:
const cloned = await rentals.cloneListByParams(
  params,
  rentals.swapPrefix({facility: 'Dallas'}, {facility: 'Austin'}),
  {maxItems: 5000}
);

// Patch every item under Dallas to mark inactive:
const edited = await rentals.editListByParams(
  params,
  vehicle => ({...vehicle, active: false}),
  {maxItems: 5000}
);

The cascade primitives (deleteAllUnder etc.) compose buildKey(srcKey) internally. The cascade recipe documents that flow; this recipe's use case is the read / hand-rolled-mass-op path where you want subtree queries without the cascade's self-phase semantics.

Partial-fill semantics

values must be contiguous-from-start: you can omit trailing keyFields, but you can't skip a middle one. buildKey({state: 'TX', vin: '1HGC…'}) throws — facility is missing, vin can't be reached. The error names which field is the gap.

This matches DynamoDB's sort-key semantics: the structural-key prefix is composed from the leftmost values you supply, in keyFields order.

Cost

Every buildKey-composed Query hits the base-table primary index (or — once declarative GSI lands — a GSI). No FilterExpression, no post-fetch filtering — DynamoDB plans against the sorted sort-key space directly, reads exactly the key range you asked for.

Shape Partition key Sort-key condition Typical RCU
buildKey({pk}) (default, single-field) = :pk — (none) 1 Query; 1 RCU per 4 KB scanned
buildKey({pk, ...}) (children) = :pk begins_with('X|Y|') 1 Query; scanned bytes = only matching children
buildKey({pk, ...}, {self: true}) = :pk begins_with('X|Y') same — includes the self row
buildKey({pk}, {partial}) = :pk begins_with('X|Prefix') same — bounded by the prefix

DynamoDB bills per scanned byte, not per matched byte — so begins_with on a sort key is cheaper than Scanning the whole partition or using a base-table FilterExpression. That's the whole point of encoding hierarchy in the key.

When this pattern fits

  • Your data has a natural 2-5-tier hierarchy (state > facility > vehicle, tenant > project > issue, org > department > user).
  • Reads against subtrees are a primary access pattern (UI drill-down, exports, cascade ops).
  • Values at each tier don't prefix-collide with their siblings (or you control insertion to enforce it).
  • You use composite keyFields + structuralKey on the base-table primary index.

When it doesn't fit

  • Flat namespaces — no hierarchy, single-field keyFields. buildKey throws on {self} / {partial} options in this case; the sugar evaporates.
  • Sibling values prefix-collide at the tier you're querying. {self: true} would over-match. Use two Queries and concatenate, or use surrogate IDs.
  • Range queries on the sort key — use buildKeyCondition directly or add conditions via applyFilter.
  • GSI-targeted queries — drop to buildKeyCondition or hand-build the KCE until the toolkit's declarative GSI-key surface lands.
  • Very shallow hierarchies (pk alone, no sk). You don't need any of this; getByKey is enough.

Related

Clone this wiki locally