UNPKG

breeze-sequelize

Version:
431 lines 22.2 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); // import Promise from 'bluebird'; const breeze_client_1 = require("breeze-client"); const _ = require("lodash"); const SaveMap_1 = require("./SaveMap"); const toposort = require("toposort"); class ServerSaveError extends Error { constructor(m, entityErrors) { super(m); this.entityErrors = entityErrors; // Set the prototype explicitly. Object.setPrototypeOf(this, ServerSaveError.prototype); } } exports.ServerSaveError = ServerSaveError; class SequelizeSaveError extends Error { constructor(e, entity, entityState) { super(e.message); e.stack = undefined; breeze_client_1.core.extend(this, e); this.entity = entity; this.entityState = entityState; Object.setPrototypeOf(this, SequelizeSaveError.prototype); } } exports.SequelizeSaveError = SequelizeSaveError; /** Handles saving entities from Breeze SaveChanges requests */ class SequelizeSaveHandler { /** Create an instance for the given save request */ constructor(sequelizeManager, req) { const reqBody = req.body; this.sequelizeManager = sequelizeManager; this.metadataStore = sequelizeManager.metadataStore; this.entitiesFromClient = reqBody.entities; this.saveOptions = reqBody.saveOptions; this._keyMappings = []; this._fkFixupMap = {}; this._savedEntities = []; this.keyGenerator = sequelizeManager.keyGenerator; } /** Save the entities in the save request, returning either the saved entities or an error collection */ save() { return __awaiter(this, void 0, void 0, function* () { const beforeSaveEntity = (this.beforeSaveEntity || noopBeforeSaveEntity).bind(this); const entityTypeMap = {}; const entityInfos = this.entitiesFromClient.map(entity => { // transform entities from how they are sent from the client // into entityInfo objects which is how they are exposed // to interception on the server. const entityAspect = entity.entityAspect; const entityTypeName = entityAspect.entityTypeName; let entityType = entityTypeMap[entityTypeName]; if (!entityType) { entityType = this.metadataStore.getEntityType(entityTypeName); if (entityType) { entityTypeMap[entityTypeName] = entityType; } else { throw new Error("Unable to locate server side metadata for an EntityType named: " + entityTypeName); } } const unmapped = entity.__unmapped; const ei = { entity: entity, entityType: entityType, entityAspect: entityAspect, unmapped: unmapped }; // just to be sure that we don't try to send it to the db server or return it to the client. delete entity.entityAspect; return ei; }); // create the saveMap (entities to be saved) grouped by entityType const saveMapData = _.groupBy(entityInfos, entityInfo => { // _.groupBy will bundle all undefined returns together. if (beforeSaveEntity(entityInfo)) { return entityInfo.entityType.name; } }); // remove the entries where beforeSaveEntity returned false ( they are all grouped under 'undefined' delete saveMapData["undefined"]; // want to have SaveMap functions available const saveMap = _.extend(new SaveMap_1.SaveMap(this), saveMapData); return this._saveWithTransaction(saveMap); }); } _saveWithTransaction(saveMap) { return __awaiter(this, void 0, void 0, function* () { const sequelize = this.sequelizeManager.sequelize; // TODO: consider making the isolation level settable on the SequelizeManager. // const trx = await sequelize.transaction( { isolationLevel: Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED }); const trx = yield sequelize.transaction(); const beforeSaveEntities = (this.beforeSaveEntities || noopBeforeSaveEntities).bind(this); // beforeSaveEntities will either return nothing or a promise. const sm = yield beforeSaveEntities(saveMap, trx); // saveCore returns either a list of entities or an object with an errors property. try { const r = yield this._saveCore(saveMap, trx); trx.commit(); return { entities: r, keyMappings: this._keyMappings }; } catch (e) { // will throw either a ServerSaveError or a SequelizeSaveError trx.rollback(); // we have to return an object with an 'errors' property if (e instanceof ServerSaveError) { return { errors: e.entityErrors, message: e.message }; } else if (e instanceof SequelizeSaveError) { return { errors: [e], message: e.name + ": " + e.message }; } else { return { errors: [], message: e.message }; } } }); } _saveCore(saveMap, transaction) { return __awaiter(this, void 0, void 0, function* () { if (saveMap.entityErrors || saveMap.errorMessage) { throw new ServerSaveError(saveMap.errorMessage, saveMap.entityErrors); } // guaranteed to succeed because these have all been looked up earlier. const entityTypes = _.keys(saveMap).map(entityTypeName => this.metadataStore.getEntityType(entityTypeName)); const sortedEntityTypes = toposortEntityTypes(entityTypes); const entityGroups = sortedEntityTypes.map((entityType) => { return { entityType: entityType, entityInfos: saveMap[entityType.name] }; }); // do adds/updates first followed by deletes in reverse order. // add/updates come first because we might move children off of a parent before deleting the parent // and we don't want to cause a constraint exception by deleting the parent before all of its // children have been moved somewhere else. const addedUpdatedEntities = []; // can't use entityGroups.map here because we DO NOT want the groups to run in parallel. for (const entityGroup of entityGroups) { const r = yield this._processEntityGroup(entityGroup, transaction, false); addedUpdatedEntities.push(...r); } const deletedEntities = []; // can't use entityGroups.map here because we DO NOT want the groups to run in parallel. for (const entityGroup of entityGroups.reverse()) { const r = yield this._processEntityGroup(entityGroup, transaction, true); deletedEntities.push(...r); } const savedEntities = [...addedUpdatedEntities, ...deletedEntities]; return savedEntities; }); } _processEntityGroup(entityGroup, transaction, processDeleted) { return __awaiter(this, void 0, void 0, function* () { const entityType = entityGroup.entityType; let entityInfos = entityGroup.entityInfos.filter(entityInfo => { const isDeleted = entityInfo.entityAspect.entityState === "Deleted"; return processDeleted ? isDeleted : !isDeleted; }); const sqModel = this.sequelizeManager.entityTypeSqModelMap[entityType.name]; entityInfos = toposortEntityInfos(entityType, entityInfos); if (processDeleted) { entityInfos = entityInfos.reverse(); } const promises = entityInfos.map((entityInfo) => __awaiter(this, void 0, void 0, function* () { return yield this._saveEntityAsync(entityInfo, sqModel, transaction); })); const savedEntities = yield Promise.all(promises); return savedEntities; }); } _saveEntityAsync(entityInfo, sqModel, transaction) { return __awaiter(this, void 0, void 0, function* () { // function returns a promise for this entity // and updates the results array. // not a "real" entityAspect - just the salient pieces sent from the client. const entity = entityInfo.entity; const entityAspect = entityInfo.entityAspect; const entityType = entityInfo.entityType; const entityTypeName = entityType.name; // TODO: determine if this is needed because we need to strip the entityAspect off the entity for inserts. entityAspect.entity = entity; // TODO: we really only need to coerce every field on an insert // only selected fields are needed for update and delete. this._coerceData(entity, entityType); const keyProperties = entityType.keyProperties; const firstKeyPropName = keyProperties[0].nameOnServer; const entityState = entityAspect.entityState; if (entityState === "Added") { let keyMapping = null; // NOTE: there are two instances of autoGeneratedKeyType available // one on entityType which is part of the metadata and a second // on the entityAspect that was sent as part of the save. // The one on the entityAspect "overrides" the one on the entityType. const autoGeneratedKey = entityAspect.autoGeneratedKey; const autoGeneratedKeyType = autoGeneratedKey && autoGeneratedKey.autoGeneratedKeyType; const tempKeyValue = entity[firstKeyPropName]; if (autoGeneratedKeyType && autoGeneratedKeyType !== "None") { let realKeyValue; if (autoGeneratedKeyType === "KeyGenerator") { if (this.keyGenerator == null) { throw new Error("No KeyGenerator was provided for property:" + keyProperties[0].name + " on entityType: " + entityType.name); } const nextId = yield this.keyGenerator.getNextId(keyProperties[0]); realKeyValue = nextId; entity[firstKeyPropName] = realKeyValue; } else if (autoGeneratedKeyType === "Identity") { const keyDataTypeName = keyProperties[0].dataType.name; if (keyDataTypeName === "Guid") { // handled here instead of one the db server. realKeyValue = createGuid(); entity[firstKeyPropName] = realKeyValue; } else { // realValue will be set during 'create' promise resolution below realKeyValue = null; // value will be set by server's autoincrement logic delete entity[firstKeyPropName]; } } // tempKeyValue will be undefined in entity was created on the server if (tempKeyValue != undefined) { keyMapping = { entityTypeName: entityTypeName, tempValue: tempKeyValue, realValue: realKeyValue }; } } try { const savedEntity = yield sqModel.create(entity, { transaction: transaction }); if (keyMapping) { if (keyMapping.realValue === null) { keyMapping.realValue = savedEntity[firstKeyPropName]; } const tempKeyString = buildKeyString(entityType, tempKeyValue); this._fkFixupMap[tempKeyString] = keyMapping.realValue; this._keyMappings.push(keyMapping); } return this._addToResults(savedEntity.dataValues, entityTypeName); } catch (e) { throw new SequelizeSaveError(e, entity, entityState); } } else if (entityState === "Modified") { const whereHash = {}; keyProperties.forEach(kp => { whereHash[kp.nameOnServer] = entity[kp.nameOnServer]; }); if (entityType.concurrencyProperties && entityType.concurrencyProperties.length > 0) { entityType.concurrencyProperties.forEach(cp => { // this is consistent with the client behaviour where it does not update the version property // if its data type is binary if (cp.dataType.name === 'Binary') { whereHash[cp.nameOnServer] = entity[cp.nameOnServer]; } else { whereHash[cp.nameOnServer] = entityAspect.originalValuesMap[cp.nameOnServer]; } }); } let setHash; if (entityInfo.forceUpdate) { setHash = _.clone(entity); // remove fields that we don't want to 'set' delete setHash.entityAspect; // TODO: should we also remove keyProps here... } else { setHash = {}; const ovm = entityAspect.originalValuesMap; if (ovm == null) { throw new Error("Unable to locate an originalValuesMap for one of the 'Modified' entities to be saved"); } Object.keys(ovm).forEach(k => { // if k is one of the entityKeys do no allow this const isKeyPropName = keyProperties.some(kp => { return kp.nameOnServer === k; }); if (isKeyPropName) { throw new Error("Breeze does not support updating any part of the entity's key insofar as this changes the identity of the entity"); } setHash[k] = entity[k]; }); } // don't bother executing update statement if nothing to update // this can happen if setModified is called without any properties being changed. if (_.isEmpty(setHash)) { return this._addToResults(entity, entityTypeName); } try { const infoArray = yield sqModel.update(setHash, { where: whereHash, transaction: transaction }); const itemsSaved = infoArray[0]; if (itemsSaved !== 1) { const err = new Error("unable to update entity - concurrency violation"); err.entity = entity; err.entityState = entityState; throw err; } // HACK: Sequelize 'update' does not return the entity; so // we are just returning the original entity here. return this._addToResults(entity, entityTypeName); } catch (e) { throw new SequelizeSaveError(e, entity, entityState); } } else if (entityState === "Deleted") { const whereHash = {}; keyProperties.forEach(kp => { whereHash[kp.nameOnServer] = entity[kp.nameOnServer]; }); try { // we don't bother with concurrency check on deletes // TODO: we may want to add a 'switch' for this later. yield sqModel.destroy({ where: whereHash, limit: 1, transaction: transaction }); // Sequelize 'destroy' does not return the entity; so // we are just returning the original entity here. return this._addToResults(entity, entityTypeName); } catch (e) { throw new SequelizeSaveError(e, entity, entityState); } } }); } _addToResults(entity, entityTypeName) { entity.$type = entityTypeName; entity.entityAspect = undefined; this._savedEntities.push(entity); return entity; } _coerceData(entity, entityType) { entityType.dataProperties.forEach(dp => { const val = entity[dp.nameOnServer]; if (val != null) { if (dp.relatedNavigationProperty != null) { // if this is an fk column and it has a value // check if there is a fixed up value. const key = buildKeyString(dp.relatedNavigationProperty.entityType, val); const newVal = this._fkFixupMap[key]; if (newVal) { entity[dp.nameOnServer] = newVal; } } const dtName = dp.dataType.name; if (dtName === "DateTime" || dtName === "DateTimeOffset") { entity[dp.nameOnServer] = new Date(Date.parse(val)); } } else { // // this allows us to avoid inserting a null. // // TODO: think about an option to allow this if someone really wants to. // delete entity[dp.name]; // } } }); } } exports.SequelizeSaveHandler = SequelizeSaveHandler; function noopBeforeSaveEntities(saveMap, trx) { return Promise.resolve(saveMap); } function noopBeforeSaveEntity(entityInfo) { return true; } /** Sort the EntityTypes based on their dependencies */ function toposortEntityTypes(entityTypes) { const edges = []; entityTypes.forEach(et => { et.foreignKeyProperties.forEach(fkp => { if (fkp.relatedNavigationProperty) { const dependsOnType = fkp.relatedNavigationProperty.entityType; if (et !== dependsOnType) { edges.push([et, dependsOnType]); } } }); }); // this should work but toposort.array seems to have a bug ... // let sortedEntityTypes = toposort.array(entityTypes, edges).reverse(); // so use this instead. const allSortedTypes = toposort(edges).reverse(); allSortedTypes.forEach(function (st, ix) { st.index = ix; }); const sortedEntityTypes = entityTypes.sort(function (a, b) { return a.index - b.index; }); return sortedEntityTypes; } /** Sort the EntityInfos of a given type based foreign key relationships */ function toposortEntityInfos(entityType, entityInfos) { const edges = []; const selfReferenceNavProp = _.find(entityType.navigationProperties, navProp => navProp.entityType === entityType); if (!selfReferenceNavProp || !selfReferenceNavProp.relatedDataProperties) { return entityInfos; } const fkDataProp = selfReferenceNavProp.relatedDataProperties[0].name; const keyProp = entityType.keyProperties[0].name; entityInfos.forEach(function (entityInfo) { const dependsOn = entityInfo.entity[fkDataProp]; if (dependsOn) { const dependsOnInfo = _.find(entityInfos, x => x.entity[keyProp] === dependsOn && x.entity !== entityInfo.entity); // avoid referencing the same object if (dependsOnInfo) { edges.push([entityInfo, dependsOnInfo]); } } }); if (edges.length === 0) { return entityInfos; } const allSortedEntityInfos = toposort(edges).reverse(); allSortedEntityInfos.forEach(function (st, ix) { st.__index = ix; }); const sortedEntityInfos = entityInfos.sort(function (a, b) { return a.__index - b.__index; }); return sortedEntityInfos; } function buildKeyString(entityType, val) { return entityType.name + "::" + val.toString(); } function createGuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { // tslint:disable-next-line: triple-equals const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } //# sourceMappingURL=SequelizeSaveHandler.js.map