UNPKG

@kitmi/data

Version:

Jacaranda Framework Data Access Model

1,038 lines (866 loc) 33.6 kB
import { _, isEmpty, waitUntil_ } from '@kitmi/utils'; import { InvalidArgument, ApplicationError } from '@kitmi/types'; import { runtime, NS_MODULE } from '@kitmi/jacaranda'; import { tryRequire } from '@kitmi/sys'; import { connectionStringToObject, FIELDS_OMIT_FROM_LOG } from '../../Connector'; import RelationalConnector, { buildDataSetQuery } from '../relational/Connector'; import { isRawSql, extractRawSql, xrCol } from '../../helpers'; const pg = runtime.get(NS_MODULE, 'pg') ?? tryRequire('pg'); pg.types.setTypeParser(pg.types.builtins.TIMESTAMP, (stringValue) => stringValue?.split(' ').join('T')); const { Pool, Client, escapeLiteral, escapeIdentifier } = pg; const connSym = Symbol.for('conn'); const RESERVED_ALIAS = new Set(['EXCLUDED']); const TYPES_MAPPING = { object: 'jsonb', text: 'text', array: 'jsonb', }; /** * SQL execution sequence * FROM clause * WHERE clause * GROUP BY clause * HAVING clause * SELECT clause * ORDER BY clause */ /** * Postgres data storage connector. * @class * @extends Connector */ class PostgresConnector extends RelationalConnector { static windowFunctions = new Set([ 'CUME_DIST', 'DENSE_RANK', 'FIRST_VALUE', 'LAG', 'LAST_VALUE', 'LEAD', 'NTH_VALUE', 'NTILE', 'PERCENT_RANK', 'RANK', 'ROW_NUMBER', ]); static windowableFunctions = new Set([ 'AVG', 'BIT_AND', 'BIT_OR', 'BIT_XOR', 'COUNT', 'JSON_ARRAYAGG', 'JSON_OBJECTAGG', 'MAX', 'MIN', 'STDDEV_POP', 'STDDEV', 'STD', 'STDDEV_SAMP', 'SUM', 'VAR_POP', 'VARIANCE', 'VAR_SAMP', ]); escapeValue = escapeLiteral; escapeId = escapeIdentifier; /** * Create a new instance of the connector. * @param {App} app * @param {string} connectionString * @param {object} options */ constructor(app, connectionString, options) { super(app, 'postgres', connectionString, options); this.poolClients = new Map(); this.otherClients = new Map(); this.executedCount = 0; this.transactionId = 0; this.connectionId = 0; } /** * [override] Database sub-collection. * @member {string} */ get collection() { if (!this._dbSchema) { this._dbSchema = new URL(this.connectionString).searchParams.get('schema') || 'public'; } return this._dbSchema; } /** * [override] Get the db name to be used in cross-db join. * @member {string} */ get crossJoinDbName() { return this.collection; } get specInClause() { /* * @see https://github.com/brianc/node-postgres/wiki/FAQ#11-how-do-i-build-a-where-foo-in--query-to-find-rows-matching-an-array-of-values */ return ` = ANY (${this.specParamToken})`; // mysql ' IN (?)' } get specNotInClause() { return ` <> ALL (${this.specParamToken})`; // mysql ' NOT IN (?)' } specGetTypeCast(opOptions, fieldName) { const fieldMeta = opOptions.$entity.meta.fields[fieldName]; if (!fieldMeta) { return undefined; } if (fieldMeta.domain) { return fieldMeta.domain; } return TYPES_MAPPING[fieldMeta.type]; } specApplyTypeCast(type, value) { return type ? `${value}::${type}` : value; } get reservedAlias() { return RESERVED_ALIAS; } /** * Close all connection initiated by this connector. */ async end_() { if (this.poolClients.size > 0 || this.otherClients.size > 0) { this.app.log('info', `${this.driver}: database query in progress...`, { pooled: this.poolClients.size, other: this.otherClients.size, }); await waitUntil_( () => { return this.poolClients.size === 0 && this.otherClients.size === 0; }, 1000, 30 ); } for (const client of this.poolClients.keys()) { await this.disconnect_(client, 'connector.end_'); } this.poolClients.clear(); for (const client of this.otherClients.keys()) { await this.disconnect_(client, 'connector.end_'); } this.otherClients.clear(); if (this.pool) { await this.pool.end(); if (this.options.logConnection) { this.app.log('info', `${this.driver}: closed connection pool "${this.pool[connSym]}".`); } delete this.pool[connSym]; delete this.pool; } } /** * Create a database connection based on the default connection string of the connector and given options. * @param {Object} [options] - Extra options for the connection, optional. * @property {bool} [options.createDatabase=false] - Flag to used when creating a database. * @returns {Promise.<Client>} */ async connect_(options, transactionId, purpose) { if (options) { let connProps; if (options.createDatabase) { // remove the database from connection if (!this.options.adminCredential) { throw new InvalidArgument( `"dataSource.postgres.${this.options.connectorName}.adminCredential" is required to connect with "createDatabase" option.` ); } connProps = { username: this.options.adminCredential.username, password: this.options.adminCredential.password, database: 'postgres', }; } const csKey = connProps ? this.makeNewConnectionString(connProps) : null; if (csKey && csKey !== this.connectionString) { if (transactionId) { throw new InvalidArgument( 'Cannot create a connection with different connection string in transaction.' ); } // create standalone connection const client = new Client(connectionStringToObject(csKey, this.driver)); await client.connect(); const meta = { purpose, connectionId: ++this.connectionId }; if (this.options.logConnection) { const connStrForDisplay = this.getConnectionStringWithoutCredential(csKey); meta.connection = connStrForDisplay; this.app.log('info', `${this.driver}: create non-pool connection to "${connStrForDisplay}".`, { purpose, connectionId: this.connectionId, }); } this.otherClients.set(client, meta); return client; } } if (!this.pool) { this.pool = new Pool(connectionStringToObject(this.connectionString)); this.pool.on('error', (err) => { this.app.logError(err, `${this.driver}: unexpected error on idle postgres client`); }); if (this.options.logConnection) { const connStrForDisplay = this.getConnectionStringWithoutCredential(); this.pool[connSym] = connStrForDisplay; this.pool.on('connect', () => { this.app.log('info', `${this.driver}: create connection pool to "${connStrForDisplay}".`, { connections: this.pool.totalCount, }); }); } } const client = await this.pool.connect(); const poolClientMeta = { connection: this.pool[connSym], transactionId, purpose }; if (transactionId == null) { poolClientMeta.connectionId = ++this.connectionId; } this.poolClients.set(client, poolClientMeta); if (this.options.logConnection) { this.app.log('info', `${this.driver}: get connection from pool "${this.pool[connSym]}".`, { transactionId, connectionId: poolClientMeta.connectionId, purpose, }); } return client; } /** * Close a database connection. * @param {Client} client - Postgres client connection. */ async disconnect_(client, purpose) { if (this.poolClients.has(client)) { const meta = this.poolClients.get(client); this.poolClients.delete(client); if (this.options.logConnection) { this.app.log('info', `${this.driver}: release connection to pool "${this.pool[connSym]}".`, { purpose: meta.purpose, disconnectCause: purpose, transactionId: meta.transactionId, connectionId: meta.connectionId, }); } // return to pool return client.release(); } else if (this.otherClients.has(client)) { const meta = this.otherClients.get(client); this.otherClients.delete(client); if (this.options.logConnection) { this.app.log('info', `${this.driver}: disconnect non-pool connection to "${meta.connection}".`, { purpose: meta.purpose, disconnectCause: purpose, connectionId: meta.connectionId, }); } // not created by pool return client.end(); } this.app.log('warn', `${this.driver}: unknown client connection to disconnect.`, { disconnectCause: purpose }); } /** * Start a transaction. Must catch error and rollback if failed! * @returns {Promise.<Client>} */ async beginTransaction_(ctx, purpose) { const transactionId = ++this.transactionId; const client = await this.connect_(null, transactionId, purpose); if (this.options.logTransaction) { this.app.log('info', `${this.driver}: begins a new transaction.`, { reqId: ctx?.state.reqId, transactionId, purpose, }); } await client.query('BEGIN'); return client; } /** * Commit a transaction. [exception safe] * @param {Client} client - Postgres client connection. */ async commit_(client, ctx, purpose) { try { await client.query('COMMIT'); if (this.options.logTransaction) { this.app.log('info', `${this.driver}: committed a transaction.`, { reqId: ctx?.state.reqId, transactionId: this.poolClients.get(client).transactionId, maxTransactionId: this.transactionId, purpose, }); } } finally { await this.disconnect_(client, purpose); } } /** * Rollback a transaction. [exception safe] * @param {Client} client - Postgres client connection. */ async rollback_(client, error, ctx, purpose) { try { await client.query('ROLLBACK;'); this.app.log('error', `${this.driver}: rollbacked a transaction.`, { reqId: ctx?.state.reqId, transactionId: this.poolClients.get(client).transactionId, maxTransactionId: this.transactionId, purpose, type: error?.name, error: error.message }); } finally { await this.disconnect_(client, purpose); } } /** * Execute the sql statement. * * @param {String} sql - The SQL statement to execute. * @param {object} params - Parameters to be placed into the SQL statement. * @param {object} [options] - Execution options. * @property {string} [options.$preparedKey] - Whether to use prepared statement which is cached and re-used by connection. * @property {boolean} [options.$asArray] - To receive rows as array of columns instead of hash with column name as key. * @param {Client} [connection] - Existing connection. * @returns {Promise.<object>} */ async execute_(sql, params, options, connection) { if (options) { delete options.$entity; } if (params && params.length) { sql = this._replaceParamToken(sql, params); } let conn; const { $preparedKey, $asArray, ...connOptions } = options ?? {}; try { conn = connection ?? (await this.connect_(connOptions)); const query = { text: sql, values: params, }; if (!connOptions.$noLog && this.options.logStatement) { const meta = { ..._.omit(options, FIELDS_OMIT_FROM_LOG), params }; if (connection) { const _meta = this.poolClients.get(connection); meta.transactionId = _meta.transactionId; } else { let _meta = this.poolClients.get(conn); if (_meta) { if (_meta.transactionId != null) { meta.transactionId = _meta.transactionId; } else { meta.connectionId = _meta.connectionId; } } else { _meta = this.otherClients.get(conn); meta.connectionId = _meta.connectionId; } } if (connOptions.$ctx) { meta.reqId = connOptions.$ctx.state.reqId; } (async () => { this.app.log('info', `${this.driver}: ${sql}`, meta); })(); } if ($preparedKey) { if (typeof $preparedKey !== 'string') { throw new InvalidArgument( 'The `postgres` connector requires `$preparedKey` to be a string as a unique name of the query.' ); } query.name = $preparedKey; } if ($asArray) { query.rowMode = 'array'; } const res = await conn.query(query); this.executedCount++; const adapted = { op: res.command, data: res.rows, fields: res.fields, affectedRows: res.rowCount, }; return adapted; } catch (err) { err.info || (err.info = {}); Object.assign(err.info, _.omit(options, FIELDS_OMIT_FROM_LOG), { params }); throw err; } finally { if (conn && connection == null) { await this.disconnect_(conn, 'connector.endExecute_'); } } } /** * Ping the database to check if it is alive. * @returns {Promise.<boolean>} */ async ping_() { const res = await this.execute_('SELECT 1 AS result'); return res && res.data[0] === 1; } /** * Create a new entity. * @param {string} model * @param {object} data * @param {object} options * @param {Client} connection * @returns {object} */ async create_(model, data, options, connection) { if (!data || isEmpty(data)) { throw new InvalidArgument(`Creating with empty "${model}" data.`); } let columns = ''; let values = ''; let params = []; _.each(data, (v, k) => { columns += this.escapeId(k) + ','; values += this._packValue(v, params) + ','; }); let sql = `INSERT INTO ${this.escapeId(model)} (${columns.slice(0, -1)}) VALUES (${values.slice(0, -1)})`; if (options.$ignore) { sql += ' ON CONFLICT DO NOTHING'; } if (options.$getCreated) { sql += ` RETURNING ${options.$getCreated .map((col) => (col === '*' ? '*' : this.escapeId(col))) .join(', ')}`; } return this.execute_(sql, params, options, connection); } /** * Create a new entity from a select result. * @param {string} model * @param {object} options * @param {array} insertColumns * @param {array} uniqueKeys * @param {Client} connection * @returns {object} */ async createFrom_(model, options, insertColumns, uniqueKeys, connection) { const sqlInfo = this.buildQuery(model, options); let columns = ''; _.each(insertColumns, (v) => { columns += this.escapeId(v) + ','; }); let sql = `INSERT INTO ${this.escapeId(model)} (${columns.slice(0, -1)}) ${sqlInfo.sql}`; if (options.$ignore) { sql += ' ON CONFLICT DO NOTHING'; } else if (options.$upsert) { if (typeof options.$upsert !== 'object') { throw new InvalidArgument('"$upsert" should be an object with data to update on conclicts.'); } let conflicts = ''; uniqueKeys.forEach((k) => { conflicts += this.escapeId(k) + ','; }); sql += ` ON CONFLICT (${conflicts.slice(0, -1)}) DO UPDATE SET ` + this._splitColumnsAsInput(options, options.$upsert, sqlInfo.params, model, { [model]: model, [`${model}.EXCLUDED`]: 'EXCLUDED', }).join(', '); } if (options.$getCreated) { sql += ` RETURNING ${options.$getCreated .map((col) => (col === '*' ? '*' : this.escapeId(col))) .join(', ')}`; } return this.execute_(sql, sqlInfo.params, options, connection); } /** * Create multiple records. * @param {string} model * @param {object} fields * @param {array} records * @param {Client} connection * @returns {object} */ async createMany_(model, fields, records, connection) { if (!Array.isArray(records) || records.length === 0 || !Array.isArray(records[1])) { throw new InvalidArgument(`"records" for createMany_ should be an two-dimension array.`); } let columns = ''; let values = ''; fields.forEach((field) => { columns += this.escapeId(field) + ','; values += this.specParamToken + ','; }); values = `(${values.slice(0, -1)}),`; let valuesList = ''; const params = records.reduce((acc, record) => { valuesList += values; return acc.concat(record); }, []); let sql = `INSERT INTO ${this.escapeId(model)} (${columns.slice(0, -1)}) VALUES ${valuesList.slice(0, -1)}`; return this.execute_(sql, params, null, connection); } /** * Create a new entity or update the old one if duplicate key found. * @param {*} model * @param {*} data * @param {*} uniqueKeys * @param {*} options * @param {object} dataOnInsert - When no duplicate record exists, extra data for inserting * @returns {object} */ async upsert_(model, data, uniqueKeys, insertData, options, connection) { const dataWithoutUK = _.omit(data, uniqueKeys); if (isEmpty(dataWithoutUK)) { // if duplicate, dont need to update return this.create_(model, insertData, { ...options, $ignore: true }, connection); } let columns = ''; let conflicts = ''; let values = ''; let params = []; _.each(insertData, (v, k) => { columns += this.escapeId(k) + ','; values += this._packValue(v, params) + ','; }); uniqueKeys.forEach((k) => { conflicts += this.escapeId(k) + ','; }); let sql = `INSERT INTO ${this.escapeId(model)} (${columns.slice(0, -1)}) VALUES (${values.slice(0, -1)})`; sql += ` ON CONFLICT (${conflicts.slice(0, -1)}) DO UPDATE SET ` + this._splitColumnsAsInput(options, dataWithoutUK, params).join(', '); if (options.$getCreated) { sql += ` RETURNING ${options.$getCreated .map((col) => (col === '*' ? '*' : this.escapeId(col))) .join(', ')}`; } return this.execute_(sql, params, options, connection); } insertOne_ = this.create_; /** * Update an existing entity. * @param {string} model * @param {object} data * @param {*} options * @property {object} [options.$where] - Where conditions * @property {object} [options.$assoc] - Parsed relatinships * @property {boolean} [options.$requireSplitColumn] - Whether to use set field=value * @property {integer} [options.$limit] * @return {object} */ async update_(model, data, options, connection) { if (isEmpty(data)) { throw new InvalidArgument('Data record is empty.', { model, options: _.omit(options, FIELDS_OMIT_FROM_LOG), }); } if (!options.$where) { throw new InvalidArgument('"$where" is required for updating.', { model, options: _.omit(options, FIELDS_OMIT_FROM_LOG), }); } let sql = ''; const aliasMap = { [model]: 'A' }; const params = []; let assoc, where, fromTable; let mainEntityForJoin; if (options.$limit) { const sqlInfo = buildDataSetQuery( options, { model, query: { $select: [options.$entity.meta.keyField], ..._.pick(options, ['$where', '$offset', '$limit', '$assoc', '$groupBy', '$orderBy', '$ctx']), }, }, {} ); sqlInfo.params && params.push(...sqlInfo.params); const { fromTable: _fromTable, withTables, model: alias, } = this._buildCTEHeader({ sql: sqlInfo.sql, alias: 'B' }); sql += withTables; fromTable = _fromTable; aliasMap[`${model}.${alias}`] = 'B'; where = { [options.$entity.meta.keyField]: xrCol(`${alias}.${options.$entity.meta.keyField}`) }; mainEntityForJoin = model; } else { where = options.$where; assoc = options.$assoc; } let joinings; let directJoinings; if (assoc) { mainEntityForJoin = model; [joinings, directJoinings] = this._joinAssociations(options, assoc, mainEntityForJoin, aliasMap, 1, params); } sql += 'UPDATE ' + this.escapeId(model); const escapedMainAlias = this.escapeId('A'); if (mainEntityForJoin) { sql += ` ${escapedMainAlias} `; if (assoc) { sql = this._concatJoinClauses(sql, directJoinings, joinings); } } sql += ' SET ' + this._splitColumnsAsInput(options, data, params, mainEntityForJoin, aliasMap).join(', '); if (fromTable) { sql += ` FROM ${fromTable}`; } const whereClause = this._joinCondition(options, where, params, null, mainEntityForJoin, aliasMap); if (whereClause) { sql += ' WHERE ' + whereClause; } if (options.$getUpdated) { let returningFields = options.$getUpdated; if (returningFields === true) { returningFields = ['*']; } sql += ` RETURNING ${ mainEntityForJoin ? returningFields .map((col) => escapedMainAlias + '.' + (col === '*' ? '*' : this.escapeId(col))) .join(', ') : returningFields.map((col) => (col === '*' ? '*' : this.escapeId(col))).join(', ') }`; } return this.execute_(sql, params, options, connection); } /** * Remove an existing entity. * @param {string} model * @param {*} deleteOptions * @param {*} options */ async delete_(model, options, connection) { const aliasMap = { [model]: 'A' }; let mainEntityForJoin; let sql = ''; let params = []; let joinings; let directJoinings; if (options.$assoc) { mainEntityForJoin = model; [joinings, directJoinings] = this._joinAssociations( options, options.$assoc, mainEntityForJoin, aliasMap, 1, params ); } sql += 'DELETE FROM ' + this.escapeId(model); if (mainEntityForJoin) { sql += ' A '; if (options.$assoc) { sql = this._concatJoinClauses(sql, directJoinings, joinings); } } const whereClause = this._joinCondition(options, options.$where, params, null, mainEntityForJoin, aliasMap); if (whereClause) { sql += ' WHERE ' + whereClause; } if (options.$getDeleted) { let returningFields = options.$getDeleted; if (returningFields === true) { returningFields = ['*']; } sql += ` RETURNING ${returningFields.map((col) => (col === '*' ? '*' : this.escapeId(col))).join(', ')}`; } return this.execute_(sql, params, options, connection); } /** * Perform select operation. * @param {string} model * @param {object} findOptions * @param {Client} connection */ async find_(model, findOptions, connection) { const sqlInfo = this.buildQuery(model, findOptions); return this._executeQuery_(sqlInfo, findOptions, connection); } _buildOrmColumns(columns, params, mainEntity, aliasMap) { // _ is for the main entity const columnsSet = { _: [] }; columns.forEach((col) => this._buildOrmColumn(col, mainEntity, aliasMap, columnsSet)); const columnList = []; columnsSet._.forEach((col) => { if (typeof col === 'string') { columnList.push(col); } else { columnList.push(col.clause); params.push(...col.params); } }); delete columnsSet._; _.each(columnsSet, (cols, alias) => { if (cols.has('*')) { columnList.push(`row_to_json(${this.escapeId(alias)}.*) AS ${this.escapeId(alias + '$')}`); return; } const list = []; cols.forEach((col) => { list.push(`'${col}', ${this.escapeId(alias)}.${this.escapeId(col)}`); }); const fieldSelect = `json_build_object(${list.join(', ')}) AS ${this.escapeId(alias + '$')}`; columnList.push(fieldSelect); }); return columnList.join(', '); } _buildOrmColumn(col, mainEntity, aliasMap, columnsSet) { if (typeof col === 'string') { // it's a string if it's quoted when passed in if (isRawSql(col)) { columnsSet._.push(extractRawSql(col)); return; } let [alias, fieldName] = this._getFieldNameWithAlias(col, mainEntity, aliasMap); if (alias === '_') { columnsSet._.push(fieldName); return; } if (typeof alias === 'object') { alias = alias.alias; } columnsSet[alias] || (columnsSet[alias] = new Set()); if (fieldName === '*' && columnsSet[alias].size > 0) { columnsSet[alias] = new Set(); } else if (columnsSet[alias].has('*')) { return; } columnsSet[alias].add(fieldName); return; } if (typeof col === 'number') { throw new InvalidArgument('Number literal is not supported in ORM query.'); } if (typeof col === 'object' && col.$xr) { if (col.alias) { const params = []; const fieldSelect = this._buildColumn(_.omit(col, ['alias']), params, mainEntity, aliasMap) + ' AS ' + this.escapeId(col.alias); columnsSet._.push({ clause: fieldSelect, params }); return; } throw new InvalidArgument('Column object without an alias is not supported in ORM query.'); } throw new ApplicationError(`Unknown column syntax: ${JSON.stringify(col)}`); } _getFieldNameWithAlias(fieldName, mainEntity, aliasMap) { if (fieldName.startsWith('::')) { // ::fieldName for skipping alias padding return ['_', this.escapeId(fieldName.substring(2))]; } const rpos = fieldName.lastIndexOf('.'); if (rpos > 0) { const actualFieldName = fieldName.substring(rpos + 1); const basePath = fieldName.substring(0, rpos); let aliasKey; if (basePath.startsWith('_.')) { aliasKey = basePath; } else { if (!mainEntity) { throw new InvalidArgument( 'Cascade alias is not allowed when the query has no associated entity populated.', { alias: fieldName, } ); } aliasKey = mainEntity + '.' + basePath; } const alias = aliasMap[aliasKey]; if (!alias) { throw new InvalidArgument(`Column reference "${fieldName}" not found in populated associations.`, { entity: mainEntity, aliasMap, }); } return [alias, actualFieldName]; } if (mainEntity) { let alias = aliasMap[mainEntity]; if (typeof alias === 'object') { alias = alias.alias; } return ['_', this.escapeId(alias) + '.' + (fieldName === '*' ? '*' : this.escapeId(fieldName))]; } return ['_', fieldName === '*' ? '*' : this.escapeId(fieldName)]; } _replaceParamToken(sql, params) { return params.reduce((_sql, p, i) => { return _sql.replace(`$?`, `$${i + 1}`); }, sql); } /** * Execute the read query. * @param {*} query * @param {*} queryOptions * @param {*} options * @param {*} connection * @returns {object} { data, affectedRows, fields, [reverseAliasMap], [totalCount] } */ async _executeQuery_(query, queryOptions, connection) { let result, totalCount; if (query.countSql) { const res = await this.execute_(query.countSql, query.countParams, queryOptions, connection); totalCount = parseInt(res.data[0].count, 10); } let options = queryOptions; if (query.hasJoining) { if (!queryOptions.$skipOrm) { // force to return as array options.$asArray = true; //todo: changed to json_build_object //const wrappedSql = `SELECT row_to_json(_row) FROM (${query.sql}) _row`; result = await this.execute_(query.sql, query.params, options, connection); const reverseAliasMap = _.reduce( query.aliasMap, (result, alias, nodePath) => { const _path = nodePath .split('.') .slice( 1 ); /* .map(n => ':' + n) changed to be padding by orm and can be customized with other key getter */ if (typeof alias === 'object') { result[alias.alias] = _path; } else { result[alias] = _path; } return result; }, {} ); if (query.countSql) { return { ...result, aliases: reverseAliasMap, totalCount }; } return { ...result, aliases: reverseAliasMap }; } if (!queryOptions.$skipOrmWarn) { this.app.log('warn', `${this.driver}: skip ORM for joining query may cause unexpected result.`, { query, }); } } result = await this.execute_(query.sql, query.params, options, connection); if (query.countSql) { return { ...result, totalCount }; } return result; } } PostgresConnector.driverLib = pg; export default PostgresConnector;