@dataplan/pg
Version:
PostgreSQL step classes for Grafast
320 lines • 14.5 kB
JavaScript
Object.defineProperty(exports, "__esModule", { value: true });
exports.PgUpdateSingleStep = void 0;
exports.pgUpdateSingle = pgUpdateSingle;
const tslib_1 = require("tslib");
const grafast_1 = require("grafast");
const pg_sql2_1 = tslib_1.__importStar(require("pg-sql2"));
const codecs_js_1 = require("../codecs.js");
const inspect_js_1 = require("../inspect.js");
const pgClassExpression_js_1 = require("./pgClassExpression.js");
/**
* Update a single row identified by the 'getBy' argument.
*/
class PgUpdateSingleStep extends grafast_1.Step {
static { this.$$export = {
moduleName: "@dataplan/pg",
exportName: "PgUpdateSingleStep",
}; }
constructor(resource, getBy, attributes) {
super();
this.isSyncAndSafe = false;
/**
* The attributes and their dependency ids for us to find the record by.
*/
this.getBys = [];
/**
* The attributes and their dependency ids for us to update.
*/
this.attributes = [];
/**
* When locked, no more values can be set, no more selects can be added
*/
this.locked = false;
/**
* When finalized, we build the SQL query, queryValues, and note where to feed in
* the relevant queryValues. This saves repeating this work at execution time.
*/
this.finalizeResults = null;
/**
* The list of things we're selecting.
*/
this.selects = [];
this.applyDepIds = [];
this.resource = resource;
this.name = resource.name;
this.symbol = Symbol(this.name);
this.alias = pg_sql2_1.default.identifier(this.symbol);
this.contextId = this.addDependency(this.resource.executor.context());
const keys = getBy
? Object.keys(getBy)
: [];
if (!this.resource.uniques.some((uniq) => uniq.attributes.every((key) => keys.includes(key)))) {
throw new Error(`Attempted to build 'PgUpdateSingleStep' with a non-unique getBy keys ('${keys.join("', '")}') - please ensure your 'getBy' spec uniquely identifiers a row (resource = ${this.resource}; supported uniques = ${(0, inspect_js_1.inspect)(this.resource.uniques)}).`);
}
keys.forEach((name) => {
if (grafast_1.isDev) {
if (this.getBys.some((col) => col.name === name)) {
throw new Error(`Attribute '${String(name)}' was specified more than once in ${this}'s getBy spec`);
}
}
const value = getBy[name];
const depId = this.addDependency(value);
const attribute = this.resource.codec.attributes[name];
const pgCodec = attribute.codec;
this.getBys.push({ name, depId, pgCodec });
});
if (attributes) {
Object.entries(attributes).forEach(([key, value]) => {
if (value) {
this.set(key, value);
}
});
}
// This must be the last thing to happen
this.hasSideEffects = true;
}
toStringMeta() {
return `${this.resource.name}(${this.getBys.map((g) => g.name)};${this.attributes.map((a) => a.name)})`;
}
set(name, value) {
if (this.locked) {
throw new Error("Cannot set after plan is locked.");
}
if (grafast_1.isDev) {
if (this.attributes.some((col) => col.name === name)) {
throw new Error(`Attribute '${String(name)}' was specified more than once in ${this}`);
}
}
const { codec: pgCodec } = this.resource.codec.attributes[name];
const depId = this.addDependency(value);
this.attributes.push({ name, depId, pgCodec });
}
/**
* Returns a plan representing a named attribute (e.g. column) from the newly
* updateed row.
*/
get(attr) {
const resourceAttribute = this.resource.codec.attributes[attr];
if (!resourceAttribute) {
throw new Error(`${this.resource} does not define an attribute named '${String(attr)}'`);
}
if (resourceAttribute?.via) {
throw new Error(`Cannot select a 'via' attribute from PgUpdateSingleStep`);
}
/*
* Only cast to `::text` during select; we want to use it uncasted in
* conditions/etc. The reasons we cast to ::text include:
*
* - to make return values consistent whether they're direct or in nested
* arrays
* - to make sure that that various PostgreSQL clients we support do not
* mangle the data in unexpected ways - we take responsibility for
* decoding these string values.
*/
const sqlExpr = (0, pgClassExpression_js_1.pgClassExpression)(this, resourceAttribute.codec, resourceAttribute.notNull);
const colPlan = resourceAttribute.expression
? sqlExpr `${pg_sql2_1.default.parens(resourceAttribute.expression(this.alias))}`
: sqlExpr `${this.alias}.${pg_sql2_1.default.identifier(String(attr))}`;
return colPlan;
}
getMeta(key) {
return (0, grafast_1.access)(this, ["m", key]);
}
record() {
return (0, pgClassExpression_js_1.pgClassExpression)(this, this.resource.codec, false) `${this.alias}`;
}
/**
* Advanced method; rather than returning a plan it returns an index.
* Generally useful for PgClassExpressionStep.
*
* @internal
*/
selectAndReturnIndex(fragment) {
// NOTE: it's okay to add selections after the plan is "locked" - lock only
// applies to which rows are being selected, not what is being queried
// about the rows.
// Optimisation: if we're already selecting this fragment, return the existing one.
const index = this.selects.findIndex((frag) => pg_sql2_1.default.isEquivalent(frag, fragment));
if (index >= 0) {
return index;
}
return this.selects.push(fragment) - 1;
}
apply($step) {
this.applyDepIds.push(this.addUnaryDependency($step));
}
/**
* `execute` will always run as a root-level query. In future we'll implement a
* `toSQL` method that allows embedding this plan within another SQL plan...
* But that's a problem for later.
*
* This runs the query for every entry in the values, and then returns an
* array of results where each entry in the results relates to the entry in
* the incoming values.
*
* NOTE: we don't know what the values being fed in are, we must feed them to
* the plans stored in this.identifiers to get actual values we can use.
*/
async execute({ indexMap, values, }) {
const { alias, contextId, finalizeResults, resource } = this;
if (!finalizeResults) {
throw new Error("Cannot execute PgSelectStep before finalizing it.");
}
const { table, returning, sqlWhere, queryValueDetailsBySymbol } = finalizeResults;
const contextDep = values[contextId];
/*
* NOTE: Though we'd like to do bulk updates, there's no way of us
* reliably linking the data back up again given users might have
* triggers manipulating the data so we can't match it back up even using
* the same getBy specs.
*
* Currently it seems that the order returned from `update ...
* from (select ... order by ...) returning ...` is the same order as the
* `order by` was, however this is not guaranteed in the documentation
* and as such cannot be relied upon. Further the pgsql-hackers list
* explicitly declined guaranteeing this behavior:
*
* https://www.postgresql.org/message-id/CAKFQuwbgdJ_xNn0YHWGR0D%2Bv%2B3mHGVqJpG_Ejt96KHoJjs6DkA%40mail.gmail.com
*
* So we have to make do with single updates, alas.
*/
// We must execute each mutation on its own, but we can at least do so in
// parallel. Note we return a list of promises, each may reject or resolve
// without causing the others to reject.
return indexMap(async (i) => {
const context = contextDep.at(i);
const sqlSets = [];
for (const { depId, name, pgCodec } of this.attributes) {
const attVal = values[depId].at(i);
// `null` is kept, `undefined` is skipped
if (attVal !== undefined) {
const sqlIdent = pg_sql2_1.default.identifier(name);
const sqlVal = (0, codecs_js_1.sqlValueWithCodec)(attVal, pgCodec);
sqlSets.push((0, pg_sql2_1.default) `${sqlIdent} = ${sqlVal}`);
}
}
const meta = Object.create(null);
const queryBuilder = {
alias,
[pg_sql2_1.$$toSQL]() {
return alias;
},
setMeta(key, value) {
meta[key] = value;
},
getMetaRaw(key) {
return meta[key];
},
set(name, attVal) {
const pgCodec = resource.codec.attributes[name]?.codec;
if (!pgCodec) {
throw new Error(`Attribute ${name} not recognized on ${resource}`);
}
const sqlIdent = pg_sql2_1.default.identifier(name);
const sqlVal = (0, codecs_js_1.sqlValueWithCodec)(attVal, pgCodec);
sqlSets.push((0, pg_sql2_1.default) `${sqlIdent} = ${sqlVal}`);
},
setBuilder() {
return (0, grafast_1.setter)(this);
},
};
for (const applyDepId of this.applyDepIds) {
const val = values[applyDepId].unaryValue();
if (Array.isArray(val)) {
val.forEach((v) => v?.(queryBuilder));
}
else {
val?.(queryBuilder);
}
}
if (sqlSets.length === 0) {
// No attributes to update?! This isn't allowed.
throw new grafast_1.SafeError("Attempted to update a record, but no new values were specified.");
}
const query = (0, pg_sql2_1.default) `update ${table} set ${pg_sql2_1.default.join(sqlSets, ", ")} where ${sqlWhere}${returning};`;
const { text, values: rawSqlValues } = pg_sql2_1.default.compile(query);
const sqlValues = queryValueDetailsBySymbol.size
? rawSqlValues.map((v) => {
if (typeof v === "symbol") {
const details = queryValueDetailsBySymbol.get(v);
if (!details) {
throw new Error(`Saw unexpected symbol '${(0, inspect_js_1.inspect)(v)}'`);
}
const val = values[details.depId].at(i);
return val == null ? null : details.processor(val);
}
else {
return v;
}
})
: rawSqlValues;
const { rows, rowCount } = await this.resource.executeMutation({
context,
text,
values: sqlValues,
});
if (rowCount === 0) {
// TODO: should we throw?
return null;
}
return {
__proto__: null,
m: meta,
t: rows[0] ?? [],
};
});
}
finalize() {
if (!this.isFinalized) {
this.locked = true;
const resourceSource = this.resource.from;
if (!pg_sql2_1.default.isSQL(resourceSource)) {
throw new Error(`Error in ${this}: can only update into resources defined as SQL, however ${this.resource} has ${(0, inspect_js_1.inspect)(this.resource.from)}`);
}
const table = (0, pg_sql2_1.default) `${resourceSource} as ${this.alias}`;
const fragmentsWithAliases = this.selects.map((frag, idx) => (0, pg_sql2_1.default) `${frag} as ${pg_sql2_1.default.identifier(String(idx))}`);
const returning = fragmentsWithAliases.length > 0
? (0, pg_sql2_1.default) ` returning\n${pg_sql2_1.default.indent(pg_sql2_1.default.join(fragmentsWithAliases, ",\n"))}`
: pg_sql2_1.default.blank;
const getByAttributesCount = this.getBys.length;
if (getByAttributesCount === 0) {
// No attributes specified to find the row?! This is forbidden.
throw new grafast_1.SafeError("Attempted to update a record, but no information on uniquely determining the record was specified.");
}
const queryValueDetailsBySymbol = new Map();
const sqlWhereClauses = [];
for (let i = 0; i < getByAttributesCount; i++) {
const { name, depId, pgCodec } = this.getBys[i];
const symbol = Symbol(name);
sqlWhereClauses[i] = pg_sql2_1.default.parens((0, pg_sql2_1.default) `${pg_sql2_1.default.identifier(this.symbol, name)} = ${pg_sql2_1.default.value(
// THIS IS A DELIBERATE HACK - we will be replacing this symbol with
// a value before executing the query.
symbol)}::${pgCodec.sqlType}`);
queryValueDetailsBySymbol.set(symbol, {
depId,
processor: pgCodec.toPg,
});
}
this.finalizeResults = {
table,
returning,
sqlWhere: pg_sql2_1.default.parens(pg_sql2_1.default.join(sqlWhereClauses, " and ")),
queryValueDetailsBySymbol,
};
}
super.finalize();
}
[pg_sql2_1.$$toSQL]() {
return this.alias;
}
}
exports.PgUpdateSingleStep = PgUpdateSingleStep;
/**
* Update a single row identified by the 'getBy' argument.
*/
function pgUpdateSingle(resource, getBy, attributes) {
return new PgUpdateSingleStep(resource, getBy, attributes);
}
(0, grafast_1.exportAs)("@dataplan/pg", pgUpdateSingle, "pgUpdateSingle");
//# sourceMappingURL=pgUpdateSingle.js.map
;