x2node-dbos
Version:
SQL database operations.
1,635 lines (1,435 loc) • 46.9 kB
JavaScript
'use strict';
const common = require('x2node-common');
const rsparser = require('x2node-rsparser');
const selectQueryBuilder = require('./select-query-builder.js');
/////////////////////////////////////////////////////////////////////////////////
// FILTER PARAMETERS HANDLER
/////////////////////////////////////////////////////////////////////////////////
/**
* Filter parameters handler used by a DBO. Any DBO that acts on a filtered set
* of records (all except the insert) use a filter parameters handler to replace
* parameter references in the SQL statements with the values provided to the DBO
* execution method.
*
* @protected
* @memberof module:x2node-dbos
* @inner
*/
class FilterParamsHandler {
/**
* Create new parameters handler for a DBO.
*/
constructor() {
this._nextParamRef = 0;
this._params = new Map();
}
/**
* Add parameter mapping.
*
* @param {string} paramName Parameter name.
* @param {function} valueFunc Function that given the parameter value
* provided with DBO call returns the ES literal for the value to include
* in the database statement.
* @returns {string} Parameter placeholder to include in the SQL in the
* form of "?{ref}", where "ref" is the parameter reference assigned by the
* handler.
*/
addParam(paramName, valueFunc) {
const paramRef = String(this._nextParamRef++);
this._params.set(paramRef, {
name: paramName,
valueFunc: valueFunc
});
return '?{' + paramRef + '}';
}
/**
* Get parameter value SQL expression.
*
* @param {module:x2node-dbos.DBDriver} dbDriver DB driver.
* @param {*} paramValue Parameter value from the DBO specification.
* @param {function} valueFunc Parameter value function (see
* <code>addParam</code> method).
* @param {string} paramName Parameter name (for error reporting only).
* @returns {string} Parameter value SQL expression to include in the
* database statement.
*/
paramValueToSql(dbDriver, paramValue, valueFunc, paramName) {
const invalid = () => new common.X2UsageError(
'Invalid ' + (paramName ? '"' + paramName + '"' : '') +
' filter test parameter value: ' + String(paramValue) + '.');
let preSql;
if (valueFunc) {
if ((paramValue === undefined) || (paramValue === null) || (
((typeof paramValue) === 'number') &&
!Number.isFinite(paramValue)) ||
Array.isArray(paramValue))
throw invalid();
preSql = valueFunc(paramValue);
} else {
preSql = paramValue;
}
const sql = dbDriver.sql(preSql);
if ((sql === null) || (sql === 'NULL'))
throw invalid();
return sql;
}
/**
* Get parameter value SQL expression given the parameter reference.
*
* @param {module:x2node-dbos.DBDriver} dbDriver DB driver.
* @param {Object.<string,*>} filterParams Parameters provided with the DBO
* execution call. Will throw error if not provided.
* @param {string} paramRef Parameter reference from the SQL.
* @returns {string} Parameter value SQL expression to include in the
* database statement.
* @throws {module:x2node-common.X2UsageError} If no value for the specified
* parameter reference.
*/
paramSql(dbDriver, filterParams, paramRef) {
const ref = this._params.get(paramRef);
const filterParam = (ref && filterParams && filterParams[ref.name]);
if (filterParam === undefined)
throw new common.X2UsageError(
`Missing filter parameter "${ref.name}".`);
const process = v => this.paramValueToSql(
dbDriver, v, ref.valueFunc, ref.name);
return (
Array.isArray(filterParam) ?
filterParam.map(v => process(v)).join(', ') :
process(filterParam)
);
}
}
/////////////////////////////////////////////////////////////////////////////////
// COMMON COMMANDS
/////////////////////////////////////////////////////////////////////////////////
/**
* Interface for DBO commands.
*
* @protected
* @interface DBOCommand
* @memberof module:x2node-dbos
*/
/**
* Queue up the command execution.
*
* @function module:x2node-dbos.DBOCommand#queueUp
* @param {Promise} promiseChain DBO execution result promise chain.
* @param {module:x2node-dbos~DBOExecutionContext ctx DBO execution context.
* @returns {Promise} New result promise chain with the command queued up.
*/
/**
* Command that simply executes the configured SQL statement.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @implements module:x2node-dbos.DBOCommand
*/
class ExecuteStatementCommand {
constructor(stmt, stmtId) {
this._stmt = stmt;
this._stmtId = stmtId;
}
// add command execution to the chain
queueUp(promiseChain, ctx) {
const stmt = this._stmt;
const stmtId = this._stmtId;
return promiseChain.then(
() => new Promise((resolve, reject) => {
let sql;
try {
sql = ctx.replaceParams(stmt);
ctx.log(`executing SQL: ${sql}`);
ctx.dbDriver.executeUpdate(
ctx.connection, sql, {
onSuccess(affectedRows) {
ctx.affectedRows(affectedRows, stmtId);
resolve();
},
onError(err) {
common.error(
`error executing SQL [${sql}]`, err);
reject(err);
}
}
);
} catch (err) {
common.error(`error executing SQL [${sql || stmt}]`, err);
reject(err);
}
}),
err => Promise.reject(err)
);
}
}
/**
* Index for the next anchor table name.
*
* @private
* @type {number}
*/
let g_nextAnchorTableInd = 0;
/**
* Command used to load matching record ids into a temporary table for a
* multi-branch fetch operation.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @implements module:x2node-dbos.DBOCommand
*/
class LoadAnchorTableCommand {
constructor(topTableName, idColumnName, idExpr, statementStump) {
this._topTableName = topTableName;
this._anchorTableName = `q_${topTableName}_${g_nextAnchorTableInd++}`;
this._idColumnName = idColumnName;
this._idExpr = idExpr;
this._statementStump = statementStump;
}
// add command execution to the chain
queueUp(promiseChain, ctx) {
const cmd = this;
return promiseChain.then(
() => new Promise((resolve, reject) => {
try {
let lastSql;
ctx.dbDriver.selectIntoAnchorTable(
ctx.connection, cmd._anchorTableName,
cmd._topTableName, cmd._idColumnName, cmd._idExpr,
ctx.replaceParams(cmd._statementStump), {
trace(sql) {
lastSql = sql;
ctx.log(`executing SQL: ${sql}`);
},
onSuccess(numRows) {
ctx.log(
`anchor table ${this._anchorTableName} has` +
` ${numRows} rows`);
resolve();
},
onError(err) {
common.error(
`error executing SQL [${lastSql}]`, err);
reject(err);
}
}
);
} catch (err) {
common.error('error loading anchor table', err);
reject(err);
}
}),
err => Promise.reject(err)
);
}
/**
* Anchor table name.
*
* @member {string}
* @readonly
*/
get anchorTableName() { return this._anchorTableName; }
}
/**
* Property value generator command. When executed, calls generator function
* associated with the configured property and adds it to the DBO execution
* context as a generared param using the property path as the param name.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @implements module:x2node-dbos.DBOCommand
*/
class GeneratorCommand {
/**
* Create new command.
*
* @param {module:x2node-records~PropertyDescriptor} propDesc Generated
* property descriptor.
*/
constructor(propDesc) {
this._propDesc = propDesc;
}
// add command execution to the chain
queueUp(promiseChain, ctx) {
const propDesc = this._propDesc;
const propPath = propDesc.container.nestedPath + propDesc.name;
return promiseChain.then(
() => {
try {
const val = propDesc.generator(ctx.connection);
if (val instanceof Promise)
return val.then(
resVal => {
ctx.addGeneratedParam(propPath, resVal);
},
err => {
common.error(
`error generating property ${propPath}` +
` value`, err);
return Promise.reject(err);
}
);
ctx.addGeneratedParam(propPath, val);
} catch (err) {
common.error(
`error generating property ${propPath} value`, err);
return Promise.reject(err);
}
},
err => Promise.reject(err)
);
}
}
/**
* Assigned id command. When executed, takes the property value from the provided
* record data and adds it to the DBO execution context as a generared param
* using the property path as the param name.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @implements module:x2node-dbos.DBOCommand
*/
class AssignedIdCommand {
/**
* Create new command.
*
* @param {module:x2node-records~PropertyDescriptor} propDesc Id property
* descriptor.
* @param {Object} data The object data.
*/
constructor(propDesc, data) {
this._propDesc = propDesc;
this._idVal = data[propDesc.name];
if ((this._idVal === undefined) || (this._idVal === null))
throw new common.X2UsageError(
`No value provided for non-generated id property ` +
`${propDesc.container.nestedPath}${propDesc.name}.`);
}
// add command execution to the chain
queueUp(promiseChain, ctx) {
return promiseChain.then(
() => {
ctx.addGeneratedParam(
this._propDesc.container.nestedPath + this._propDesc.name,
this._idVal
);
},
err => Promise.reject(err)
);
}
}
/**
* SQL <code>INSERT</code> statement command. When executed, builds a SQL
* <code>INSERT</code> statement for the configured table using column/value
* pairs added to the command and executes the statement.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @implements module:x2node-dbos.DBOCommand
*/
class InsertCommand {
/**
* Create new command.
*
* @param {string} table The table.
*/
constructor(table) {
this._table = table;
this._columns = new Array();
this._values = new Array();
}
/**
* Add column/value pair to the statement's <code>SET</code> clause.
*
* @param {string} column Column name.
* @param {string} value Value SQL expression.
*/
add(column, value) {
this._columns.push(column);
this._values.push(value);
}
// add command execution to the chain
queueUp(promiseChain, ctx) {
const stmt = this._buildStatement();
return promiseChain.then(
() => new Promise((resolve, reject) => {
let sql;
try {
sql = ctx.replaceParams(stmt);
ctx.log(`executing SQL: ${sql}`);
ctx.dbDriver.executeInsert(
ctx.connection, sql, {
onSuccess() {
resolve();
},
onError(err) {
common.error(
`error executing SQL [${sql}]`, err);
reject(err);
}
});
} catch (err) {
common.error(`error executing SQL [${sql || stmt}]`, err);
reject(err);
}
}),
err => Promise.reject(err)
);
}
/**
* Build SQL <code>INSERT</code> statement.
*
* @protected
* @returns {string} The SQL statement (may contain param placeholders).
*/
_buildStatement() {
return 'INSERT INTO ' + this._table + ' (' +
this._columns.join(', ') + ') VALUES (' +
this._values.join(', ') + ')';
}
}
/**
* SQL <code>INSERT</code> statement with auto-generated id command. Similar to
* {module:x2node-dbos~InsertCommand}, but upon execution sets the generated by
* the database id value for the new record or nested object into the record
* object and also adds it to the DBO execution context as a generared param
* using the id property path as the param name.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @extends module:x2node-dbos~InsertCommand
*/
class InsertWithGeneratedIdCommand extends InsertCommand {
/**
* Create new command.
*
* @param {string} table The table.
* @param {module:x2node-records~PropertyDescriptor} propDesc Auto-generated
* id property descriptor.
* @param {Object} obj Object, into which to set the generated id property
* after the command is executed.
*/
constructor(table, propDesc, obj) {
super(table);
this._propDesc = propDesc;
this._obj = obj;
}
// add command execution to the chain
queueUp(promiseChain, ctx) {
const stmt = this._buildStatement();
const propDesc = this._propDesc;
const propPath = propDesc.container.nestedPath + propDesc.name;
const obj = this._obj;
return promiseChain.then(
() => new Promise((resolve, reject) => {
let sql;
try {
sql = ctx.replaceParams(stmt);
ctx.log(`executing SQL: ${sql}`);
ctx.dbDriver.executeInsert(
ctx.connection, sql, {
onSuccess(rawId) {
const id = rsparser.extractValue(
propDesc.scalarValueType, rawId);
obj[propDesc.name] = id;
ctx.addGeneratedParam(propPath, id);
resolve();
},
onError(err) {
common.error(
`error executing SQL [${sql}]`, err);
reject(err);
}
}, propDesc.column);
} catch (err) {
common.error(`error executing SQL [${sql || stmt}]`, err);
reject(err);
}
}),
err => Promise.reject(err)
);
}
}
/**
* Update entangled records command executed at the end of the DBO. When
* executed, the command takes the updated entangled records information from the
* DBO execution context, generates the appropriate SQL <code>UPDATE</code>
* statements and executes them.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @implements module:x2node-dbos.DBOCommand
*/
class UpdateEntangledRecordsCommand {
constructor(updatedRecordTypeNames) {
this._updatedRecordTypeNames = updatedRecordTypeNames;
}
// add command execution to the chain
queueUp(promiseChain, ctx) {
return promiseChain.then(
() => {
// pre-resolve the completion promise
let resPromise = Promise.resolve();
// go over entangled record types and generate the updates
const sets = new Array();
for (let recordTypeName in ctx.entangledUpdates) {
// get the entangled record ids
const ids = ctx.entangledUpdates[recordTypeName];
if (ids.size === 0)
continue;
// get record type descriptor
const recordTypeDesc = ctx.recordTypes.getRecordTypeDesc(
recordTypeName);
// check if has anything to update and build SET clause
getModificationMetaInfoSets(sets, recordTypeDesc);
if (sets.length === 0)
continue;
// register record type update
if (this._updatedRecordTypeNames)
this._updatedRecordTypeNames.add(recordTypeName);
// build the UPDATE statement
const idColumn = recordTypeDesc.getPropertyDesc(
recordTypeDesc.idPropertyName).column;
const stmt =
'UPDATE ' + recordTypeDesc.table + ' SET ' + sets.map(
s => `${s.columnName} = ${s.value}`).join(', ') +
' WHERE ' + (
ids.size === 1 ?
idColumn + ' = ' + ctx.dbDriver.sql(
ids.values().next().value) :
idColumn + ' IN (' + Array.from(ids).map(
v => ctx.dbDriver.sql(v)).join(', ') + ')'
);
// queue up UPDATE statement execution
resPromise = resPromise.then(
() => new Promise((resolve, reject) => {
let sql;
try {
sql = ctx.replaceParams(stmt);
ctx.log(`executing SQL: ${sql}`);
ctx.dbDriver.executeUpdate(ctx.connection, sql, {
onSuccess() {
resolve();
},
onError(err) {
common.error(
`error executing SQL [${sql}]`, err);
reject(err);
}
});
} catch (err) {
common.error(
`error executing SQL [${sql || stmt}]`, err);
reject(err);
}
}),
err => Promise.reject(err)
);
}
// return the completion promise
return resPromise;
},
err => Promise.reject(err)
);
}
}
/**
* Symbol used to store a set on the transaction that keeps track of modification
* of what record collections has been already reported to the monitor during
* transaction.
*
* @private
*/
const UPDATED_COLLECTIONS = Symbol('UPDATED_COLLECTIONS');
/**
* Call record collections monitor and notify it about a record collections
* update.
*
* @private
* @memberof module:x2node-dbos
* @inner
* @implements module:x2node-dbos.DBOCommand
*/
class NotifyRecordCollectionsMonitorCommand {
constructor(monitor, recordTypeNames) {
this._monitor = monitor;
this._recordTypeNames = recordTypeNames;
}
// add command execution to the chain
queueUp(promiseChain, ctx) {
if (!this._monitor)
return promiseChain;
return promiseChain.then(() => {
let updatedCollections = ctx.transaction[UPDATED_COLLECTIONS];
if (!updatedCollections) {
updatedCollections = ctx.transaction[UPDATED_COLLECTIONS] =
new Set();
}
const namesToAdd = new Set();
for (let name of this._recordTypeNames) {
if (!updatedCollections.has(name)) {
updatedCollections.add(name);
namesToAdd.add(name);
}
}
if (namesToAdd.size > 0)
return this._monitor.collectionsUpdated(ctx, namesToAdd);
});
}
}
/////////////////////////////////////////////////////////////////////////////////
// THE ABSTRACT DBO
/////////////////////////////////////////////////////////////////////////////////
/**
* Get elements of an UPDATE statement's SET clause for updating record
* modification meta-info.
*
* @function module:x2node-dbos~AbstractDBO.getModificationMetaInfoSets
* @param {Array} [sets] Array to use for the SET clause elements. If provided,
* it is cleared before adding the SET clause elements. If not provided, a new
* array is created.
* @param {module:x2node-records~RecordTypeDescriptor} recordTypeDesc Record type
* descriptor.
* @param {string} [tableAlias] Alias of the record type main table in the UPDATE
* statement, or nothing is not used.
* @returns {Array} SET clause elements, or empty array is no record modification
* meta-info on the record type.
*/
function getModificationMetaInfoSets(sets, recordTypeDesc, tableAlias) {
if (sets)
sets.length = 0;
else
sets = new Array();
let propName = recordTypeDesc.getRecordMetaInfoPropName('version');
if (propName) {
const column = recordTypeDesc.getPropertyDesc(propName).column;
sets.push({
columnName: column,
value: (tableAlias ? tableAlias + '.' : '') + column + ' + 1'
});
}
propName = recordTypeDesc.getRecordMetaInfoPropName('modificationTimestamp');
if (propName)
sets.push({
columnName: recordTypeDesc.getPropertyDesc(propName).column,
value: '?{ctx.executedOn}'
});
propName = recordTypeDesc.getRecordMetaInfoPropName('modificationActor');
if (propName)
sets.push({
columnName: recordTypeDesc.getPropertyDesc(propName).column,
value: '?{ctx.actor}'
});
return sets;
}
/**
* Abstract database operation implementation.
*
* @protected
* @memberof module:x2node-dbos
* @inner
* @abstract
*/
class AbstractDBO {
/**
* Create new DBO.
*
* @constructor
* @param {module:x2node-dbos.DBDriver} dbDriver The database driver.
* @param {module:x2node-records~RecordTypesLibrary} recordTypes Record types
* library.
* @param {module:x2node-dbos.RecordCollectionsMonitor} [rcMonitor] The
* record collections monitor.
*/
constructor(dbDriver, recordTypes, rcMonitor) {
/**
* Database driver.
*
* @protected
* @member {module:x2node-dbos.DBDriver} module:x2node-dbos~AbstractDBO#_dbDriver
*/
this._dbDriver = dbDriver;
/**
* The record types library.
*
* @protected
* @member {module:x2node-records~RecordTypesLibrary} module:x2node-dbos~AbstractDBO#_recordTypes
*/
this._recordTypes = recordTypes;
this._rcMonitor = rcMonitor;
this._updatedRecordTypeNames = (rcMonitor ? new Set() : null);
/**
* Debug logger.
*
* @protected
* @member {function} module:x2node-dbos~AbstractDBO#_log
*/
this._log = common.getDebugLogger('X2_DBO');
/**
* Filter parameters handler.
*
* @protected
* @member {module:x2node-dbos~FilterParamsHandler} module:x2node-dbos~AbstractDBO#_paramsHandler
*/
this._paramsHandler = new FilterParamsHandler();
/**
* Tells if actor is required for the DBO execution. Initially is set to
* <code>false</code> allowing anonymous DBO executions.
*
* @protected
* @member {boolean} module:x2node-dbos~AbstractDBO#_actorRequired
*/
this._actorRequired = false;
}
/////////////////////////////////////////////////////////////////////////////
// DBO commands construction helper methods
/////////////////////////////////////////////////////////////////////////////
// make it a static class function so it gets exported
static getModificationMetaInfoSets(sets, recordTypeDesc, tableAlias) {
return getModificationMetaInfoSets(sets, recordTypeDesc, tableAlias);
}
/**
* Build a DBO command that simply executes the specified SQL statement. The
* command also calls context's
* [affectedRows()]{@link module:x2node-dbos~DBOExecutionContext#affectedRows}
* method upon completion.
*
* @protected
* @param {string} stmt The SQL statement (may contain param placeholders).
* @param {*} [stmtId] DBO-specific statement id, if any.
* @returns {module:x2node-dbos.DBOCommand} The command.
*/
_createExecuteStatementCommand(stmt, stmtId) {
return new ExecuteStatementCommand(stmt, stmtId);
}
/**
* Build a DBO command that loads record ids into a temporary anchor table
* used for multi-branch fetches. The anchor table name is available on the
* resulting command. The table has two columns: "id" and "ord".
*
* @protected
* @param {module:x2node-dbos~QueryTreeNode} idsQueryTree Query tree for the
* record ids.
* @param {?module:x2node-dbos~RecordsFilter} [filter] Optional filter for
* the ids <code>SELECT</code> query.
* @param {?module:x2node-dbos~RecordsOrder} [order] Optional order for the
* ids <code>SELECT</code> query.
* @param {?module:x2node-dbos~RecordsRange} [range] Optional range for the
* ids <code>SELECT</code> query.
* @param {?string} [lockType] Optional lock type: "exclusive" or "shared".
* @returns {module:x2node-dbos.DBOCommand} The command.
*/
_createLoadAnchorTableCommand(idsQueryTree, filter, order, range, lockType) {
const idsQuery = this._assembleSelect(idsQueryTree, filter, order);
let idsQuerySql = idsQuery.toSql(true);
if (range)
idsQuerySql = this._dbDriver.makeRangedSelect(
idsQuerySql, range.offset, range.limit);
if (lockType) {
const exclusiveLockTables = new Array();
const sharedLockTables = new Array();
idsQuery.getTablesForLock(
lockType, exclusiveLockTables, sharedLockTables);
idsQuerySql = this._dbDriver.makeSelectWithLocks(
idsQuerySql, exclusiveLockTables, sharedLockTables);
}
return new LoadAnchorTableCommand(
idsQueryTree.table, idsQueryTree.keyColumn,
idsQuery.getIdValueExpr(), idsQuerySql);
}
/**
* Assemble a <code>SELECT</code> query.
*
* @protected
* @param {module:x2node-dbos~QueryTreeNode} idsQueryTree Query tree.
* @param {?module:x2node-dbos~RecordsFilter} [filter] Optional filter.
* @param {?module:x2node-dbos~RecordsOrder} [order] Optional order.
* @returns {module:x2node-dbos~SelectQuery} The query builder.
*/
_assembleSelect(queryTree, filter, order) {
return selectQueryBuilder.assembleSelect(
queryTree, queryTree.getTopTranslationContext(this._paramsHandler),
filter, order);
}
/**
* Build DBO commands used to insert records and values and add them to the
* provided commands sequence.
*
* @protected
* @param {Array.<module:x2node-dbos.DBOCommand>} commands The commands
* sequence, to which to add the insert commands.
* @param {string} table The table.
* @param {?string} parentIdColumn Parent id column, or <code>null</code> for
* the top record type table.
* @param {string} [parentIdPropPath] If <code>parentIdColumn</code> is
* provided, this is the path to the id property in the parent container.
* @param {module:x2node-records~PropertyDescriptor} [colPropDesc] If
* commands are created for inserting nested objects into a nested object
* array or map property, then this is the array or the map property
* descriptor.
* @param {(number|string)} [keyVal] Array element index or map key value if
* <code>colPropDesc</code> is provided.
* @param {module:x2node-records~PropertiesContainer} container Top container
* for the table.
* @param {Object} data Object matching the container.
*/
_createInsertCommands(
commands, table, parentIdColumn, parentIdPropPath, colPropDesc, keyVal,
container, data) {
// create insert command
let insertCmd, idPropPath;
if (container.idPropertyName) {
const idPropDesc = container.getPropertyDesc(
container.idPropertyName);
idPropPath = idPropDesc.container.nestedPath + idPropDesc.name;
if (idPropDesc.isGenerated()) {
if (idPropDesc.generator === 'auto') {
insertCmd = new InsertWithGeneratedIdCommand(
table, idPropDesc, data);
} else { // generator function
commands.push(new GeneratorCommand(idPropDesc));
insertCmd = new InsertCommand(table);
insertCmd.add(idPropDesc.column, '?{' + idPropPath + '}');
}
} else {
commands.push(new AssignedIdCommand(idPropDesc, data));
insertCmd = new InsertCommand(table);
insertCmd.add(idPropDesc.column, '?{' + idPropPath + '}');
}
} else {
insertCmd = new InsertCommand(table);
idPropPath = parentIdPropPath;
}
// add parent id column
if (parentIdColumn)
insertCmd.add(parentIdColumn, '?{' + parentIdPropPath + '}');
// add array element index column
if (colPropDesc && colPropDesc.isArray() && colPropDesc.indexColumn)
insertCmd.add(
colPropDesc.indexColumn, this._dbDriver.sql(keyVal));
// add map key column
if (colPropDesc && colPropDesc.isMap() && colPropDesc.keyColumn)
insertCmd.add(
colPropDesc.keyColumn, this._makeMapKeySql(colPropDesc, keyVal));
// add record meta-info properties
if (container.isRecordType()) {
let propName = container.getRecordMetaInfoPropName('version');
if (propName)
insertCmd.add(
container.getPropertyDesc(propName).column,
'1'
);
propName = container.getRecordMetaInfoPropName('creationTimestamp');
if (propName)
insertCmd.add(
container.getPropertyDesc(propName).column,
'?{ctx.executedOn}'
);
propName = container.getRecordMetaInfoPropName('creationActor');
if (propName) {
this._actorRequired = true;
insertCmd.add(
container.getPropertyDesc(propName).column,
'?{ctx.actor}'
);
}
}
// process the container properties
this._createInsertCommandsForContainer(
commands, commands.push(insertCmd) - 1, idPropPath, container, data);
}
/**
* Process record, add columns to the insert command and recursively add
* dependent inserts to the sequence.
*
* @private
* @param {Array.<module:x2node-dbos.DBOCommand>} commands The commands
* sequence, to which to add the insert commands.
* @param {number} insertCmdInd Index of the current insert command in the
* commands sequence.
* @param {string} idPropPath Property path of the id in the current insert
* command.
* @param {module:x2node-records~PropertiesContainer} container Container
* descriptor with the properties to add.
* @param {Object} data Object matching the container.
* @returns {number} The current insert command index in the sequence, which
* may have been changed by the method (generators could have been inserted
* before the command shifting it down the sequence).
*/
_createInsertCommandsForContainer(
commands, insertCmdInd, idPropPath, container, data) {
// get the current insert command
const insertCmd = commands[insertCmdInd];
// go over the container properties
for (let propName of container.allPropertyNames) {
const propDesc = container.getPropertyDesc(propName);
// skip properties we don't need to insert
if (propDesc.isCalculated() || propDesc.isId() ||
propDesc.isRecordMetaInfo() || propDesc.isView() ||
propDesc.reverseRefPropertyName)
continue;
// get the value
const propVal = data[propName];
// check if nested object
if (propDesc.scalarValueType === 'object') {
// check if there is a value
if ((propVal === undefined) || (propVal === null)) {
if (!propDesc.optional)
throw new common.X2UsageError(
'No value provided for required property ' +
container.nestedPath + propName + '.');
continue;
}
// process depending on the structural type
if (propDesc.isArray()) {
// check the type
if (!Array.isArray(propVal))
new common.X2UsageError(
'Invalid value type for property ' +
container.nestedPath + propName +
', expected an array.');
// check if empty
if (propVal.length === 0) {
if (!propDesc.optional)
throw new common.X2UsageError(
'No value provided for required property ' +
container.nestedPath + propName + '.');
continue;
}
// add the inserts
propVal.forEach((v, i) => {
if ((v === undefined) || (v === null) ||
((typeof v) !== 'object') || Array.isArray(v))
new common.X2UsageError(
'Invalid array element for property ' +
container.nestedPath + propName +
', expected an object.');
this._createInsertCommands(
commands, propDesc.table, propDesc.parentIdColumn,
idPropPath, propDesc, i, propDesc.nestedProperties,
v);
});
} else if (propDesc.isMap()) {
// check the type
if (((typeof propVal) !== 'object') ||
Array.isArray(propVal))
new common.X2UsageError(
'Invalid value type for property ' +
container.nestedPath + propName +
', expected an object.');
// check if empty
const keys = Object.keys(propVal);
if (keys.length === 0) {
if (!propDesc.optional)
throw new common.X2UsageError(
'No value provided for required property ' +
container.nestedPath + propName + '.');
continue;
}
// add the inserts
keys.forEach(key => {
const v = propVal[key];
if ((v === undefined) || (v === null) ||
((typeof v) !== 'object') || Array.isArray(v))
new common.X2UsageError(
'Invalid map element for property ' +
container.nestedPath + propName +
', expected an object.');
this._createInsertCommands(
commands, propDesc.table, propDesc.parentIdColumn,
idPropPath, propDesc, key, propDesc.nestedProperties,
v);
});
} else {
// check the type
if (((typeof propVal) !== 'object') ||
Array.isArray(propVal))
new common.X2UsageError(
'Invalid value type for property ' +
container.nestedPath + propName +
', expected an object.');
// check if in a separate table
if (propDesc.table) {
this._createInsertCommands(
commands, propDesc.table, propDesc.parentIdColumn,
idPropPath, null, null, propDesc.nestedProperties,
propVal);
} else {
insertCmdInd = this._createInsertCommandsForContainer(
commands, insertCmdInd, idPropPath,
propDesc.nestedProperties, propVal);
}
}
} else { // simple value or ref
// value SQL to add to the insert
let propValSql;
// check if generated and no value
if ((propVal === undefined) && propDesc.isGenerated()) {
// skip if auto-generated
if (propDesc.generator === 'auto')
continue;
// add generator command
commands.splice(
insertCmdInd++, 0, new GeneratorCommand(propDesc));
propValSql = '?{' + propDesc.container.nestedPath +
propDesc.name + '}';
} else if ((propVal !== undefined) && (propVal !== null)) {
if (propDesc.isArray()) {
if (!Array.isArray(propVal))
new common.X2UsageError(
'Invalid value type for property ' +
container.nestedPath + propName +
', expected an array.');
if (propVal.length > 0)
propValSql = propVal.map(
v => this._makePropValSql(propDesc, v));
} else if (propDesc.isMap()) {
if (((typeof propVal) !== 'object') ||
Array.isArray(propVal))
new common.X2UsageError(
'Invalid value type for property ' +
container.nestedPath + propName +
', expected an object.');
const keys = Object.keys(propVal);
if (keys.length > 0) {
propValSql = keys.reduce((res, k) => {
res.set(k, this._makePropValSql(
propDesc, propVal[k]));
return res;
}, new Map());
}
} else {
propValSql = this._makePropValSql(propDesc, propVal);
}
}
// check if no value
if (propValSql === undefined) {
if (!propDesc.optional)
throw new common.X2UsageError(
'No value provided for required property ' +
container.nestedPath + propName + '.');
continue;
}
// make the corresponding inserts
if (propDesc.table) {
const idSql = '?{' + idPropPath + '}';
if (propDesc.isArray()) {
propValSql.forEach((v, i) => {
const valInsert = new InsertCommand(propDesc.table);
valInsert.add(propDesc.parentIdColumn, idSql);
if (propDesc.indexColumn)
valInsert.add(
propDesc.indexColumn, this._dbDriver.sql(i));
valInsert.add(propDesc.column, v);
commands.push(valInsert);
});
} else if (propDesc.isMap()) {
propValSql.forEach((v, k) => {
const valInsert = new InsertCommand(propDesc.table);
valInsert.add(propDesc.parentIdColumn, idSql);
if (propDesc.keyColumn)
valInsert.add(
propDesc.keyColumn,
this._makeMapKeySql(propDesc, k)
);
valInsert.add(propDesc.column, v);
commands.push(valInsert);
});
} else {
const valInsert = new InsertCommand(propDesc.table);
valInsert.add(propDesc.parentIdColumn, idSql);
valInsert.add(propDesc.column, propValSql);
commands.push(valInsert);
}
} else {
insertCmd.add(propDesc.column, propValSql);
}
}
}
// go over subtype-specific properties if polymorph object
if (container.isPolymorphObject()) {
// get the subtype
const subtypeName = data[container.typePropertyName];
if ((typeof subtypeName) !== 'string')
throw new common.X2UsageError(
'Type property of polymorphic ' + (
container.isRecordType() ?
'record' :
'property ' + container.nestedPath
) + ' is missing or is not a string.');
// get the subtype extension container
if (!container.hasProperty(subtypeName))
throw new common.X2UsageError(
'Unknown type "' + subtypeName + '" of polymorphic ' + (
container.isRecordType() ?
'record' :
'property ' + container.nestedPath
) + '.');
const subtypeDesc = container.getPropertyDesc(subtypeName);
if (!subtypeDesc.isSubtype())
throw new common.X2UsageError(
'Unknown type "' + subtypeName + '" of polymorphic ' + (
container.isRecordType() ?
'record' :
'property ' + container.nestedPath
) + '.');
// add type column to the insert if any
const typePropDesc = container.getPropertyDesc(
container.typePropertyName);
if (typePropDesc.column)
insertCmd.add(
typePropDesc.column, this._dbDriver.sql(subtypeName));
// add insert commands for the subtype-specific properties
if (subtypeDesc.table) {
this._createInsertCommands(
commands, subtypeDesc.table, subtypeDesc.parentIdColumn,
idPropPath, null, null, subtypeDesc.nestedProperties, data);
} else {
insertCmdInd = this._createInsertCommandsForContainer(
commands, insertCmdInd, idPropPath,
subtypeDesc.nestedProperties, data);
}
}
// TODO: support polymorphic reference container
// return the insert command index
return insertCmdInd;
}
/**
* Validate property value and make SQL for it.
*
* @protected
* @param {module:x2node-records~PropertyDescriptor} propDesc Property
* descriptor.
* @param {*} val Property value. If <code>null</code> or
* <code>undefined</code>, SQL <code>NULL</code> is returned.
* @returns {string} The value SQL.
* @throws {module:x2node-common.X2UsageError} If the value is not good.
*/
_makePropValSql(propDesc, val) {
if ((val === null) || (val === undefined))
return this._dbDriver.sql(null);
const valSql = this._valueToSql(
val, propDesc.scalarValueType, propDesc.refTarget,
expectedType => new common.X2UsageError(
'Invalid value type [' + (typeof val) + '] for property ' +
propDesc.container.nestedPath + propDesc.name +
', expected [' + expectedType + '].')
);
if (valSql === null)
throw new common.X2UsageError(
'Invalid value [' + String(val) + '] for property ' +
propDesc.container.nestedPath + propDesc.name + '.');
return valSql;
}
/**
* Validate value for a map property key and make SQL for it. The method is
* also capable of converting the key value from string to the expected key
* value type (relevant for "number" and "boolean" types).
*
* @protected
* @param {module:x2node-records~PropertyDescriptor} mapPropDesc Map property
* descriptor.
* @param {*} key Key value. May not be <code>null</code> or
* <code>undefined</code>.
* @returns {string} The key value SQL.
* @throws {module:x2node-common.X2UsageError} If the key value is not good.
*/
_makeMapKeySql(mapPropDesc, key) {
const invalidKeyVal = () => new common.X2UsageError(
`Invalid key value [${String(key)}] for map property ` +
`${mapPropDesc.container.nestedPath}${mapPropDesc.name}.`);
if ((key === null) || (key === undefined))
throw invalidKeyVal();
let keyToUse;
if ((typeof key) === 'string') {
switch (mapPropDesc.keyValueType) {
case 'number':
keyToUse = Number(key);
break;
case 'boolean':
keyToUse = (
key === 'true' ? true : (key === 'false' ? false : null));
break;
default:
keyToUse = key;
}
}
const keySql = this._valueToSql(
keyToUse, mapPropDesc.keyValueType, mapPropDesc.keyRefTarget,
expectedType => new common.X2UsageError(
`Invalid key value type [${typeof key}] for map property ` +
`${mapPropDesc.container.nestedPath}${mapPropDesc.name},` +
` expected [${expectedType}].`)
);
if (keySql === null)
throw invalidKeyVal();
return keySql;
}
/**
* Validate value against expected value type and convert it into SQL value
* expression.
*
* @private
* @param {*} val The value.
* @param {string} valueType Scalar value type.
* @param {string} [expectedRefTarget] If expected value type is "ref", then
* this is expected target record type name.
* @param {function} invalidValType Function to use to create throwable error
* when the specified value's ES type does not match the specified expected
* value type. The function takes the expected ES type as an argument.
* @returns {string} SQL value expression, or <code>null</code> if the value
* is invalid, including <code>null</code> and <code>undefined</code>.
*/
_valueToSql(val, valueType, expectedRefTarget, invalidValType) {
let valSql = null;
let hashInd;
switch (valueType) {
case 'string':
if ((typeof val) !== 'string')
throw invalidValType('string');
valSql = this._dbDriver.stringLiteral(val);
break;
case 'number':
if ((typeof val) !== 'number')
throw invalidValType('number');
valSql = this._dbDriver.sql(val);
break;
case 'boolean':
if ((typeof val) !== 'boolean')
throw invalidValType('boolean');
valSql = this._dbDriver.booleanLiteral(val);
break;
case 'datetime':
if (val instanceof Date) {
valSql = this._dbDriver.datetimeLiteral(val);
} else if ((typeof val) === 'string') {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(val)) {
const dateVal = Date.parse(val);
if (!Number.isNaN(dateVal))
valSql = this._dbDriver.datetimeLiteral(
new Date(dateVal));
}
} else {
throw invalidValType('string or Date');
}
break;
case 'ref':
if ((typeof val) !== 'string')
throw invalidValType('string');
hashInd = val.indexOf('#');
if ((hashInd > 0) && (hashInd < val.length - 1)) {
const refTarget = val.substring(0, hashInd);
if (refTarget === expectedRefTarget) {
const refTargetDesc = this._recordTypes.getRecordTypeDesc(
refTarget);
const refIdPropDesc = refTargetDesc.getPropertyDesc(
refTargetDesc.idPropertyName);
valSql = this._dbDriver.sql(
refIdPropDesc.scalarValueType === 'number' ?
Number(val.substring(hashInd + 1)) :
val.substring(hashInd + 1)
);
}
}
}
return valSql;
}
/**
* Build DBO command used to update entangled records modification meta-info
* at the end of the DBO. The updated entangled records information is taken
* from the DBO execution context's
* [entangledUpdates]{@link module:x2node-dbos~DBOExecutionContext#entangledUpdates}
* property.
*
* @protected
* @returns {module:x2node-dbos.DBOCommand} The command.
*/
_createUpdateEntangledRecordsCommand() {
return new UpdateEntangledRecordsCommand(this._updatedRecordTypeNames);
}
/**
* Build DBO command that notifies registered record collections monitor
* about the updated record types. The record type names are registered by
* the DBO throughout its execution via the
* [_registerRecordTypeUpdate()]{@link module:x2node-dbos~AbstractDBO#_registerRecordTypeUpdate}
* method.
*
* @protected
* @returns {module:x2node-dbos.DBOCommand} The command.
*/
_createNotifyRecordCollectionsMonitorCommand() {
return new NotifyRecordCollectionsMonitorCommand(
this._rcMonitor, this._updatedRecordTypeNames);
}
/////////////////////////////////////////////////////////////////////////////
// DBO execution helper methods
/////////////////////////////////////////////////////////////////////////////
/**
* Register an update of a record of the specified record type.
*
* @protected
* @param {string} recordTypeName Updated record type name.
*/
_registerRecordTypeUpdate(recordTypeName) {
if (this._updatedRecordTypeNames)
this._updatedRecordTypeNames.add(recordTypeName);
}
/**
* Execute DBO commands.
*
* @protected
* @param {module:x2node-dbos~DBOExecutionContext} ctx The operation
* execution context.
* @returns {Promise} Operation result promise.
*/
_executeCommands(ctx) {
// check if actor is required
if (this._actorRequired && !ctx.actor)
throw new common.X2UsageError('Operation may not be anonymous.');
// initial pre-resolved result promise for the chain
let resPromise = Promise.resolve();
// start transaction if necessary
if (ctx.wrapInTx)
resPromise = this._startTx(resPromise, ctx);
// queue up the commands
this._commands.forEach(cmd => {
resPromise = cmd.queueUp(resPromise, ctx);
});
// finish transaction if necessary
if (ctx.wrapInTx)
resPromise = this._endTx(resPromise, ctx);
// build the final result object
resPromise = resPromise.then(() => ctx.getResult());
// return the result promise chain
return resPromise;
}
/**
* Add transaction start to the operation execution promise chain.
*
* @private
* @param {Promise} promiseChain The promise chain.
* @param {module:x2node-dbos~DBOExecutionContext} ctx The operation
* execution context.
* @returns {Promise} The promise chain with the transaction operation added.
*/
_startTx(promiseChain, ctx) {
return promiseChain.then(
() => {
try {
return ctx.transaction.start();
} catch (err) {
common.error('error starting transaction', err);
return Promise.reject(err);
}
}
);
}
/**
* Add transaction end to the operation execution promise chain.
*
* @private
* @param {Promise} promiseChain The promise chain.
* @param {module:x2node-dbos~DBOExecutionContext} ctx The operation
* execution context.
* @returns {Promise} The promise chain with the transaction operation added.
*/
_endTx(promiseChain, ctx) {
const rollbackAfterFailedCommit = err => new Promise((resolve, reject) => {
this._log('rolling back transaction after failed commit');
this._dbDriver.rollbackTransaction(ctx.connection, {
onSuccess() {
reject(err);
},
onError(rollbackErr) {
common.error(
'error rolling transaction back after' +
' failed commit', rollbackErr);
reject(err);
}
});
});
return promiseChain.then(
() => {
try {
return ctx.transaction.commit().catch(err => {
return rollbackAfterFailedCommit(err);
});
} catch (err) {
common.error('error committing transaction', err);
return rollbackAfterFailedCommit(err);
}
},
err => {
if (ctx.rollbackOnError) {
try {
return ctx.transaction.rollback().then(
() => Promise.reject(err),
rollbackErr => {
common.error(
'error rolling transaction back',
rollbackErr);
return Promise.reject(err);
}
);
} catch (rollbackErr) {
common.error(
'error rolling transaction back', rollbackErr);
return Promise.reject(err);
}
} else {
return Promise.reject(err);
}
}
);
}
/**
* Replace parameter placeholders in the specified SQL statement with the
* corresponding values.
*
* @protected
* @param {string} stmt SQL statement text with parameter placeholders. Each
* placeholder has format "?{ref}" where "ref" is the parameter reference in
* the operation's records filter parameters handler.
* @param {module:x2node-dbos~DBOExecutionContext} ctx The operation
* execution context. The method uses context's
* [getParamSql()]{@link module:x2node-dbos~DBOExecutionContext#getParamSql}
* method to get values for the parameter placeholders.
* @returns {string} Ready to execute SQL statement with parameter
* placeholders replaced.
*/
_replaceParams(stmt, ctx) {
let res = '';
const re = new RegExp('(\'(?!\'))|(\')|\\?\\{([^}]+)\\}', 'g');
let m, inLiteral = false, lastMatchIndex = 0;
while ((m = re.exec(stmt)) !== null) {
res += stmt.substring(lastMatchIndex, m.index);
lastMatchIndex = re.lastIndex;
const s = m[0];
if (inLiteral) {
res += s;
if (m[1]) {
inLiteral = false;
} else if (m[2]) {
re.lastIndex++;
}
} else {
if (s === '\'') {
res += s;
inLiteral = true;
} else {
res += ctx.getParamSql(m[3]);
}
}
}
res += stmt.substring(lastMatchIndex);
return res;
}
}
// export the class
module.exports = AbstractDBO;