UNPKG

@kitmi/data

Version:

Jacaranda Framework Data Access Model

1,393 lines (1,169 loc) 69 kB
import { ApplicationError, InvalidArgument, ValidationError, HttpCode } from '@kitmi/types'; import { _, eachAsync_, isPlainObject, isEmpty, get as _get } from '@kitmi/utils'; import typeSystem from '@kitmi/validators/allSync'; import * as Features from './entityFeatures'; import Rules from './Rules'; import defaultGenerator from './TypeGenerators'; import { hasValueInAny, xrDataSet, xrSql } from './helpers'; import { OpCompleted } from './utils/errors'; const featureRules = _.reduce( Features, (acc, feature, name) => { _.each(feature, (action, rule) => { acc[name + '.' + rule] = action; }); return acc; }, {} ); const NEED_OVERRIDE = 'Should be overrided by driver-specific subclass.'; export const FLAG_DRY_RUN_IGNORE = Symbol('dryRun'); /** * Base entity model class. * @class */ class EntityModel { /** * @param {Object} [rawData] - Raw data object */ constructor(db) { this.db = db; } get meta() { return this.constructor.meta; } /** * Get the value of key field/fields from data. * @param {object} data * @returns {*} */ valueOfKey(data) { return Array.isArray(this.meta.keyField) ? _.pick(data, this.meta.keyField) : data[this.meta.keyField]; } /** * Get a map of fields schema by predefined input set. * @param {string} datasetName - Input set name, predefined in geml * @return {object} Schema object */ datasetSchema(datasetName) { const creator = this.meta.schemas[datasetName]; if (creator == null) { throw new InvalidArgument(`Unknown dataset "${datasetName}" of entity "${this.meta.name}".`); } return creator(); } /** * Remove readonly fields from data record. * @param {Object} data * @returns {Object} */ omitReadOnly(data) { return _.omitBy(data, (value, key) => !this.meta.fields[key] || this.meta.fields[key].readOnly); } /** * Get related entity model by anchor. * @param {string} anchor * @returns {EntityModel} */ getRelatedEntity(anchor) { const assocInfo = this.meta.associations[anchor]; if (!assocInfo) { throw new InvalidArgument(`Unknown association "${anchor}" of entity "${this.meta.name}".`); } return this.db.entity(assocInfo.entity); } /** * Get chained related entity model by dot-separated name path. * @param {*} dotPath * @returns {EntityModel} */ getChainedRelatedEntity(dotPath) { Array.isArray(dotPath) || (dotPath = dotPath.split('.')); let base = this; while (dotPath.length) { base = base.getRelatedEntity(dotPath.shift()); } return base; } /** * Get field names array of a unique key from input data. * @param {object} data - Input data. */ getUniqueKeyFieldsFrom(data) { return _.find(this.meta.uniqueKeys, (fields) => _.every(fields, (f) => data[f] != null)); } /** * Get key-value pairs of a unique key from input data. * @param {object} data - Input data. */ getUniqueKeyValuePairsFrom(data) { const ukFields = this.getUniqueKeyFieldsFrom(data); return _.pick(data, ukFields); } /** * Ensure context.latest be the just created entity. * @param {*} context * @param {*} customOptions */ ensureRetrieveCreated(context, customOptions) { if (!context.options.$getCreated) { context.options.$getCreated = customOptions || true; } } /** * Ensure context.latest be the just updated entity. * @param {*} context * @param {*} customOptions */ ensureRetrieveUpdated(context, customOptions) { if (!context.options.$getUpdated) { context.options.$getUpdated = customOptions || true; } } /** * Ensure context.exisintg be the just deleted entity. * @param {*} context * @param {*} customOptions */ ensureRetrieveDeleted(context, customOptions) { if (!context.options.$getDeleted) { context.options.$getDeleted = customOptions || true; } } /** * Get value from context, e.g. session, query ... * @param {*} context * @param {string} key * @returns {*} */ getValueFromContext(context, key) { return _get(context.options.$ctx, key); } /** * Pick only allowed fields from ctx. * @param {*} options * @returns {object} */ _wrapCtx(options) { if (options && options.$ctx && options.$ctx.module) { return { ...options, $ctx: this.extractFromCtx(options.$ctx), }; } if (this.db.ctx) { return { ...options, $ctx: this.db.ctx }; } return options ?? {}; } async applyRules_(ruleName, context) { for (const featureName in this.meta.features) { const key = featureName + '.' + ruleName; const action = featureRules[key]; if (action) { let featureInfo = this.meta.features[featureName]; if (context.options.$features && featureName in context.options.$features) { const customFeatureInfo = context.options.$features[featureName]; if (!customFeatureInfo) { continue; } featureInfo = { ...featureInfo, ...customFeatureInfo }; } await action(featureInfo, this, context); } } } applyRulesSync(ruleName, context) { for (const featureName in this.meta.features) { const key = featureName + '.' + ruleName + 'Sync'; const action = featureRules[key]; if (action) { let featureInfo = this.meta.features[featureName]; if (context.options.$features && featureName in context.options.$features) { const customFeatureInfo = context.options.$features[featureName]; if (!customFeatureInfo) { continue; } featureInfo = { ...featureInfo, ...customFeatureInfo }; } action(featureInfo, this, context); } } } /** * Find a record by unique keys, returns a model object containing the record or undefined if nothing found. * @param {object} [findOptions] - findOptions * @property {object} [findOptions.$relation] - Joinings * @property {object} [findOptions.$select] - Selected fields * @property {object} [findOptions.$where] - Extra condition * @property {object} [findOptions.$groupBy] - Group by fields * @property {object} [findOptions.$orderBy] - Order by fields * @property {number} [findOptions.$offset] - Offset * @property {number} [findOptions.$limit] - Limit * @property {bool} [findOptions.$includeDeleted=false] - Include those marked as logical deleted. * @property {bool} [findOptions.$skipOrm=false] - Skip ORM mapping * @property {object} [findOptions.$ctx] * @returns {*} */ async findOne_(findOptions) { return this._find_(findOptions, true); } /** * Find records matching the condition, returns an array of records. * @param {object} [findOptions] - findOptions * @property {object} [findOptions.$relation] - Joinings * @property {object} [findOptions.$select] - Selected fields * @property {object} [findOptions.$where] - Extra condition * @property {object} [findOptions.$groupBy] - Group by fields * @property {object} [findOptions.$orderBy] - Order by fields * @property {number} [findOptions.$offset] - Offset * @property {number} [findOptions.$limit] - Limit * @property {string} [findOptions.$countBy] - Return totalCount * @property {bool} [findOptions.$includeDeleted=false] - Include those marked as logical deleted. * @property {bool} [findOptions.$skipOrm=false] - Skip ORM mapping * @property {object} [findOptions.$view] - Use a view * @returns {array} */ async findMany_(findOptions) { return this._find_(findOptions, false); } /** * Find records matching the condition and returns page by page. * @param {object} findOptions * @param {integer} page * @param {integer} rowsPerPage * @returns {array} */ async findManyByPage_(findOptions, page, rowsPerPage) { return this.findMany_({ ...findOptions, $limit: rowsPerPage, $offset: (page - 1) * rowsPerPage }); } findSql(findOptions) { findOptions = this._wrapCtx(findOptions); findOptions = this._normalizeQuery(findOptions, false /* for multiple record */); findOptions.$skipOrm = true; findOptions.$sqlOnly = true; const context = { op: 'find', options: findOptions, isOne: false, }; const opOptions = context.options; this.applyRulesSync(Rules.RULE_BEFORE_FIND, context); this._preProcessOptions(opOptions, false /* for multiple record */); return this.db.connector.buildQuery(this.meta.name, opOptions); } async _find_(findOptions, isOne) { findOptions = this._wrapCtx(findOptions); findOptions = this._tryUseView(findOptions); findOptions = this._normalizeQuery(findOptions, isOne /* for single record */); const context = { op: 'find', options: findOptions, isOne, }; const opOptions = context.options; await this.applyRules_(Rules.RULE_BEFORE_FIND, context); this._preProcessOptions(opOptions, isOne /* for single record */); return this._safeExecute_(async () => { const result = await this.db.connector.find_(this.meta.name, opOptions, this.db.transaction); let data = result.data; if (opOptions.$assoc && !opOptions.$skipOrm) { // rows, coloumns, aliasMap if (data.length > 0) { data = this._mapRecordsToObjects(result, opOptions.$assoc, opOptions.$nestedGet); } } if (isOne && data.length > 1) { this.db.app.log('warn', `"findOne_" returns more than one record.`, { entity: this.meta.name, options: _.omit(opOptions, ['$assoc']), reqId: findOptions?.$ctx?.state.reqId, }); } result.data = isOne ? data[0] : data ?? []; delete result.fields; delete result.aliases; delete result.affectedRows; context.latest = context.result = result; return isOne ? result.data : result; }, context); } async ensureTransaction_(ctx, purpose) { if (!this.db.transaction) { if (!this._safeFlag) { throw new ApplicationError('Transaction is not allowed outside of a safe execution block.'); } this.db = this.db.fork(await this.db.connector.beginTransaction_(ctx, purpose)); } } async _commitOwnedTransaction_(context) { if (this.db.transaction) { // exception safe await this.db.connector.commit_( this.db.transaction, context.options.$ctx, `${this.meta.name}.${context.op}._commitOwnedTransaction_` ); const newDbInstance = this.db.fork(); this.db.end(); // reset db with a new instance this.db = newDbInstance; } } async _rollbackOwnedTransaction_(error, context) { if (this.db.transaction) { // exception safe await this.db.connector.rollback_( this.db.transaction, error, context.options.$ctx, `${this.meta.name}.${context.op}_rollbackOwnedTransaction_` ); const newDbInstance = this.db.fork(); this.db.end(); // reset db with a new instance this.db = newDbInstance; } } /** * Safely execute a function with transaction support, returns false to cancel at anytime. * @param {*} executor * @param {*} context * @returns {Promise<boolean> | boolean} */ async _safeExecute_(executor, context) { executor = executor.bind(this); let ownedTransaction = this.db.transaction == null; try { this._safeFlag = true; const result = await executor(); // if the executor have initiated a transaction if (ownedTransaction) { ownedTransaction = false; await this._commitOwnedTransaction_(context); } return result; } catch (error) { if (error instanceof OpCompleted) { if (ownedTransaction) { await this._commitOwnedTransaction_(context); } return error.payload; } if (ownedTransaction) { await this._rollbackOwnedTransaction_(error, context); } throw error; } finally { this._safeFlag = false; } } _tryUseView(findOptions) { if (findOptions.$view) { const baseView = this.meta.views[findOptions.$view]; if (baseView == null) { throw new InvalidArgument( `View "${findOptions.$view}" not found, available: ${Object.keys(this.meta.views)}` ); } const { $orderBy } = baseView; if ($orderBy) { const orderBy = findOptions.$orderByGroup ? $orderBy[findOptions.$orderByGroup] : $orderBy.$default; if (orderBy) { return { ...baseView, ...findOptions, $orderBy: orderBy }; } } return { ...baseView, ...findOptions }; } return findOptions; } /** * Normalize the `$getXXXed` query options. * @param {object} opOptions - [out] */ _normalizeReturning(opOptions, returningKey) { let returningFields = opOptions[returningKey]; if (returningFields) { const t = typeof returningFields; if (t === 'string') { if (returningFields.indexOf('.') !== -1) { throw new InvalidArgument(`"${returningKey}" does not support retrieving with associations.`); } opOptions[returningKey] = [returningFields]; return; } if (t === 'boolean') { opOptions[returningKey] = ['*']; return; } if (t === 'object') { if (isPlainObject(returningFields)) { const _returningFields = this._tryUseView(returningFields).$select; if (_returningFields == null) { throw new InvalidArgument(`Invalid "${returningKey}": ${JSON.stringify(returningFields)}`); } returningFields = _returningFields; } if (Array.isArray(returningFields)) { if (returningFields.every((v) => v.indexOf('.') === -1)) { opOptions[returningKey] = returningFields; return; } throw new InvalidArgument(`"${returningKey}" does not support retrieving with associations.`); } } throw new InvalidArgument( `Invalid value for "${returningKey}", expected: column, column array or boolean for returning all.`, { [returningKey]: returningFields, type: t, } ); } } /** * Ensure the returning of auto id if the entity has auto increment feature. * @param {object} opOptions */ _ensureReturningAutoId(opOptions) { if (this.hasAutoIncrement) { if (!opOptions.$getCreated) { opOptions.$getCreated = [this.meta.features.autoId.field]; } else if (Array.isArray(opOptions.$getCreated)) { if (opOptions.$getCreated.length === 1 && opOptions.$getCreated[0] === '*') { return; } opOptions.$getCreated = _.uniq([this.meta.features.autoId.field, ...opOptions.$getCreated]); } } } /** * Create a new entity with given data. * @param {object} data - Entity data * @param {object} [createOptions] - Create options * @property {bool|array|object} [createOptions.$getCreated=false] - Retrieve the newly created record from db, can also retrieve with a complex query. * @property {bool} [createOptions.$ignore=false] - If already exist, just ignore the insert. * @property {bool} [createOptions.$upsert=false] - If already exist, just update the record. * @property {bool} [createOptions.$dryRun=false] - Do not actually insert the record. * @returns {Promise.<EntityModel>} */ async create_(data, createOptions) { createOptions = this._wrapCtx(createOptions); // check if data contains any associations (associcated creation) or references (create with references to other entities) let [raw, associations, references] = this._extractAssociations(data, true); const context = { op: 'create', raw, options: createOptions, }; const opOptions = context.options; this._normalizeReturning(opOptions, '$getCreated'); this._ensureReturningAutoId(opOptions); // overrided by the genreated entity model if (!(await this.beforeCreate_(context))) { return context.result; } await this._safeExecute_(async () => { // use foreign data as input if (!isEmpty(references)) { if (!opOptions.$dryRun) { await this.ensureTransaction_( context.options.$ctx, `${this.meta.name}.create._populateReferences_` ); } await this._populateReferences_(context, references); } let needCreateAssocs = !isEmpty(associations); if (needCreateAssocs) { if (!opOptions.$dryRun) { await this.ensureTransaction_(context.options.$ctx, `${this.meta.name}.create._createAssocs_`); } associations = await this._createAssocs_(context, associations, true /* before create */); // check any other associations left needCreateAssocs = !isEmpty(associations); } if (opOptions.$dryRun) { await this._prepareEntityDataDryRun_(context); } else { await this._prepareEntityData_(context); } await this.applyRules_(Rules.RULE_BEFORE_CREATE, context); if (!opOptions.$dryRun) { let shouldUpsert = false; let dataForUpdating; let uniqueKeys; if (opOptions.$upsert) { dataForUpdating = _.pick(context.latest, Object.keys(context.raw)); // only update the raw part uniqueKeys = this.getUniqueKeyFieldsFrom(context.latest); if (!isEmpty(_.pick(context.latest, uniqueKeys))) { shouldUpsert = true; } } if (shouldUpsert) { let result = await this.db.connector.upsert_( this.meta.name, typeof opOptions.$upsert === 'object' ? { ...dataForUpdating, ...opOptions.$upsert } : dataForUpdating, uniqueKeys, context.latest, opOptions, this.db.transaction ); if (result.affectedRows === 0) { // insert ignored const _data = await this.findOne_({ $where: _.pick(context.latest, uniqueKeys) }); result = { data: [_data], affectedRows: 0 }; } context.result = result; } else { context.result = await this.db.connector.create_( this.meta.name, context.latest, opOptions, this.db.transaction ); } context.result.data = { ...context.latest, ...context.result.data[0] }; context.latest = context.result.data; if (this.hasAutoIncrement) { context.result.insertId = context.latest[this.meta.features.autoId.field]; } delete context.result.fields; } else { context.result = { data: context.latest, affectedRows: 1 }; } delete opOptions.$data; if (needCreateAssocs) { await this._createAssocs_(context, associations); } await this.applyRules_(Rules.RULE_AFTER_CREATE, context); }, context); if (!opOptions.$dryRun) { await this.afterCreate_(context); } return context.result; } /** * Create a new entity with selecting data from database and return the created entity. * @param {object} findOptions * @param {object} columnMapping - Column mapping from the selected data to the input of new entity * @returns {object} { data, affectedRows } */ async createFrom_(findOptions, columnMapping) { findOptions = this._wrapCtx(findOptions); findOptions = this._normalizeQuery(findOptions, false /* for single record */); const context = { op: 'find', options: findOptions, isOne: false, }; const opOptions = context.options; opOptions.$skipOrm = true; opOptions.$skipOrmWarn = true; this._normalizeReturning(opOptions, '$getCreated'); await this.applyRules_(Rules.RULE_BEFORE_FIND, context); this._preProcessOptions(opOptions, false /* for single record */); let uniqueKeys; _.each(columnMapping, (v, key) => { if (!key.startsWith('::') && !opOptions.$select.has(key)) { throw new InvalidArgument('Column mapping key not found in the select list.', { key, select: opOptions.$select, }); } }); if (opOptions.$upsert) { uniqueKeys = this.getUniqueKeyFieldsFrom(_.invert(columnMapping)); } return this._safeExecute_(async () => { context.result = await this.db.connector.createFrom_( this.meta.name, opOptions, columnMapping, uniqueKeys, this.db.transaction ); context.latest = context.result.data; delete context.result.fields; return context.result; }, context); } /** * Spin up a new entity with data generator. * @param {Function} dataGenerator_ * @param {Object} createOptions * @returns {Promise<Object>} */ async spinCreate_(dataGenerator_, createOptions) { while (true) { const data = await dataGenerator_(); try { return await this.create_(data, createOptions); } catch (err) { if (err.code === this.errCodeDuplicate) { this.db.app.warn(`Duplicate "${this.meta.name}", will retry.`, { reqId: createOptions?.$ctx?.state.reqId, }); } else { throw err; } } } } /** * Update an existing entity with given data. * @param {object} data - Entity data with at least one unique key (pair) given * @param {object} [updateOptions] - Update options * @property {object} [updateOptions.$where] - Extra condition * @property {bool} [updateOptions.$getUpdated=false] - Retrieve the updated entity from database * @returns {object} */ async updateOne_(data, updateOptions) { if (!updateOptions) { // if no condition given, extract from data const conditionFields = this.getUniqueKeyFieldsFrom(data); if (isEmpty(conditionFields)) { throw new InvalidArgument( 'Primary key value(s) or at least one group of unique key value(s) is required for updating an entity.', { entity: this.meta.name, data, } ); } updateOptions = { $where: _.pick(data, conditionFields) }; data = _.omit(data, conditionFields); } updateOptions = this._wrapCtx(updateOptions); // see if there is associated entity data provided together let [raw, associations, references] = this._extractAssociations(data); const isOne = true; const context = { op: 'update', raw, options: this._normalizeQuery(updateOptions, isOne /* for single record */), isOne, }; // see if there is any runtime feature stopping the update const toUpdate = await this.beforeUpdate_(context); if (!toUpdate) { return context.result; } const opOptions = context.options; this._normalizeReturning(opOptions, '$getUpdated'); this._preProcessOptions(opOptions, isOne /* for single record */); await this._safeExecute_(async () => { if (!isEmpty(references)) { if (!opOptions.$dryRun) { await this.ensureTransaction_( context.options.$ctx, `${this.meta.name}.update._populateReferences_` ); } await this._populateReferences_(context, references); } let needUpdateAssocs = !isEmpty(associations); let doneUpdateAssocs; if (needUpdateAssocs) { if (!opOptions.$dryRun) { await this.ensureTransaction_(context.options.$ctx, `${this.meta.name}.update._updateAssocs_`); } associations = await this._updateAssocs_(context, associations, true /* before update */, isOne); needUpdateAssocs = !isEmpty(associations); doneUpdateAssocs = true; } if (opOptions.$dryRun) { await this._prepareEntityDataDryRun_(context, true /* is updating */, isOne); } else { await this._prepareEntityData_(context, true /* is updating */, isOne); } await this.applyRules_(Rules.RULE_BEFORE_UPDATE, context); if (isEmpty(context.latest)) { if (!doneUpdateAssocs && !needUpdateAssocs) { throw new InvalidArgument('Cannot do the update with empty record. Entity: ' + this.meta.name); } const data = await this.findOne_(opOptions.$where); context.result = { data, affectedRows: 0 }; } else { if ( needUpdateAssocs && !hasValueInAny([opOptions.$where, context.latest], this.meta.keyField) && !opOptions.$getUpdated ) { // has associated data depending on this record // should ensure the latest result will contain the key of this record opOptions.$getUpdated = true; } context.result = await this.db.connector.update_( this.meta.name, context.latest, opOptions, this.db.transaction ); context.result.data = context.result.data[0] ?? {}; delete context.result.fields; } await this.applyRules_(Rules.RULE_AFTER_UPDATE, context); delete opOptions.$data; if (needUpdateAssocs) { await this._updateAssocs_(context, associations, false, isOne); } }, context); if (!opOptions.$dryRun) { await this.afterUpdate_(context); } return context.result; } /** * Update many existing entites with given data. * @param {*} data * @param {*} updateOptions * @returns {object} */ async updateMany_(data, updateOptions) { updateOptions = this._wrapCtx(updateOptions); this._ensureNoAssociations(data); const isOne = false; const context = { op: 'update', raw: data, options: this._normalizeQuery(updateOptions, isOne /* for single record */), isOne, }; // see if there is any runtime feature stopping the update const toUpdate = await this.beforeUpdateMany_(context); if (!toUpdate) { return context.result; } const opOptions = context.options; this._normalizeReturning(opOptions, '$getUpdated'); this._preProcessOptions(opOptions, isOne /* for single record */); await this._safeExecute_(async () => { if (opOptions.$dryRun) { await this._prepareEntityDataDryRun_(context, true /* is updating */, isOne); } else { await this._prepareEntityData_(context, true /* is updating */, isOne); } await this.applyRules_(Rules.RULE_BEFORE_UPDATE, context); if (isEmpty(context.latest)) { throw new InvalidArgument('Cannot do the update with empty record. Entity: ' + this.meta.name); } context.result = await this.db.connector.update_( this.meta.name, context.latest, opOptions, this.db.transaction ); context.result.data = context.result.data ?? []; delete context.result.fields; await this.applyRules_(Rules.RULE_AFTER_UPDATE, context); delete opOptions.$data; }, context); if (!opOptions.$dryRun) { await this.afterUpdateMany_(context); } return context.result; } /** * Remove an existing entity with given data. * @param {object} [deleteOptions] - Update options * @property {object} [deleteOptions.$where] - Extra condition * @property {bool} [deleteOptions.$getDeleted=false] - Retrieve the deleted entity from database * @property {bool} [deleteOptions.$physical=false] - When $physical = true, deletetion will not take into account logicaldeletion feature */ async deleteOne_(deleteOptions) { deleteOptions = this._wrapCtx(deleteOptions); return this._delete_(deleteOptions, true); } /** * Remove an existing entity with given data. * @param {object} [deleteOptions] - Update options * @property {object} [deleteOptions.$where] - Extra condition * @property {bool} [deleteOptions.$getDeleted=false] - Retrieve the deleted entity from database * @property {bool} [deleteOptions.$physical=false] - When $physical = true, deletetion will not take into account logicaldeletion feature * @property {bool} [deleteOptions.$deleteAll=false] - When $deleteAll = true, the operation will proceed even empty condition is given */ async deleteMany_(deleteOptions) { deleteOptions = this._wrapCtx(deleteOptions); return this._delete_(deleteOptions, false); } async deleteAll_(deleteOptions) { return this.deleteMany_({ ...deleteOptions, $deleteAll: true }); } /** * Remove an existing entity with given data. * @param {object} [deleteOptions] - Update options * @property {object} [deleteOptions.$where] - Extra condition * @property {bool} [deleteOptions.$getDeleted=false] - Retrieve the deleted entity from database * @property {bool} [deleteOptions.$physical=false] - When $physical = true, deletetion will not take into account logicaldeletion feature */ async _delete_(deleteOptions, isOne) { deleteOptions = this._normalizeQuery(deleteOptions, isOne /* for single record */); if (isEmpty(deleteOptions.$where) && (isOne || !deleteOptions.$deleteAll)) { throw new InvalidArgument( 'Empty condition is not allowed for deleting unless `{ $deleteAll: true }` is set in options.', { entity: this.meta.name, deleteOptions, isOne, } ); } const context = { op: 'delete', options: deleteOptions, isOne, }; let toDelete; if (isOne) { toDelete = await this.beforeDelete_(context); } else { toDelete = await this.beforeDeleteMany_(context); } if (!toDelete) { return context.result; } const opOptions = context.options; this._normalizeReturning(opOptions, '$getDeleted'); this._preProcessOptions(opOptions, isOne /* for single record */); await this._safeExecute_(async () => { await this.applyRules_(Rules.RULE_BEFORE_DELETE, context); context.result = await this.db.connector.delete_(this.meta.name, opOptions, this.db.transaction); delete context.result.fields; await this.applyRules_(Rules.RULE_AFTER_DELETE, context); }, context); if (!opOptions.$dryRun) { if (isOne) { await this.afterDelete_(context); } else { await this.afterDeleteMany_(context); } } return context.result; } /** * Check whether a data record contains primary key or at least one unique key pair. * @param {object} data */ _containsUniqueKey(data) { let hasKeyName = false; const hasNotNullKey = _.find(this.meta.uniqueKeys, (fields) => { const hasKeys = _.every(fields, (f) => f in data); hasKeyName = hasKeyName || hasKeys; return _.every(fields, (f) => data[f] != null); }); return [hasNotNullKey, hasKeyName]; } /** * Ensure the condition contains one of the unique keys. * @param {*} condition */ _ensureContainsUniqueKey(condition) { const [containsUniqueKeyAndValue, containsUniqueKeyName] = this._containsUniqueKey(condition); if (!containsUniqueKeyAndValue) { if (containsUniqueKeyName) { throw new ValidationError( 'One of the unique key field as query condition is null. Condition: ' + JSON.stringify(condition) ); } throw new InvalidArgument( 'Single record operation requires at least one unique key value pair in the query condition.', { entity: this.meta.name, condition, } ); } } /** * Prepare valid and sanitized entity data for sending to database. * @param {object} context - Operation context. * @property {object} context.raw - Raw input data. * @param {bool} isUpdating - Flag for updating existing entity. */ async _prepareEntityData_(context, isUpdating = false, isOne = true) { const i18n = this.i18n; const { name, fields } = this.meta; let { raw } = context; let latest = {}; let existing = context.existing; context.latest = latest; if (!context.i18n) { context.i18n = i18n; } const opOptions = context.options; if (opOptions.$upsert && typeof opOptions.$upsert === 'object') { raw = { ...raw, ...opOptions.$upsert }; } if (isUpdating && isEmpty(existing) && this._dependsOnExistingData(raw)) { await this.ensureTransaction_(opOptions.$ctx, `${this.meta.name}.${context.op}._dependsOnExistingData`); if (isOne) { existing = await this.findOne_({ $where: opOptions.$where, $ctx: opOptions.$ctx }); } else { throw new InvalidArgument('Cannot access existing record in multiple update.'); } context.existing = existing; } await this.applyRules_(Rules.RULE_BEFORE_VALIDATION, context); await eachAsync_(fields, async (fieldInfo, fieldName) => { let value; let useRaw = false; if (fieldName in raw) { value = raw[fieldName]; useRaw = true; } else if (fieldName in latest) { value = latest[fieldName]; } if (typeof value !== 'undefined') { // field value given in raw data if (fieldInfo.readOnly && useRaw) { if ( !opOptions.$migration && (!opOptions.$bypassReadOnly || !opOptions.$bypassReadOnly.has(fieldName)) ) { // read only, not allow to set by input value throw new ValidationError( `Read-only field "${fieldName}" is not allowed to be set by manual input.`, { entity: name, fieldInfo: fieldInfo, } ); } } if (isUpdating && fieldInfo.freezeAfterNonDefault) { if (!existing) { throw new Error('"freezeAfterNonDefault" qualifier requires existing data.'); } if (existing[fieldName] !== fieldInfo.default) { // freezeAfterNonDefault, not allow to change if value is non-default throw new ValidationError( `"freezeAfterNonDefault" field "${fieldName}" is not allowed to be changed.`, { entity: name, fieldInfo: fieldInfo, } ); } } /** todo: fix dependency, check writeProtect if (isUpdating && fieldInfo.writeOnce) { assert: existing, '"writeOnce" qualifier requires existing data.'; if (!_.isNil(existing[fieldName])) { throw new ValidationError(`Write-once field "${fieldName}" is not allowed to be update once it was set.`, { entity: name, fieldInfo: fieldInfo }); } } */ // sanitize first if (value == null) { if (fieldInfo.default != null) { // has default setting in meta data latest[fieldName] = fieldInfo.default; } else if (!fieldInfo.optional) { throw new ValidationError(`The "${fieldName}" value of "${name}" entity cannot be null.`, { entity: name, fieldInfo: fieldInfo, }); } else { latest[fieldName] = null; } } else { if ( typeof value === 'object' && (value.$xr || value.$set || value.$setAt || value.$setSlice || value.$case) ) { latest[fieldName] = value; return; } try { latest[fieldName] = typeSystem.sanitize(value, fieldInfo, i18n); } catch (error) { throw new ValidationError(`Invalid "${fieldName}" value of "${name}" entity.`, { entity: name, fieldInfo: fieldInfo, value, error: error.message, }); } } return; } // not given in raw data if (isUpdating) { if (fieldInfo.forceUpdate) { // has force update policy, e.g. updateTimestamp if (fieldInfo.updateByDb || fieldInfo.hasActivator) { return; } // require generator to refresh auto generated value if (fieldInfo.auto) { latest[fieldName] = await defaultGenerator(fieldInfo, i18n); return; } throw new ValidationError(`Field "${fieldName}" of "${name}" entity is required for each update.`, { entity: name, fieldInfo: fieldInfo, }); } return; } // new record if (!fieldInfo.autoByDb) { if ('default' in fieldInfo) { // has default setting in meta data latest[fieldName] = fieldInfo.default; } else if (fieldInfo.optional) { // ignore } else if (fieldInfo.auto) { // automatically generated latest[fieldName] = await defaultGenerator(fieldInfo, i18n); } else if (!fieldInfo.hasActivator && !fieldInfo.fillByRule) { // skip those have activators or fill by beforeCreate rule throw new ValidationError(`Field "${fieldName}" of "${name}" entity is required.`, { entity: name, fieldInfo: fieldInfo, raw, }); } } // else default value set by database or by rules }); latest = context.latest = this._translateValue(latest, opOptions); await this.applyRules_(Rules.RULE_AFTER_VALIDATION, context); if (!opOptions.$skipModifiers) { if (opOptions.$skipValidators && Array.isArray(opOptions.$skipValidators)) { opOptions.$skipValidators = new Set(opOptions.$skipValidators); } if (opOptions.$skipProcessors && Array.isArray(opOptions.$skipProcessors)) { opOptions.$skipProcessors = new Set(opOptions.$skipProcessors); } await this.applyModifiers_(context, isUpdating); } // final round process before entering database context.latest = _.mapValues(latest, (value, key) => { if (value == null) return value; if (isPlainObject(value) && value.$xr) { // there is special input column which maybe a function or an expression // postgres only support split columns, i.e. INSERT INTO VALUES // mysql support INSERT INTO SET ?? //opOptions.$requireSplitColumns = true; return value; } const fieldInfo = fields[key]; return this._serializeByTypeInfo(value, fieldInfo); }); opOptions.$data = { latest: context.latest, raw: context.raw, }; if (isUpdating) { opOptions.$data.existing = existing; } } async _prepareEntityDataDryRun_(context, isUpdating = false, isOne = true) { const i18n = this.i18n; const { name, fields } = this.meta; let { raw } = context; let latest = {}; let existing = context.existing; context.latest = latest; if (!context.i18n) { context.i18n = i18n; } const errors = []; const opOptions = context.options; if (opOptions.$upsert && typeof opOptions.$upsert === 'object') { raw = { ...raw, ...opOptions.$upsert }; } if (isUpdating && isEmpty(existing) && this._dependsOnExistingData(raw)) { if (isOne) { existing = await this.findOne_({ $where: opOptions.$where, $ctx: opOptions.$ctx }); } else { throw new InvalidArgument('Cannot access existing record in multiple update.'); } context.existing = existing; } try { await this.applyRules_(Rules.RULE_BEFORE_VALIDATION, context); } catch (error) { errors.push(error); } await eachAsync_(fields, async (fieldInfo, fieldName) => { try { let value; let useRaw = false; if (fieldName in raw) { value = raw[fieldName]; useRaw = true; } else if (fieldName in latest) { value = latest[fieldName]; } if (typeof value !== 'undefined') { // field value given in raw data if (fieldInfo.readOnly && useRaw) { if ( !opOptions.$migration && (!opOptions.$bypassReadOnly || !opOptions.$bypassReadOnly.has(fieldName)) ) { // read only, not allow to set by input value throw new ValidationError( `Read-only field "${fieldName}" is not allowed to be set by manual input.`, { entity: name, field: fieldName, value, } ); } } if (isUpdating && fieldInfo.freezeAfterNonDefault) { if (!existing) { throw new Error('"freezeAfterNonDefault" qualifier requires existing data.'); } if (existing[fieldName] !== fieldInfo.default) { // freezeAfterNonDefault, not allow to change if value is non-default throw new ValidationError( `"freezeAfterNonDefault" field "${fieldName}" is not allowed to be changed.`, { entity: name, field: fieldName, value, } ); } } /** todo: fix dependency, check writeProtect if (isUpdating && fieldInfo.writeOnce) { assert: existing, '"writeOnce" qualifier requires existing data.'; if (!_.isNil(existing[fieldName])) { throw new ValidationError(`Write-once field "${fieldName}" is not allowed to be update once it was set.`, { entity: name, fieldInfo: fieldInfo }); } } */ // sanitize first if (value == null) { if (fieldInfo.default != null) { // has default setting in meta data latest[fieldName] = fieldInfo.default; } else if (!fieldInfo.optional) { throw new ValidationError(`The "${fieldName}" value of "${name}" entity cannot be null.`, { entity: name, field: fieldName, }); } else { latest[fieldName] = null; } } else { if (typeof value === 'object' && value.$xr) { latest[fieldName] = value; return; } if (value === FLAG_DRY_RUN_IGNORE) { return; } try {