@kitmi/data
Version:
Jacaranda Framework Data Access Model
1,152 lines • 70.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
function _export(target, all) {
for(var name in all)Object.defineProperty(target, name, {
enumerable: true,
get: all[name]
});
}
_export(exports, {
FLAG_DRY_RUN_IGNORE: function() {
return FLAG_DRY_RUN_IGNORE;
},
default: function() {
return _default;
}
});
const _types = require("@kitmi/types");
const _utils = require("@kitmi/utils");
const _allSync = /*#__PURE__*/ _interop_require_default(require("@kitmi/validators/allSync"));
const _entityFeatures = /*#__PURE__*/ _interop_require_wildcard(require("./entityFeatures"));
const _Rules = /*#__PURE__*/ _interop_require_default(require("./Rules"));
const _TypeGenerators = /*#__PURE__*/ _interop_require_default(require("./TypeGenerators"));
const _helpers = require("./helpers");
const _errors = require("./utils/errors");
function _interop_require_default(obj) {
return obj && obj.__esModule ? obj : {
default: obj
};
}
function _getRequireWildcardCache(nodeInterop) {
if (typeof WeakMap !== "function") return null;
var cacheBabelInterop = new WeakMap();
var cacheNodeInterop = new WeakMap();
return (_getRequireWildcardCache = function(nodeInterop) {
return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
})(nodeInterop);
}
function _interop_require_wildcard(obj, nodeInterop) {
if (!nodeInterop && obj && obj.__esModule) {
return obj;
}
if (obj === null || typeof obj !== "object" && typeof obj !== "function") {
return {
default: obj
};
}
var cache = _getRequireWildcardCache(nodeInterop);
if (cache && cache.has(obj)) {
return cache.get(obj);
}
var newObj = {
__proto__: null
};
var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor;
for(var key in obj){
if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) {
var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null;
if (desc && (desc.get || desc.set)) {
Object.defineProperty(newObj, key, desc);
} else {
newObj[key] = obj[key];
}
}
}
newObj.default = obj;
if (cache) {
cache.set(obj, newObj);
}
return newObj;
}
const featureRules = _utils._.reduce(_entityFeatures, (acc, feature, name)=>{
_utils._.each(feature, (action, rule)=>{
acc[name + '.' + rule] = action;
});
return acc;
}, {});
const NEED_OVERRIDE = 'Should be overrided by driver-specific subclass.';
const FLAG_DRY_RUN_IGNORE = Symbol('dryRun');
/**
* Base entity model class.
* @class
*/ class EntityModel {
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) ? _utils._.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 _types.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 _utils._.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 _types.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 _utils._.find(this.meta.uniqueKeys, (fields)=>_utils._.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 _utils._.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 (0, _utils.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);
findOptions.$skipOrm = true;
findOptions.$sqlOnly = true;
const context = {
op: 'find',
options: findOptions,
isOne: false
};
const opOptions = context.options;
this.applyRulesSync(_Rules.default.RULE_BEFORE_FIND, context);
this._preProcessOptions(opOptions, false);
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.default.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: _utils._.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 _types.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 _errors.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 _types.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 _types.InvalidArgument(`"${returningKey}" does not support retrieving with associations.`);
}
opOptions[returningKey] = [
returningFields
];
return;
}
if (t === 'boolean') {
opOptions[returningKey] = [
'*'
];
return;
}
if (t === 'object') {
if ((0, _utils.isPlainObject)(returningFields)) {
const _returningFields = this._tryUseView(returningFields).$select;
if (_returningFields == null) {
throw new _types.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 _types.InvalidArgument(`"${returningKey}" does not support retrieving with associations.`);
}
}
throw new _types.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 = _utils._.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 (!(0, _utils.isEmpty)(references)) {
if (!opOptions.$dryRun) {
await this.ensureTransaction_(context.options.$ctx, `${this.meta.name}.create._populateReferences_`);
}
await this._populateReferences_(context, references);
}
let needCreateAssocs = !(0, _utils.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);
// check any other associations left
needCreateAssocs = !(0, _utils.isEmpty)(associations);
}
if (opOptions.$dryRun) {
await this._prepareEntityDataDryRun_(context);
} else {
await this._prepareEntityData_(context);
}
await this.applyRules_(_Rules.default.RULE_BEFORE_CREATE, context);
if (!opOptions.$dryRun) {
let shouldUpsert = false;
let dataForUpdating;
let uniqueKeys;
if (opOptions.$upsert) {
dataForUpdating = _utils._.pick(context.latest, Object.keys(context.raw)); // only update the raw part
uniqueKeys = this.getUniqueKeyFieldsFrom(context.latest);
if (!(0, _utils.isEmpty)(_utils._.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: _utils._.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.default.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);
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.default.RULE_BEFORE_FIND, context);
this._preProcessOptions(opOptions, false);
let uniqueKeys;
_utils._.each(columnMapping, (v, key)=>{
if (!key.startsWith('::') && !opOptions.$select.has(key)) {
throw new _types.InvalidArgument('Column mapping key not found in the select list.', {
key,
select: opOptions.$select
});
}
});
if (opOptions.$upsert) {
uniqueKeys = this.getUniqueKeyFieldsFrom(_utils._.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 ((0, _utils.isEmpty)(conditionFields)) {
throw new _types.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: _utils._.pick(data, conditionFields)
};
data = _utils._.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 (!(0, _utils.isEmpty)(references)) {
if (!opOptions.$dryRun) {
await this.ensureTransaction_(context.options.$ctx, `${this.meta.name}.update._populateReferences_`);
}
await this._populateReferences_(context, references);
}
let needUpdateAssocs = !(0, _utils.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, isOne);
needUpdateAssocs = !(0, _utils.isEmpty)(associations);
doneUpdateAssocs = true;
}
if (opOptions.$dryRun) {
await this._prepareEntityDataDryRun_(context, true, isOne);
} else {
await this._prepareEntityData_(context, true, isOne);
}
await this.applyRules_(_Rules.default.RULE_BEFORE_UPDATE, context);
if ((0, _utils.isEmpty)(context.latest)) {
if (!doneUpdateAssocs && !needUpdateAssocs) {
throw new _types.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 && !(0, _helpers.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.default.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, isOne);
} else {
await this._prepareEntityData_(context, true, isOne);
}
await this.applyRules_(_Rules.default.RULE_BEFORE_UPDATE, context);
if ((0, _utils.isEmpty)(context.latest)) {
throw new _types.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.default.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 ((0, _utils.isEmpty)(deleteOptions.$where) && (isOne || !deleteOptions.$deleteAll)) {
throw new _types.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.default.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.default.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 = _utils._.find(this.meta.uniqueKeys, (fields)=>{
const hasKeys = _utils._.every(fields, (f)=>f in data);
hasKeyName = hasKeyName || hasKeys;
return _utils._.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 _types.ValidationError('One of the unique key field as query condition is null. Condition: ' + JSON.stringify(condition));
}
throw new _types.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 && (0, _utils.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 _types.InvalidArgument('Cannot access existing record in multiple update.');
}
context.existing = existing;
}
await this.applyRules_(_Rules.default.RULE_BEFORE_VALIDATION, context);
await (0, _utils.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 _types.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 _types.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 _types.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] = _allSync.default.sanitize(value, fieldInfo, i18n);
} catch (error) {
throw new _types.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 (0, _TypeGenerators.default)(fieldInfo, i18n);
return;
}
throw new _types.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 (0, _TypeGenerators.default)(fieldInfo, i18n);
} else if (!fieldInfo.hasActivator && !fieldInfo.fillByRule) {
// skip those have activators or fill by beforeCreate rule
throw new _types.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.default.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 = _utils._.mapValues(latest, (value, key)=>{
if (value == null) return value;
if ((0, _utils.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 && (0, _utils.isEmpty)(existing) && this._dependsOnExistingData(raw)) {
if (isOne) {
existing = await this.findOne_({
$where: opOptions.$where,
$ctx: opOptions.$ctx
});
} else {
throw new _types.InvalidArgument('Cannot access existing record in multiple update.');
}
context.existing = existing;
}
try {
await this.applyRules_(_Rules.default.RULE_BEFORE_VALIDATION, context);
} catch (error) {
errors.push(error);
}
await (0, _utils.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 _types.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 _types.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 "${fieldNa