betterddb
Version:
A definition-based DynamoDB wrapper library that provides a schema-driven and fully typesafe DAL.
301 lines • 11.9 kB
JavaScript
/* eslint-disable no-unused-vars */
import { TransactWriteCommand, UpdateCommand, } from "@aws-sdk/lib-dynamodb";
import {} from "../betterddb.js";
import {} from "@aws-sdk/client-dynamodb";
export class UpdateBuilder {
parent;
key;
actions = {};
condition;
// When using transaction mode, we store extra transaction items.
extraTransactItems = [];
// Reference to the parent BetterDDB instance and key.
constructor(parent, key) {
this.parent = parent;
this.key = key;
}
// Chainable methods:
set(attrs) {
// Separate values into sets and removes
const { toSet, toRemove } = Object.entries(attrs).reduce((acc, [key, value]) => {
if (value === undefined ||
(typeof value === "string" &&
value.trim() === "" &&
this.parent.getSchema().shape[key]?.isOptional?.())) {
acc.toRemove.push(key);
}
else {
acc.toSet[key] = value;
}
return acc;
}, { toSet: {}, toRemove: [] });
// Handle non-empty values with set
if (Object.keys(toSet).length > 0) {
const partialSchema = this.parent.getSchema().partial();
const validated = partialSchema.parse(toSet);
this.actions.set = { ...this.actions.set, ...validated };
}
// Handle empty/undefined values with remove
if (toRemove.length > 0) {
this.remove(toRemove);
}
return this;
}
remove(attrs) {
this.actions.remove = [...(this.actions.remove ?? []), ...attrs];
return this;
}
add(attrs) {
const partialSchema = this.parent.getSchema().partial();
const validated = partialSchema.parse(attrs);
this.actions.add = { ...this.actions.add, ...validated };
return this;
}
delete(attrs) {
this.actions.delete = { ...this.actions.delete, ...attrs };
return this;
}
/**
* Adds a condition expression to the update.
*/
setCondition(expression, attributeValues, attributeNames) {
if (this.condition) {
// Merge conditions with AND.
this.condition.expression += ` AND ${expression}`;
Object.assign(this.condition.attributeValues, attributeValues);
Object.assign(this.condition.attributeNames, attributeNames);
}
else {
this.condition = {
expression,
attributeValues,
attributeNames,
};
}
return this;
}
/**
* Specifies additional transaction items to include when executing this update as a transaction.
*/
transactWrite(ops) {
if (Array.isArray(ops)) {
this.extraTransactItems.push(...ops);
}
else {
this.extraTransactItems.push(ops);
}
return this;
}
/**
* Builds the update expression and associated maps.
*/
buildExpression(newItemForIndexes) {
const ExpressionAttributeNames = {};
const ExpressionAttributeValues = {};
const clauses = [];
// 1) SET – from actions.set
const setParts = [];
if (this.actions.set) {
for (const [attr, value] of Object.entries(this.actions.set)) {
const nameKey = `#n_${attr}`;
const valueKey = `:v_${attr}`;
ExpressionAttributeNames[nameKey] = attr;
ExpressionAttributeValues[valueKey] = value;
setParts.push(`${nameKey} = ${valueKey}`);
}
}
// 2) also SET – from index‐rebuild if requested
if (newItemForIndexes) {
const indexAttrs = this.parent.buildIndexes(newItemForIndexes);
for (const [attr, value] of Object.entries(indexAttrs)) {
const nameKey = `#n_idx_${attr}`;
const valueKey = `:v_idx_${attr}`;
ExpressionAttributeNames[nameKey] = attr;
ExpressionAttributeValues[valueKey] = value;
setParts.push(`${nameKey} = ${valueKey}`);
}
}
if (setParts.length > 0) {
clauses.push(`SET ${setParts.join(", ")}`);
}
// 3) REMOVE
if (this.actions.remove?.length) {
const removeParts = this.actions.remove.map((attr) => {
const nameKey = `#n_${String(attr)}`;
ExpressionAttributeNames[nameKey] = String(attr);
return nameKey;
});
clauses.push(`REMOVE ${removeParts.join(", ")}`);
}
// 4) ADD
if (this.actions.add) {
const addParts = [];
for (const [attr, value] of Object.entries(this.actions.add)) {
const nameKey = `#n_${attr}`;
const valueKey = `:v_${attr}`;
ExpressionAttributeNames[nameKey] = attr;
ExpressionAttributeValues[valueKey] = value;
addParts.push(`${nameKey} ${valueKey}`);
}
if (addParts.length) {
clauses.push(`ADD ${addParts.join(", ")}`);
}
}
// 5) DELETE
if (this.actions.delete) {
const deleteParts = [];
for (const [attr, value] of Object.entries(this.actions.delete)) {
const nameKey = `#n_${attr}`;
const valueKey = `:v_${attr}`;
ExpressionAttributeNames[nameKey] = attr;
ExpressionAttributeValues[valueKey] = value;
deleteParts.push(`${nameKey} ${valueKey}`);
}
if (deleteParts.length) {
clauses.push(`DELETE ${deleteParts.join(", ")}`);
}
}
// 6) merge in condition‐names/values
if (this.condition) {
Object.assign(ExpressionAttributeNames, this.condition.attributeNames);
Object.assign(ExpressionAttributeValues, this.condition.attributeValues);
}
// 7) normalize empty values
const hasValues = Object.keys(ExpressionAttributeValues).length > 0;
const finalValues = hasValues ? ExpressionAttributeValues : undefined;
if (clauses.length === 0) {
throw new Error("No attributes to update – all values were empty or undefined");
}
return {
updateExpression: clauses.join(" "),
attributeNames: ExpressionAttributeNames,
attributeValues: finalValues,
};
}
/**
* Returns a transaction update item that can be included in a transactWrite call.
*/
async toTransactUpdate(newItemForIndexes) {
if (!newItemForIndexes) {
newItemForIndexes = await this.createExpectedNewItem();
}
const { updateExpression, attributeNames, attributeValues } = this.buildExpression(newItemForIndexes);
const updateItem = {
TableName: this.parent.getTableName(),
Key: this.parent.buildKey(this.key),
UpdateExpression: updateExpression,
ExpressionAttributeNames: attributeNames,
ExpressionAttributeValues: attributeValues,
};
if (this.condition?.expression) {
updateItem.ConditionExpression = this.condition.expression;
}
return { Update: updateItem };
}
async createExpectedNewItem() {
const existingItem = await this.parent.get(this.key).execute();
if (existingItem === null) {
throw new Error("Item not found");
}
const expectedNewItem = {
...existingItem,
...this.actions.set,
};
if (this.actions.remove) {
this.actions.remove.forEach((attr) => {
delete expectedNewItem[String(attr)];
});
}
if (this.actions.add) {
Object.entries(this.actions.add).forEach(([attr, value]) => {
const currentValue = expectedNewItem[attr] ?? 0;
if (typeof value === "number") {
expectedNewItem[attr] = currentValue + value;
}
else if (value instanceof Set) {
const currentSet = expectedNewItem[attr] instanceof Set
? expectedNewItem[attr]
: new Set();
expectedNewItem[attr] = new Set([...currentSet, ...value]);
}
});
}
if (this.actions.delete) {
Object.entries(this.actions.delete).forEach(([attr, value]) => {
if (value instanceof Set) {
const currentSet = expectedNewItem[attr];
if (currentSet instanceof Set) {
value.forEach((v) => {
currentSet.delete(v);
});
}
}
});
}
return this.parent.getSchema().parse(expectedNewItem);
}
/**
* Commits the update immediately by calling the parent's update method.
*/
async execute() {
const expectedNewItem = await this.createExpectedNewItem();
if (this.parent.getTimestamps()) {
const now = new Date().toISOString();
if (!this.actions.set) {
this.actions.set = {};
}
this.actions.set = { ...this.actions.set, updatedAt: now };
}
if (this.extraTransactItems.length > 0) {
// For transactions, we must throw if there's nothing to update
// since we can't safely skip updates in a transaction
const myTransactItem = await this.toTransactUpdate(expectedNewItem);
const allItems = [...this.extraTransactItems, myTransactItem];
await this.parent.getClient().send(new TransactWriteCommand({
TransactItems: allItems,
}));
// After transaction, retrieve the updated item.
const result = await this.parent.get(this.key).execute();
if (result === null) {
throw new Error("Item not found after transaction update");
}
return result;
}
// For normal updates, handle empty updates gracefully
try {
const { updateExpression, attributeNames, attributeValues } = this.buildExpression(expectedNewItem);
let params = {
TableName: this.parent.getTableName(),
Key: this.parent.buildKey(this.key),
UpdateExpression: updateExpression,
ExpressionAttributeNames: attributeNames,
ExpressionAttributeValues: attributeValues,
ReturnValues: "ALL_NEW",
};
if (this.condition?.expression) {
params.ConditionExpression = this.condition.expression;
}
const result = await this.parent
.getClient()
.send(new UpdateCommand(params));
if (!result.Attributes) {
throw new Error("No attributes returned after update");
}
return this.parent.getSchema().parse(result.Attributes);
}
catch (error) {
// If there's nothing to update, just return the existing item
if (error instanceof Error &&
error.message ===
"No attributes to update - all values were empty or undefined") {
const existingItem = await this.parent.get(this.key).execute();
if (existingItem === null) {
throw new Error("Item not found");
}
return existingItem;
}
throw error;
}
}
}
//# sourceMappingURL=update-builder.js.map