breeze-sequelize
Version:
Breeze Sequelize server implementation
431 lines • 22.2 kB
JavaScript
"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