breeze-sequelize
Version:
Breeze Sequelize server implementation
504 lines • 21.5 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 });
const breeze_client_1 = require("breeze-client");
const sequelize_1 = require("sequelize");
const SQVisitor_1 = require("./SQVisitor");
const _ = require("lodash");
const urlUtils = require("url");
/** Create an EntityQuery from a JSON-format breeze query string
* @param url - url containing query, e.g. `/orders?{freight:{">":100}}`
* @param resourceName - Name of the resource/entity. If omitted, resourceName is derived from the pathname of the url.
*/
function urlToEntityQuery(url, resourceName) {
const parsedUrl = urlUtils.parse(url, true);
resourceName = resourceName || parsedUrl.pathname;
// this is because everything after the '?' is turned into a query object with a single key
// where the key is the value of the string after the '?" and with a 'value' that is an empty string.
// So we want the key and not the value.
const keys = Object.keys(parsedUrl.query);
const jsonQueryString = keys.length ? keys[0] : '{}';
const jsonQuery = JSON.parse(jsonQueryString);
let entityQuery = new breeze_client_1.EntityQuery(jsonQuery);
entityQuery = entityQuery.from(resourceName).useNameOnServer(true);
// for debugging
entityQuery['jsonQuery'] = jsonQuery;
return entityQuery;
}
exports.urlToEntityQuery = urlToEntityQuery;
// legacy support
// export { urlToEntityQuery as entityQueryFromUrl };
// patch Breeze EntityQuery for server-side use
// TODO make this a method on SequelizeQuery, so we don't have to patch Breeze?
// Bad idea because this means we can have two different versions of EntityQuery
breeze_client_1.EntityQuery['fromUrl'] = urlToEntityQuery;
// TODO: still need to add support for fns like toUpper, length etc.
// TODO: still need to add support for some portions of any/all
// config.url:
// config.pathName: if null - url
// config.entityQuery:
// config.entityQueryFn: a fn(entityQuery) -> entityQuery
/** Converts Breeze queries to Sequelize queries */
class SequelizeQuery {
/** Create instance for the given EntityQuery, and process the query into Sequelize form */
constructor(sequelizeManager, serverSideEntityQuery) {
this.wasLogged = false;
this.sequelizeManager = sequelizeManager;
this.metadataStore = sequelizeManager.metadataStore;
this.entityQuery = serverSideEntityQuery;
try {
this.entityType = serverSideEntityQuery._getFromEntityType(this.metadataStore, true);
this.sqQuery = this._processQuery();
}
catch (err) {
this.logQuery();
throw err;
}
if (this.sequelizeManager.sequelizeOptions.logging) {
this.logQuery();
}
}
logQuery() {
if (this.wasLogged) {
return;
}
// log jsonQuery, not EntityQuery, or we get whole metadatastore
const { resourceName, jsonQuery } = this.entityQuery;
console.dir({ resourceName, jsonQuery }, { depth: 10 });
// remove `include` and `order` for logging, or we could get the whole sequelize model
const lob = Object.assign({}, this.sqQuery);
delete lob.include;
delete lob.order;
console.dir(lob, { depth: 10 });
this.wasLogged = true;
}
/** Execute the current query and return data objects */
execute(options) {
return __awaiter(this, void 0, void 0, function* () {
const r = yield this.executeRaw(options);
const result = this._reshapeResults(r);
return result;
});
}
/** Execute the current query and return the Sequelize Models */
executeRaw(options) {
return __awaiter(this, void 0, void 0, function* () {
const model = this.sequelizeManager.resourceNameSqModelMap[this.entityQuery.resourceName];
const methodName = this.entityQuery.inlineCountEnabled ? "findAndCountAll" : "findAll";
options = options || { useTransaction: false, beforeQueryEntities: undefined };
if (options.useTransaction) {
const trans = yield this.sequelizeManager.sequelize.transaction();
this.transaction = trans;
this.sqQuery.transaction = trans;
}
if (options.beforeQueryEntities) {
options.beforeQueryEntities.call(this, this);
}
try {
const results = yield model[methodName].call(model, this.sqQuery);
if (options.useTransaction) {
this.sqQuery.transaction.commit();
}
return results;
}
catch (e) {
if (options.useTransaction) {
this.sqQuery.transaction.rollback();
}
this.logQuery();
throw e;
}
});
}
// pass in either a query string or a urlQuery object
// a urlQuery object is what is returned by node's url.parse(aUrl, true).query;
_processQuery() {
const entityQuery = this.entityQuery;
const sqQuery = this.sqQuery = {};
sqQuery.include = [];
this._processWhere();
this._processSelect();
this._processOrderBy();
this._processExpand();
let section = entityQuery.takeCount;
// not ok to ignore top: 0
if (section != null) {
// HACK: sequelize limit ignores limit(0) so we need to turn it into a limit(1)
// and then 'ignore' the result later.
sqQuery.limit = entityQuery.takeCount || 1;
}
section = entityQuery.skipCount;
// ok to ignore skip: 0
if (section) {
sqQuery.offset = entityQuery.skipCount;
}
// Empty include is ok with Sequelize, but we clean it up.
if (_.isEmpty(this.sqQuery.include)) {
delete this.sqQuery.include;
}
return this.sqQuery;
}
_processWhere() {
const wherePredicate = this.entityQuery.wherePredicate;
if (wherePredicate == null) {
return;
}
const sqQuery = wherePredicate.visit({
entityType: this.entityType,
toNameOnServer: this.entityQuery.usesNameOnServer,
sequelizeQuery: this,
metadataStore: this.metadataStore
}, SQVisitor_1.toSQVisitor);
if (sqQuery && sqQuery.where) {
this.sqQuery.where = sqQuery.where;
}
if (sqQuery && sqQuery.include) {
this.sqQuery.include = sqQuery.include;
}
processAndOr(this.sqQuery);
}
_processSelect() {
const selectClause = this.entityQuery.selectClause;
const usesNameOnServer = this.entityQuery.usesNameOnServer;
if (selectClause == null) {
return;
}
// extract any nest paths and move them onto the include
const navPropertyPaths = [];
this.sqQuery.attributes = selectClause.propertyPaths.map(pp => {
const props = this.entityType.getPropertiesOnPath(pp, usesNameOnServer, true);
const isNavPropertyPath = props[0].isNavigationProperty;
if (isNavPropertyPath) {
this._addFetchInclude(this.sqQuery, props, false);
}
if (isNavPropertyPath) {
return null;
}
return usesNameOnServer ? pp : _.map(props, "nameOnServer").join(".");
}, this).filter(pp => {
return pp != null;
});
}
_processOrderBy() {
const orderByClause = this.entityQuery.orderByClause;
const usesNameOnServer = this.entityQuery.usesNameOnServer;
if (orderByClause == null) {
return;
}
const orders = this.sqQuery.order = [];
orderByClause.items.forEach(item => {
const pp = item.propertyPath;
const props = this.entityType.getPropertiesOnPath(pp, usesNameOnServer, true);
const isNavPropertyPath = props[0].isNavigationProperty;
if (isNavPropertyPath) {
this._addInclude(this.sqQuery, props);
}
const r = [];
orders.push(r);
props.forEach((prop) => {
if (prop.isNavigationProperty) {
const modelAs = this._getModelAs(prop);
r.push(modelAs);
}
else {
r.push(prop.nameOnServer);
if (item.isDesc) {
r.push("DESC");
}
}
}, this);
}, this);
}
_processExpand() {
const expandClause = this.entityQuery.expandClause;
const usesNameOnServer = this.entityQuery.usesNameOnServer;
if (expandClause == null) {
return;
}
expandClause.propertyPaths.forEach(pp => {
const props = this.entityType.getPropertiesOnPath(pp, usesNameOnServer, true);
this._addFetchInclude(this.sqQuery, props, true);
}, this);
}
_reshapeResults(sqResults) {
// -) nested projections need to be promoted up to the top level
// because sequelize will have them appearing on nested objects.
// -) Sequelize nested projections need to be removed from final results if not part of select
// -) need to support nested select aliasing
// -) inlineCount handling
this._nextId = 1;
this._keyMap = {};
this._refMap = {};
if (this.entityQuery.selectClause) {
return this._reshapeSelectResults(sqResults);
}
let inlineCount;
if (this.entityQuery.inlineCountEnabled && sqResults.count) {
inlineCount = sqResults.count;
sqResults = sqResults.rows;
}
const expandClause = this.entityQuery.expandClause;
const usesNameOnServer = this.entityQuery.usesNameOnServer;
let expandPaths = [];
if (expandClause) {
// each expand path consist of an array of expand props.
expandPaths = expandClause.propertyPaths.map(pp => {
return this.entityType.getPropertiesOnPath(pp, usesNameOnServer, true);
}, this);
}
// needed because we had to turn take(0) into limit(1)
if (this.entityQuery.takeCount === 0) {
sqResults = [];
}
const results = sqResults.map(sqResult => {
const result = this._createResult(sqResult, this.entityType, expandClause != null);
// each expandPath is a collection of expandProps
// if (!result.$ref) {
expandPaths.forEach(expandProps => {
this._populateExpand(result, sqResult, expandProps);
}, this);
// }
return result;
}, this);
if (inlineCount != undefined) {
return { results: results, inlineCount: inlineCount };
}
else {
return results;
}
}
_reshapeSelectResults(sqResults) {
let inlineCount;
if (this.entityQuery.inlineCountEnabled) {
inlineCount = sqResults.count;
sqResults = sqResults.rows;
}
const propertyPaths = this.entityQuery.selectClause.propertyPaths;
const usesNameOnServer = this.entityQuery.usesNameOnServer;
const results = sqResults.map(sqResult => {
// start with the sqResult and then promote nested properties up to the top level
// while removing nested path.
const result = sqResult.dataValues;
let parent;
propertyPaths.forEach(pp => {
parent = sqResult;
const props = this.entityType.getPropertiesOnPath(pp, usesNameOnServer, true);
let nextProp = props[0];
let remainingProps = props.slice(0);
while (remainingProps.length > 1 && nextProp.isNavigationProperty) {
parent = parent[nextProp.nameOnServer];
remainingProps = remainingProps.slice(1);
nextProp = remainingProps[0];
}
let val = parent && parent[nextProp.nameOnServer];
// if last property in path is a nav prop then we need to wrap the results
// as either an entity or entities.
if (nextProp.isNavigationProperty) {
if (nextProp.isScalar) {
val = this._createResult(val, nextProp.entityType, true);
}
else {
val = val.map((v) => {
return this._createResult(v, nextProp.entityType, true);
}, this);
}
}
else {
val = val && (val.dataValues || val);
}
pp = usesNameOnServer ? pp : _.map(props, "nameOnServer").join(".");
result[pp] = val;
}, this);
return result;
}, this);
if (inlineCount != undefined) {
return { results: results, inlineCount: inlineCount };
}
else {
return results;
}
}
_createResult(sqResult, entityType, checkCache) {
if (!sqResult) {
return null;
}
const result = sqResult.dataValues;
if (checkCache) {
const key = getKey(sqResult, entityType);
const cachedItem = this._keyMap[key];
if (cachedItem) {
return { $ref: cachedItem.$id };
}
else {
result.$id = this._nextId;
this._nextId += 1;
this._keyMap[key] = result;
this._refMap[result.$id] = result;
}
}
result.$type = entityType.name;
const nps = entityType.navigationProperties;
// first remove all nav props
nps.forEach(np => {
const navValue = sqResult[np.nameOnServer];
if (navValue) {
result[np.nameOnServer] = undefined;
}
});
return result;
}
_populateExpand(result, sqResult, expandProps) {
if (expandProps == null || expandProps.length === 0) {
return;
}
if (result.$ref) {
result = this._refMap[result.$ref];
}
// now blow out all of the expands
// each expand path consist of an array of expand props.
const npName = expandProps[0].nameOnServer;
let nextResult = result[npName];
const nextEntityType = expandProps[0].entityType;
const nextSqResult = sqResult[npName];
// if it doesn't already exist then create it
if (nextResult == null) {
if (_.isArray(nextSqResult)) {
nextResult = nextSqResult.map(nextSqr => {
return this._createResult(nextSqr, nextEntityType, true);
}, this).filter(r => r != null);
}
else {
nextResult = this._createResult(nextSqResult, nextEntityType, true);
}
result[npName] = nextResult;
}
if (_.isArray(nextSqResult)) {
nextSqResult.forEach((nextSqr, ix) => {
this._populateExpand(nextResult[ix], nextSqr, expandProps.slice(1));
}, this);
}
else {
if (nextResult) {
this._populateExpand(nextResult, nextSqResult, expandProps.slice(1));
}
}
}
// Add an include for a where or order by clause. Returns last include in the props chain.
_addInclude(parent, props) {
const include = this._getIncludeFor(parent, props[0]);
// empty attributes array tells sequelize not to retrieve the entity data
if (!include['$disallowAttributes']) {
include.attributes = include.attributes || [];
}
props = props.slice(1);
if (props.length > 0) {
if (props[0].isNavigationProperty) {
return this._addInclude(include, props);
}
}
return include;
}
// Add an include for a select or expand clause. Returns last include in the props chain.
_addFetchInclude(parent, props, isExpand) {
// $disallowAttributes code is used to insure two things
// 1) if a navigation property is declared as the last prop of a select or expand expression
// that it is not 'trimmed' i.e. has further 'attributes' added that would narrow the projection.
// 2) that we support restricted projections on expanded nodes as long as we don't
// violate #1 above.
const include = this._getIncludeFor(parent, props[0]);
props = props.slice(1);
if (props.length > 0) {
if (props[0].isNavigationProperty) {
if (isExpand) {
// expand = include the whole entity = no attributes
include['$disallowAttributes'] = true;
delete include.attributes;
}
else {
// select = include at least one attribute at each level, so sequelize will create an object
if (!include['$disallowAttributes']) {
include.attributes = include.attributes || [];
if (include.attributes.length === 0) {
include.attributes = include.model.primaryKeyAttributes;
}
}
}
return this._addFetchInclude(include, props, isExpand);
}
else {
// dataProperty
if (!include['$disallowAttributes']) {
include.attributes = include.attributes || [];
include.attributes.push(props[0].nameOnServer);
}
}
}
else {
// do not allow attributes set on any final navNodes nodes
include['$disallowAttributes'] = true;
// and remove any that might have been added.
delete include.attributes;
}
return include;
}
// Find or create an include object, and attach it to parent
_getIncludeFor(parent, prop) {
const sqModel = this.sequelizeManager.entityTypeSqModelMap[prop.entityType.name];
const includes = parent.include = (parent.include || []);
const findInclude = { model: sqModel, as: prop.nameOnServer };
let include = _.find(includes, findInclude);
if (!include) {
includes.push(findInclude);
include = findInclude;
}
return include;
}
_getModelAs(prop) {
const sqModel = this.sequelizeManager.entityTypeSqModelMap[prop.entityType.name];
return { model: sqModel, as: prop.nameOnServer };
}
}
exports.SequelizeQuery = SequelizeQuery;
function getKey(sqResult, entityType) {
const key = entityType.keyProperties
.map(kp => sqResult[kp.nameOnServer])
.join("::") + "^" + entityType.name;
return key;
}
// needed to convert 'or:' and 'and:' clauses into Sequelize.and/or clauses
function processAndOr(parent) {
if (parent == null) {
return;
}
if (parent.where) {
parent.where = processAndOrClause(parent.where);
}
parent.include && parent.include.forEach(inc => processAndOr(inc));
// console.trace(parent);
}
function processAndOrClause(where) {
// console.log("processAndOrClause", where);
const ands = (where[sequelize_1.Op.and] || where['and']);
const ors = (where[sequelize_1.Op.or] || where['or']);
if (ands) {
const clauses = ands.map(clause => processAndOrClause(clause));
return sequelize_1.Sequelize.and.apply(null, clauses);
// return Sequelize.and(clauses[0], clauses[1]);
}
else if (ors) {
const clauses = ors.map(clause => processAndOrClause(clause));
return sequelize_1.Sequelize.or.apply(null, clauses);
}
else {
return where;
}
}
//# sourceMappingURL=SequelizeQuery.js.map