@clickup/ent-framework
Version:
A PostgreSQL graph-database-alike library with microsharding and row-level security
877 lines • 37.1 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PgRunner = void 0;
const assert_1 = __importDefault(require("assert"));
const difference_1 = __importDefault(require("lodash/difference"));
const last_1 = __importDefault(require("lodash/last"));
const random_1 = __importDefault(require("lodash/random"));
const uniq_1 = __importDefault(require("lodash/uniq"));
const Runner_1 = require("../abstract/Runner");
const misc_1 = require("../internal/misc");
const types_1 = require("../types");
const escapeIdent_1 = require("./helpers/escapeIdent");
const escapeLiteral_1 = require("./helpers/escapeLiteral");
const escapeBoolean_1 = require("./internal/escapeBoolean");
const escapeComposite_1 = require("./internal/escapeComposite");
const escapeDate_1 = require("./internal/escapeDate");
const escapeID_1 = require("./internal/escapeID");
const escapeIdentComposite_1 = require("./internal/escapeIdentComposite");
const escapeString_1 = require("./internal/escapeString");
const escapeStringify_1 = require("./internal/escapeStringify");
const parseCompositeRow_1 = require("./internal/parseCompositeRow");
const PgError_1 = require("./PgError");
const DEADLOCK_RETRY_MS_MIN = 2000;
const DEADLOCK_RETRY_MS_MAX = 5000;
const ERROR_DEADLOCK = "deadlock detected";
const ERROR_FK = "violates foreign key constraint ";
const ERROR_CONFLICT_RECOVERY = "canceling statement due to conflict with recovery";
// "Class 22 — Data Exception" errors are typically caused by invalid
// input values (e.g. invalid date format or type cast). See for details:
// https://www.postgresql.org/docs/14/errcodes-appendix.html
const ERROR_CODE_PREFIX_DATA_EXCEPTION = "22";
/**
* A convenient pile of helper methods usable by most of PgQuery* classes. In
* some sense it's an anti-pattern, but still reduces the boilerplate.
*
* PgRunner is also responsible for stringifying the values passed to the
* queries and parsing values returned from the DB according to the field types
* specs.
*/
class PgRunner extends Runner_1.Runner {
async clientQuery(sql, annotations, batchFactor, hints) {
const rows = await this.client.query({
query: [sql],
hints,
isWrite: this.constructor.IS_WRITE,
annotations,
op: this.op,
table: this.name,
batchFactor,
});
// Apply parsers only for known field names. Notice that TOutput is not
// necessarily a type of the table's row, it can be something else (in e.g.
// INSERT or DELETE operations).
if (rows.length > 0) {
for (const [field, dbValueToJs] of this.dbValueToJs) {
if (field in rows[0]) {
for (const row of rows) {
const dbValue = row[field];
if (dbValue !== null && dbValue !== undefined) {
row[field] = dbValueToJs(dbValue);
}
}
}
}
}
return rows;
}
/**
* Formats prefixes/suffixes of various compound SQL clauses. Don't use on
* performance-critical path!
*/
fmt(template, args = {}) {
return template.replace(/%(?:T|SELECT_FIELDS|FIELDS|UPDATE_FIELD_VALUE_PAIRS|PK)(?:\(([%\w]+)\))?/g, (c, a) => {
a = a?.replace(/%T/g, this.name);
// Table name.
if (c === "%T") {
return (0, escapeIdent_1.escapeIdent)(this.name);
}
// Comma-separated list of ALL fields in the table to be used in SELECT
// clauses (always includes ID field).
if (c === "%SELECT_FIELDS") {
return (0, uniq_1.default)([...Object.keys(this.schema.table), types_1.ID])
.map((f) => this.escapeField(f) + (f === types_1.ID ? ` AS ${types_1.ID}` : ""))
.join(", ");
}
// Comma-separated list of the passed fields (never with AS clause).
if (c.startsWith("%FIELDS")) {
(0, assert_1.default)(args.fields, `BUG: no args.fields passed in ${template}`);
return args.fields
.map((field) => this.escapeField(field, {
withTable: a,
normalize: args.normalize,
}))
.join(", ");
}
// field1=X.field1, field2=X.field2, ...
if (c.startsWith("%UPDATE_FIELD_VALUE_PAIRS")) {
(0, assert_1.default)(args.fields, `BUG: no args.fields passed in ${template}`);
(0, assert_1.default)(a, "BUG: you must pass an argument, source table alias name");
return args.fields
.map((field) => `${this.escapeField(field)}=` +
this.escapeField(field, { withTable: a }))
.join(", ");
}
// Primary key (simple or composite).
if (c.startsWith("%PK")) {
return this.escapeField(types_1.ID, { withTable: a });
}
throw Error(`Unknown format spec "${c}" in "${template}"`);
});
}
/**
* Escapes a value at runtime using the codegen functions created above. We
* use escapers table and the codegen for the following reasons:
* 1. We want to be sure that we know in advance, how to escape all table
* fields (and not fail at runtime).
* 2. We want to make createEscapeCode() the single source of truth about
* fields escaping, even at runtime.
*/
escapeValue(field, value) {
const escaper = this.nullThrowsUnknownField(this.escapers[field], field);
return escaper(value);
}
/**
* Escapes field name identifier.
* - In case it's a composite primary key, returns its `ROW(f1,f2,...)`
* representation.
* - A field may be aliased, e.g. if `{ field: "abc", alias: "$cas.abc" }` is
* passed, then the returned value will be `"$cas.abc"`. Basically, `field`
* name is used only to verify that such field is presented in the schema.
*/
escapeField(info, { withTable, normalize } = {}) {
const [field, alias] = typeof info === "string" ? [info, info] : [info.field, info.alias];
if (this.schema.table[field]) {
const sql = withTable
? `${(0, escapeIdent_1.escapeIdent)(withTable)}.` + (0, escapeIdent_1.escapeIdent)(alias)
: (0, escapeIdent_1.escapeIdent)(alias);
return normalize ? this.normalizeSQLExpr(field, sql) : sql;
}
if (field === types_1.ID) {
return (0, escapeIdentComposite_1.escapeIdentComposite)(this.schema.uniqueKey, withTable);
}
return this.nullThrowsUnknownField(null, field);
}
/**
* Returns a newly created JS function which, when called with a row set,
* returns the following SQL clause:
*
* ```
* WITH rows(id, a, b, _key) AS (VALUES
* ((NULL::tbl).id, (NULL::tbl).a, (NULL::tbl).b, 'k0'),
* ('123', 'xyz', 'nn', 'kSome'),
* ('456', 'abc', 'nn', 'kOther'),
* ...
* )
* {suffix}
* ```
*
* For composite primary key, its parts (fields) are always prepended. The set
* of columns is passed in specs.
*/
createWithBuilder({ fields, suffix, }) {
const cols = [
...fields.map((info) => {
const [field, alias] = typeof info === "string" ? [info, info] : [info.field, info.alias];
return {
field: (0, escapeIdent_1.escapeIdent)(alias),
escapedValue: this.fmt("(NULL::%T).") + this.escapeField(field),
};
}),
{ field: "_key", escapedValue: "'k0'" },
];
// We prepend VALUES with a row which consists of all NULL values, but typed
// to the actual table's columns types. This hints PG how to cast input.
return this.createValuesBuilder({
prefix: `WITH rows(${cols.map(({ field }) => field).join(", ")}) AS (VALUES\n` +
` (${cols.map(({ escapedValue }) => escapedValue).join(", ")}),`,
indent: " ",
fields,
withKey: true,
suffix: ")\n" + suffix.replace(/^/gm, " "),
});
}
/**
* Returns a newly created JS function which, when called with a row set,
* returns the following SQL clause (when called with withKey=true):
*
* ```
* ('123', 'xyz', 'nn', 'kSome'),
* ('456', 'abc', 'nn', 'kOther'),
* ...
* )
* ```
*
* or (when called without withKey):
*
* ```
* ('123', 'xyz', 'nn'),
* ('456', 'abc', 'nn'),
* ...
* ```
*
* The set of columns is passed in fields.
*
* When the builder func is called, the actual values for some field in a row
* is extracted from the same-named prop of the row, but if a `{ field,
* rowPath }` object is passed in `fields` array, then the value is extracted
* from the `rowPath` sub-prop of the row. This is used to e.g. access
* `row.$cas.blah` value for a field named blah (in this case,
* `rowPath="$cas"`).
*
* Notice that either a simple primary key or a composite primary key columns
* are always prepended to the list of values since it makes no sense to
* generate VALUES clause without exact identification of the destination.
*/
createValuesBuilder({ prefix, indent, fields, withKey, skipSorting, suffix, }) {
const cols = fields.map((info) => {
const [field, fieldValCode] = typeof info === "string"
? [info, `$input.${info}`]
: [info.field, `$input.${info.alias}`];
const spec = this.nullThrowsUnknownField(this.schema.table[field], field);
return this.createEscapeCode(field, fieldValCode, spec.autoInsert !== undefined ? spec.autoInsert : spec.autoUpdate);
});
const rowFunc = this.newFunction("$key", "$input", "return " +
(indent ? `${JSON.stringify("\n" + indent)} +` : "") +
'"(" + ' +
cols.join(" + ', ' + ") +
(withKey ? '+ ", " + this.escapeString($key)' : "") +
'+ ")"');
return {
prefix,
func: (entries) => {
const parts = [];
for (const [key, input] of entries) {
parts.push(rowFunc(key, this.unfoldCompositePK(input)));
}
// To eliminate deadlocks in parallel batched inserts, we sort rows.
// This prevents deadlocks when two batched queries are running in
// different connections, and the table has some unique key.
if (!skipSorting) {
parts.sort();
}
return parts.join(",");
},
suffix,
};
}
/**
* Returns a newly created JS function which, when called with an object,
* returns the following SQL clause:
*
* id='123', a='xyz', b='nnn' [, {literal}]
*
* The set of columns is passed in specs, all other columns are ignored.
*/
createUpdateKVsBuilder(fields) {
const parts = fields.map((field) => JSON.stringify(this.escapeField(field) + "=") +
" + " +
this.createEscapeCode(field, `$input.${field}`, this.schema.table[field].autoUpdate));
const func = this.newFunction("$input", "return " + (parts.length ? parts.join(" + ', ' + ") : '""'));
return (input, literal) => {
const kvs = func(input);
const custom = literal ? (0, escapeLiteral_1.escapeLiteral)(literal) : "";
return kvs && custom ? `${kvs}, ${custom}` : kvs ? kvs : custom;
};
}
/**
* Prefers to do utilize createAnyBuilder() if it can (i.e. build
* a=ANY('{...}') clause). Otherwise, builds an IN(...) clause.
*/
createOneOfBuilder(field, fieldValCode = "$value") {
const specType = this.schema.table[field]?.type;
return specType === Boolean ||
specType === types_1.ID ||
specType === Number ||
specType === String
? this.createAnyBuilder(field, fieldValCode)
: this.createInBuilder(field, fieldValCode);
}
/**
* Given a list of fields, returns two builders:
*
* 1. "Optimized": a newly created JS function which, when called with a row
* set, returns one the following SQL clauses:
*
* ```
* WHERE (field1, field2) IN(VALUES
* ((NULL::tbl).field1, (NULL::tbl).field2),
* ('aa', 'bb'),
* ('cc', 'dd'))
*
* or
*
* WHERE (field1='a' AND field2='b' AND field3 IN('a', 'b', 'c', ...)) OR (...)
* ^^^^^^^^^^prefix^^^^^^^^^ ^^^^^^^^ins^^^^^^^
* ```
*
* 2. "Plain": the last one builder mentioned above (good to always use for
* non-batched queries for instance).
*/
createWhereBuildersFieldsEq(args) {
const plain = this.createWhereBuilderFieldsEqOrBased(args);
return {
plain,
optimized: args.fields.length > 1 &&
args.fields.every((field) => !this.schema.table[field].allowNull)
? this.createWhereBuilderFieldsEqTuplesBased(args)
: plain,
};
}
/**
* Returns a newly created JS function which, when called with a Where object,
* returns the generated SQL WHERE clause.
*
* - The building is relatively expensive, since it traverses the Where object
* at run-time and doesn't know the shape beforehand.
* - If the Where object is undefined, skips the entire WHERE clause.
*/
createWhereBuilder({ prefix, suffix, }) {
return {
prefix: prefix + "WHERE ",
func: (where) => this.buildWhere(this.schema.table, where, true),
suffix,
};
}
/**
* Prepends or appends a primary key to the list of fields. In case the
* primary key is plain (i.e. "id" field), it's just added as a field;
* otherwise, the unique key fields are added.
*
* For INSERT/UPSERT operations, we want to append the primary key, since it's
* often types pre-generated as a random-looking value. In many places, we
* sort batched lists of rows before e.g. inserting them, so we order them by
* their natural data order which prevents deadlocks on unique key conflict
* when multiple concurrent transactions try to insert the same set of rows in
* different order ("while inserting index tuple").
*
* For UPDATE operations though, we want to prepend the primary key, to make
* sure we run batched updates in the same order in multiple concurrent
* transactions. This lowers the chances of deadlocks too.
*/
addPK(fields, mode) {
const pkFields = this.schema.table[types_1.ID] ? [types_1.ID] : this.schema.uniqueKey;
fields = (0, difference_1.default)(fields, pkFields);
return mode === "prepend"
? [...pkFields, ...fields]
: [...fields, ...pkFields];
}
constructor(schema, client) {
super(schema.name);
this.schema = schema;
this.client = client;
this.escapers = {};
this.oneOfBuilders = {};
this.dbValueToJs = [];
this.stringify = {};
// For tables with composite primary key and no explicit "id" column, we
// still need an ID escaper (where id looks like "(1,2)" anonymous row).
for (const field of [types_1.ID, ...Object.keys(this.schema.table)]) {
const body = "return " + this.createEscapeCode(field, "$value");
this.escapers[field] = this.newFunction("$value", body);
this.oneOfBuilders[field] = this.createOneOfBuilder(field);
}
for (const [field, { type }] of Object.entries(this.schema.table)) {
if ((0, misc_1.hasKey)("dbValueToJs", type) && (0, misc_1.hasKey)("stringify", type)) {
this.dbValueToJs.push([field, type.dbValueToJs.bind(type)]);
this.stringify[field] = type.stringify.bind(type);
}
}
}
delayForSingleQueryRetryOnError(e) {
// Deadlocks may happen when a simple query involves multiple rows (e.g.
// deleting a row by ID, but this row has foreign keys, especially with ON
// DELETE CASCADE).
return e instanceof PgError_1.PgError && e.message.includes(ERROR_DEADLOCK)
? (0, random_1.default)(DEADLOCK_RETRY_MS_MIN, DEADLOCK_RETRY_MS_MAX)
: e instanceof PgError_1.PgError && e.message.includes(ERROR_CONFLICT_RECOVERY)
? "immediate_retry"
: "no_retry";
}
shouldDebatchOnError(e) {
return (
// Debatch some of SQL WRITE query errors.
(e instanceof PgError_1.PgError && e.message.includes(ERROR_DEADLOCK)) ||
(e instanceof PgError_1.PgError && e.message.includes(ERROR_FK)) ||
// Debatch "conflict with recovery" errors (we support retries only after
// debatching, so have to return true here).
(e instanceof PgError_1.PgError && e.message.includes(ERROR_CONFLICT_RECOVERY)) ||
(e instanceof PgError_1.PgError &&
!!e.cause?.code?.startsWith(ERROR_CODE_PREFIX_DATA_EXCEPTION)));
}
/**
* Given a list of fields, returns a newly created JS function which, when
* called with a row set, returns the following SQL clause:
*
* ```
* WHERE (field1='a' AND field2='b' AND field3 IN('a', 'b', 'c', ...)) OR (...)
* ^^^^^^^^^^prefix^^^^^^^^^ ^^^^^^^^ins^^^^^^^
* ```
*
* The assumption is that the last field in the list is the most variable,
* whilst all previous fields compose a more or less static prefix
*
* - ATTENTION: if at least one OR is produced, it will likely result in a
* slower Bitmap Index Scan.
* - Used in runSingle() (no ORs there) or when optimized builder is not
* available (e.g. when unique key contains nullable fields).
*/
createWhereBuilderFieldsEqOrBased({ prefix, fields, suffix, }) {
const lastField = (0, last_1.default)(fields);
// fieldN IN('aa', 'bb', 'cc', ...)
const lastFieldOneOf = this.createOneOfBuilder(lastField, `$value[1].${lastField}`);
if (fields.length === 1) {
// If we have only one field, we can use the plain oneOfBuilder (which is
// either an IN(...) or =ANY(...) clause).
return {
prefix: prefix + "WHERE ",
func: lastFieldOneOf,
suffix,
};
}
return {
prefix: prefix + "WHERE ",
func: (inputs) => {
const insByPrefix = new Map();
for (const input of inputs) {
let prefix = "";
for (let i = 0; i < fields.length - 1; i++) {
const field = fields[i];
if (prefix !== "") {
prefix += " AND ";
}
const value = input[1][field];
prefix +=
value !== null
? field + "=" + this.escapeValue(field, value)
: field + " IS NULL";
}
let ins = insByPrefix.get(prefix);
if (!ins) {
ins = [];
insByPrefix.set(prefix, ins);
}
ins.push(input);
}
let sql = "";
for (const [prefix, ins] of insByPrefix) {
if (sql !== "") {
sql += " OR ";
}
const inClause = lastFieldOneOf(ins);
if (prefix !== "") {
sql += "(" + prefix + " AND " + inClause + ")";
}
else {
sql += inClause;
}
}
return sql;
},
suffix,
};
}
/**
* Given a list of fields, returns a newly created JS function which, when
* called with a row set, returns the following SQL clause:
*
* ```
* WHERE (field1, field2) IN(VALUES
* ((NULL::tbl).field1, (NULL::tbl).field2),
* ('aa', 'bb'),
* ('cc', 'dd'))
* ```
*
* The assumption is that all fields are non-nullable.
*
* - This clause always produces an Index Scan (not Bitmap Index Scan).
* - Used in most of the cases in runBatch(), e.g. when unique key has >1
* fields, and they are all non-nullable.
*/
createWhereBuilderFieldsEqTuplesBased({ prefix, fields, suffix, }) {
const escapedFields = fields.map((f) => this.escapeField(f));
return this.createValuesBuilder({
prefix: prefix +
`WHERE (${escapedFields.join(", ")}) IN(VALUES\n` +
" (" +
escapedFields.map((f) => this.fmt(`(NULL::%T).${f}`)).join(", ") +
"),",
indent: " ",
fields,
skipSorting: true, // for JS perf
suffix: ")" + suffix,
});
}
buildWhere(specs, where, isTopLevel = false) {
const pieces = [];
for (const key of Object.keys(where)) {
const value = where[key];
if (value === undefined) {
continue;
}
if (key[0] === "$") {
continue;
}
let foundOp = false;
if ((0, misc_1.hasKey)("$gte", value)) {
pieces.push(this.buildFieldBinOp(key, ">=", value.$gte));
foundOp = true;
}
if ((0, misc_1.hasKey)("$gt", value)) {
pieces.push(this.buildFieldBinOp(key, ">", value.$gt));
foundOp = true;
}
if ((0, misc_1.hasKey)("$lte", value)) {
pieces.push(this.buildFieldBinOp(key, "<=", value.$lte));
foundOp = true;
}
if ((0, misc_1.hasKey)("$lt", value)) {
pieces.push(this.buildFieldBinOp(key, "<", value.$lt));
foundOp = true;
}
if ((0, misc_1.hasKey)("$ne", value)) {
pieces.push(this.buildFieldNe(key, value.$ne));
foundOp = true;
}
if ((0, misc_1.hasKey)("$isDistinctFrom", value)) {
pieces.push(this.buildFieldIsDistinctFrom(key, value.$isDistinctFrom));
foundOp = true;
}
if ((0, misc_1.hasKey)("$overlap", value)) {
pieces.push(this.buildFieldBinOp(key, "&&", value.$overlap));
foundOp = true;
}
if (!foundOp) {
pieces.push(this.buildFieldEq(key, value));
}
}
if ((0, misc_1.hasKey)("$and", where)) {
const clause = this.buildLogical(specs, "AND", where.$and);
if (clause.length) {
pieces.push(clause);
}
}
if ((0, misc_1.hasKey)("$or", where)) {
const clause = this.buildLogical(specs, "OR", where.$or);
if (clause.length) {
pieces.push(clause);
}
}
if ((0, misc_1.hasKey)("$not", where)) {
pieces.push(this.buildNot(specs, where.$not));
}
if ((0, misc_1.hasKey)("$literal", where)) {
// $literal clause in WHERE may look like "abc OR def", and to make sure
// this OR doesn't interfere with priorities of other operators around, we
// always wrap the literal with (). We must wrap in WHERE only, not in
// e.g. ORDER BY or CTEs.
pieces.push("(" + (0, escapeLiteral_1.escapeLiteral)(where.$literal) + ")");
}
if (!pieces.length) {
// This is for cases like { [$and]: [{}, {}] }
pieces.push("true");
}
const sql = pieces.join(" AND ");
return pieces.length > 1 && !isTopLevel ? "(" + sql + ")" : sql;
}
buildFieldBinOp(field, binOp, value) {
return this.escapeField(field) + binOp + this.escapeValue(field, value);
}
buildFieldIsDistinctFrom(field, value) {
return (this.escapeField(field) +
" IS DISTINCT FROM " +
this.escapeValue(field, value));
}
buildFieldEq(field, value) {
if (value === null) {
return this.escapeField(field) + " IS NULL";
}
else if (value instanceof Array) {
const inBuilder = this.nullThrowsUnknownField(this.oneOfBuilders[field], field);
return inBuilder(value);
}
else {
return this.escapeField(field) + "=" + this.escapeValue(field, value);
}
}
buildLogical(specs, op, items) {
const clause = op === "OR" ? " OR " : " AND ";
if (items.length === 0) {
return ` false /* Empty${clause}*/ `;
}
const sql = items.map((item) => this.buildWhere(specs, item)).join(clause);
return items.length > 1 ? "(" + sql + ")" : sql;
}
buildNot(specs, where) {
return "NOT " + this.buildWhere(specs, where);
}
buildFieldNe(field, value) {
if (value === null) {
return this.escapeField(field) + " IS NOT NULL";
}
else if (value instanceof Array) {
let andIsNotNull = false;
const pieces = [];
for (const v of value) {
if (v === null) {
andIsNotNull = true;
}
else {
pieces.push(this.escapeValue(field, v));
}
}
const sql = pieces.length
? this.escapeField(field) + " NOT IN(" + pieces.join(",") + ")"
: "true/*empty_NOT_IN*/";
return andIsNotNull
? "(" + sql + " AND " + this.escapeField(field) + " IS NOT NULL)"
: sql;
}
else {
return this.escapeField(field) + "<>" + this.escapeValue(field, value);
}
}
/**
* Returns a newly created JS function which, when called with an array of
* values, returns one of following SQL clauses:
*
* - $field=ANY('{aaa,bbb,ccc}')
* - ($field=ANY('{aaa,bbb}') OR $field IS NULL)
* - $field='aaa' (see below, why)
* - ($field='aaa' OR $field IS NULL)
* - $field IS NULL
* - false
*/
createAnyBuilder(field, fieldValCode = "$value") {
// Notes:
//
// - See arrayfuncs.c, array_out() function (needquote logic):
// https://github.com/postgres/postgres/blob/4ddfbd2/src/backend/utils/adt/arrayfuncs.c#L1136-L1156
// - Why will it work not worse (index wise) than multi-value IN():
// https://www.postgresql.org/message-id/1761901.1668657080%40sss.pgh.pa.us
// - We can't easily use a general-purpose quoting function here, because we
// must exclude nulls from the values, to add an explicit "OR IS NULL"
// clause instead.
// - We sacrifice performance a little and not quote everything blindly.
// This is to gain the generated SQL queries some more readability.
//
// Also one more thing. Imagine we have a `btree(a, b)` index. Compare two
// queries for one-element use case:
//
// 1. `a='aaa' AND b=ANY('{bbb}')`
// 2. `a='aaa' AND b IN('bbb')`
//
// They may produce different plans: IN() always coalesces to `b='bbb'` in
// the plan (and thus, to an btree index scan), whilst =ANY() always remains
// =ANY(). This causes PG to choose a "post-filtering" plan for one-element
// use case sometimes:
//
// 1. For =ANY: Index Cond: (a='aaa'); Filter: b=ANY('{bbb}') - BAD!
// 2. For IN(): Index Cond: (a='aaa') AND (b='bbb')
//
// So to be on a safe side, we never emit a one-element =ANY(); instead, we
// turn `b=ANY('{bbb}')` into `b='bbb'`.
//
const escapedFieldCode = JSON.stringify(this.escapeField(field));
const body = `
let sql = '';
let lastValue = null;
let nonNullCount = 0;
let hasIsNull = false;
for (const $value of $values) {
if (${fieldValCode} != null) {
if (sql) sql += ',';
nonNullCount++;
lastValue = "" + ${fieldValCode};
sql += lastValue.match(/^$|^NULL$|[ \\t\\n\\r\\v\\f]|["\\\\{},]/is)
? '"' + lastValue.replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"') + '"'
: lastValue;
} else {
hasIsNull = true;
}
}
if (sql) {
if (nonNullCount > 1) {
sql = '{' + sql + '}';
sql = ${escapedFieldCode} + '=ANY(' + this.escapeString(sql) + ')';
} else {
sql = ${escapedFieldCode} + '=' + this.escapeString(lastValue);
}
}
return sql && hasIsNull
? '(' + sql + ' OR ' + ${escapedFieldCode} + ' IS NULL)'
: hasIsNull
? ${escapedFieldCode} + ' IS NULL'
: sql
? sql
: 'false/*empty_ANY*/';
`;
return this.newFunction("$values", body);
}
/**
* Returns a newly created JS function which, when called with an array of
* values, returns one of following SQL clauses:
*
* - $field IN('aaa', 'bbb', 'ccc')
* - ($field IN('aaa', 'bbb') OR $field IS NULL)
* - $field IS NULL
* - false
*
* This only works for primitive types.
*/
createInBuilder(field, fieldValCode = "$value") {
const escapedFieldCode = JSON.stringify(this.escapeField(field));
const valueCode = this.createEscapeCode(field, fieldValCode);
const body = `
let sql = '';
let hasIsNull = false;
for (const $value of $values) {
if (${fieldValCode} != null) {
if (sql) sql += ',';
sql += ${valueCode};
} else {
hasIsNull = true;
}
}
if (sql) {
sql = ${escapedFieldCode} + ' IN(' + sql + ')';
}
return sql && hasIsNull
? '(' + sql + ' OR ' + ${escapedFieldCode} + ' IS NULL)'
: hasIsNull
? ${escapedFieldCode} + ' IS NULL'
: sql
? sql
: 'false/*empty_IN*/';
`;
return this.newFunction("$values", body);
}
/**
* For codegen, returns the following piece of JS code:
*
* '($fieldValCode !== undefined ? this.escapeXyz($fieldValCode) : "$defSQL")'
*
* It's expected that, while running the generated code, `this` points to an
* object with a) `escapeXyz()` functions, b) `stringify` object containing
* the table fields custom to-string converters.
*/
createEscapeCode(field, fieldValCode, defSQL) {
const specType = this.schema.table[field]?.type;
if (!specType && field !== types_1.ID) {
throw Error(`BUG: cannot find the field "${field}" in the schema`);
}
const escapeCode = specType === undefined && field === types_1.ID
? `this.escapeComposite(${fieldValCode})`
: specType === Boolean
? `this.escapeBoolean(${fieldValCode})`
: specType === Date
? `this.escapeDate(${fieldValCode}, ${JSON.stringify(field)})`
: specType === types_1.ID
? `this.escapeID(${fieldValCode})`
: specType === Number
? `this.escapeString(${fieldValCode})`
: specType === String
? `this.escapeString(${fieldValCode})`
: (0, misc_1.hasKey)("stringify", specType)
? `this.escapeStringify(${fieldValCode}, this.stringify.${field})`
: (() => {
throw Error(`BUG: unknown spec type ${specType} for field ${field}`);
})();
if (defSQL !== undefined) {
return (`(${fieldValCode} !== undefined ` +
`? ${escapeCode} ` +
`: ${JSON.stringify(defSQL)})`);
}
else {
return escapeCode;
}
}
/**
* Compiles a function body with `this` bound to some well-known properties
* which are available in the body.
*
* For each table, we compile frequently accessible pieces of code which
* serialize data in SQL format. This allows to remove lots of logic and "ifs"
* from runtime and speed up hot code paths.
*/
newFunction(...argsAndBody) {
return new Function(...argsAndBody).bind({
escapeComposite: escapeComposite_1.escapeComposite,
escapeBoolean: escapeBoolean_1.escapeBoolean,
escapeDate: escapeDate_1.escapeDate,
escapeID: escapeID_1.escapeID,
escapeString: escapeString_1.escapeString,
escapeStringify: escapeStringify_1.escapeStringify,
stringify: this.stringify,
});
}
/**
* The problem: PG is not working fine with queries like:
*
* ```
* WITH rows(composite_id, c) AS (
* VALUES
* ( ROW((NULL::tbl).x, (NULL::tbl).y), (NULL::tbl).c ),
* ( ROW(1,2), 3 ),
* ( ROW(3,4), 5 )
* )
* UPDATE tbl SET c=rows.c
* FROM rows WHERE ROW(tbl.x, tbl.y)=composite_id
* ```
*
* It cannot match the type of composite_id with the row, and even the trick
* with NULLs doesn't help it to infer types. It's a limitation of WITH clause
* (because in INSERT ... VALUES, there is no such problem).
*
* So the only solution is to parse/decompose the row string into individual
* unique key columns at runtime for batched UPDATEs. And yes, it's slow.
*
* ```
* WITH rows(x, y, c) AS (
* VALUES
* ( (NULL::tbl).x, (NULL::tbl).y, (NULL::tbl).c ),
* ( 1, 2, 3 ),
* ( 3, 4, 5 )
* )
* UPDATE tbl SET c=rows.c
* FROM rows WHERE ROW(tbl.x, tbl.y)=ROW(rows.x, ROW.y)
* ```
*/
unfoldCompositePK(inputIn) {
let input = inputIn;
if (!this.schema.table[types_1.ID] && typeof input[types_1.ID] === "string") {
const compositePK = (0, parseCompositeRow_1.parseCompositeRow)(input[types_1.ID]);
input = { ...input };
for (const [i, field] of this.schema.uniqueKey.entries()) {
input[field] = compositePK[i];
}
}
return input;
}
/**
* Some data types are different between PG and JS. Here we have a chance to
* "normalize" them. E.g. in JS, Date is truncated to milliseconds (3 digits),
* whilst in PG, it's by default of 6 digits precision (so if we didn't
* normalize, then JS Date would've been never equal to a PG timestamp).
*/
normalizeSQLExpr(field, sql) {
const spec = this.nullThrowsUnknownField(this.schema.table[field], field);
if (spec.type === Date) {
// Notice that `CAST(x AS timestamptz(3))` does ROUNDING, and we need
// TRUNCATION, since it's the default behavior of postgres-date (they
// changed it to rounding once, but then reverted intentionally) and
// node-postgres. See https://github.com/brianc/node-postgres/issues/1200
sql = `date_trunc('ms', ${sql})`;
}
return sql;
}
/**
* Throws an exception about some field being not mentioned in the table
* schema if the passed data is undefined. Notice that ID is treated as always
* available in this message.
*/
nullThrowsUnknownField(data, field) {
if (data === null || data === undefined) {
throw Error(`Unknown field: ${field}; allowed fields: ` +
[types_1.ID, ...Object.keys(this.schema.table)]);
}
else {
return data;
}
}
}
exports.PgRunner = PgRunner;
//# sourceMappingURL=PgRunner.js.map
;