UNPKG

@kitmi/data

Version:

Jacaranda Framework Data Access Model

1,298 lines (1,117 loc) 57.7 kB
import ntol from 'number-to-letter'; import { isPlainObject, isEmpty, snakeCase, isInteger, _, countOfChar, prefixKeys } from '@kitmi/utils'; import { ApplicationError, InvalidArgument } from '@kitmi/types'; import Connector from '../../Connector'; import { isRawSql, extractRawSql, isSelectAll, xrCol } from '../../helpers'; export const buildDataSetQuery = (opOptions, dataSet, aliasMap) => { let Entity = opOptions.$entity; if (Entity == null) { throw new ApplicationError('Entity instance should be prepared in the operation options for delayed DataSet.', { dataSet, }); } if (Entity.meta.name !== dataSet.model) { Entity = Entity.db.entity(dataSet.model); } return Entity.findSql({ ...dataSet.query, $aliasMap: aliasMap }); }; class RelationalConnector extends Connector { /** * Create a new instance of the connector. * @param {App} app * @param {string} connectionString * @param {object} options * @property {boolean} [options.logStatement] - Flag to log executed SQL statement. * @property {boolean} [options.logConnection] - Flag to log connect and disconnect. * @property {boolean} [options.logTransaction] - Flag to log connect and disconnect. * @property {boolean} [options.verboseAlias] - Flag to use verbose alias. */ constructor(app, driver, connectionString, options) { super(app, driver, connectionString, options); this.relational = true; } get specParamToken() { return `$?`; } /** * Run aggregate pipeline * @param {string} model * @param {array} pipeline * @param {object} [options] * @returns {*} */ async aggregate_(model, pipeline, options, connection) { if (!Array.isArray(pipeline) || pipeline.length === 0) { throw new InvalidArgument('"pipeline" should be an unempty array.'); } const [startingQuery, ..._pipeline] = pipeline; let query = this.buildQuery(model, startingQuery); _pipeline.forEach((stage, i) => { let _params = query.params; query = this.buildQuery( { sql: query.sql, alias: `_STAGE_${i}`, }, stage ); query.params = _params.concat(query.params); }); return this._executeQuery_(query, options, connection); } /** * Build sql statement. * @param {*} model * @param {*} condition */ buildQuery(model, opOptions) { let { $assoc, $select, $where, $groupBy, $orderBy, $countBy, $offset, $limit, $hasSubQuery, $aliasMap, $skipOrm, } = opOptions; const { fromTable, withTables, model: _model } = this._buildCTEHeader(model); model = _model; let startId = $aliasMap ? Object.keys($aliasMap).length : 0; const mainAlias = ntol(startId++); const aliasMap = { ...($aliasMap && prefixKeys($aliasMap, '_.')), [model]: mainAlias }; let joinings; let mainEntityForJoin; const joiningParams = []; let directJoinings; let needDistinctForLimit = false; // build alias map first // cache params if ($assoc) { mainEntityForJoin = model; let nextId; [joinings, directJoinings, nextId] = this._joinAssociations( opOptions, $assoc, mainEntityForJoin, aliasMap, startId, joiningParams ); startId = nextId; // when limit or offset is set, distinct is required, otherwise nested records will be also be counted for limit needDistinctForLimit = $limit != null || $offset != null; } else if ($hasSubQuery) { mainEntityForJoin = model; } // Build select columns if (!$skipOrm) { if (isSelectAll($select)) { const l = model.length + 1; $select || ($select = new Set(['*'])); _.each(aliasMap, (v, k) => { if (k !== model) { $select.add(k.substring(l) + '.*'); } }); } } const selectParams = []; const selectColomns = $select ? $skipOrm ? this._buildColumns($select, selectParams, mainEntityForJoin, aliasMap) : this._buildOrmColumns($select, selectParams, mainEntityForJoin, aliasMap) : '*'; // Build from clause let fromClause = ' FROM ' + fromTable; let fromAndJoin = fromClause; if (mainEntityForJoin) { fromAndJoin += ` ${this.escapeId(mainAlias)} `; if ($assoc) { fromAndJoin = this._concatJoinClauses(fromAndJoin, directJoinings, joinings); } } // Build where clause let whereClause = ''; const whereParams = []; if ($where) { whereClause = this._joinCondition(opOptions, $where, whereParams, null, mainEntityForJoin, aliasMap); if (whereClause) { whereClause = ' WHERE ' + whereClause; } } // Build group by clause let groupByClause = ''; if ($groupBy) { groupByClause += ' ' + this._buildGroupBy($groupBy, mainEntityForJoin, aliasMap); } // Build order by clause let orderByClause = ''; if ($orderBy) { orderByClause += ' ' + this._buildOrderBy($orderBy, mainEntityForJoin, aliasMap); } // Build limit & offset clause const limitOffetParams = []; let limitOffset = this._buildLimitOffset($limit, $offset, limitOffetParams); const result = { hasJoining: $assoc != null, aliasMap, startId }; if (needDistinctForLimit) { const pKey = this._escapeIdWithAlias(opOptions.$key, mainEntityForJoin, aliasMap); const distinctFieldWithAlias = `DISTINCT(${pKey}) AS key_`; const keysSql = orderByClause ? `WITH records_ AS (SELECT ${distinctFieldWithAlias}, ROW_NUMBER() OVER(PARTITION BY ${pKey}${orderByClause}) AS row_${fromAndJoin}${whereClause}${groupByClause}) SELECT key_ FROM records_ ORDER BY row_${limitOffset}` : `WITH records_ AS (SELECT ${distinctFieldWithAlias}${fromAndJoin}${whereClause}${groupByClause}) SELECT key_ FROM records_ GROUP BY key_${limitOffset}`; let _nextId = Object.keys(aliasMap).length; const keySqlAnchor = ntol(_nextId++); const _joinings = []; const _directJoinings = []; const _joiningParams = []; this._joinAssociation( opOptions, { alias: keySqlAnchor, sql: keysSql, params: joiningParams.concat(whereParams, limitOffetParams), joinType: 'INNER JOIN', on: { [opOptions.$key]: xrCol(`${keySqlAnchor}.key_`), }, }, keySqlAnchor, _joinings, _directJoinings, model, aliasMap, _nextId, _joiningParams ); if (_joinings.length) { fromAndJoin += ' ' + _joinings.join(' '); } result.sql = withTables + 'SELECT ' + selectColomns + fromAndJoin + whereClause + groupByClause + orderByClause; result.params = selectParams.concat(joiningParams, _joiningParams, whereParams); } else { result.sql = withTables + 'SELECT ' + selectColomns + fromAndJoin + whereClause + groupByClause + orderByClause + limitOffset; result.params = selectParams.concat(joiningParams, whereParams, limitOffetParams); } if ($countBy) { result.countSql = withTables + `SELECT COUNT(*) AS count FROM (SELECT DISTINCT ` + this._escapeIdWithAlias($countBy, mainEntityForJoin, aliasMap) + fromAndJoin + whereClause + groupByClause + ')'; result.countParams = joiningParams.concat(whereParams); } return result; } _concatJoinClauses(fromAndJoin, directJoinings, joinings) { directJoinings.forEach((dj) => { fromAndJoin += `, ${dj.entity ? this.escapeId(dj.entity) : dj.sql} ${this.escapeId(dj.alias)}`; }); if (joinings.length) { fromAndJoin += joinings.join(' '); } return fromAndJoin; } /** * Build CTE header and return the select from target and CTE header * @param {string} model * @returns {object} { fromTable, withTables, model } */ _buildCTEHeader(model) { let withTables = ''; let fromTable; // CTE, used by aggregation if (typeof model === 'object') { const { sql: subSql, alias } = model; model = alias; fromTable = alias; withTables = `WITH ${this.escapeId(alias)} AS (${subSql}) `; } fromTable = this.escapeId(model); return { fromTable, withTables, model }; } /** * Generate an alias * @param {*} index * @param {*} anchor * @returns {string} */ _generateAlias(index, anchor) { if (this.options.verboseAlias) { return `${snakeCase(anchor).toUpperCase()}${index}`; } return ntol(index); } /** * Extract associations into joining clauses. * { * entity: <remote entity> * joinType: 'LEFT JOIN|INNER JOIN|FULL OUTER JOIN' * anchor: 'local property to place the remote entity' * localField: <local field to join> * remoteField: <remote field to join> * subAssocs: { ... } * } * * @param {*} opOptions * @param {*} associations * @param {*} parentAliasKey * @param {*} aliasMap * @param {*} params * @param {*} startId * @param {*} params * @returns {object} */ _joinAssociations(opOptions, associations, parentAliasKey, aliasMap, startId, params) { let joinings = []; let directJoinings = []; _.each(associations, (assocInfo, anchor) => { startId = this._joinAssociation( opOptions, assocInfo, anchor, joinings, directJoinings, parentAliasKey, aliasMap, startId, params ); }); return [joinings, directJoinings, startId]; } _joinAssociation( opOptions, assocInfo, anchor, joinings, directJoinings, parentAliasKey, aliasMap, startId, params ) { const alias = assocInfo.alias || this._generateAlias(startId++, anchor); let { joinType, on } = assocInfo; joinType || (joinType = 'LEFT JOIN'); if (assocInfo.sql) { aliasMap[parentAliasKey + '.' + alias] = assocInfo.list ? { list: true, alias } : alias; if (assocInfo.params) { params.push(...assocInfo.params); } if (on == null) { throw new InvalidArgument('Joining custom SQL relation without "on" is not supported.', { mainEntity: parentAliasKey, sql: assocInfo.sql, }); } joinings.push( `${joinType} (${assocInfo.sql}) ${this.escapeId(alias)} ON ${this._joinCondition( opOptions, on, params, null, parentAliasKey, aliasMap )}` ); return startId; } const { entity, subAssocs, list } = assocInfo; const aliasKey = parentAliasKey + '.' + anchor; aliasMap[aliasKey] = list ? { list, alias } : alias; let subJoinings; const subJoiningParams = []; if (subAssocs) { let _directJoinings = []; let nextId; [subJoinings, _directJoinings, nextId] = this._joinAssociations( opOptions, subAssocs, aliasKey, aliasMap, startId, subJoiningParams ); startId = nextId; if (_directJoinings.length) { throw new InvalidArgument('Direct joining without "on" is not supported in sub-associations.', { entity: entity, mainEntity: parentAliasKey, }); } } if (on == null) { directJoinings.push({ entity, alias, }); } else { joinings.push( `${joinType} ${this.escapeId(entity)} ${this.escapeId(alias)} ON ${this._joinCondition( opOptions, on, params, null, parentAliasKey, aliasMap )}` ); } if (subJoinings) { subJoinings.forEach((sj) => joinings.push(sj)); params.push(...subJoiningParams); } return startId; } /** * SQL condition representation * Rules: * default: * array: OR * kv-pair: AND * $all: * array: AND * $any: * kv-pair: OR * $not: * array: not ( or ) * kv-pair: not ( and ) * @param {object} opOptions * @param {object|array|string} condition - The condition object * @param {array} params - The parameters array * @param {string} joinOperator - 'AND' or 'OR' * @param {string} mainEntity - The entity name that has joining * @param {object} aliasMap - The alias map, dot separated key -> alias * @returns {string} */ _joinCondition(opOptions, condition, params, joinOperator, mainEntity, aliasMap) { if (Array.isArray(condition)) { if (!joinOperator) { joinOperator = 'OR'; } return condition .map((c) => '(' + this._joinCondition(opOptions, c, params, null, mainEntity, aliasMap) + ')') .join(` ${joinOperator} `); } if (isPlainObject(condition)) { if (condition.$xr) { return this._packValue(condition, params, mainEntity, aliasMap); } if (!joinOperator) { joinOperator = 'AND'; } return _.map(condition, (value, key) => { if (key === '$all' || key === '$and' || key.startsWith('$and_')) { // for avoiding duplicate, $and_1, $and_2 is valid if (!Array.isArray(value) && !isPlainObject(value)) { throw new InvalidArgument('"$and" operator value should be an array or plain object.', { key, value, }); } return '(' + this._joinCondition(opOptions, value, params, 'AND', mainEntity, aliasMap) + ')'; } if (key === '$any' || key === '$or' || key.startsWith('$or_')) { // for avoiding dupliate, $or_1, $or_2 is valid if (!Array.isArray(value) && !isPlainObject(value)) { throw new InvalidArgument('"$or" operator value should be an array or plain object.', { key, value, }); } return '(' + this._joinCondition(opOptions, value, params, 'OR', mainEntity, aliasMap) + ')'; } if (key === '$not' || key.startsWith('$not_')) { if (Array.isArray(value)) { if (value.length === 0) { throw new InvalidArgument('"$not" operator value should be non-empty.', { key, value, }); } return ( 'NOT (' + this._joinCondition(opOptions, value, params, null, mainEntity, aliasMap) + ')' ); } if (isPlainObject(value)) { if (isEmpty(value)) { throw new InvalidArgument('"$not" operator value should be non-empty.', { key, value, }); } return ( 'NOT (' + this._joinCondition(opOptions, value, params, null, mainEntity, aliasMap) + ')' ); } if (typeof value !== 'string') { throw new InvalidArgument('Unsupported condition expression!', { key, value, }); } return 'NOT (' + condition + ')'; } if ((key === '$expr' || key.startsWith('$expr_')) && typeof value === 'object') { if (value.$xr) { if (value.$xr === 'BinExpr') { const left = this._packValue(value.left, params, mainEntity, aliasMap); const right = this._packValue(value.right, params, mainEntity, aliasMap); return left + ` ${value.op} ` + right; } if (value.$xr === 'Raw') { if (value.params) { const numParamTokens = countOfChar(value.value, this.specParamToken); if (numParamTokens !== value.params.length) { throw new InvalidArgument( 'Parameter placeholder count mismatch in raw SQL expression.', { expected: value.params.length, actual: numParamTokens, } ); } params.push(...value.params); } return value.value; } throw new InvalidArgument('Unsupported $xr type in $expr.', { value, }); } let dataSet; let exists = true; if (value.$exist || value.$exists) { dataSet = value.$exist || value.$exists; } else if (value.$notExist || value.$notExists) { dataSet = value.$notExist || value.$notExists; exists = false; } if (typeof dataSet === 'object') { if (dataSet.$xr === 'DataSet') { const sqlInfo = buildDataSetQuery(opOptions, dataSet, aliasMap); sqlInfo.params && params.push(...sqlInfo.params); return `(${exists ? 'EXISTS' : 'NOT EXISTS'} (${sqlInfo.sql}))`; } } throw new InvalidArgument('Unsupported $expr value.', { value, }); } return this._packCondition(opOptions, key, value, params, mainEntity, aliasMap); }).join(` ${joinOperator} `); } if (typeof condition !== 'string') { throw new InvalidArgument('Unsupported condition!', { condition, }); } return condition; } /** * Build limit and offset clause * @param {*} $limit * @param {*} $offset * @param {*} params * @returns {string} '' or ' LIMIT X OFFSET Y' */ _buildLimitOffset($limit, $offset, params) { let sql = ''; if (isInteger($limit) && $limit > 0) { if (isInteger($offset) && $offset > 0) { params.push($offset); sql = ` OFFSET ${this.specParamToken} LIMIT ${this.specParamToken}`; params.push($limit); } else { params.push($limit); sql = ` LIMIT ${this.specParamToken}`; } } else if (isInteger($offset) && $offset > 0) { params.push($offset); sql = ` OFFSET ${this.specParamToken}`; } return sql; } /** * Convert the dot separated field name to alias padded and escaped field name * @param {string} fieldName - The dot separate field name or starting with "::" for skipping alias padding * @param {string} mainEntity - Only called when mainEntity != null * @param {*} aliasMap * @returns {string} */ _escapeIdWithAlias(fieldName, mainEntity, aliasMap, dontEscape, disallowList) { 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; } return this._buildAliasedColumn( aliasMap, aliasMap[aliasKey], mainEntity, fieldName, actualFieldName, dontEscape, disallowList ); } if (!mainEntity) { return fieldName === '*' ? '*' : dontEscape ? fieldName : this.escapeId(fieldName); } return this._buildAliasedColumn( aliasMap, aliasMap[mainEntity], mainEntity, fieldName, fieldName, dontEscape, disallowList ); } _buildAliasedColumn(aliasMap, alias, mainEntity, fieldName, actualFieldName, dontEscape, disallowList) { if (!alias) { throw new InvalidArgument(`Column reference "${fieldName}" not found in populated associations.`, { entity: mainEntity, aliasMap, }); } if (typeof alias === 'object') { // note: Although order by a column in a list relation is non-sense, but it may be join with a unique condition and the result is not a list /* if (disallowList && alias.list) { throw new InvalidArgument(`Reference to a column "${fieldName}" in a list relation is not allowed.`, { entity: mainEntity, aliasMap, }) } */ alias = alias.alias; } return ( (this.reservedAlias.has(alias) ? alias : this.escapeId(alias)) + '.' + (actualFieldName === '*' ? '*' : dontEscape ? actualFieldName : this.escapeId(actualFieldName)) ); } _splitColumnsAsInput(opOptions, data, params, mainEntity, aliasMap) { return _.reduce( data, (result, v, fieldName) => { const _packed = this._packSetValue(opOptions, fieldName, v, params, mainEntity, aliasMap); if (typeof _packed === 'string') { result.push(_packed); } else { result.push(..._packed); } return result; }, [] ); } /** * Pack an array of values into params and return the parameterized string with placeholders * @param {*} array * @param {*} params * @param {*} mainEntity * @param {*} aliasMap * @returns {string} */ _packArray(array, params, mainEntity, aliasMap) { return array.map((value) => this._packValue(value, params, mainEntity, aliasMap)).join(','); } /** * Pack a value into params and return the parameter placeholder * @param {*} value * @param {*} params * @param {*} mainEntity * @param {*} aliasMap * @returns {string} */ _packValue(value, params, mainEntity, aliasMap) { if (isPlainObject(value)) { if (value.$xr) { switch (value.$xr) { case 'Column': return this._escapeIdWithAlias(value.name, mainEntity, aliasMap); case 'Function': return ( value.name + '(' + (value.args ? this._packArray(value.args, params, mainEntity, aliasMap) : '') + ')' ); case 'Raw': if (value.params.length > 0) { params.push(...value.params); } return value.value; case 'BinExpr': { const left = this._packValue(value.left, params, mainEntity, aliasMap); const right = this._packValue(value.right, params, mainEntity, aliasMap); return '(' + left + ` ${value.op} ` + right + ')'; } default: throw new Error(`Unknown xeml runtime type: ${value.$xr}, value: ${JSON.stringify(value)}`); } } } params.push(value); return this.specParamToken; } /** * Pack a condition clause * * Value can be a literal or a plain condition object. * 1. fieldName, <literal> * 2. fieldName, { normal object } * * @param {object} opOptions * @param {string} fieldName - The field name in a condition object. * @param {*} value * @param {array} params - The parameters array * @param {string} mainEntity - The entity name that has joining * @param {object} aliasMap - The alias map, dot separated key -> alias * @returns {string} */ _packCondition(opOptions, fieldName, value, params, mainEntity, aliasMap) { if (value == null) { return this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ' IS NULL'; } if (Array.isArray(value)) { return this._packCondition(opOptions, fieldName, { $in: value }, params, mainEntity, aliasMap); } if (typeof value === 'object') { if (value.$xr) { return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ' = ' + this._packValue(value, params, mainEntity, aliasMap) ); } const hasOperator = _.find(Object.keys(value), (k) => k && k[0] === '$'); if (hasOperator) { return _.map(value, (v, k) => { if (k && k[0] === '$') { // operator switch (k) { case '$exist': case '$exists': return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + (v ? ' IS NOT NULL' : 'IS NULL') ); case '$notExist': case '$notExists': return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + (v ? ' IS NULL' : 'IS NOT NULL') ); case '$eq': case '$equal': return this._packCondition(opOptions, fieldName, v, params, mainEntity, aliasMap); case '$ne': case '$neq': case '$notEqual': if (v == null) { return this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ' IS NOT NULL'; } return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` <> ${this._packValue(v, params, mainEntity, aliasMap)}` ); case '$>': case '$gt': case '$greaterThan': return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` > ${this._packValue(v, params, mainEntity, aliasMap)}` ); case '$>=': case '$gte': case '$greaterThanOrEqual': return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` >= ${this._packValue(v, params, mainEntity, aliasMap)}` ); case '$<': case '$lt': case '$lessThan': return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` < ${this._packValue(v, params, mainEntity, aliasMap)}` ); case '$<=': case '$lte': case '$lessThanOrEqual': return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` <= ${this._packValue(v, params, mainEntity, aliasMap)}` ); case '$between': if (!Array.isArray(v) || v.length !== 2) { throw new InvalidArgument( 'The value should be an array with 2 elements when using "$between" operator.', { value: v, } ); } params.push(v[0]); params.push(v[1]); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` BETWEEN ${this.specParamToken} AND ${this.specParamToken}` ); case '$notBetween': if (!Array.isArray(v) || v.length !== 2) { throw new InvalidArgument( 'The value should be an array with 2 elements when using "$notBetween" operator.', { value: v, } ); } params.push(v[0]); params.push(v[1]); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` NOT BETWEEN ${this.specParamToken} AND ${this.specParamToken}` ); case '$in': if (Array.isArray(v)) { params.push(v); return this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + this.specInClause; } if (typeof v === 'object') { if (v.$xr === 'Raw') { v.params && v.params.forEach((p) => params.push(p)); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` IN (${v.value})` ); } if (v.$xr === 'DataSet') { const sqlInfo = buildDataSetQuery(opOptions, v, aliasMap); sqlInfo.params && params.push(...sqlInfo.params); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` IN (${sqlInfo.sql})` ); } } throw new InvalidArgument( 'The value should be a dataset or an array when using "$in" operator.', { value: v, } ); case '$nin': case '$notIn': if (Array.isArray(v)) { params.push(v); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + this.specNotInClause ); } if (typeof v === 'object') { if (v.$xr === 'Raw') { v.params && v.params.forEach((p) => params.push(p)); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` NOT IN (${v.value})` ); } if (v.$xr === 'DataSet') { const sqlInfo = buildDataSetQuery(opOptions, v, aliasMap); sqlInfo.params && params.push(...sqlInfo.params); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` NOT IN (${sqlInfo.sql})` ); } } throw new InvalidArgument( 'The value should be an array when using "$notIn" operator.', { value: v, } ); case '$startWith': case '$startsWith': if (typeof v !== 'string') { throw new InvalidArgument( 'The value should be a string when using "$startWith" operator.', { value: v, } ); } params.push(`${v}%`); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ' LIKE ' + this.specParamToken ); case '$endWith': case '$endsWith': if (typeof v !== 'string') { throw new InvalidArgument( 'The value should be a string when using "$endWith" operator.', { value: v, } ); } params.push(`%${v}`); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ' LIKE ' + this.specParamToken ); case '$like': case '$likes': if (typeof v !== 'string') { throw new InvalidArgument( 'The value should be a string when using "$like" operator.', { value: v, } ); } params.push(`%${v}%`); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ' LIKE ' + this.specParamToken ); case '$filter': if (typeof v !== 'object') { throw new InvalidArgument( 'The value should be an object when using "$filter" operator.', { value: v, } ); } params.push(v); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ' @> ' + this.specParamToken ); default: break; } throw new InvalidArgument(`Unsupported condition operator: "${k}"!`, { key: fieldName, value: v, }); } else { throw new InvalidArgument('Operator should not be mixed with condition value.', { key: fieldName, value, }); } }).join(' AND '); } } params.push(value); return this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ' = ' + this.specParamToken; } _packSetValue(opOptions, fieldName, value, params, mainEntity, aliasMap) { if (value == null) { return this._escapeIdWithAlias(fieldName) + ' = NULL'; } if (isPlainObject(value)) { if (value.$xr) { return ( this._escapeIdWithAlias(fieldName) + ' = ' + this._packValue(value, params, mainEntity, aliasMap) ); } const hasOperator = _.find(Object.keys(value), (k) => k && k[0] === '$'); if (hasOperator) { return _.map(value, (v, k) => { if (k && k[0] === '$') { // operator switch (k) { case '$set': if (typeof v !== 'object') { throw new InvalidArgument( 'The value should be an object when using "$set" operator.', { value: v, } ); } return _.map(v, (_v, _k) => { if (typeof _v === 'string') { _v = JSON.stringify(_v); } params.push(_v); const accessor = _k .split('.') .map((k) => `[${this.escapeValue(k)}]`) .join(''); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + accessor + ' = ' + this.specParamToken ); }).join(', '); case '$setAt': if (typeof v !== 'object') { throw new InvalidArgument( 'The value should be an object when using "$setAt" operator.', { value: v, } ); } params.push(v.value); const dontEscape = true; let index; if (typeof v.at === 'object') { if (v.at.$xr !== 'Column') { throw new InvalidArgument( 'Invalid "$set" operator value. "$at" should be a column reference or one-based index literal.', { value: v, } ); } index = this._escapeIdWithAlias(v.at.name, mainEntity, aliasMap, dontEscape); } else { if (v.at <= 0) { throw new InvalidArgument( 'Invalid "$set" operator value. "$at" should be a positive integer.', { value: v, } ); } index = v.at; } return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + `[${index}] = ` + this.specParamToken ); case '$setSlice': if (typeof v !== 'object') { throw new InvalidArgument( 'The value should be an object when using "$setSlice" operator.', { value: v, } ); } const begin = v.begin > 0 ? `${v.begin}` : ''; const end = v.end > 0 ? `:${v.end}` : ':'; params.push(v.value); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + `[${begin}${end}] = ` + this.specParamToken ); case '$case': if (typeof v !== 'object') { throw new InvalidArgument( 'The value should be an object when using "$case" operator.', { value: v, } ); } const { $when, $then, $else } = v; const whenClause = this._joinCondition( opOptions, $when, params, 'AND', mainEntity, aliasMap ); // case result requires type cast const typeCastId = this.specGetTypeCast(opOptions, fieldName); const thenClause = this.specApplyTypeCast( typeCastId, this._packValue($then, params, mainEntity, aliasMap) ); const elseClause = this.specApplyTypeCast( typeCastId, this._packValue($else, params, mainEntity, aliasMap) ); return ( this._escapeIdWithAlias(fieldName, mainEntity, aliasMap) + ` = CASE WHEN ${whenClause} THEN ${thenClause} ELSE ${elseClause} END` ); default: break; } throw new InvalidArgument(`Unsupported set value operator: "${k}"!`, { key: fieldName, value: v, }); } else { throw new InvalidArgument('Operator should not be mixed with condition value.', { key: fieldName, value, }); } }); } } params.push(value); return this._escapeIdWithAlias(fieldName) + ' = ' + this.specParamToken; } /** * Build column list * @param {array} columns * @param {array} params * @param {*} mainEntity * @param {*} aliasMap * @returns {string} */ _buildColumns(columns, params, mainEntity, aliasMap) { let colList = ''; columns.forEach((col) => (colList += this._buildColumn(col, params, mainEntity, aliasMap) + ', ')); return colList.substring(0, colList.length - 2); } /** * * @param {string|number|object} col - The column object or string, quoted string will be treated as raw SQL * @property {string} [col.alias] - The alias of the column, can be a dot separated