diff --git a/.gitignore b/.gitignore index 79e269a..0d167ae 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ *.csproj *.sln *.log +*.tgz +.vscode node_modules bin diff --git a/breeze-sequelize/README.md b/breeze-sequelize/README.md new file mode 100644 index 0000000..e4bfdee --- /dev/null +++ b/breeze-sequelize/README.md @@ -0,0 +1,21 @@ +# breeze-sequelize + +The `breeze-sequelize` library lets you easily build a Sequelize server for managing relational data. +Starting with Breeze metadata, it will create the Sequelize model for you, and Sequelize can create a database from the model. + +Once you have the model and database, `breeze-sequelize` makes it easy to query and update data from your Breeze client. + +## Repo + +The [src](./src) folder contains the TypeScript source code. + +The [test](./test) folder contains the unit tests (work in progress). + +The [test/ExpressDemo](./test/ExpressDemo) folder contains a complete server using breeze-sequelize, for running an end-to-end test suite. + +## Documentation + +[Breeze/Sequelize documentation here](http://breeze.github.io/doc-node-sequelize/ "breeze-sequelize documentation") + +[Learn more about Breeze](http://breeze.github.io/doc-js/ "breezejs"). + diff --git a/breeze-sequelize/src/MetadataMapper.js b/breeze-sequelize/old/MetadataMapper.js similarity index 100% rename from breeze-sequelize/src/MetadataMapper.js rename to breeze-sequelize/old/MetadataMapper.js diff --git a/breeze-sequelize/old/README.md b/breeze-sequelize/old/README.md new file mode 100644 index 0000000..d9df17a --- /dev/null +++ b/breeze-sequelize/old/README.md @@ -0,0 +1,17 @@ +# The "breeze-sequelize" npm package + +This is the official NPM package for the Breeze Sequelize integration. The package files are in the [breeze.server.node](https://github.com/Breeze/breeze.server.node "github: "breeze-server-node") repository in the 'breeze-sequelize' subfolder. + +To install with npm, open a terminal or command window and enter: + +`npm install breeze-sequelize` + +>Case matters! Be sure to spell "breeze-sequelize" in all lowercase. + +[Learn more about Breeze](http://breeze.github.io/doc-js/ "breezejs"). + +## More documentation + +[Breeze/Sequelize documentation here](http://breeze.github.io/doc-node-sequelize/ "breeze-sequelize documentation") + + diff --git a/breeze-sequelize/src/SequelizeManager.js b/breeze-sequelize/old/SequelizeManager.js similarity index 100% rename from breeze-sequelize/src/SequelizeManager.js rename to breeze-sequelize/old/SequelizeManager.js diff --git a/breeze-sequelize/src/SequelizeQuery.json.js b/breeze-sequelize/old/SequelizeQuery.json.js similarity index 100% rename from breeze-sequelize/src/SequelizeQuery.json.js rename to breeze-sequelize/old/SequelizeQuery.json.js diff --git a/breeze-sequelize/src/SequelizeSaveHandler.js b/breeze-sequelize/old/SequelizeSaveHandler.js similarity index 100% rename from breeze-sequelize/src/SequelizeSaveHandler.js rename to breeze-sequelize/old/SequelizeSaveHandler.js diff --git a/breeze-sequelize/src/dbUtils.js b/breeze-sequelize/old/dbUtils.js similarity index 100% rename from breeze-sequelize/src/dbUtils.js rename to breeze-sequelize/old/dbUtils.js diff --git a/breeze-sequelize/src/main.js b/breeze-sequelize/old/main.js similarity index 89% rename from breeze-sequelize/src/main.js rename to breeze-sequelize/old/main.js index daec0fa..d051d76 100644 --- a/breeze-sequelize/src/main.js +++ b/breeze-sequelize/old/main.js @@ -8,3 +8,5 @@ exports.dbUtils = require("./dbUtils"); exports.breeze = require("breeze-client"); // needed because we have augmented breeze in the SequelizeQuery component exports.Sequelize = exports.SequelizeManager.Sequelize; + +exports.ModelMapper = require("./ModelMapper.js"); diff --git a/breeze-sequelize/old/package.json b/breeze-sequelize/old/package.json new file mode 100644 index 0000000..87d56e0 --- /dev/null +++ b/breeze-sequelize/old/package.json @@ -0,0 +1,47 @@ +{ + "name": "breeze-sequelize", + "version": "0.3.0", + "description": "Breeze Sequelize server implementation", + "keywords": [ + "breeze", + "sequelize", + "orm", + "query", + "linq", + "graph" + ], + "main": "main.js", + "directories": {}, + "dependencies": { + "bluebird": "^3.5.5", + "breeze-client": "^2.0.2", + "lodash": "^4.17.15", + "sequelize": "^5.21.3", + "toposort": "^2.0.2" + }, + "devDependencies": { + "chai": "^4.2.0", + "mocha": "^6.2.0" + }, + "scripts": { + "test": "mocha" + }, + "repository": { + "type": "git", + "url": "https://github.com/Breeze/breeze.server.node.git" + }, + "homepage": "http://breeze.github.io/doc-node-sequelize/introduction.html", + "bugs": "https://github.com/Breeze/breeze.server.node/issues", + "author": { + "name": "IdeaBlade", + "email": "jayt@ideablade.com", + "url": "https://www.ideablade.com/" + }, + "contributors": [ + "Jay Traband", + "Steve Schmitt", + "Marcel Good", + "Ward Bell" + ], + "license": "MIT" +} diff --git a/breeze-sequelize/src/sequelizeUtils.js b/breeze-sequelize/old/sequelizeUtils.js similarity index 100% rename from breeze-sequelize/src/sequelizeUtils.js rename to breeze-sequelize/old/sequelizeUtils.js diff --git a/breeze-sequelize/src/utils.js b/breeze-sequelize/old/utils.js similarity index 100% rename from breeze-sequelize/src/utils.js rename to breeze-sequelize/old/utils.js diff --git a/breeze-sequelize/src/MetadataMapper.ts b/breeze-sequelize/src/MetadataMapper.ts new file mode 100644 index 0000000..605a7d1 --- /dev/null +++ b/breeze-sequelize/src/MetadataMapper.ts @@ -0,0 +1,172 @@ +import { breeze, ComplexType, DataProperty, EntityType, MetadataStore, NavigationProperty, StructuralType } from "breeze-client"; +import { AbstractDataType, DataTypes, Model, ModelAttributeColumnOptions, ModelAttributes, ModelOptions, Sequelize } from "sequelize"; +import * as _ from 'lodash'; +import * as utils from "./utils"; + +let log = utils.log; + +export interface NameModelMap { [modelName: string]: { new(): Model } & typeof Model }; + +// TODO: still need to handle inherited entity types - TPT +/** Maps Breeze metadata to Sequelize Models */ +export class MetadataMapper { + sequelize: Sequelize + metadataStore: MetadataStore; + entityTypeSqModelMap: NameModelMap; + resourceNameSqModelMap: NameModelMap; + + constructor(breezeMetadata: MetadataStore | string | Object, sequelize: Sequelize) { + this.sequelize = sequelize; + let ms; + if (breezeMetadata instanceof MetadataStore) { + ms = breezeMetadata; + } else { + ms = new breeze.MetadataStore(); + ms.importMetadata(breezeMetadata); + } + + this.metadataStore = ms; + this._createMaps(); + } + + /** creates entityTypeSqModelMap and resourceNameSqModelMap */ + private _createMaps() { + + let ms = this.metadataStore; + let allTypes = ms.getEntityTypes(); + let typeMap = _.groupBy(allTypes, t => { + return t.isComplexType ? "complexType" : "entityType"; + }); + // let complexTypes = typeMap["complexType"]; + let entityTypes = typeMap["entityType"]; + + // map of entityTypeName to sqModel + let entityTypeSqModelMap: NameModelMap = this.entityTypeSqModelMap = {}; + // first create all of the sequelize types with just data properties + entityTypes.forEach(entityType => { + let typeConfig = this.mapToSqModelConfig(entityType); + let options: ModelOptions = { + // NOTE: case sensitivity of the table name may not be the same on some sql databases. + modelName: entityType.shortName, // this will define the table's name; see options.define + }; + let sqModel = this.sequelize.define(entityType.shortName, typeConfig, options) as { new(): Model } & typeof Model; + entityTypeSqModelMap[entityType.name] = sqModel; + + }, this); + // now add navigation props + this.createNavProps(entityTypes, entityTypeSqModelMap); + // map of breeze resourceName to sequelize model + this.resourceNameSqModelMap = _.mapValues(ms._resourceEntityTypeMap, (value, key) => { + return entityTypeSqModelMap[value]; + }); + + } + + // source.fn(target, { foreignKey: }) + // hasOne - adds a foreign key to target + // belongsTo - add a foreign key to source + // hasMany - adds a foreign key to target, unless you also specifiy that target hasMany source, in which case a junction table is created with sourceId and targetId + + /** Adds relationships to the Models based on Breeze NavigationProperties */ + private createNavProps(entityTypes: StructuralType[], entityTypeSqModelMap: NameModelMap) { + // TODO: we only support single column foreignKeys for now. + + entityTypes.forEach(entityType => { + let navProps = entityType.navigationProperties as NavigationProperty[]; + let sqModel = entityTypeSqModelMap[entityType.name]; + navProps.forEach(np => { + let npName = np.nameOnServer; + + let targetEntityType = np.entityType; + let targetSqModel = entityTypeSqModelMap[targetEntityType.name]; + if (np.isScalar) { + if (np.foreignKeyNamesOnServer.length > 0) { + sqModel.belongsTo(targetSqModel, { as: npName, foreignKey: np.foreignKeyNamesOnServer[0], onDelete: "no action" }); // Product, Category + } else { + sqModel.hasOne(targetSqModel, { as: npName, foreignKey: np.invForeignKeyNamesOnServer[0], onDelete: "no action" }); // Order, InternationalOrder + } + } else { + if (np.foreignKeyNamesOnServer.length > 0) { + throw new Error("not sure what kind of reln this is"); + // sqModel.hasMany(targetSqModel, { as: npName, foreignKey: np.foreignKeyNamesOnServer[0]}) + } else { + sqModel.hasMany(targetSqModel, { as: npName, foreignKey: np.invForeignKeyNamesOnServer[0], onDelete: "no action" }) // Category, Product + } + } + }); + }); + + } + + /** Creates a set of Sequelize attributes based on DataProperties */ + private mapToSqModelConfig(entityOrComplexType: StructuralType): ModelAttributes { + // propConfig looks like + // { firstProp: { type: Sequelize.XXX, ... }, + // secondProp: { type: Sequelize.XXX, ... } + // .. + // } + + let typeConfig = {} as ModelAttributes; + entityOrComplexType.dataProperties.forEach(dataProperty => { + let propConfig = this.mapToSqPropConfig(dataProperty); + _.merge(typeConfig, propConfig); + }); + + return typeConfig; + } + + /** Creates Sequelize column attributes based on a DataProperty */ + private mapToSqPropConfig(dataProperty: DataProperty): ModelAttributes { + if (dataProperty.isComplexProperty) { + return this.mapToSqModelConfig(dataProperty.dataType as ComplexType); + } + let propConfig = {} as ModelAttributes; + let attributes = {} as ModelAttributeColumnOptions; + propConfig[dataProperty.nameOnServer] = attributes; + let sqModel = _dataTypeMap[dataProperty.dataType.name]; + if (sqModel == null) { + let template = _.template("Unable to map the dataType '${ dataType }' of dataProperty: '${ dataProperty }'"); + throw new Error(template({ dataProperty: dataProperty.parentType.shortName + "." + dataProperty.name, dataType: dataProperty.dataType.name })); + } + attributes.type = sqModel; + if (dataProperty.dataType == breeze.DataType.String && dataProperty.maxLength) { + attributes.type = DataTypes.STRING(dataProperty.maxLength); + } + if (!dataProperty.isNullable) { + attributes.allowNull = false; + } + if (dataProperty.isPartOfKey) { + attributes.primaryKey = true; + if ((dataProperty.parentType as EntityType).autoGeneratedKeyType == breeze.AutoGeneratedKeyType.Identity) { + let dt = attributes.type as AbstractDataType; + if (dt.key == "INTEGER" || dt.key == "BIGINT") { + attributes.autoIncrement = true; + } + } + } + if (dataProperty.defaultValue !== undefined && !dataProperty.isPartOfKey) { + // if (dataProperty.defaultValue !== undefined) { + attributes.defaultValue = dataProperty.defaultValue; + } + return propConfig; + } +} + + +let _dataTypeMap = { + String: DataTypes.STRING, + Boolean: DataTypes.BOOLEAN, + DateTime: DataTypes.DATE, + DateTimeOffset: DataTypes.DATE, + Byte: DataTypes.INTEGER.UNSIGNED, + Int16: DataTypes.INTEGER, + Int32: DataTypes.INTEGER, + Int64: DataTypes.BIGINT, + Decimal: DataTypes.DECIMAL(19, 4), + Double: DataTypes.FLOAT, + Single: DataTypes.FLOAT, + Guid: DataTypes.UUID, + Binary: DataTypes.STRING().BINARY, + Time: DataTypes.STRING, + Undefined: DataTypes.BLOB +}; \ No newline at end of file diff --git a/breeze-sequelize/src/README.md b/breeze-sequelize/src/README.md index d9df17a..59846a4 100644 --- a/breeze-sequelize/src/README.md +++ b/breeze-sequelize/src/README.md @@ -1,7 +1,14 @@ -# The "breeze-sequelize" npm package +# breeze-sequelize This is the official NPM package for the Breeze Sequelize integration. The package files are in the [breeze.server.node](https://github.com/Breeze/breeze.server.node "github: "breeze-server-node") repository in the 'breeze-sequelize' subfolder. +The `breeze-sequelize` library lets you easily build a Sequelize server for managing relational data. +Starting with Breeze metadata, it will create the Sequelize model for you, and Sequelize can create a database from the model. + +Once you have the model and database, `breeze-sequelize` makes it easy to query and update data from your Breeze client. + +## Install + To install with npm, open a terminal or command window and enter: `npm install breeze-sequelize` diff --git a/breeze-sequelize/src/SQVisitor.ts b/breeze-sequelize/src/SQVisitor.ts new file mode 100644 index 0000000..28a495a --- /dev/null +++ b/breeze-sequelize/src/SQVisitor.ts @@ -0,0 +1,420 @@ +import { NavigationProperty, Predicate, VisitContext } from "breeze-client"; +import { FnExpr, LitExpr, PredicateExpression, PropExpr } from "breeze-client/src/predicate"; +import * as _ from 'lodash'; +import { FindOptions, IncludeOptions, LogicType, Op, Sequelize, WhereOptions } from "sequelize"; +import { Where } from "sequelize/types/lib/utils"; +import { SqVisitContext } from "./SequelizeQuery"; + +export interface ExprResult { + include: IncludeOptions[], + lastInclude: IncludeOptions, + where: WhereOptions +} + +/** Visit the nodes in a Breeze query, converting it to a Sequelize query */ +const toSQVisitor = (function () { + let visitor = { + + passthruPredicate: function () { + return this.value; + }, + + unaryPredicate: function (context: VisitContext) { + let predSq = this.pred.visit(context); + if (this.op.key !== "not") { + throw new Error("Not yet implemented: Unary operation: " + this.op.key + " pred: " + JSON.stringify(this.pred)); + } + if (!_.isEmpty(predSq.include)) { + throw new Error("Unable to negate an expression that requires a Sequelize 'include'"); + } + predSq.where = applyNot(predSq.where); + return predSq; + }, + + binaryPredicate: function (context: SqVisitContext) { + let result = {} as ExprResult; + let op = this.op.key; + // TODO: right now only handling case where e1 : PropExpr and e2 : LitExpr | PropExpr + // not yet handled: e1: FnExpr | e2: FnExpr + + let where, p1Value, p2Value; + if (this.expr1.visitorMethodName === "propExpr") { + p1Value = processPropExpr(this.expr1, context, result); + } else if (this.expr1.visitorMethodName == "fnExpr") { + p1Value = processFnExpr(this.expr1, context, result); + } else { + // also note that literal exprs are not allowed for expr1 ( i.e. only allowed on expr2) + throw new Error("Not yet implemented: binary predicate with a expr1 type of: " + this.expr1.visitorMethodName + " - " + this.expr1.toString()); + } + + let crit; + let like = _boolOpMap.like.sequelizeOp; + if (this.expr2.visitorMethodName === "litExpr") { + p2Value = this.expr2.value; + if (op === "eq") { + crit = p2Value; + // where[p1Value] = p2Value; + + } else if (op == "startswith") { + crit = { [like]: p2Value + "%" }; + } else if (op === "endswith") { + crit = { [like]: "%" + p2Value }; + } else if (op === "contains") { + crit = { [like]: "%" + p2Value + "%" }; + } else { + crit = {}; + let mop = _boolOpMap[op].sequelizeOp; + crit[mop] = p2Value; + } + + } else if (this.expr2.visitorMethodName == "propExpr") { + let p2Value = this.expr2.propertyPath; + let props = context.entityType.getPropertiesOnPath(p2Value, context.toNameOnServer, true); + p2Value = props.map(function (p) { + return p.nameOnServer; + }).join("."); + let colVal = Sequelize.col(p2Value); + if (op === "eq") { + crit = colVal; + } else if (op === "startswith") { + crit = { [like]: Sequelize.literal("concat(" + p2Value + ",'%')") }; + } else if (op === "endswith") { + crit = { [like]: Sequelize.literal("concat('%'," + p2Value + ")") }; + } else if (op === "contains") { + crit = { [like]: Sequelize.literal("concat('%'," + p2Value + ",'%')") }; + } else { + crit = {}; + let mop = _boolOpMap[op].sequelizeOp; + crit[mop] = colVal; + } + } else { + throw new Error("Not yet implemented: binary predicate with a expr2 type of: " + this.expr2.visitorMethodName + " - " + this.expr2.toString()); + } + where = makeWhere(p1Value, crit); + // the 'where' clause may be on a nested include + if (result.lastInclude) { + result.lastInclude.where = where; + } else if (result.include && result.include.length > 0) { + result.include[0].where = where; + } else { + result.where = where; + } + return result; + }, + + andOrPredicate: function (context: VisitContext) { + let result = {} as ExprResult; + let predSqs = this.preds.map(function (pred: Predicate) { + return pred.visit(context); + }); + + let wheres = [] as WhereOptions[]; + let includes = [] as IncludeOptions[]; + if (predSqs.length == 0) { + return null; + } else if (predSqs.length == 1) { + return predSqs[0]; + } else { + let that = this; + predSqs.forEach(function (predSq: FindOptions) { + if (!_.isEmpty(predSq.where)) { + wheres.push(predSq.where); + } + if (!_.isEmpty(predSq.include)) { + let processIncludes = function (sourceIncludes: IncludeOptions[], targetIncludes: IncludeOptions[]) { + sourceIncludes.forEach(function (sourceInclude: IncludeOptions) { + if (!targetIncludes) + targetIncludes = []; + let include = _.find(targetIncludes, { model: sourceInclude.model }); + if (!include) { + targetIncludes.push(sourceInclude); + } else { + if (include.where == null) { + include.where = sourceInclude.where; + } else if (sourceInclude.where != null) { + let where = {} as Where; + where[that.op.key] = [include.where, sourceInclude.where]; + include.where = where; + } + if (include.attributes == null || (include.attributes as any[]).length == 0) { + include.attributes = sourceInclude.attributes; + } else if (sourceInclude.attributes != null) { + include.attributes = _.uniq((include.attributes as any[]).concat(sourceInclude.attributes)); + } + if (!_.isEmpty(sourceInclude.include)) + processIncludes(sourceInclude.include as IncludeOptions[], include.include as IncludeOptions[]); + } + }); + }; + processIncludes(predSq.include as IncludeOptions[], includes); + } + }); + } + if (this.op.key === "and") { + if (wheres.length > 0) { + result.where = wheres.length == 1 ? wheres[0] : { [Op.and]: wheres }; + } + // q = Sequelize.and(q1, q2); + } else { + if (includes.length > 1 || (includes.length == 1 && wheres.length != 0)) { + throw new Error("Cannot translate a query with nested property paths and 'OR' conditions to Sequelize. (Sorry).") + } + if (wheres.length > 0) { + result.where = wheres.length == 1 ? wheres[0] : { [Op.or]: wheres }; + } + // q = Sequelize.or(q1, q2); + } + result.include = includes; + return result; + }, + + + + anyAllPredicate: function (context: SqVisitContext) { + if (this.op.key === "all") { + throw new Error("The 'all' predicate is not currently supported for Sequelize"); + } + + let props = context.entityType.getPropertiesOnPath(this.expr.propertyPath, context.toNameOnServer, true) as NavigationProperty[]; + let parent = {} as ExprResult; + let include = context.sequelizeQuery._addInclude(parent, props); + let newContext = _.clone(context); + newContext.entityType = this.expr.dataType; + + // after this line the logic below will apply to the include instead of the top level where. + // predicate is applied to inner context + + let r = this.pred.visit(newContext); + include.where = r.where || {}; + include.required = true; + if (r.include) include.include = r.include; + return { include: parent.include }; + + }, + + litExpr: function () { + + }, + + propExpr: function (context: any) { + + }, + + fnExpr: function (context: any) { + } + + + }; + + function makeWhere(p1Value: any, crit: LogicType) { + let where: Where; + if (typeof (p1Value) == 'string') { + where = {} as Where; + where[p1Value] = crit; + } else { + where = Sequelize.where(p1Value, crit); + } + return where; + } + + + function processPropExpr(expr: PropExpr, context: SqVisitContext, result: ExprResult) { + let exprVal; + let pp = expr.propertyPath; + let props = context.entityType.getPropertiesOnPath(pp, context.toNameOnServer, true) as NavigationProperty[]; + if (props.length > 1) { + // handle a nested property path on the LHS - query gets moved into the include + // context.include starts out null at top level + let parent = {} as { include: any[] }; + let include = context.sequelizeQuery._addInclude(parent, props); + include.where = {}; + result.include = parent.include; + result.lastInclude = include; + exprVal = props[props.length - 1].nameOnServer; + } else { + result.where = {} as Where; + exprVal = props[0].nameOnServer; + } + return exprVal; + } + + function processFnExpr(expr: FnExpr, context: SqVisitContext, result: ExprResult) { + let fnName = expr.fnName; + let methodInfo = translateMap[fnName]; + if (methodInfo == null) { + throw new Error('Unable to locate fn: ' + fnName); + } + methodInfo.validate && methodInfo.validate(expr.exprs); + + let exprs = expr.exprs.map(function (ex) { + return processNestedExpr(ex, context, result); + }) + let exprVal = methodInfo.fn(exprs); + return exprVal; + } + + function processNestedExpr(expr: PredicateExpression, context: SqVisitContext, result: ExprResult): any { + let exprVal; + if (expr.visitorMethodName === 'propExpr') { + exprVal = processPropExpr(expr as PropExpr, context, result); + return Sequelize.col(exprVal); + } else if (expr.visitorMethodName == 'fnExpr') { + let exprVal = processFnExpr(expr as FnExpr, context, result); + return exprVal; + } else if (expr.visitorMethodName = 'litExpr') { + return (expr as LitExpr).value; + } else { + throw new Error("Unable to understand expr for: " + this.expr.visitorMethodName + " - " + this.expr.toString()); + } + } + + let translateMap = { + toupper: { + fn: function (sqArgs: any) { + return Sequelize.fn("UPPER", sqArgs[0]); + }, + validate: function (exprs: PredicateExpression[]) { + validateMonadicFn("toUpper", exprs); + } + }, + tolower: { + fn: function (sqArgs: any) { + return Sequelize.fn("LOWER", sqArgs[0]); + }, + validate: function (exprs: PredicateExpression[]) { + validateMonadicFn("toLower", exprs); + } + }, + substring: { + fn: function (sqArgs: string[]) { + // MySQL's substring is 1 origin - javascript ( and breeze's ) is O origin. + return Sequelize.fn("SUBSTRING", sqArgs[0], 1 + parseInt(sqArgs[1], 10), parseInt(sqArgs[2], 10)); + } + } + } + + let simpleFnNames = ['length', 'trim', 'ceiling', 'floor', 'round', 'second', 'minute', 'hour', 'day', 'month', 'year']; + simpleFnNames.forEach(function (fnName) { + translateMap[fnName] = { + fn: function (sqArgs: any[]) { + return Sequelize.fn(fnName.toUpperCase(), sqArgs[0]); + }, + validate: function (exprs: PredicateExpression[]) { + validateMonadicFn(fnName, exprs); + } + } + }); + + function validateMonadicFn(fnName: string, exprs: PredicateExpression[]) { + let errTmpl = "Error with call to the '%1' function."; + let errMsg; + if (exprs.length != 1) { + errMsg = formatString(errTmpl + " This function only takes a single parameter", fnName); + } else if (exprs[0].visitorMethodName == 'litExpr') { + errMsg = formatString(errTmpl + " The single parameter may not be a literal expression. Param: %2", fnName, exprs[0].toString()); + } + if (errMsg) { + throw new Error(errMsg); + } + } + + // Based on fragment from Dean Edwards' Base 2 library + // format("a %1 and a %2", "cat", "dog") -> "a cat and a dog" + function formatString(string: string, ...rest: any) { + let args = arguments; + let pattern = RegExp("%([1-" + (arguments.length - 1) + "])", "g"); + return string.replace(pattern, function (match, index) { + return args[index]; + }); + } + + return visitor; +}()); + +export { toSQVisitor }; + +function applyNot(q1: Where): any { + + // rules are: + // not { a: 1} -> { a: { ne: 1 }} + // not { a: { gt: 1 }} -> { a: { le: 1}}} + // not { and: { a: 1, b: 2 } -> { or: { a: { $ne: 1 }, b: { $ne 2 }}} + // not { or { a: 1, b: 2 } -> { and: [ a: { $ne: 1 }, b: { $ne 2 }]} + + let results = [], result; + let keys = Reflect.ownKeys(q1); + for (let k of keys) { + let v = q1[k]; + if (k === Op.or) { + result = { [Op.and]: [applyNot(v[0]), applyNot(v[1])] }; + } else if (k === Op.and) { + result = { [Op.or]: [applyNot(v[0]), applyNot(v[1])] }; + } else if (_notOps[k]) { + result = {}; + result[_notOps[k]] = v; + } else { + result = {}; + if (v != null && typeof (v) === "object") { + result[k] = applyNot(v); + } else { + result[k] = { [Op.ne]: v }; + } + } + + results.push(result); + } + if (results.length === 1) { + return results[0]; + } else { + // Don't think we should ever get here with the current logic because all + // queries should only have a single node + return { [Op.or]: results }; + } +} + + +let _boolOpMap = { + eq: { not: Op.ne }, + gt: { sequelizeOp: Op.gt, not: Op.lte }, + ge: { sequelizeOp: Op.gte, not: Op.lt }, + lt: { sequelizeOp: Op.lt, not: Op.gte }, + le: { sequelizeOp: Op.lte, not: Op.gt }, + ne: { sequelizeOp: Op.ne, not: Op.eq }, + in: { sequelizeOp: Op.in }, + like: { sequelizeOp: Op.like } +}; + +let _notOps = { + gt: "lte", + lte: "gt", + gte: "lt", + lt: "gte", + ne: "eq", + eq: "ne", + like: "nlike", + nlike: "like", + in: "notIn", + notIn: "in", + + [Op.gt]: Op.lte, + [Op.lte]: Op.gt, + [Op.gte]: Op.lt, + [Op.lt]: Op.gte, + [Op.ne]: Op.eq, + [Op.like]: Op.notLike, + [Op.notLike]: Op.like, + [Op.in]: Op.notIn, + [Op.notIn]: Op.in + +}; + +// Used to determine if a clause is the result of a Sequelize.and/or method call. +// Not currently need because of processAndOr method below +//let isSequelizeAnd = function(o) { +// return Object.getPrototypeOf(o).constructor == Sequelize.Utils.and; +//} +// +//let isSequelizeOr = function(o) { +// return Object.getPrototypeOf(o).constructor == Sequelize.Utils.or; +//} +// -------------------------------- diff --git a/breeze-sequelize/src/SaveMap.ts b/breeze-sequelize/src/SaveMap.ts new file mode 100644 index 0000000..bc1bea2 --- /dev/null +++ b/breeze-sequelize/src/SaveMap.ts @@ -0,0 +1,95 @@ +import { Entity, EntityState, EntityType, StructuralType } from "breeze-client"; +import { SequelizeSaveHandler } from "./SequelizeSaveHandler"; + +/** Server-side representation of entity that came from the client */ +export interface EntityInfo { + entity: Entity; + entityType: EntityType; + wasAddedOnServer: boolean; + forceUpdate: boolean; + entityAspect: { + entityTypeName: string; + entityState: EntityState | string; + entity?: Entity; + autoGeneratedKey?: { + autoGeneratedKeyType: string; + } + originalValuesMap?: { [prop: string]: any }; + }; +} + +/** Validation error created on the server */ +export interface ServerEntityError { + entityTypeName: string; + errorName: string; + errorMessage: string; + propertyName: string; + keyValues: any[]; +} + +/** Maps EntityType names to arrays of EntityInfo */ +export class SaveMap { + private sequelizeSaveHandler: SequelizeSaveHandler; + entityErrors: ServerEntityError[]; + errorMessage: string; + + constructor(sequelizeSaveHandler: SequelizeSaveHandler) { + // make sequelizeSaveHandler non-enumerable so it won't be in Object.keys() + Object.defineProperty(this, "sequelizeSaveHandler", { value: sequelizeSaveHandler }); + } + + getEntityType(entityTypeName: string): StructuralType { + return this.sequelizeSaveHandler.metadataStore.getEntityType(entityTypeName); + } + + getEntityInfosOfType(entityTypeName: string): EntityInfo[] { + var entityType = this.getEntityType(entityTypeName); + // entityType.name is fully qualified. + return this[entityType.name] || []; + } + + /** Add an entity to the map */ + addEntity(entityTypeName: string, entity: Entity, entityState: EntityState): EntityInfo { + var entityType = this.getEntityType(entityTypeName); + entityTypeName = entityType.name; // fully qualified now. + var entityInfo = { + entity: entity, entityType: entityType, wasAddedOnServer: true, + entityAspect: { + entityTypeName: entityTypeName, + entityState: entityState || "Added" + } + } as EntityInfo; + var entityInfoList = this[entityTypeName]; + if (entityInfoList) { + entityInfoList.push(entityInfo); + } else { + this[entityTypeName] = [entityInfo]; + } + return entityInfo; + } + + /** Add an error to the entityErrors collection */ + addEntityError(entityInfo: EntityInfo, errorName: string, errorMessage: string, propertyName: string) { + if (!this.entityErrors) { + this.entityErrors = []; + } + + var entityType = entityInfo.entityType; + var keyValues = entityType.keyProperties.map(kp => { + return entityInfo.entity[kp.nameOnServer]; + }); + this.entityErrors.push({ + entityTypeName: entityType.name, + errorName: errorName, + errorMessage: errorMessage, + propertyName: propertyName, + keyValues: keyValues + }); + } + + /** Set the error message to return to the client */ + setErrorMessage(errorMessage: string) { + this.errorMessage = errorMessage; + } +} + diff --git a/breeze-sequelize/src/SequelizeManager.ts b/breeze-sequelize/src/SequelizeManager.ts new file mode 100644 index 0000000..d6e4820 --- /dev/null +++ b/breeze-sequelize/src/SequelizeManager.ts @@ -0,0 +1,95 @@ +import { Sequelize, Options, SyncOptions } from "sequelize"; +import { MetadataStore, DataProperty } from "breeze-client"; +import { DbConfig, createDb } from "./dbUtils"; +import { MetadataMapper, NameModelMap } from "./MetadataMapper"; + +import * as _ from 'lodash'; +import * as utils from "./utils"; +let log = utils.log; + +export interface KeyGenerator { + getNextId: (prop: DataProperty) => any; +} + +/** Manages the Sequelize instance for Breeze query and save operations */ +export class SequelizeManager { + static Sequelize = Sequelize; + sequelizeOptions: Options; + dbConfig: DbConfig; + sequelize: Sequelize; + models: NameModelMap; + resourceNameSqModelMap: NameModelMap; + entityTypeSqModelMap: NameModelMap; + metadataStore: MetadataStore; + keyGenerator: KeyGenerator; + + constructor(dbConfig: DbConfig, sequelizeOptions: Options) { + let defaultOptions: Options = { + dialect: "mysql", // or 'sqlite', 'postgres', 'mariadb' + port: 3306, // or 5432 (for postgres) + // omitNull: true, + logging: console.log, + dialectOptions: { decimalNumbers: true }, + define: { + freezeTableName: true, // prevent sequelize from pluralizing table names + timestamps: false // deactivate the timestamp columns (createdAt, etc.) + } + }; + let define = defaultOptions.define; + this.sequelizeOptions = _.extend(defaultOptions, sequelizeOptions || {}); + this.sequelizeOptions.define = _.extend(define, (sequelizeOptions && sequelizeOptions.define) || {}); + this.dbConfig = dbConfig; + this.sequelize = new Sequelize(dbConfig.dbName, dbConfig.user, dbConfig.password, this.sequelizeOptions); + } + + /** Connect to the database */ + authenticate(): Promise { + // check database connection + return this.sequelize.authenticate().then(() => { + log('Connection has been established successfully.'); + }).error(err => { + log('Unable to connect to the database:', err); + throw err; + }); + } + + /** Create a new database */ + createDb() { + return createDb(this.dbConfig, this.sequelizeOptions); + } + + /** Convert Breeze metadata to Sequelize models */ + importMetadata(breezeMetadata: MetadataStore | string | Object) { + let metadataMapper = new MetadataMapper(breezeMetadata, this.sequelize); + // TODO: should we merge here instead ; i.e. allow multiple imports... + this.models = this.resourceNameSqModelMap = metadataMapper.resourceNameSqModelMap; + this.entityTypeSqModelMap = metadataMapper.entityTypeSqModelMap; + this.metadataStore = metadataMapper.metadataStore; + } + + /** Sync the Sequelize model with the database */ + sync(shouldCreateDb: boolean, sequelizeOpts: SyncOptions): Promise { + if (shouldCreateDb) { + return this.createDb().then(() => { + return this.syncCore(this.sequelize, sequelizeOpts); + }); + } else { + return this.syncCore(this.sequelize, sequelizeOpts); + } + } + + private syncCore(sequelize: Sequelize, sequelizeOpts: SyncOptions): Promise { + let defaultOptions = { force: true }; + sequelizeOpts = _.extend(defaultOptions, sequelizeOpts || {}); + + return sequelize.sync(sequelizeOpts).then(() => { + log("schema created"); + return sequelize; + }).catch(err => { + console.log("schema creation failed"); + throw err; + }); + + } +} + diff --git a/breeze-sequelize/src/SequelizeQuery.ts b/breeze-sequelize/src/SequelizeQuery.ts new file mode 100644 index 0000000..0939872 --- /dev/null +++ b/breeze-sequelize/src/SequelizeQuery.ts @@ -0,0 +1,520 @@ +import { DataProperty, EntityProperty, EntityQuery, EntityType, MetadataStore, NavigationProperty, Predicate, VisitContext } from "breeze-client"; +import * as _ from 'lodash'; +import { FindOptions, IncludeOptions, Model, Op, OrderItem, Sequelize, Transaction, WhereOptions } from "sequelize"; +import { SequelizeManager } from "./SequelizeManager"; +import * as urlUtils from "url"; +import { toSQVisitor } from "./SQVisitor"; + +// patch Breeze EntityQuery for server-side use +// TODO make this a method on SequelizeQuery, so we don't have to patch Breeze? +EntityQuery['fromUrl'] = function (url: string, resourceName: string) { + let 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. + let keys = Object.keys(parsedUrl.query); + let jsonQueryString = keys.length ? keys[0] : '{}'; + let jsonQuery = JSON.parse(jsonQueryString); + + let entityQuery = new EntityQuery(jsonQuery); + entityQuery = entityQuery.from(resourceName).useNameOnServer(true); + + // for debugging + entityQuery['jsonQuery'] = jsonQuery; + return entityQuery; +} + +export interface SequelizeQueryOptions { + useTransaction: boolean; + beforeQueryEntities: (sq: SequelizeQuery) => void; +} + +interface CountModel { + rows: Model[]; + count: number; +} + +export interface SqVisitContext extends VisitContext { + sequelizeQuery: SequelizeQuery +} + +// TODO: still need to add support for fns like toUpper, length etc. +// TODO: still need to add support for any/all + +// config.url: +// config.pathName: if null - url +// config.entityQuery: +// config.entityQueryFn: a fn(entityQuery) -> entityQuery + +/** Converts Breeze queries to Sequelize queries */ +export class SequelizeQuery { + sequelizeManager: SequelizeManager; + metadataStore: MetadataStore; + entityType: EntityType; + entityQuery: EntityQuery; + sqQuery: FindOptions; + transaction: Transaction; + private _nextId: any; + private _keyMap: { [key: string]: any }; + private _refMap: { [key: string]: any }; + + /** Create instance for the given EntityQuery, and process the query into Sequelize form */ + constructor(sequelizeManager: SequelizeManager, serverSideEntityQuery: EntityQuery) { + this.sequelizeManager = sequelizeManager; + this.metadataStore = sequelizeManager.metadataStore; + + this.entityType = serverSideEntityQuery._getFromEntityType(this.metadataStore, true); + this.entityQuery = serverSideEntityQuery; + this.sqQuery = this._processQuery(); + } + + /** Execute the current query and return data objects */ + execute(options: SequelizeQueryOptions) { + return this.executeRaw(options).then(r => { + let result = this._reshapeResults(r); + return Promise.resolve(result); + }); + } + + /** Execute the current query and return the Sequelize Models */ + executeRaw(options: SequelizeQueryOptions): Promise { + let self = this; + let model = self.sequelizeManager.resourceNameSqModelMap[self.entityQuery.resourceName]; + let methodName = self.entityQuery.inlineCountEnabled ? "findAndCountAll" : "findAll"; + options = options || { useTransaction: false, beforeQueryEntities: undefined }; + + return (function () { + if (options.useTransaction) + return self.sequelizeManager.sequelize.transaction() + .then(function (trans) { + self.transaction = trans; + self.sqQuery.transaction = trans; + }); + else + return Promise.resolve(); + })() + .then(function () { + if (options.beforeQueryEntities) + return options.beforeQueryEntities.call(self); + else + return Promise.resolve(); + }) + .then(function () { + return model[methodName].call(model, self.sqQuery); + }) + .then( + function (results) { + if (options.useTransaction) + self.sqQuery.transaction.commit(); + return results; + }, + function (e) { + if (options.useTransaction) + self.sqQuery.transaction.rollback(); + 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; + private _processQuery(): FindOptions { + let entityQuery = this.entityQuery; + let sqQuery: FindOptions = 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; + + } + + private _processWhere() { + let wherePredicate = this.entityQuery.wherePredicate as Predicate; + if (wherePredicate == null) return; + let sqQuery = wherePredicate.visit({ + entityType: this.entityType, + toNameOnServer: this.entityQuery.usesNameOnServer, + sequelizeQuery: this, + metadataStore: this.metadataStore + } as SqVisitContext, toSQVisitor); + + if (sqQuery && sqQuery.where) this.sqQuery.where = sqQuery.where; + if (sqQuery && sqQuery.include) this.sqQuery.include = sqQuery.include; + + processAndOr(this.sqQuery); + } + + private _processSelect() { + let selectClause = this.entityQuery.selectClause; + let usesNameOnServer = this.entityQuery.usesNameOnServer; + if (selectClause == null) return; + // extract any nest paths and move them onto the include + let navPropertyPaths = []; + this.sqQuery.attributes = selectClause.propertyPaths.map(pp => { + let props = this.entityType.getPropertiesOnPath(pp, usesNameOnServer, true); + let isNavPropertyPath = props[0].isNavigationProperty; + if (isNavPropertyPath) { + this._addFetchInclude(this.sqQuery, props as NavigationProperty[], false); + } + if (isNavPropertyPath) return null; + return usesNameOnServer ? pp : _.map(props, "nameOnServer").join("."); + }, this).filter(pp => { + return pp != null; + }); + } + + private _processOrderBy() { + let orderByClause = this.entityQuery.orderByClause; + let usesNameOnServer = this.entityQuery.usesNameOnServer; + if (orderByClause == null) return; + let orders: OrderItem[] = this.sqQuery.order = []; + orderByClause.items.forEach(item => { + let pp = item.propertyPath; + let props = this.entityType.getPropertiesOnPath(pp, usesNameOnServer, true); + let isNavPropertyPath = props[0].isNavigationProperty; + if (isNavPropertyPath) { + this._addInclude(this.sqQuery, props as NavigationProperty[]); + } + + let r: any = []; + orders.push(r); + + props.forEach((prop: DataProperty | NavigationProperty) => { + if (prop.isNavigationProperty) { + let modelAs = this._getModelAs(prop as NavigationProperty); + r.push(modelAs); + } else { + r.push(prop.nameOnServer); + if (item.isDesc) { + r.push("DESC"); + } + } + }, this); + }, this); + + } + + private _processExpand() { + let expandClause = this.entityQuery.expandClause; + let usesNameOnServer = this.entityQuery.usesNameOnServer; + if (expandClause == null) return; + expandClause.propertyPaths.forEach(pp => { + let props = this.entityType.getPropertiesOnPath(pp, usesNameOnServer, true); + this._addFetchInclude(this.sqQuery, props as NavigationProperty[], true); + }, this); + + } + + private _reshapeResults(sqResults: CountModel | Model[]) { + // -) 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 as CountModel).count) { + inlineCount = (sqResults as CountModel).count; + sqResults = (sqResults as CountModel).rows; + } + let expandClause = this.entityQuery.expandClause; + let usesNameOnServer = this.entityQuery.usesNameOnServer; + let expandPaths: EntityProperty[][] = []; + 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 = []; + } + let results = (sqResults as Model[]).map(sqResult => { + let 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 as NavigationProperty[]); + }, this); + // } + return result; + }, this); + + if (inlineCount != undefined) { + return { results: results, inlineCount: inlineCount }; + } else { + return results; + } + + } + + private _reshapeSelectResults(sqResults: CountModel | Model[]) { + let inlineCount; + if (this.entityQuery.inlineCountEnabled) { + inlineCount = (sqResults as CountModel).count; + sqResults = (sqResults as CountModel).rows; + } + let propertyPaths = this.entityQuery.selectClause.propertyPaths; + let usesNameOnServer = this.entityQuery.usesNameOnServer; + let results = (sqResults as Model[]).map(sqResult => { + // start with the sqResult and then promote nested properties up to the top level + // while removing nested path. + let result = (sqResult as any).dataValues; + let parent; + propertyPaths.forEach(pp => { + parent = sqResult; + let 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 as NavigationProperty).entityType, true); + } else { + val = val.map((v: any) => { + return this._createResult(v, (nextProp as NavigationProperty).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; + } + return results; + + } + + private _createResult(sqResult: Model, entityType: EntityType, checkCache: boolean) { + if (!sqResult) return null; + let result = (sqResult as any).dataValues; + if (checkCache) { + let key = getKey(sqResult, entityType); + let 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; + let nps = entityType.navigationProperties; + // first remove all nav props + nps.forEach(np => { + let navValue = sqResult[np.nameOnServer]; + if (navValue) { + result[np.nameOnServer] = undefined; + } + }); + return result; + } + + private _populateExpand(result: any, sqResult: Model, expandProps: NavigationProperty[]) { + if (result.$ref) { + result = this._refMap[result.$ref]; + } + if (expandProps == null || expandProps.length == 0) return; + // now blow out all of the expands + // each expand path consist of an array of expand props. + let npName = expandProps[0].nameOnServer; + let nextResult = result[npName]; + + let nextEntityType = expandProps[0].entityType; + let 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 => { + return 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. + public _addInclude(parent: FindOptions, props: NavigationProperty[]): IncludeOptions { + let 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. + private _addFetchInclude(parent: FindOptions, props: NavigationProperty[], isExpand: boolean): IncludeOptions { + // $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. + + let include = this._getIncludeFor(parent, props[0]) as IncludeOptions; + 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 as string[]).length == 0) { + include.attributes = include.model.primaryKeyAttributes; + } + } + } + return this._addFetchInclude(include, props, isExpand); + } else { + // dataProperty + if (!include['$disallowAttributes']) { + include.attributes = include.attributes || []; + (include.attributes as string[]).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 + private _getIncludeFor(parent: FindOptions, prop: NavigationProperty): IncludeOptions { + let sqModel = this.sequelizeManager.entityTypeSqModelMap[prop.entityType.name]; + let includes = parent.include = parent.include || []; + let findInclude = { model: sqModel, as: prop.nameOnServer }; + let include = _.find(includes, findInclude) as IncludeOptions; + if (!include) { + includes.push(findInclude); + include = findInclude; + } + return include; + } + + private _getModelAs(prop: NavigationProperty) { + let sqModel = this.sequelizeManager.entityTypeSqModelMap[prop.entityType.name]; + return { model: sqModel, as: prop.nameOnServer } + } +} + +function getKey(sqResult: Model, entityType: EntityType) { + let key = entityType.keyProperties.map(function (kp) { + return sqResult[kp.nameOnServer]; + }).join("::") + "^" + entityType.name; + return key; +} + +// needed to convert 'or:' and 'and:' clauses into Sequelize.and/or clauses +function processAndOr(parent: IncludeOptions) { + if (parent == null) return; + if (parent.where) { + parent.where = processAndOrClause(parent.where); + } + parent.include && parent.include.forEach(function (inc) { + processAndOr(inc as IncludeOptions); + }); + console.trace(parent); +} + +function processAndOrClause(where: WhereOptions): WhereOptions { + console.log("processAndOrClause", where); + let ands = (where[Op.and] || where['and']) as WhereOptions[]; + let ors = (where[Op.or] || where['or']) as WhereOptions[]; + if (ands) { + let clauses = ands.map(function (clause) { + return processAndOrClause(clause); + }) + return Sequelize.and.apply(null, clauses) + // return Sequelize.and(clauses[0], clauses[1]); + } else if (ors) { + let clauses = ors.map(function (clause) { + return processAndOrClause(clause); + }) + return Sequelize.or.apply(null, clauses); + } else { + return where; + } +} + diff --git a/breeze-sequelize/src/SequelizeSaveHandler.ts b/breeze-sequelize/src/SequelizeSaveHandler.ts new file mode 100644 index 0000000..faafe7e --- /dev/null +++ b/breeze-sequelize/src/SequelizeSaveHandler.ts @@ -0,0 +1,450 @@ +import { Promise } from "bluebird"; +import { Entity, EntityState, EntityType, KeyMapping, MetadataStore, SaveOptions } from "breeze-client"; +import * as _ from 'lodash'; +import { EntityInfo, SaveMap, ServerEntityError } from "./SaveMap"; +import { Model, Transaction } from "sequelize"; +import { KeyGenerator, SequelizeManager } from "./SequelizeManager"; +let toposort = require("toposort") as (ar: any[]) => any[]; + + +export interface SaveRequest { + body: { entities: Entity[], saveOptions: SaveOptions } +} + +interface EntityGroup { + entityType: EntityType; + entityInfos: EntityInfo[]; +} + +/** Handles saving entities from Breeze SaveChanges requests */ +export class SequelizeSaveHandler { + sequelizeManager: SequelizeManager; + metadataStore: MetadataStore; + entitiesFromClient: Entity[]; + saveOptions: SaveOptions; + private _keyMappings: KeyMapping[]; + private _fkFixupMap: { [entityKeyName: string]: any }; + private _savedEntities: Entity[]; + keyGenerator: KeyGenerator; + beforeSaveEntity: (ei: EntityInfo) => EntityInfo; + beforeSaveEntities: (sm: SaveMap, trx?: Transaction) => SaveMap; + + /** Create an instance for the given save request */ + constructor(sequelizeManager: SequelizeManager, req: SaveRequest) { + let 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 */ + save(): Promise<{ errors: ServerEntityError[], message: string }> | Promise { + let beforeSaveEntity = (this.beforeSaveEntity || noopBeforeSaveEntity).bind(this); + let entityTypeMap = {}; + + let 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. + let entityAspect = entity.entityAspect; + let entityTypeName = (entityAspect as any).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); + } + } + let unmapped = (entity as any).__unmapped; + let 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; + }, this); + + // create the saveMap (entities to be saved) grouped by entityType + let 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 + let saveMap = _.extend(new SaveMap(this), saveMapData); + + return this._saveWithTransaction(saveMap); + + } + + private _saveWithTransaction(saveMap: SaveMap) { + let sequelize = this.sequelizeManager.sequelize; + return sequelize.transaction().then(trx => { + // this.transaction = trx; + + let beforeSaveEntities = (this.beforeSaveEntities || noopBeforeSaveEntities).bind(this) as (sm: SaveMap, trx: Transaction) => SaveMap; + // beforeSaveEntities will either return nothing or a promise. + let nextPromise = Promise.resolve(beforeSaveEntities(saveMap, trx)); + + // saveCore returns either a list of entities or an object with an errors property. + return nextPromise.then(sm => { + return this._saveCore(saveMap, trx) as any; + }).then((r: any) => { + if (r.errors) { + trx.rollback(); + return r; + } else { + trx.commit(); + return { entities: r, keyMappings: this._keyMappings }; + } + }).catch((e: Error) => { + trx.rollback(); + throw e; + }); + }); + }; + + + private _saveCore(saveMap: SaveMap, transaction: Transaction): Promise<{ errors: ServerEntityError[], message: string }> | Promise { + if (saveMap.entityErrors || saveMap.errorMessage) { + return Promise.resolve({ errors: saveMap.entityErrors || [], message: saveMap.errorMessage }); + } + + let entityTypes = _.keys(saveMap).map((entityTypeName: string) => { + // guaranteed to succeed because these have all been looked up earlier. + return this.metadataStore.getEntityType(entityTypeName); + }, this); + + let sortedEntityTypes = toposortEntityTypes(entityTypes as EntityType[]); + let entityGroups = sortedEntityTypes.map((entityType: EntityType) => { + return { entityType: entityType, entityInfos: saveMap[entityType.name] } as EntityGroup; + }); + + // 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. + return Promise.reduce(entityGroups, (savedEntities, entityGroup) => { + return this._processEntityGroup(entityGroup, transaction, false).then(entities => { + Array.prototype.push.apply(savedEntities, entities); + return savedEntities; + }); + }, [] as Entity[]).then(entitiesHandledSoFar => { + return Promise.reduce(entityGroups.reverse(), (savedEntities, entityGroup) => { + return this._processEntityGroup(entityGroup, transaction, true).then(entities => { + Array.prototype.push.apply(savedEntities, entities); + return savedEntities; + }); + }, entitiesHandledSoFar); + }); + } + + private _processEntityGroup(entityGroup: EntityGroup, transaction: Transaction, processDeleted: boolean): Promise { + + let entityType = entityGroup.entityType; + + let entityInfos = entityGroup.entityInfos.filter(entityInfo => { + let isDeleted = entityInfo.entityAspect.entityState == "Deleted" + return processDeleted ? isDeleted : !isDeleted; + }); + + let sqModel = this.sequelizeManager.entityTypeSqModelMap[entityType.name]; + + entityInfos = toposortEntityInfos(entityType, entityInfos); + if (processDeleted) { + entityInfos = entityInfos.reverse(); + } + + return Promise.reduce(entityInfos, (savedEntities, entityInfo) => { + // function returns a promise for this entity + // and updates the results array. + return this._saveEntityAsync(entityInfo, sqModel, transaction).then(savedEntity => { + savedEntities.push(savedEntity); + return savedEntities; + }); + }, [] as Entity[]); + }; + + private _saveEntityAsync(entityInfo: EntityInfo, sqModel: { new(): Model } & typeof Model, transaction: Transaction): Promise { + // function returns a promise for this entity + // and updates the results array. + + // not a "real" entityAspect - just the salient pieces sent from the client. + let entity = entityInfo.entity; + let entityAspect = entityInfo.entityAspect; + let entityType = entityInfo.entityType; + let 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); + let keyProperties = entityType.keyProperties; + let firstKeyPropName = keyProperties[0].nameOnServer; + + let entityState = entityAspect.entityState; + let trxOptions = { transaction: transaction }; + let promise; + if (entityState === "Added") { + let keyMapping: 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. + + let autoGeneratedKey = entityAspect.autoGeneratedKey; + let autoGeneratedKeyType = autoGeneratedKey && autoGeneratedKey.autoGeneratedKeyType; + let tempKeyValue = entity[firstKeyPropName]; + if (autoGeneratedKeyType && autoGeneratedKeyType !== "None") { + let realKeyValue: any; + if (autoGeneratedKeyType == "KeyGenerator") { + if (this.keyGenerator == null) { + throw new Error("No KeyGenerator was provided for property:" + keyProperties[0].name + " on entityType: " + entityType.name); + } + promise = this.keyGenerator.getNextId(keyProperties[0]).then((nextId: any) => { + realKeyValue = nextId; + entity[firstKeyPropName] = realKeyValue; + }); + } else if (autoGeneratedKeyType == "Identity") { + let 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]; + } + } + promise = promise || Promise.resolve(null); + promise = promise.then(() => { + // tempKeyValue will be undefined in entity was created on the server + if (tempKeyValue != undefined) { + keyMapping = { entityTypeName: entityTypeName, tempValue: tempKeyValue, realValue: realKeyValue }; + } + }) + } + promise = promise || Promise.resolve(null); + return promise.then(() => { + return sqModel.create(entity, { transaction: transaction }).then((savedEntity: Model) => { + if (keyMapping) { + if (keyMapping.realValue === null) { + keyMapping.realValue = savedEntity[firstKeyPropName]; + } + let tempKeyString = buildKeyString(entityType, tempKeyValue); + this._fkFixupMap[tempKeyString] = keyMapping.realValue; + this._keyMappings.push(keyMapping); + } + return this._addToResults((savedEntity as any).dataValues, entityTypeName); + }).catch(handleItemSaveError(entity, entityState)); + }); + } else if (entityState === "Modified") { + let 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: object; + if (entityInfo.forceUpdate) { + setHash = _.clone(entity); + // remove fields that we don't want to 'set' + delete (setHash as any).entityAspect; + // TODO: should we also remove keyProps here... + } else { + setHash = {}; + let 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 + let 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 Promise.resolve(this._addToResults(entity, entityTypeName)); + } + return sqModel.update(setHash, { where: whereHash, transaction: transaction }).then(infoArray => { + let itemsSaved = infoArray[0]; + if (itemsSaved != 1) { + let err = new Error("unable to update entity - concurrency violation") as any; + 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(handleItemSaveError(entity, entityState)); + } else if (entityState = "Deleted") { + let whereHash = {}; + keyProperties.forEach(kp => { + whereHash[kp.nameOnServer] = entity[kp.nameOnServer]; + }); + // we don't bother with concurrency check on deletes + // TODO: we may want to add a 'switch' for this later. + return sqModel.destroy({ where: whereHash, limit: 1, transaction: transaction }).then(() => { + // Sequelize 'destroy' does not return the entity; so + // we are just returning the original entity here. + return this._addToResults(entity, entityTypeName); + }).catch(handleItemSaveError(entity, entityState)) + } + } + + private _addToResults(entity: Entity, entityTypeName: string) { + (entity as any).$type = entityTypeName; + this._savedEntities.push(entity); + return entity; + } + + private _coerceData(entity: Entity, entityType: EntityType) { + entityType.dataProperties.forEach(dp => { + + let 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. + let key = buildKeyString(dp.relatedNavigationProperty.entityType, val); + let newVal = this._fkFixupMap[key]; + if (newVal) { + entity[dp.nameOnServer] = newVal; + } + } + + let 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]; + // } + } + }) + } + +} + +function noopBeforeSaveEntities(saveMap: SaveMap, trx?: Transaction) { + return saveMap; +} + +function noopBeforeSaveEntity(entityInfo: EntityInfo) { + return true; +} + +/** Sort the EntityTypes based on their dependencies */ +function toposortEntityTypes(entityTypes: EntityType[]) { + let edges: [EntityType, EntityType][] = []; + entityTypes.forEach(et => { + et.foreignKeyProperties.forEach(fkp => { + if (fkp.relatedNavigationProperty) { + let 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. + let allSortedTypes = toposort(edges).reverse(); + allSortedTypes.forEach(function (st, ix) { + st.index = ix; + }); + let sortedEntityTypes = entityTypes.sort(function (a, b) { + return (a as any).index - (b as any).index; + }); + return sortedEntityTypes; +} + +/** Sort the EntityInfos of a given type based foreign key relationships */ +function toposortEntityInfos(entityType: EntityType, entityInfos: EntityInfo[]) { + let edges: [EntityInfo, EntityInfo][] = []; + let selfReferenceNavProp = _.find(entityType.navigationProperties, navProp => navProp.entityType === entityType); + if (!selfReferenceNavProp || !selfReferenceNavProp.relatedDataProperties) { + return entityInfos; + } + + let fkDataProp = selfReferenceNavProp.relatedDataProperties[0].name; + let keyProp = entityType.keyProperties[0].name; + entityInfos.forEach(function (entityInfo) { + let dependsOn = entityInfo.entity[fkDataProp]; + if (dependsOn) { + let 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; + } + + let allSortedEntityInfos = toposort(edges).reverse(); + allSortedEntityInfos.forEach(function (st, ix) { + st.__index = ix; + }); + let sortedEntityInfos = entityInfos.sort(function (a, b) { + return (a as any).__index - (b as any).__index; + }); + return sortedEntityInfos; +} + +function buildKeyString(entityType: EntityType, val: any) { + return entityType.name + "::" + val.toString(); +} + +function handleItemSaveError(entity: Entity, entityState: EntityState | string) { + return function (err: any) { + err = typeof (err) == 'string' ? new Error(err) : err; + let detailedMsg = (err.name ? "error name: " + err.name : "") + (err.sql ? " sql: " + err.sql : ""); + err.message = err.message ? err.message + ". " + detailedMsg : detailedMsg; + err.entity = entity; + err.entityState = entityState; + throw err; + } +} + +function createGuid() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} diff --git a/breeze-sequelize/src/dbUtils.ts b/breeze-sequelize/src/dbUtils.ts new file mode 100644 index 0000000..63d4144 --- /dev/null +++ b/breeze-sequelize/src/dbUtils.ts @@ -0,0 +1,61 @@ +import { Options, QueryTypes, Sequelize } from "sequelize"; + +let utils = require('./utils.js'); +let log = utils.log; + +exports.connect = connect; +exports.createDb = createDb; + +export interface DbConfig { + dbName: string; + user: string; + password: string; +} + +/** @returns Promise<"success"> or throws an error */ +export function connect(dbConfig: DbConfig, sequelizeOptions: Options): Promise { + + let sequelize = new Sequelize(dbConfig.dbName, dbConfig.user, dbConfig.password, sequelizeOptions); + let statement = 'SELECT 1'; + return sequelize.query(statement, { type: QueryTypes.RAW }).then(function (results) { + log("Connected to database: " + dbConfig.dbName); + return "success"; + }).error(function (err) { + log("Database error: " + dbConfig.dbName + " error: " + err.message); + throw err; + }); +}; + +/** @returns Promise or throws an error */ +export function createDb(dbConfig: DbConfig, sequelizeOptions: Options): Promise { + let sequelize = new Sequelize(null, dbConfig.user, dbConfig.password, sequelizeOptions); + let statement = 'CREATE DATABASE ' + dbConfig.dbName; + return sequelize.query(statement, { type: QueryTypes.RAW }).then(() => { + log("Database created: " + dbConfig.dbName); + }).error(err => { + if (err.message && err.message.indexOf("ER_DB_CREATE_EXISTS") >= 0) { + log("Database already exists: " + dbConfig.dbName); + } else { + log("Database creation error: " + dbConfig.dbName + " error: " + err.message); + throw err; + } + }); +}; + +// old version using node 'next' semantics. +// next => function(err, connection); +//function createDb(dbConfig, next ) { +// connect(dbConfig, function(err, connection) { +// if (err) return next(err); +// +// connection.query('CREATE DATABASE ' + dbConfig.dbName, function(err, results) { +// if (err && err.code != "ER_DB_CREATE_EXISTS") { +// log("Database creation error: " + err.message); +// next(err); +// } +// log("database created OR already exists."); +// next(null, connection); +// }); +// }); +//} + diff --git a/breeze-sequelize/src/index.ts b/breeze-sequelize/src/index.ts new file mode 100644 index 0000000..6f7e4a0 --- /dev/null +++ b/breeze-sequelize/src/index.ts @@ -0,0 +1 @@ +export * from "main"; \ No newline at end of file diff --git a/breeze-sequelize/src/main.ts b/breeze-sequelize/src/main.ts new file mode 100644 index 0000000..8e5c65d --- /dev/null +++ b/breeze-sequelize/src/main.ts @@ -0,0 +1,9 @@ +import { SequelizeQuery } from "./SequelizeQuery"; +import { SequelizeManager } from "./SequelizeManager"; +import { SequelizeSaveHandler } from "./SequelizeSaveHandler"; +import * as utils from "./utils"; +import * as dbUtils from "./dbUtils"; +import { breeze } from "breeze-client"; + +const Sequelize = SequelizeManager.Sequelize; +export { SequelizeQuery, SequelizeManager, Sequelize, SequelizeSaveHandler, utils, dbUtils, breeze } diff --git a/breeze-sequelize/src/package.json b/breeze-sequelize/src/package.json index 15ae2c2..6fc3c9c 100644 --- a/breeze-sequelize/src/package.json +++ b/breeze-sequelize/src/package.json @@ -1,30 +1,44 @@ { "name": "breeze-sequelize", - "version": "0.3.0", + "version": "0.4.0", "description": "Breeze Sequelize server implementation", "keywords": [ "breeze", "sequelize", "orm", "query", + "relational", "linq", "graph" ], "main": "main.js", + "types": "types/main.d.ts", + "files": [ + "*.js", + "types/*" + ], "directories": {}, "dependencies": { - "bluebird": "^3.5.5", - "breeze-client": "next", + "bluebird": "^3.7.2", + "breeze-client": ">=2.0.3", "lodash": "^4.17.15", - "sequelize": "^5.18.1", + "sequelize": "^5.21.3", "toposort": "^2.0.2" }, "devDependencies": { + "@types/bluebird": "^3.5.29", + "@types/lodash": "^4.14.137", + "@types/node": "^12.12.25", + "@types/validator": "^12.0.1", "chai": "^4.2.0", - "mocha": "^6.2.0" + "mocha": "^6.2.0", + "rimraf": "^3.0.0", + "typescript": "~3.4.5" }, "scripts": { - "test": "mocha" + "test": "mocha", + "build": "tsc && npm pack", + "clean": "rimraf *.js && rimraf *.map && rimraf types" }, "repository": { "type": "git", diff --git a/breeze-sequelize/src/tsconfig.json b/breeze-sequelize/src/tsconfig.json new file mode 100644 index 0000000..778aac3 --- /dev/null +++ b/breeze-sequelize/src/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "declaration": true, + "declarationDir": "types", + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "stripInternal": true, + "baseUrl": ".", + "sourceMap": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "removeComments": false, + "noImplicitAny": true, + "strictNullChecks": false, + "suppressImplicitAnyIndexErrors": true, + "noUnusedLocals": false, + "skipLibCheck": true, + "typeRoots": [ + "./node_modules/@types" + ], + "lib": ["es2017"] + }, + "exclude": [ + "node_modules", + "types" + ] + +} diff --git a/breeze-sequelize/src/utils.ts b/breeze-sequelize/src/utils.ts new file mode 100644 index 0000000..3179088 --- /dev/null +++ b/breeze-sequelize/src/utils.ts @@ -0,0 +1,7 @@ + +export function log(s: any, ...args: any[]) { + if (!log['enabled']) return; + console.log('[Breeze] ' + s + '\n', args); +} +log['enabled'] = true; + diff --git a/breeze-sequelize/test/EntityQuery-parse.test.js b/breeze-sequelize/test/EntityQuery-parse.test.js index 0eeaf76..f765eea 100644 --- a/breeze-sequelize/test/EntityQuery-parse.test.js +++ b/breeze-sequelize/test/EntityQuery-parse.test.js @@ -1,9 +1,9 @@ var fs = require('fs'); var expect = require('chai').expect; -var breeze = require('breeze-client'); var Sequelize = require('sequelize'); var testFns = require('./testFns.js'); +var breeze = testFns.breeze; var EntityManager = breeze.EntityManager; var EntityQuery = breeze.EntityQuery; diff --git a/breeze-sequelize/test/EntityQueryToSequelizeQuery-execute.test.js b/breeze-sequelize/test/EntityQueryToSequelizeQuery-execute.test.js index 7ee5970..1cb2eed 100644 --- a/breeze-sequelize/test/EntityQueryToSequelizeQuery-execute.test.js +++ b/breeze-sequelize/test/EntityQueryToSequelizeQuery-execute.test.js @@ -8,9 +8,8 @@ var breezeSequelize = require("breeze-sequelize"); // Don't use this // var breeze = require('breeze-client'); // Use this -var breeze = breezeSequelize.breeze; - var testFns = require('./testFns.js'); +var breeze = testFns.breeze; var SequelizeManager = breezeSequelize.SequelizeManager; var SequelizeQuery = breezeSequelize.SequelizeQuery; @@ -58,7 +57,7 @@ describe("EntityQuery to SequelizeQuery - execute", function () { // .from("Customers") // .where("toUpper(substring(companyName, 1, 2))", "startsWith", "OM"); var q = EntityQuery.from("Customers") - .where("toUpper(CompanyName)", "startsWith", "C") + .where("toUpper(companyName)", "startsWith", "C") var sq = toSequelizeQuery(q) sq.executeRaw().then(function (r) { expect(r).to.have.length.greaterThan(1); @@ -73,7 +72,7 @@ describe("EntityQuery to SequelizeQuery - execute", function () { it("should be able to use fn 'tolower'", function (done) { var q = EntityQuery.from("Customers") - .where("toLower(CompanyName)", "startsWith", "c") + .where("toLower(companyName)", "startsWith", "c") var sq = toSequelizeQuery(q) sq.executeRaw().then(function (r) { expect(r).to.have.length.greaterThan(1); @@ -87,7 +86,7 @@ describe("EntityQuery to SequelizeQuery - execute", function () { it("should be able to use fn 'substring'", function (done) { var q = EntityQuery.from("Customers") - .where("substring(CompanyName,0,2)", "==", "Co"); + .where("substring(companyName,0,2)", "==", "Co"); var sq = toSequelizeQuery(q) sq.executeRaw().then(function (r) { expect(r).to.have.length.greaterThan(1); @@ -101,7 +100,7 @@ describe("EntityQuery to SequelizeQuery - execute", function () { it("should be able to use fn 'substring' and 'tolower'", function (done) { var q = EntityQuery.from("Customers") - .where("substring(toLower(CompanyName),1,2)", "==", "om"); + .where("substring(toLower(companyName),1,2)", "==", "om"); var sq = toSequelizeQuery(q) sq.executeRaw().then(function (r) { expect(r).to.have.length.greaterThan(0); @@ -116,7 +115,7 @@ describe("EntityQuery to SequelizeQuery - execute", function () { it("should be able to use fn 'length'", function (done) { var minLength = 26; var q = EntityQuery.from("Customers") - .where("length(Address)", ">", minLength) + .where("length(address)", ">", minLength) var sq = toSequelizeQuery(q) sq.executeRaw().then(function (r) { expect(r).to.have.length.greaterThan(5); @@ -130,7 +129,7 @@ describe("EntityQuery to SequelizeQuery - execute", function () { it("should be able to use fn 'month'", function (done) { var q = EntityQuery.from("Employees") - .where("month(BirthDate)", "==", 12) + .where("month(birthDate)", "==", 12) var sq = toSequelizeQuery(q) sq.executeRaw().then(function (r) { expect(r).to.have.length.greaterThan(0); @@ -144,7 +143,7 @@ describe("EntityQuery to SequelizeQuery - execute", function () { it("should be able to use fn 'day'", function (done) { var q = EntityQuery.from("Employees") - .where("day(BirthDate)", ">", 20) + .where("day(birthDate)", ">", 20) var sq = toSequelizeQuery(q) sq.executeRaw().then(function (r) { expect(r).to.have.length.greaterThan(0); @@ -865,6 +864,7 @@ describe("EntityQuery to SequelizeQuery - execute", function () { var sq = toSequelizeQuery(q0); sq.executeRaw().then(function (r) { expect(r).to.have.length.above(1); + console.log(r); r.forEach(function (emp) { expect(emp).to.have.property("HireDate"); expect(emp.HireDate).to.be.above(new Date(1994, 0, 1)); @@ -1043,11 +1043,11 @@ describe("EntityQuery to SequelizeQuery - execute", function () { it("should be able to use 'not' array with 'in' inside 'and'", function(done) { var p2 = { and: [ - { companyName: { like: 'B%'} }, + { companyName: { startsWith: 'B'} }, { not: { country: { in: [ 'Belgium', 'Germany'] } } }, ] }; - + var p = Predicate.create(p2); var q = new EntityQuery("Customers").where(p); toSequelizeQuery(q).executeRaw().then(function (r) { diff --git a/breeze-sequelize/test/EntityQueryToSequelizeQuery-parse.test.js b/breeze-sequelize/test/EntityQueryToSequelizeQuery-parse.test.js index 77f22f5..9d8d136 100644 --- a/breeze-sequelize/test/EntityQueryToSequelizeQuery-parse.test.js +++ b/breeze-sequelize/test/EntityQueryToSequelizeQuery-parse.test.js @@ -9,7 +9,7 @@ var testFns = require('./testFns.js'); var SequelizeManager = breezeSequelize.SequelizeManager; var SequelizeQuery = breezeSequelize.SequelizeQuery; -var breeze = breezeSequelize.breeze; +var breeze = testFns.breeze; var EntityManager = breeze.EntityManager; var EntityQuery = breeze.EntityQuery; var Predicate = breeze.Predicate; diff --git a/breeze-sequelize/test/ExpressDemo/package.json b/breeze-sequelize/test/ExpressDemo/package.json index 8943593..a5cf7db 100644 --- a/breeze-sequelize/test/ExpressDemo/package.json +++ b/breeze-sequelize/test/ExpressDemo/package.json @@ -1,12 +1,12 @@ { - "name": "Breeze-Sequelize-Express-Demo", + "name": "breeze-sequelize-express-demo", "description": "A Breeze-Sequelize-Express Demo", "version": "0.0.3", "private": true, "dependencies": { - "breeze-client": "next", - "breeze-sequelize": "^0.3.0", + "breeze-client": ">=2.0.3", + "breeze-sequelize": ">=0.4.0", "express": "3.x", - "mysql2": "^1.5.2" + "mysql2": "^1.7.0" } } diff --git a/breeze-sequelize/test/ExpressDemo/routes.js b/breeze-sequelize/test/ExpressDemo/routes.js index 05e4171..3772e35 100644 --- a/breeze-sequelize/test/ExpressDemo/routes.js +++ b/breeze-sequelize/test/ExpressDemo/routes.js @@ -4,7 +4,6 @@ var Promise = require("bluebird"); var breezeSequelize = require('breeze-sequelize'); var adapter_model = require("breeze-client/adapter-model-library-backing-store"); -adapter_model.ModelLibraryBackingStoreAdapter.register(); var SequelizeManager =breezeSequelize.SequelizeManager; var SequelizeQuery = breezeSequelize.SequelizeQuery; @@ -14,6 +13,7 @@ var SequelizeSaveHandler = breezeSequelize.SequelizeSaveHandler; // var breeze = require('breeze-client'); // Use this var breeze = breezeSequelize.breeze; +adapter_model.ModelLibraryBackingStoreAdapter.register(breeze.config); var EntityQuery = breeze.EntityQuery; var _dbConfigNw = { @@ -53,7 +53,7 @@ function KeyGenerator(sequelize, groupSize) { this.nextIdModel = sequelize.define('nextid', { Name: { type: sequelize.Sequelize.STRING, primaryKey: true }, NextId: sequelize.Sequelize.INTEGER - }, { freezeTableName: true, timestamps: false }); + }, { freezeTableName: true, timestamps: false }); this.nextId = null; this.groupSize = groupSize || 100; } @@ -77,7 +77,7 @@ KeyGenerator.prototype._updateNextId = function() { var that = this; var nextId; - return this.nextIdModel.findById("GLOBAL").then(function(nextIdItem) { + return this.nextIdModel.findByPk("GLOBAL").then(function(nextIdItem) { nextId = nextIdItem["NextId"]; var nextIdToSave = nextId + that.groupSize; return that.nextIdModel.update({ NextId: nextIdToSave }, { where: { Name: "GLOBAL", NextId: nextId }}); @@ -403,10 +403,11 @@ function beforeSaveEntities(saveMap) { var customers = saveMap.getEntityInfosOfType("Customer"); customers.forEach(function(custInfo) { if (custInfo.entityAspect.entityState != "Deleted") { - if (custInfo.entity.companyName.toLowerCase().indexOf("error") === 0) { + var companyName = custInfo.entity.companyName || custInfo.entity.CompanyName; + if (companyName.toLowerCase().indexOf("error") === 0) { saveMap.addEntityError(custInfo, "Bad customer", "This customer is not valid!", "companyName"); } - var contactName = custInfo.entity.contactName; + var contactName = custInfo.entity.contactName || custInfo.entity.ContactName; if (contactName && contactName.toLowerCase().indexOf("error") === 0) { saveMap.addEntityError(custInfo, "Bad ContactName", "This contact name should not contain the word 'Error'", "contactName"); } diff --git a/breeze-sequelize/test/Predicate.test.js b/breeze-sequelize/test/Predicate.test.js index 3d61190..53dec42 100644 --- a/breeze-sequelize/test/Predicate.test.js +++ b/breeze-sequelize/test/Predicate.test.js @@ -2,24 +2,18 @@ var fs = require('fs'); var expect = require('chai').expect; var Sequelize = require('sequelize'); var testFns = require('./testFns.js'); +var adapter_odata = require("breeze-client/adapter-uri-builder-odata"); var breezeSequelize = require("breeze-sequelize"); // Don't use this // var breeze = require('breeze-client'); // Use this -var breeze = breezeSequelize.breeze; +var breeze = testFns.breeze; +adapter_odata.UriBuilderODataAdapter.register(breeze.config); -var EntityManager = breeze.EntityManager; -var EntityQuery = breeze.EntityQuery; -var Predicate = breeze.Predicate; -var DataService = breeze.DataService; - -var EntityManager = breeze.EntityManager; -var EntityQuery = breeze.EntityQuery; var Predicate = breeze.Predicate; var FilterQueryOp = breeze.FilterQueryOp; -var log = testFns.log; describe("Predicate - parse", function() { this.enableTimeouts(false); @@ -389,7 +383,7 @@ describe("Predicate - parse", function() { { not: { orders: { any: {} }}} ] }; var p = Predicate.create(p2); - test(p, entityType, "(substringof('ar',CompanyName) eq true) and (not (Orders/any()))"); + test(p, entityType, "(substringof('ar',CompanyName) eq true) and (not (Orders/any(x1: )))"); }); xit("and with with not in - json", function() { diff --git a/breeze-sequelize/test/SaveHandler.test.js b/breeze-sequelize/test/SaveHandler.test.js new file mode 100644 index 0000000..ebfcced --- /dev/null +++ b/breeze-sequelize/test/SaveHandler.test.js @@ -0,0 +1,61 @@ +var expect = require('chai').expect; + +var breezeSequelize = require("breeze-sequelize"); +// Don't use this +// var breeze = require('breeze-client'); +// Use this +var testFns = require('./testFns.js'); +var breeze = testFns.breeze; + +var SequelizeManager = breezeSequelize.SequelizeManager; +var SequelizeSaveHandler = breezeSequelize.SequelizeSaveHandler; + +describe("SaveHandler", function () { + + this.enableTimeouts(false); + + var _ms, _em, _sm; + + before(function () { + _em = testFns.newEm(); + _ms = _em.metadataStore; + + _sm = new SequelizeManager(testFns.dbConfigNw); + _sm.importMetadata(testFns.getMetadata()); + }); + + + it("should be able to save a new Customer", function (done) { + var guid = breeze.core.getUuid(); + var cust = { CustomerID: guid, CompanyName: "NewCo", entityAspect: { + entityTypeName: "Customer:#Foo", entityState: "Added" }}; + var request = { body: { entities: [cust], saveOptions: {} }}; + var handler = new SequelizeSaveHandler(_sm, request); + + handler.save().then(r => { + var entities = r.entities; + expect(entities).to.have.length(1); + var rcust = entities[0]; + expect(rcust.CustomerID).to.equal(guid); + expect(rcust.CompanyName).to.equal("NewCo"); + }).then(done, done); + }); + + var custOrderPayload = {"entities":[ + {"OrderID":10351,"CustomerID":"042a91cd-9d3e-4576-8200-e33c3dd8ec82","EmployeeID":1,"OrderDate":"1996-11-11T00:00:00.000Z","RequiredDate":"1996-12-09T00:00:00.000Z","ShippedDate":"1996-11-20T00:00:00.000Z","Freight":162.33,"ShipName":"Ernst Handel","ShipAddress":"Kirchgasse 6","ShipCity":"Graz","ShipRegion":null,"ShipPostalCode":"8010","ShipCountry":"Austria","RowVersion":0, + "entityAspect":{"entityTypeName":"Order:#Foo","defaultResourceName":"Orders","entityState":"Modified","originalValuesMap":{"CustomerID":"a3246674-7989-415b-93ec-59c1c8dbe320"},"autoGeneratedKey":{"propertyName":"OrderID","autoGeneratedKeyType":"Identity"}}}, + {"CustomerID":"042a91cd-9d3e-4576-8200-e33c3dd8ec82","CustomerID_OLD":null,"CompanyName":"Test_compName","ContactName":null,"ContactTitle":null,"Address":null,"City":null,"Region":null,"PostalCode":null,"Country":null,"Phone":null,"Fax":null,"RowVersion":null, + "entityAspect":{"entityTypeName":"Customer:#Foo","defaultResourceName":"Customers","entityState":"Added","originalValuesMap":{},"autoGeneratedKey":{"propertyName":"CustomerID","autoGeneratedKeyType":"Identity"}}}],"saveOptions":{}} + + it("should be able to save a new Customer and change an Order", function (done) { + var request = { body: custOrderPayload }; + var handler = new SequelizeSaveHandler(_sm, request); + + handler.save().then(r => { + var entities = r.entities; + expect(entities).to.have.length(2); + }).then(done, done); + }); + + +}); \ No newline at end of file diff --git a/breeze-sequelize/test/package.json b/breeze-sequelize/test/package.json index 9bb7e50..467ba44 100644 --- a/breeze-sequelize/test/package.json +++ b/breeze-sequelize/test/package.json @@ -1,23 +1,27 @@ { "name": "breeze-sequelize-tests", - "version": "0.0.16", + "version": "0.0.17", + "private": true, "description": "Breeze Sequelize server tests", "directories": {}, "dependencies": { - "bluebird": "^3.5.5", - "breeze-client": "next", - "breeze-sequelize": "^0.3.0", + "bluebird": "^3.7.2", + "breeze-client": ">=2.0.3", + "breeze-sequelize": ">=0.4.0", "lodash": "^4.17.15", + "mysql": "^2.18.0", "mysql2": "^1.7.0", - "uuid": "^3.3.3" + "uuid": "^3.4.0" }, "devDependencies": { - "mocha": "^6.2.0", - "chai": "^4.2.0" + "chai": "^4.2.0", + "mocha": "^6.2.2", + "sequelize-auto": "^0.4.29" }, "scripts": { "test": "mocha", - "debug": "node --inspect-brk node_modules/mocha/bin/mocha -b" + "debug": "node --inspect-brk node_modules/mocha/bin/mocha -b", + "gen-model": "sequelize-auto -o \"./models\" -d northwindib -h localhost -u mysql -p 3306 -x mysql -e mysql" }, "repository": { "type": "git", diff --git a/breeze-sequelize/test/testFns.js b/breeze-sequelize/test/testFns.js index 37dcd2f..a391906 100644 --- a/breeze-sequelize/test/testFns.js +++ b/breeze-sequelize/test/testFns.js @@ -1,5 +1,9 @@ var breezeSequelize = require("breeze-sequelize"); var adapter_model = require("breeze-client/adapter-model-library-backing-store"); +var adapter_json = require("breeze-client/adapter-uri-builder-json"); +var adapter_data = require("breeze-client/adapter-data-service-webapi"); +var adapter_ajax = require("breeze-client/adapter-ajax-fetch"); + var fs = require('fs'); var expect = require('chai').expect; var _ = require('lodash'); @@ -8,21 +12,32 @@ var _ = require('lodash'); // var breeze = require('breeze-client'); // Use this var breeze = breezeSequelize.breeze; +exports.breeze = breeze; var EntityManager = breeze.EntityManager; var EntityQuery = breeze.EntityQuery; var Predicate = breeze.Predicate; var DataService = breeze.DataService -adapter_model.ModelLibraryBackingStoreAdapter.register(); +// breeze.config.registerAdapter("modelLibrary", adapter_model.ModelLibraryBackingStoreAdapter); +// breeze.config.initializeAdapterInstance("modelLibrary", "backingStore", true); + +adapter_model.ModelLibraryBackingStoreAdapter.register(breeze.config); +adapter_json.UriBuilderJsonAdapter.register(breeze.config); +adapter_ajax.AjaxFetchAdapter.register(breeze.config); +adapter_data.DataServiceWebApiAdapter.register(breeze.config); + +// breeze.ModelLibraryBackingStoreAdapter.register(); +// breeze.NamingConvention.none.setAsDefault(); +console.log("registered adapters"); -// test predicate extension -breeze.Predicate.extendBinaryPredicateFn( { like: {}, nlike: { alias: 'notLike' }}, function(context, expr1, expr2) { - var e2 = "^" + expr2.replace("%", ".*?") + "$"; - var rx = new RegEx(e2); - var isLike = rx.test(expr1); - return (this.op.key == 'like') ? isLike : !isLike; -}); +// test predicate extension - REMOVED in breeze-client 2.0 - TODO should we restore it? +// breeze.Predicate.extendBinaryPredicateFn( { like: {}, nlike: { alias: 'notLike' }}, function(context, expr1, expr2) { +// var e2 = "^" + expr2.replace("%", ".*?") + "$"; +// var rx = new RegEx(e2); +// var isLike = rx.test(expr1); +// return (this.op.key == 'like') ? isLike : !isLike; +// }); var __dbConfigNw = { host: "localhost", @@ -40,7 +55,8 @@ exports.dbConfigNw = __dbConfigNw; exports.getSequelizeQuery = function(uriBuilderName) { uriBuilderName = uriBuilderName || exports.uriBuilderName; - return require('./../SequelizeQuery.' + uriBuilderName + '.js'); + // return require('./../SequelizeQuery.' + uriBuilderName + '.js'); + return null; }