UNPKG

@kitmi/data

Version:

Jacaranda Framework Data Access Model

627 lines (525 loc) 24.3 kB
import { _, eachAsync_, batchAsync_, isPlainObject, isEmpty, set as _set, get as _get } from '@kitmi/utils'; import EntityModel, { FLAG_DRY_RUN_IGNORE } from '../../EntityModel'; import { ApplicationError, ReferencedNotExist, ValidationError, InvalidArgument, DatabaseError } from '@kitmi/types'; import { TopoSort } from '@kitmi/algo'; import { getValueFromAny, isSelectAll } from '../../helpers'; export function putInTopoSort(topoSort, assoc, anchor) { if (assoc.on) { _.each(assoc.on, (value, key) => { const pos = key.indexOf('.'); if (pos > 0) { const base = key.substring(0, pos); if (base !== anchor) { topoSort.add(base, anchor); } } if (typeof value === 'object' && value.$xr === 'Column') { const pos2 = value.name.indexOf('.'); if (pos2 > 0) { const base = value.name.substring(0, pos2); if (base !== anchor) { topoSort.add(base, anchor); } } } }); } } /** * Relational entity model class. */ class RelationalEntityModel extends EntityModel { /** * [specific] Check if this entity has auto increment feature. */ get hasAutoIncrement() { const autoId = this.meta.features.autoId; return autoId && this.meta.fields[autoId.field].autoIncrementId; } _ensureNoAssociations(data) { for (const k in data) { if (k[0] === ':' || k[0] === '@') { throw new ValidationError(`Association data "${k}" is not allowed in entity "${this.meta.name}".`); } } } _uniqueRelations(relations) { const [normalAssocs, customAssocs] = _.partition(relations, (assoc) => typeof assoc === 'string'); return customAssocs.concat(_.uniq(normalAssocs).sort()); } /** * Preprocess relationships for non-ORM operations. * @param {*} findOptions */ _prepareAssociations(findOptions, isOne) { const associations = this._uniqueRelations(findOptions.$relation); const assocTable = {}; const cache = {}; const topoSort = new TopoSort(); associations.forEach((assoc) => { if (typeof assoc === 'object') { let { anchor, alias, ...override } = assoc; if (anchor) { this._loadAssocIntoTable(findOptions, assocTable, cache, anchor, override); return; } if (!alias) { alias = assoc.entity; } if (this.meta.associations && alias in this.meta.associations) { throw new InvalidArgument(`Alias "${alias}" conflicts with a predefined association.`, { entity: this.meta.name, alias, }); } if (!assoc.entity) { throw new InvalidArgument('Missing "entity" for custom association.', { entity: this.meta.name, alias, }); } putInTopoSort(topoSort, assoc, alias); cache[alias] = assocTable[alias] = { ...assoc, }; } else { this._loadAssocIntoTable(findOptions, assocTable, cache, assoc); } }); const dependencies = topoSort.sort(); dependencies.forEach((dep) => { const item = assocTable[dep]; if (item == null) { throw new InvalidArgument(`Association "${dep}" not found.`, { entity: this.meta.name, assoc: dep, }); } delete assocTable[dep]; assocTable[dep] = item; }); if (!isOne && associations.length > 0 && !findOptions.$skipOrm) { // to ensure correct merging, root id is required for fetching a list findOptions.$select?.add(this.meta.keyField); } return assocTable; } /** * Load association into table * @param {*} assocTable - Hierarchy with subAssocs * @param {*} cache - Dotted path as key * @param {*} assoc - Dotted path */ _loadAssocIntoTable(findOptions, assocTable, cache, assoc, override) { if (cache[assoc]) return cache[assoc]; const lastPos = assoc.lastIndexOf('.'); let result; if (lastPos === -1) { // direct association const assocInfo = { ...this.meta.associations[assoc], ...override }; if (isEmpty(assocInfo)) { throw new InvalidArgument(`Entity "${this.meta.name}" does not have the association "${assoc}".`); } if (assocInfo.list && !findOptions.$skipOrm && !isSelectAll(findOptions.$select)) { findOptions.$select.add(assoc + '.' + assocInfo.key); } result = assocTable[assoc] = assocInfo; cache[assoc] = result; } else { const base = assoc.substring(0, lastPos); const last = assoc.substring(lastPos + 1); let baseNode = cache[base]; if (!baseNode) { baseNode = this._loadAssocIntoTable(findOptions, assocTable, cache, base); } const entity = this.db.entity(baseNode.entity); const assocInfo = { ...entity.meta.associations[last], ...override }; if (isEmpty(assocInfo)) { throw new InvalidArgument(`Entity "${entity.meta.name}" does not have the association "${assoc}".`); } result = assocInfo; if (assocInfo.list && !findOptions.$skipOrm && !isSelectAll(findOptions.$select)) { findOptions.$select.add(assoc + '.' + assocInfo.key); } if (!baseNode.subAssocs) { baseNode.subAssocs = {}; } baseNode.subAssocs[last] = result; cache[assoc] = result; } return result; } /** * Pre-process assoicated db operation * @param {*} data * @param {*} isNew - New record flag, true for creating, false for updating * @returns {Array} [raw, assocs, refs]; */ _extractAssociations(data, isNew) { const raw = {}; const assocs = {}; const refs = {}; const meta = this.meta.associations; _.each(data, (v, k) => { if (k[0] === ':') { // cascade update const anchor = k.substring(1); const assocMeta = meta?.[anchor]; if (!assocMeta) { throw new ValidationError(`Unknown association "${anchor}" of entity "${this.meta.name}".`); } if (isNew && assocMeta.type && anchor in data) { throw new ValidationError( `Association data ":${anchor}" of entity "${this.meta.name}" conflicts with input value of field "${anchor}".` ); } assocs[anchor] = v; return; } if (k[0] === '@') { // update by reference const anchor = k.substring(1); const assocMeta = meta?.[anchor]; if (!assocMeta) { throw new ValidationError(`Unknown association "${anchor}" of entity "${this.meta.name}".`); } if (!assocMeta.type) { throw new ValidationError( `Association "${anchor}" cannot be used for updating by reference. Only "refersTo" or "belongsTo" is suppported.`, { entity: this.meta.name, anchor, } ); } if (isNew && anchor in data) { throw new ValidationError( `Association reference "@${anchor}" of entity "${this.meta.name}" conflicts with input value of field "${anchor}".` ); } const assocAnchor = ':' + anchor; if (assocAnchor in data) { throw new ValidationError( `Association reference "@${anchor}" of entity "${this.meta.name}" conflicts with association data "${assocAnchor}".` ); } if (v == null) { raw[anchor] = null; } else { if (typeof v !== 'object') { throw new ValidationError( `Association reference "@${anchor}" of entity "${this.meta.name}" expects an object as query condition.`, { entity: this.meta.name, anchor, } ); } refs[anchor] = v; } return; } raw[k] = v; }); return [raw, assocs, refs]; } async _populateReferences_(context, references) { const meta = this.meta.associations; await eachAsync_(references, async (refQuery, anchor) => { const assocMeta = meta[anchor]; const ReferencedEntity = this.db.entity(assocMeta.entity); const created = await ReferencedEntity.findOne_({ ...refQuery, $select: [assocMeta.field] }); if (created == null) { throw new ReferencedNotExist( `Referenced entity "${ReferencedEntity.meta.name}" with ${JSON.stringify(refQuery)} not exist.` ); } context.raw[anchor] = created[assocMeta.field]; }); } async _createAssocs_(context, assocs, beforeEntityCreate) { const meta = this.meta.associations; const opOptions = context.options; let keyValue; if (!beforeEntityCreate) { keyValue = context.result.data[this.meta.keyField]; if (keyValue == null) { if (context.result.affectedRows === 0) { // only happens when insert ignored const query = this.getUniqueKeyValuePairsFrom(context.result.data); const data = await this.findOne_({ $select: [this.meta.keyField], $where: query }); if (data == null) { throw new ApplicationError( 'The parent entity cannot be found using the unique key(s) from data.', { entity: this.meta.name, query, } ); } Object.assign(context.result.data, data); keyValue = data[this.meta.keyField]; } if (keyValue == null) { throw new ApplicationError('Missing required primary key field value.', { entity: this.meta.name, data: context.result.data, }); } } } const pendingAssocs = {}; const finished = {}; // todo: double check to ensure including all required options const passOnOptions = _.pick(opOptions, ['$skipModifiers', '$migration', '$ctx', '$dryRun']); await eachAsync_(assocs, async (data, anchor) => { const assocMeta = meta?.[anchor]; if (assocMeta == null) { throw new InvalidArgument(`Association "${anchor}" of entity "${this.meta.name}" not found.`, { entity: this.meta.name, anchor, }); } if (beforeEntityCreate && !assocMeta.type) { // reverse reference should be created after the entity is created pendingAssocs[anchor] = data; return; } const assocModel = this.db.entity(assocMeta.entity); if (assocMeta.list) { // hasMany if (!Array.isArray(data)) { throw new InvalidArgument(`Association "${anchor}" is a list, array is expected.`, { entity: this.meta.name, anchor, remote: assocMeta.entity, }); } return batchAsync_(data, (item) => assocModel.create_({ ...item, [assocMeta.field]: keyValue }, { ...passOnOptions, $upsert: true }) ); } if (!isPlainObject(data)) { throw new InvalidArgument(`Association "${anchor}" expects an object as input.`, { entity: this.meta.name, anchor, remote: assocMeta.entity, }); } if (!beforeEntityCreate) { // hasOne data = { ...data, [assocMeta.field]: keyValue }; } let created = await assocModel.create_(data, { ...passOnOptions, $upsert: true }); if (!beforeEntityCreate) { return; } if (created.affectedRows === 0 || (assocModel.hasAutoIncrement && created.insertId === 0)) { // insert ignored or upserted const assocQuery = assocModel.getUniqueKeyValuePairsFrom(data); const _data = await assocModel.findOne_({ $select: [assocModel.meta.keyField], $where: assocQuery }); if (_data == null) { throw new ApplicationError('The child entity cannot be found using the unique key(s) from data.', { entity: assocModel.meta.name, query: assocQuery, }); } } finished[anchor] = opOptions.$dryRun ? FLAG_DRY_RUN_IGNORE : created.data[assocMeta.field]; }); if (beforeEntityCreate) { Object.assign(context.raw, finished); } return pendingAssocs; } async _updateAssocs_(context, assocs, beforeEntityUpdate) { const meta = this.meta.associations; const opOptions = context.options; let currentKeyValue; if (!beforeEntityUpdate) { currentKeyValue = getValueFromAny([opOptions.$where, context.result.data], this.meta.keyField); if (currentKeyValue == null) { // should have in updating throw new ApplicationError('Missing required primary key field value.', { entity: this.meta.name, query: opOptions.$where, data: context.result.data, }); } } const pendingAssocs = {}; // todo: double check to ensure including all required options const passOnOptions = _.pick(context.options, ['$skipModifiers', '$migration', '$ctx', '$upsert']); await eachAsync_(assocs, async (data, anchor) => { const assocMeta = meta?.[anchor]; if (assocMeta == null) { throw new InvalidArgument(`Association "${anchor}" of entity "${this.meta.name}" not found.`, { entity: this.meta.name, anchor, }); } if (beforeEntityUpdate && !assocMeta.type) { // reverse reference should be updated after the entity is updated pendingAssocs[anchor] = data; return; } const assocModel = this.db.entity(assocMeta.entity); if (assocMeta.list) { // has many if (!Array.isArray(data)) { if (typeof data === 'object') { const { $delete, $update, $create, ...others } = data; if (!isEmpty(others)) { throw new InvalidArgument( `Association "${anchor}" is a list and array or object with { $delete, $update, $create } is expected.`, { entity: this.meta.name, anchor, remote: assocMeta.entity, } ); } if ($delete) { if (!Array.isArray($delete)) { throw new InvalidArgument( `"$delete" operation of "hasMany" association "${anchor}" requires an array.`, { entity: this.meta.name, anchor, remote: assocMeta.entity, } ); } const keysToDelete = $delete.map((item, index) => { const keyValue = item[assocModel.meta.keyField]; if (keyValue == null) { throw new InvalidArgument( `The key field "${assocModel.meta.keyField}" is required for deletion.`, { mainEntity: this.meta.name, childEntity: assocModel.meta.name, index, item, } ); } return keyValue; }); await assocModel.deleteMany_( { [assocModel.meta.keyField]: { $in: keysToDelete } }, { ...passOnOptions } ); } if ($update) { if (!Array.isArray($update)) { throw new InvalidArgument( `"$update" operation of "hasMany" association "${anchor}" requires an array.`, { entity: this.meta.name, anchor, remote: assocMeta.entity, } ); } await batchAsync_($update, async (item, index) => { const keyValue = item[assocModel.meta.keyField]; if (keyValue == null) { throw new InvalidArgument( `The key field "${assocModel.meta.keyField}" is required for updating.`, { mainEntity: this.meta.name, childEntity: assocModel.meta.name, index, item, } ); } await assocModel.updateOne_( { ...item, [assocModel.meta.keyField]: undefined }, { ...passOnOptions, $where: { [assocModel.meta.keyField]: keyValue } } ); }); } if ($create) { if (!Array.isArray($create)) { throw new InvalidArgument( `"$create" operation of "hasMany" association "${anchor}" requires an array.`, { entity: this.meta.name, anchor, remote: assocMeta.entity, } ); } await batchAsync_($create, (item) => assocModel.create_( { ...item, [assocMeta.field]: currentKeyValue }, { ...passOnOptions, $getCreated: null } ) ); } return; } throw new InvalidArgument(`Association "${anchor}" is a list and array is expected.`, { entity: this.meta.name, anchor, remote: assocMeta.entity, }); } return batchAsync_(data, (item) => assocModel.create_( { ...item, [assocMeta.field]: currentKeyValue }, { ...passOnOptions, $upsert: true, $getCreated: null } ) ); } if (!isPlainObject(data)) { throw new InvalidArgument(`Association "${anchor}" expects an object as input.`, { entity: this.meta.name, anchor, remote: assocMeta.entity, }); } if (beforeEntityUpdate) { if (isEmpty(data)) return; // refersTo or belongsTo let destEntityId = getValueFromAny([context.existing, context.options.$where, context.raw], anchor); if (destEntityId == null) { if (isEmpty(context.existing)) { context.existing = await this.findOne_({ $where: context.options.$where }); if (!context.existing) { throw new ValidationError(`The entity "${this.meta.name}" to be update is not found.`, { query: context.options.$where, }); } destEntityId = context.existing[anchor]; } if (destEntityId == null) { // to create the associated, existing is null let created = await assocModel.create_(data, { ...passOnOptions, $upsert: true, $getCreated: [assocMeta.field], }); if (created.affectedRows === 0) { throw new DatabaseError('Failed to create or update the referenced entity.', { entity: assocModel.meta.name, data, }); } context.raw[anchor] = created.data[assocMeta.field]; return; } } return assocModel.updateOne_(data, { ...passOnOptions, [assocMeta.field]: destEntityId }); } // hasOne return assocModel.create_( { ...data, [assocMeta.field]: currentKeyValue }, { ...passOnOptions, $upsert: true, $getCreated: null } ); }); return pendingAssocs; } } export default RelationalEntityModel;