UNPKG

@clickup/ent-framework

Version:

A PostgreSQL graph-database-alike library with microsharding and row-level security

877 lines 37.1 kB
"use strict"; 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