UNPKG

x2node-dbos

Version:
1,710 lines (1,448 loc) 51.6 kB
'use strict'; const common = require('x2node-common'); const pointers = require('x2node-pointers'); const AbstractDBO = require('./abstract-dbo.js'); const DBOExecutionContext = require('./dbo-execution-context.js'); const FetchDBO = require('./fetch-dbo.js'); const ValueExpressionContext = require('./value-expression-context.js'); const propsTreeBuilder = require('./props-tree-builder.js'); const queryTreeBuilder = require('./query-tree-builder.js'); ///////////////////////////////////////////////////////////////////////////////// // COMMANDS ///////////////////////////////////////////////////////////////////////////////// /** * Pre-fetch command. When executed, fetches all matching records due for update * and sets them in the context. * * @private * @memberof module:x2node-dbos * @inner * @implements module:x2node-dbos.DBOCommand */ class PrefetchCommand { constructor(fetchDBO) { this._fetchDBO = fetchDBO; } // add command execution to the chain queueUp(promiseChain, ctx) { return promiseChain.then( () => this._fetchDBO.execute( ctx.transaction, ctx.actor, ctx.filterParams) ).then( result => { ctx.setRecords(result.records); } ); } } /** * Call provided fetcher function command. When executed, gets the records from * the fetcher function and sets them in the context. * * @private * @memberof module:x2node-dbos * @inner * @implements module:x2node-dbos.DBOCommand */ class CallFetcherCommand { constructor(fetcher) { this._fetcher = fetcher; } // add command execution to the chain queueUp(promiseChain, ctx) { return promiseChain.then( () => this._fetcher(ctx) ).then( result => { ctx.setRecords(result); } ); } } /** * Update pre-fetched records command. When executed, goes over each record set * in the context, applies the patch, validates and flushes it to the database. * * @private * @memberof module:x2node-dbos * @inner * @implements module:x2node-dbos.DBOCommand */ class UpdateRecordsCommand { constructor(patch) { this._patch = patch; } // add command execution to the chain queueUp(promiseChain, ctx) { return promiseChain.then(() => { // pre-resolve updates sub-chain let recordsChain = Promise.resolve(); // add processing for each record in the context for (let n = ctx.numRecords; n > 0; n--) { // proceed to the next record recordsChain = recordsChain.then( () => ctx.nextRecord()); // add validator, if any if (ctx.beforePatchRecordValidator) recordsChain = recordsChain.then( () => ctx.beforePatchRecordValidator(ctx.currentRecord)); // add the patch recordsChain = recordsChain.then( () => this._patch.apply(ctx.currentRecord, ctx)); // add validator, if any if (ctx.afterPatchRecordValidator) recordsChain = recordsChain.then( () => ctx.afterPatchRecordValidator(ctx.currentRecord)); // add updates flush to the database recordsChain = recordsChain.then( () => ctx.flushRecord()); } // catch sub-chain error and pass it up recordsChain = recordsChain.catch( err => Promise.reject(err)); // return record updates sub-chain return recordsChain; }); } } /** * Abstract base for commands used to update the matched records. * * @private * @memberof module:x2node-dbos * @inner * @implements module:x2node-dbos.DBOCommand * @abstract */ class AbstractUpdateCommand { /** * Create new command. * * @param {Object} propCtx Property context object. */ constructor(propCtx) { this._propCtx = propCtx; } /** * Get reference tables for the command. * * @protected */ _getRefTables(tableDesc, tableChain) { // find the unique table in the chain const uniqueIdTableAlias = this._propCtx.uniqueIdColumnInfo.tableAlias; const uniqueIdTableIndex = tableChain.findIndex( t => (t.tableAlias === uniqueIdTableAlias)); // no ref tables if unique id table is outside the chain if (uniqueIdTableIndex < 0) return null; // get needed tables from the chain let refTables; if (uniqueIdTableIndex === 0) // use full chain refTables = tableChain; else // cut the chain up to the unique id table and return the result refTables = tableChain.slice(uniqueIdTableIndex); // flip the table chain if (refTables.length > 0) refTables[0].joinCondition = tableDesc.joinCondition; // return the result return refTables; } } /** * Command for updating column in a table. * * @private * @memberof module:x2node-dbos * @inner * @extends module:x2node-dbos~AbstractUpdateCommand */ class UpdateColumnCommand extends AbstractUpdateCommand { constructor(propCtx, columnInfo, valueExpr, addlCondExpr) { super(propCtx); this._columnInfo = columnInfo; this._sets = [ { columnName: columnInfo.columnName, value: valueExpr } ]; this._addlCondExpr = (addlCondExpr || null); } /** * Attempt to merge another update command into this one. * * @param {module:x2node-dbos~UpdateColumnCommand} otherCmd The other update * command. * @returns {boolean} <code>true</code> if merged. */ merge(otherCmd) { // check if updates same table if (otherCmd._columnInfo.tableAlias !== this._columnInfo.tableAlias) return false; // check if same anchors if (otherCmd._propCtx.anchorsExpr !== this._propCtx.anchorsExpr) return false; // check if same additional condition if (otherCmd._addlCondExpr !== this._addlCondExpr) return false; // all good, import the sets otherCmd._sets.forEach(s => { this._sets.push(s); }); // merged return true; } // add command execution to the chain queueUp(promiseChain, ctx) { // find the updated table node and build the UPDATE statement const sql = ctx.updateQueryTree.forTableAlias( ctx.translationCtx, this._columnInfo.tableAlias, (propNode, tableDesc, tableChain) => { // build the UPDATE statement return ctx.dbDriver.buildUpdateWithJoins( tableDesc.tableName, tableDesc.tableAlias, this._sets, this._getRefTables(tableDesc, tableChain), this._propCtx.anchorsExpr + ( this._addlCondExpr ? ' AND ' + this._addlCondExpr : ''), false); } ); // queue up execution of the UPDATE statement return promiseChain.then( () => new Promise((resolve, reject) => { try { 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}]`, err); reject(err); } }), err => Promise.reject(err) ); } } /** * Command for clearing a simple value collection table. * * @private * @memberof module:x2node-dbos * @inner * @extends module:x2node-dbos~AbstractUpdateCommand */ class ClearSimpleCollectionCommand extends AbstractUpdateCommand { constructor(propCtx, tableAlias, addlCondExpr) { super(propCtx); this._tableAlias = tableAlias; this._addlCondExpr = addlCondExpr; } // add command execution to the chain queueUp(promiseChain, ctx) { // find the table node and build the DELETE statement const sql = ctx.updateQueryTree.forTableAlias( ctx.translationCtx, this._tableAlias, (propNode, tableDesc, tableChain) => { // build the DELETE statement return ctx.dbDriver.buildDeleteWithJoins( tableDesc.tableName, tableDesc.tableAlias, this._getRefTables(tableDesc, tableChain), this._propCtx.anchorsExpr + ( this._addlCondExpr ? ' AND ' + this._addlCondExpr : ''), false); } ); // queue up execution of the DELETE statement return promiseChain.then( () => new Promise((resolve, reject) => { try { 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}]`, err); reject(err); } }), err => Promise.reject(err) ); } } /** * Command for populating a simple value array table. * * @private * @memberof module:x2node-dbos * @inner * @extends module:x2node-dbos~AbstractUpdateCommand */ class PopulateSimpleArrayCommand extends AbstractUpdateCommand { constructor( tableName, parentIdColumn, parentIdExpr, indexColumn, baseIndex, valueColumn, valueExprs) { super(); this._tableName = tableName; this._parentIdColumn = parentIdColumn; this._parentIdExpr = parentIdExpr; this._indexColumn = indexColumn; this._baseIndex = baseIndex; this._valueColumn = valueColumn; this._valueExprs = valueExprs; } // add command execution to the chain queueUp(promiseChain, ctx) { // build the INSERT statements const sqls = this._valueExprs.map((valueExpr, i) => ( 'INSERT INTO ' + this._tableName + ' (' + this._parentIdColumn + (this._indexColumn ? ', ' + this._indexColumn : '') + ', ' + this._valueColumn + ') VALUES (' + this._parentIdExpr + (this._indexColumn ? ', ' + String(this._baseIndex + i) : '') + ', ' + valueExpr + ')' )); // queue up execution of the INSERT statements for (let sql of sqls) { promiseChain = promiseChain.then( () => new Promise((resolve, reject) => { try { 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}]`, err); reject(err); } }), err => Promise.reject(err) ); } // return the resulting chain return promiseChain; } } /** * Command for populating a simple value map table. * * @private * @memberof module:x2node-dbos * @inner * @extends module:x2node-dbos~AbstractUpdateCommand */ class PopulateSimpleMapCommand extends AbstractUpdateCommand { constructor( tableName, parentIdColumn, parentIdExpr, keyColumn, valueColumn, keyValueExprs) { super(); this._tableName = tableName; this._parentIdColumn = parentIdColumn; this._parentIdExpr = parentIdExpr; this._keyColumn = keyColumn; this._valueColumn = valueColumn; this._keyValueExprs = keyValueExprs; } // add command execution to the chain queueUp(promiseChain, ctx) { // build the INSERT statements const sqls = new Array(); this._keyValueExprs.forEach(keyValueExprsPair => { sqls.push( 'INSERT INTO ' + this._tableName + ' (' + this._parentIdColumn + ', ' + this._keyColumn + ', ' + this._valueColumn + ') VALUES (' + this._parentIdExpr + ', ' + keyValueExprsPair[0] + ', ' + keyValueExprsPair[1] + ')' ); }); // queue up execution of the INSERT statements for (let sql of sqls) { promiseChain = promiseChain.then( () => new Promise((resolve, reject) => { try { 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}]`, err); reject(err); } }), err => Promise.reject(err) ); } // return the resulting chain return promiseChain; } } /** * Command for recursively clearing a table with nested objects (scalar or * collection). * * @private * @memberof module:x2node-dbos * @inner * @extends module:x2node-dbos~AbstractUpdateCommand */ class ClearObjectsCommand extends AbstractUpdateCommand { constructor(propCtx, tableAlias) { super(propCtx); this._tableAlias = tableAlias; } // add command execution to the chain queueUp(promiseChain, ctx) { // find the table node and build the DELETE statements const sqls = new Array(); ctx.updateQueryTree.walkReverse( ctx.transactionCtx, (propNode, tableDesc, tableChain) => { if (tableDesc.tableAlias.startsWith(this._tableAlias)) { // build the DELETE statement sqls.push(ctx.dbDriver.buildDeleteWithJoins( tableDesc.tableName, tableDesc.tableAlias, this._getRefTables(tableDesc, tableChain), this._propCtx.anchorsExpr, false)); } }); // queue up execution of the DELETE statements for (let sql of sqls) { promiseChain = promiseChain.then( () => new Promise((resolve, reject) => { try { 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}]`, err); reject(err); } }), err => Promise.reject(err) ); } // return the resulting chain return promiseChain; } } ///////////////////////////////////////////////////////////////////////////////// // EXECUTION CONTEXT ///////////////////////////////////////////////////////////////////////////////// /** * Operation execution context. * * @private * @memberof module:x2node-dbos * @inner * @extends module:x2node-dbos~DBOExecutionContext * @implements module:x2node-patches.RecordPatchHandlers */ class UpdateDBOExecutionContext extends DBOExecutionContext { constructor(dbo, txOrCon, actor, filterParams, recordValidators) { super(dbo, txOrCon, actor, filterParams, new Object()); // operation specific info this._updateQueryTree = dbo._updateQueryTree; this._translationCtx = this._updateQueryTree.getTopTranslationContext( dbo._paramsHandler); this._recordTypeDesc = dbo._recordTypeDesc; this._recordIdPropName = dbo._recordTypeDesc.idPropertyName; if ((typeof recordValidators) === 'function') { this._afterPatchRecordValidator = recordValidators; } else if (((typeof recordValidators) === 'object') && (recordValidators !== null)) { this._beforePatchRecordValidator = recordValidators.beforePatch; this._afterPatchRecordValidator = recordValidators.afterPatch; } // whole operation result this._records = undefined; this._updatedRecordIds = new Array(); this._testFailed = false; this._failedRecordIds = undefined; // context record data this._recordIndex = undefined; this._record = null; this._commands = new Array(); this._recordTestFailed = null; this._recordEntangledUpdates = null; } /** * Update query tree. * * @protected * @member {module:x2node-dbos~QueryTreeNode} * @readonly */ get updateQueryTree() { return this._updateQueryTree; } /** * Translation context. * * @protected * @member {module:x2node-dbos~TranslationContext} * @readonly */ get translationCtx() { return this._translationCtx; } /** * Record validator function called before applying the patch. * * @member {?module:x2node-dbos~UpdateDBO~recordValidator} * @readonly */ get beforePatchRecordValidator() { return this._beforePatchRecordValidator; } /** * Record validator function called after applying the patch. * * @member {?module:x2node-dbos~UpdateDBO~recordValidator} * @readonly */ get afterPatchRecordValidator() { return this._afterPatchRecordValidator; } // execution cycle methods: /** * Set matched records to the context before starting applying the updates. * * @param {Array.<Object>} records Matched records. */ setRecords(records) { this._records = records; this._recordIndex = -1; } /** * Number of records to process in the context. Set after * <code>setRecords()</code> is called. * * @member {number} * @readonly */ get numRecords() { return this._records.length; } /** * Start processing next record update. * * @returns {Object} The record data read from the database for update, or * <code>null</code> if no more records. */ nextRecord() { // check if no more records if (this._recordIndex === this._records.length - 1) return null; // set the current record in the context this._record = this._records[++this._recordIndex]; // reset the context record commands this._commands.length = 0; this._recordTestFailed = false; this._recordEntangledUpdates = null; // return the record return this._record; } /** * Current record. Has value after <code>nextRecord()</code> call. * * @member {Object} * @readonly */ get currentRecord() { return this._record; } /** * Flush current record updates. * * @returns {Promise} Promise of the flush completion, or nothing if no flush * is required. */ flushRecord() { // check if "test" operation failed for the current record if (this._recordTestFailed) { this._testFailed = true; if (!this._failedRecordIds) this._failedRecordIds = new Array(); this._failedRecordIds.push(this._record[this._recordIdPropName]); return; } // check if the current record needs any modification if (this._commands.length === 0) return; // the record was modified, save the modifications into the database: // register record type update this._dbo._registerRecordTypeUpdate(this._recordTypeDesc.name); // merge entangled record ids into the main registry if (this._recordEntangledUpdates) { for (let recordTypeName in this._recordEntangledUpdates) { this.addEntangledUpdates( recordTypeName, this._recordEntangledUpdates[recordTypeName]); } } // insert meta-info property update commands const rootPropCtx = this._getPropertyContext( pointers.parse(this._recordTypeDesc, '')); let metaPropName = this._recordTypeDesc.getRecordMetaInfoPropName( 'modificationActor'); if (metaPropName) { this._record[metaPropName] = this._actor.stamp; this._commands.unshift(new UpdateColumnCommand( rootPropCtx, this._translationCtx.getPropValueColumn(metaPropName), this._dbDriver.sql(this._actor.stamp) )); } metaPropName = this._recordTypeDesc.getRecordMetaInfoPropName( 'modificationTimestamp'); if (metaPropName) { const date = this._executedOn; this._record[metaPropName] = date; this._commands.unshift(new UpdateColumnCommand( rootPropCtx, this._translationCtx.getPropValueColumn(metaPropName), this._dbDriver.datetimeLiteral(date) )); } metaPropName = this._recordTypeDesc.getRecordMetaInfoPropName( 'version'); if (metaPropName) { this._commands.unshift(new UpdateColumnCommand( rootPropCtx, this._translationCtx.getPropValueColumn(metaPropName), this._dbDriver.sql(++this._record[metaPropName]) // locked )); } // merge commands for (let i = 0; i < this._commands.length; i++) { const baseCmd = this._commands[i]; if (baseCmd instanceof UpdateColumnCommand) { for (let j = i + 1; j < this._commands.length; j++) { const cmd = this._commands[j]; if (!(cmd instanceof UpdateColumnCommand)) break; if (baseCmd.merge(cmd)) this._commands.splice(j--, 1); } } } // initial result promise let resPromise = Promise.resolve(); // queue up the commands for (let cmd of this._commands) resPromise = cmd.queueUp(resPromise, this); // update the updated record ids list resPromise = resPromise.then( () => { this._updatedRecordIds.push( this._record[this._recordIdPropName]); this.clearGeneratedParams(); }, err => Promise.reject(err) ); // return the result promise return resPromise; } /** * Get update DBO execution result object. * * @returns {Object} The DBO result object. */ getResult() { return { records: this._records, updatedRecordIds: this._updatedRecordIds, testFailed: this._testFailed, failedRecordIds: this._failedRecordIds }; } // patch handler methods: // process array/map element insert onInsert(op, ptr, newValue) { // get property context const propCtx = this._getPropertyContext(ptr); // update entanglements const propDesc = ptr.propDesc; if (propDesc.isEntangled() && ((typeof newValue) === 'string')) this._getEntangledRecordIds(propDesc).push( propDesc.nestedProperties.refToId(newValue)); // create the commands depending on whether it's an array or map if (propDesc.isArray()) { // get element index column info const propIndColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$index'); // make room for the new element and get new element index let newElementInd; if (propIndColumnInfo) { // determine base prop ctx (remove last anchor if nested object) const basePropCtx = ( propDesc.scalarValueType === 'object' ? this._getPropertyContext(ptr.parent) : propCtx); // get number of elements in the array after insert const arrayLen = ptr.parent.getValue(this._record).length; // make room if not adding to the end of the array if (ptr.collectionElementIndex !== '-') { newElementInd = ptr.collectionElementIndex; const indColExpr = propIndColumnInfo.tableAlias + '.' + propIndColumnInfo.columnName; if (newElementInd === arrayLen - 2) { this._commands.push(new UpdateColumnCommand( basePropCtx, propIndColumnInfo, `${indColExpr} + 1`, `${indColExpr} >= ${newElementInd}`)); } else { this._commands.push(new UpdateColumnCommand( basePropCtx, propIndColumnInfo, `${indColExpr} + ${arrayLen}`, `${indColExpr} >= ${newElementInd}`)); this._commands.push(new UpdateColumnCommand( basePropCtx, propIndColumnInfo, `${indColExpr} - ${arrayLen - 1}`, `${indColExpr} >= ${arrayLen}`)); } } else { newElementInd = arrayLen - 1; } } // object or simple? if (propDesc.scalarValueType === 'object') { // insert new object this.addGeneratedParam( propCtx.parentIdPropPath, propCtx.parentIdValue); this._dbo._createInsertCommands( this._commands, propDesc.table, propDesc.parentIdColumn, propCtx.parentIdPropPath, propDesc, newElementInd, propDesc.nestedProperties, newValue); } else { // simple value // add new element const propValColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$value'); this._commands.push(new PopulateSimpleArrayCommand( propDesc.table, propDesc.parentIdColumn, propCtx.anchors[propCtx.anchors.length - 1].valueExpr, propDesc.indexColumn, newElementInd, propValColumnInfo.columnName, [ this._valueToSql(propDesc, newValue) ] )); } } else { // map element // object or simple? if (propDesc.scalarValueType === 'object') { // insert new object this.addGeneratedParam( propCtx.parentIdPropPath, propCtx.parentIdValue); this._dbo._createInsertCommands( this._commands, propDesc.table, propDesc.parentIdColumn, propCtx.parentIdPropPath, propDesc, ptr.collectionElementIndex, propDesc.nestedProperties, newValue); } else { // simple value // add new element const propValColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$value'); const propKeyColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$key'); this._commands.push(new PopulateSimpleMapCommand( propDesc.table, propDesc.parentIdColumn, propCtx.anchors[propCtx.anchors.length - 1].valueExpr, propKeyColumnInfo.columnName, propValColumnInfo.columnName, [[ this._dbo._makeMapKeySql( propDesc, ptr.collectionElementIndex), this._valueToSql(propDesc, newValue) ]] )); } } } // process array/map element removal onRemove(op, ptr, oldValue) { // get property context const propCtx = this._getPropertyContext(ptr, oldValue); // update entanglements const propDesc = ptr.propDesc; if (propDesc.isEntangled() && ((typeof oldValue) === 'string')) this._getEntangledRecordIds(propDesc).push( propDesc.nestedProperties.refToId(oldValue)); // create the commands depending on whether it's an array or map if (propDesc.isArray()) { // get element index column info const propIndColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$index'); // object or simple? if (propDesc.scalarValueType === 'object') { // delete existing object const pidColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$parentId'); this._commands.push(new ClearObjectsCommand( propCtx, pidColumnInfo.tableAlias)); // shift element indexes left if (propIndColumnInfo) { const indColExpr = propIndColumnInfo.tableAlias + '.' + propIndColumnInfo.columnName; this._commands.push(new UpdateColumnCommand( this._getPropertyContext(ptr.parent), propIndColumnInfo, `${indColExpr} - 1`, `${indColExpr} > ${ptr.collectionElementIndex}`)); } } else { // simple value // get value column info const propValColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$value'); // delete single element identified by index if index column if (propIndColumnInfo) { // index column expression for SQL const indColExpr = propIndColumnInfo.tableAlias + '.' + propIndColumnInfo.columnName; // delete the element identified by index this._commands.push(new ClearSimpleCollectionCommand( propCtx, propValColumnInfo.tableAlias, `${indColExpr} = ${ptr.collectionElementIndex}`)); // shift element indexes left this._commands.push(new UpdateColumnCommand( propCtx, propIndColumnInfo, `${indColExpr} - 1`, `${indColExpr} > ${ptr.collectionElementIndex}`)); } else if (propDesc.allowDuplicates) { // re-populate if dupes // clear existing array this._commands.push(new ClearSimpleCollectionCommand( propCtx, propValColumnInfo.tableAlias)); // re-populate new array this._commands.push(new PopulateSimpleArrayCommand( propDesc.table, propDesc.parentIdColumn, this._dbDriver.sql(propCtx.parentIdValue), null, null, propValColumnInfo.columnName, propCtx.fullArray.map(v => this._valueToSql(propDesc, v)) )); } else { // unique values, no index column // delete the element identified by value this._commands.push(new ClearSimpleCollectionCommand( propCtx, propValColumnInfo.tableAlias, propValColumnInfo.tableAlias + '.' + propValColumnInfo.columnName + ' = ' + this._valueToSql(propDesc, oldValue))); } } } else { // map element // object or simple? if (propDesc.scalarValueType === 'object') { // delete existing object const pidColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$parentId'); this._commands.push(new ClearObjectsCommand( propCtx, pidColumnInfo.tableAlias)); } else { // simple value // delete element const propValColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$value'); this._commands.push(new ClearSimpleCollectionCommand( propCtx, propValColumnInfo.tableAlias)); } } } // process update onSet(op, ptr, newValue, oldValue) { // get property context const propDesc = ptr.propDesc; const propCtx = this._getPropertyContext(ptr, ( (propDesc.scalarValueType === 'object') && propDesc.table ? oldValue : undefined)); // update entanglements if (propDesc.isEntangled()) { const entangledIds = this._getEntangledRecordIds(propDesc); const targetRecordTypeDesc = propDesc.nestedProperties; if (propDesc.isArray() && !ptr.collectionElement) { if (Array.isArray(oldValue)) for (let ref of oldValue) if ((typeof ref) === 'string') entangledIds.push(targetRecordTypeDesc.refToId(ref)); if (Array.isArray(newValue)) for (let ref of newValue) if ((typeof ref) === 'string') entangledIds.push(targetRecordTypeDesc.refToId(ref)); } else if (propDesc.isMap() && !ptr.collectionElement) { if (((typeof oldValue) === 'object') && (oldValue !== null)) for (let key in oldValue) { const ref = oldValue[key]; if ((typeof ref) === 'string') entangledIds.push(targetRecordTypeDesc.refToId(ref)); } if (((typeof newValue) === 'object') && (newValue !== null)) for (let key in newValue) { const ref = newValue[key]; if ((typeof ref) === 'string') entangledIds.push(targetRecordTypeDesc.refToId(ref)); } } else { if ((typeof oldValue) === 'string') entangledIds.push(targetRecordTypeDesc.refToId(oldValue)); if ((typeof newValue) === 'string') entangledIds.push(targetRecordTypeDesc.refToId(newValue)); } } // create the commands depending on the target type if (!ptr.collectionElement && propDesc.isArray()) { // whole array // object or simple? if (propDesc.scalarValueType === 'object') { // delete existing objects if any const pidColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$parentId'); if (oldValue && (oldValue.length > 0)) this._commands.push(new ClearObjectsCommand( propCtx, pidColumnInfo.tableAlias)); // insert new objects if any if (newValue && (newValue.length > 0)) { this.addGeneratedParam( propCtx.parentIdPropPath, propCtx.parentIdValue); for (let i = 0, len = newValue.length; i < len; i++) this._dbo._createInsertCommands( this._commands, propDesc.table, propDesc.parentIdColumn, propCtx.parentIdPropPath, propDesc, i, propDesc.nestedProperties, newValue[i]); } } else { // simple value // clear existing array if not empty const propValColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$value'); if (oldValue && (oldValue.length > 0)) this._commands.push(new ClearSimpleCollectionCommand( propCtx, propValColumnInfo.tableAlias)); // populate new array if not empty if (newValue && (newValue.length > 0)) this._commands.push(new PopulateSimpleArrayCommand( propDesc.table, propDesc.parentIdColumn, propCtx.anchors[propCtx.anchors.length - 1].valueExpr, propDesc.indexColumn, 0, propValColumnInfo.columnName, newValue.map(v => this._valueToSql(propDesc, v)) )); } } else if (!ptr.collectionElement && propDesc.isMap()) { // whole map // object or simple? if (propDesc.scalarValueType === 'object') { // delete existing objects if any const pidColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$parentId'); if (oldValue && (Object.keys(oldValue).length > 0)) this._commands.push(new ClearObjectsCommand( propCtx, pidColumnInfo.tableAlias)); // insert new objects if any const newKeys = (newValue && Object.keys(newValue)); if (newKeys && (newKeys.length > 0)) { this.addGeneratedParam( propCtx.parentIdPropPath, propCtx.parentIdValue); for (let key of newKeys) this._dbo._createInsertCommands( this._commands, propDesc.table, propDesc.parentIdColumn, propCtx.parentIdPropPath, propDesc, key, propDesc.nestedProperties, newValue[key]); } } else { // simple value // clear existing map if not empty const propValColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$value'); if (oldValue && (Object.keys(oldValue).length > 0)) this._commands.push(new ClearSimpleCollectionCommand( propCtx, propValColumnInfo.tableAlias)); // populate new map if not empty const newKeys = (newValue && Object.keys(newValue)); if (newKeys && (newKeys.length > 0)) { const propKeyColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$key'); this._commands.push(new PopulateSimpleMapCommand( propDesc.table, propDesc.parentIdColumn, this._dbDriver.sql(propCtx.parentIdValue), propKeyColumnInfo.columnName, propValColumnInfo.columnName, newKeys.map(k => [ this._dbo._makeMapKeySql(propDesc, k), this._valueToSql(propDesc, newValue[k]) ]) )); } } } else if (propDesc.isArray()) { // array element // object or simple? if (propDesc.scalarValueType === 'object') { // delete existing object const pidColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$parentId'); this._commands.push(new ClearObjectsCommand( propCtx, pidColumnInfo.tableAlias)); // insert new object this.addGeneratedParam( propCtx.parentIdPropPath, propCtx.parentIdValue); this._dbo._createInsertCommands( this._commands, propDesc.table, propDesc.parentIdColumn, propCtx.parentIdPropPath, propDesc, ptr.collectionElementIndex, propDesc.nestedProperties, newValue); } else { // simple value // get value and element index columns infos const propValColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$value'); const propIndColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$index'); // update single element identified by index if index column if (propIndColumnInfo) { // index column expression for SQL const indColExpr = propIndColumnInfo.tableAlias + '.' + propIndColumnInfo.columnName; // update the element identified by index this._commands.push(new UpdateColumnCommand( propCtx, propValColumnInfo, this._valueToSql(propDesc, newValue), `${indColExpr} = ${ptr.collectionElementIndex}`)); } else if (propDesc.allowDuplicates) { // re-populate if dupes // clear existing array this._commands.push(new ClearSimpleCollectionCommand( propCtx, propValColumnInfo.tableAlias)); // re-populate new array this._commands.push(new PopulateSimpleArrayCommand( propDesc.table, propDesc.parentIdColumn, this._dbDriver.sql(propCtx.parentIdValue), null, null, propValColumnInfo.columnName, propCtx.fullArray.map(v => this._valueToSql(propDesc, v)) )); } else { // unique values, no index column // update the element identified by value this._commands.push(new UpdateColumnCommand( propCtx, propValColumnInfo, this._valueToSql(propDesc, newValue), propValColumnInfo.tableAlias + '.' + propValColumnInfo.columnName + ' = ' + this._valueToSql(propDesc, oldValue))); } } } else if (propDesc.isMap()) { // map element // object or simple? if (propDesc.scalarValueType === 'object') { // delete existing object const pidColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$parentId'); this._commands.push(new ClearObjectsCommand( propCtx, pidColumnInfo.tableAlias)); // insert new object this.addGeneratedParam( propCtx.parentIdPropPath, propCtx.parentIdValue); this._dbo._createInsertCommands( this._commands, propDesc.table, propDesc.parentIdColumn, propCtx.parentIdPropPath, propDesc, ptr.collectionElementIndex, propDesc.nestedProperties, newValue); } else { // simple value // replace existing value const propValColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$value'); this._commands.push(new UpdateColumnCommand( propCtx, propValColumnInfo, this._valueToSql(propDesc, newValue))); } } else { // scalar // object or simple? if (propDesc.scalarValueType === 'object') { // stored in its own table? if (propDesc.table) { // delete existing object if any const pidColumnInfo = this._translationCtx.getPropValueColumn( ptr.propPath + '.$parentId'); if (oldValue) this._commands.push(new ClearObjectsCommand( propCtx, pidColumnInfo.tableAlias)); // insert new object if any if (newValue) { this.addGeneratedParam( propCtx.parentIdPropPath, propCtx.parentIdValue); this._dbo._createInsertCommands( this._commands, propDesc.table, propDesc.parentIdColumn, propCtx.parentIdPropPath, null, null, propDesc.nestedProperties, newValue); } } else { // same table // temporarily restore the deleted nested object let oldValueRestored = false; if (!newValue && oldValue) { ptr.replaceValue(this._record, oldValue); oldValueRestored = true; } // go over every stored property const container = propDesc.nestedProperties; for (let childPropName of container.allPropertyNames) { const childPropDesc = container.getPropertyDesc( childPropName); if (childPropDesc.isCalculated() || childPropDesc.isView()) continue; this.onSet( op, ptr.createChildPointer(childPropName), (newValue && newValue[childPropName]), (oldValue && oldValue[childPropName])); } // clear temporarily restored deleted nested object if (oldValueRestored) ptr.removeValue(this._record); } } else { // simple value // replace existing value this._commands.push(new UpdateColumnCommand( propCtx, this._translationCtx.getPropValueColumn(ptr.propPath), this._valueToSql(propDesc, newValue) )); } } } /** * Convert property value to SQL value expression. * * @private * @param {module:x2node-records~PropertyDescriptor} propDesc Property * descriptor. * @param {*} val Property value. * @returns {string} Value SQL. */ _valueToSql(propDesc, val) { if (propDesc.isRef() && ((typeof val) === 'string')) return this._dbDriver.sql(propDesc.nestedProperties.refToId(val)); if ((propDesc.scalarValueType === 'datetime') && ((typeof val) === 'string')) return this._dbDriver.datetimeLiteral(new Date(val)); return this._dbDriver.sql(val); } /** * Get entangled record ids list for the specified reference property. The * returned list is for the current context record only. * * @private * @param {module:x2node-records~PropertyDescrpitor} propDesc Entangled * reference property descrpitor. * @returns {Array.<(string|number)>} The entangled record ids list. */ _getEntangledRecordIds(propDesc) { if (!this._recordEntangledUpdates) this._recordEntangledUpdates = new Object(); let ids = this._recordEntangledUpdates[propDesc.refTarget]; if (!ids) this._recordEntangledUpdates[propDesc.refTarget] = ids = new Array(); return ids; } /** * Get context of the property pointed by the specified pointer. The context * links the property to its parents and helps perform modification * operations on it. * * @private * @param {module:x2node-pointers~RecordElementPointer} ptr Property pointer. * @param {*} [removedElementValue] If called for removed collection element, * this is the removed element's value. * @returns {Object} The property context object. */ _getPropertyContext(ptr, removedElementValue) { // initial property context object const propCtx = { anchors: new Array(), _uniqueIdPropPath: undefined, _containerDesc: undefined, _containerObj: undefined, fullArray: undefined, get parentIdPropPath() { return this._containerDesc.nestedPath + this._containerDesc.idPropertyName; }, get parentIdValue() { return this._containerObj[this._containerDesc.idPropertyName]; }, get anchorsExpr() { return (this._anchorsExpr || ( this._anchorsExpr = this.anchors.map( a => a.columnExpr + ' = ' + a.valueExpr).join(' AND '))); } }; // trace the pointer through the record and extract relevant context data ptr.getValue(this._record, (prefixPtr, value, prefixDepth) => { // initialize context with root data if (prefixPtr.isRoot()) { propCtx.anchors.push({ columnExpr: this._translationCtx.translatePropPath( this._recordIdPropName), valueExpr: this._dbDriver.sql(value[this._recordIdPropName]) }); if (!propCtx._containerDesc) { propCtx._containerDesc = this._recordTypeDesc; propCtx._containerObj = this._record; } propCtx._uniqueIdPropPath = this._recordIdPropName; } else { // not root // get intermediate/leaf property descriptor const propDesc = prefixPtr.propDesc; // add anchor if collection element let idAnchorAdded = false; if (prefixPtr.collectionElement) { if (propDesc.isArray()) { if ((propDesc.scalarValueType === 'object') && (prefixPtr.collectionElementIndex !== '-')) { const idPropName = propDesc.nestedProperties.idPropertyName; const elementObj = ( prefixDepth > 0 ? value : (removedElementValue || value)); propCtx.anchors.push({ columnExpr: this._translationCtx.translatePropPath( prefixPtr.propPath + '.' + idPropName), valueExpr: this._dbDriver.sql(elementObj[idPropName]) }); idAnchorAdded = true; } } else { // it's a map propCtx.anchors.push({ columnExpr: this._translationCtx.translatePropPath( prefixPtr.propPath + '.$key'), valueExpr: this._dbo._makeMapKeySql( prefixPtr.propDesc, prefixPtr.collectionElementIndex) }); } } // save reference to the full array else if (propDesc.isArray() && !propCtx.fullArray) { propCtx.fullArray = value; } // check if object with id const objectWithId = ( (propDesc.scalarValueType === 'object') && propDesc.nestedProperties.idPropertyName); // save immediate container if ((prefixDepth > 0) && objectWithId && !propCtx._containerDesc) { propCtx._containerDesc = propDesc.nestedProperties; propCtx._containerObj = value; } // chop the anchors if unique id table if (objectWithId) { const nestedProps = propDesc.nestedProperties; const idPropName = nestedProps.idPropertyName; if ((propDesc.isScalar() || ( prefixPtr.collectionElement && (prefixPtr.collectionElementIndex !== '-'))) && nestedProps.getPropertyDesc(idPropName).tableUnique) { propCtx._uniqueIdPropPath = prefixPtr.propPath + '.' + idPropName; if (idAnchorAdded) { propCtx.anchors.splice( 0, propCtx.anchors.length - 1); } else { propCtx.anchors.length = 0; const elementObj = ( prefixDepth > 0 ? value : (removedElementValue || value)); propCtx.anchors.push({ columnExpr: this._translationCtx.translatePropPath( propCtx._uniqueIdPropPath), valueExpr: this._dbDriver.sql(elementObj[idPropName]) }); } } } } }); // get unique id table id column info propCtx.uniqueIdColumnInfo = this._translationCtx.getPropValueColumn(propCtx._uniqueIdPropPath); // return the property context return propCtx; } // process test onTest(ptr, value, passed) { if (!passed) this._recordTestFailed = true; } } ///////////////////////////////////////////////////////////////////////////////// // THE DBO ///////////////////////////////////////////////////////////////////////////////// /** * Update database operation implementation (potentially a combination of SQL * <code>UPDATE</code>, <code>INSERT</code> and <code>DELETE</code> queries). * * @memberof module:x2node-dbos * @inner * @extends module:x2node-dbos~AbstractDBO */ class UpdateDBO extends AbstractDBO { /** * <strong>Note:</strong> The constructor is not accessible from the client * code. Instances are created using * [DBOFactory]{@link module:x2node-dbos~DBOFactory}. * * @protected * @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. * @param {module:x2node-records~RecordTypeDescriptor} recordTypeDesc The * record type descriptor. * @param {module:x2node-patches~RecordPatch} patch The patch. * @param {(Array.<Array>|module:x2node-dbos~UpdateDBO~recordsFetcher)} [filterOrFetcher] * Optional filter specification or fetcher function. * @throws {module:x2node-common.X2UsageError} If the provided data is * invalid. */ constructor( dbDriver, recordTypes, rcMonitor, recordTypeDesc, patch, filterOrFetcher) { super(dbDriver, recordTypes, rcMonitor); // save the record type descriptor (used by the execution context) this._recordTypeDesc = recordTypeDesc; // the operation commands sequence this._commands = new Array(); // add the records pre-fetch command if ((typeof filterOrFetcher) === 'function') { this._commands.push(new CallFetcherCommand(filterOrFetcher)); } else { // filter this._commands.push(new PrefetchCommand(new FetchDBO( dbDriver, recordTypes, recordTypeDesc.name, [ '*' ], null, filterOrFetcher, null, null, 'exclusive'))); } // add record updates command this._commands.push(new UpdateRecordsCommand(patch)); // add entangled records update commands this._commands.push(this._createUpdateEntangledRecordsCommand()); // add record collections monitor notification command this._commands.push(this._createNotifyRecordCollectionsMonitorCommand()); // build update query tree: // add nested object ids to the update properties tree const involvedPropPaths = new Set(patch.involvedPropPaths); for (let propPath of involvedPropPaths) { let container = recordTypeDesc; for (let propName of propPath.split('.')) { const propDesc = container.getPropertyDesc(propName); container = propDesc.nestedProperties; if (container && container.idPropertyName) involvedPropPaths.add( container.nestedPath + container.idPropertyName); } } // add record meta-info props to the update properties tree [ 'version', 'modificationTimestamp', 'modificationActor' ] .forEach(r => { const propName = recordTypeDesc.getRecordMetaInfoPropName(r); if (propName) { involvedPropPaths.add(propName); if (r === 'modificationActor') this._actorRequired = true; } }); // build update properties tree const baseValueExprCtx = new ValueExpressionContext( '', [ recordTypeDesc ]); const recordsPropDesc = recordTypes.getRecordTypeDesc( recordTypeDesc.superRecordTypeName).getPropertyDesc('records'); const updatePropsTree = propsTreeBuilder.buildSimplePropsTree( recordTypes, recordsPropDesc, 'update', baseValueExprCtx, involvedPropPaths); // build update query tree (used by the execution context) this._updateQueryTree = queryTreeBuilder.forDirectQuery( dbDriver, recordTypes, 'update', false, updatePropsTree); } /** * Provides the update DBO with records to update. * * @callback module:x2node-dbos~UpdateDBO~recordsFetcher * @param {module:x2node-dbos~DBOExecutionContext} ctx DBO execution context. * @returns {(Array.<Object>|Promise.<Array.<Object>>)} The records or a * promise of the records. The records must include all properties fetched by * default. Also, it is recommended that the fetcher makes sure that the * records are exclusively locked in the database. */ /** * Validates the record after applying the patch but before saving it to the * database. * * @callback module:x2node-dbos~UpdateDBO~recordValidator * @param {Object} record The record after the patch has been applied. The * record includes all properties that are fetched by default (by default, * that includes all properties that are not views, not calculated and not * dependent record references). * @returns {(*|Promise)} If returns a promise, it can be either resolved to * proceed with the record save or rejected, in which case the whole DBO * execution is aborted and the promise returned by the DBO's * [execute()]{@link module:x2node-dbos~UpdateDBO~execute} method is rejected * with the record validator's rejection object. If returned value is not a * promise (including nothing), the record save continues. */ /** * Record validator functions. * * @typedef {Object} module:x2node-dbos~UpdateDBO~RecordValidators * @property {module:x2node-dbos~UpdateDBO~recordValidator} [beforePatch] * Validator function invoked before applying the patch. * @property {module:x2node-dbos~UpdateDBO~recordValidator} [afterPatch] * Validator function invoked after applying the patch. */ /** * Update DBO execution result object. * * @typedef module:x2node-dbos~UpdateDBO~Result * @property {Array.<Object>} records All patched matched records with all * properties that are fetched by default. * @property {Array.<(string|number)>} updatedRecordIds Ids of records * actually modified by the operation, empty array if none. * @property {boolean} testFailed <code>true</code> if any of the matched * records were not updated because a "test" patch operation failed. * @property {Array} [failedRecordIds] If <code>testFailed</code> is * <code>true</code>, this lists the ids of the failed records. */ /** * Execute the operation. * * @param {(module:x2node-dbos~Transaction|*)} txOrCon The active database * transaction, or database connection object compatible with the database * driver to have the method automatically organize the transaction around * the operation execution. * @param {?module:x2node-common.Actor} actor Actor executing the DBO. * @param {?(module:x2node-dbos~UpdateDBO~RecordValidators|module:x2node-dbos~Update