├── .npmignore ├── tslint.json ├── mocha.opts ├── src ├── index.ts ├── where.spec.ts ├── database.spec.ts ├── where.ts ├── sequelize.d.ts ├── cloner.js ├── metadata.ts ├── queryable.spec.ts ├── query.spec.ts ├── database.ts ├── queryable.ts └── query.ts ├── .editorconfig ├── tsconfig.json ├── .gitignore ├── LICENSE.md ├── package.json ├── README.md └── CHANGELOG.md /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-ccs" 3 | } 4 | -------------------------------------------------------------------------------- /mocha.opts: -------------------------------------------------------------------------------- 1 | --timeout 30000 2 | --require ts-node/register 3 | --watch-extensions ts 4 | src/**/*.spec.ts 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './database'; 2 | export * from './metadata'; 3 | export * from './queryable'; 4 | export * from './query'; 5 | export * from './where'; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | # Change these settings to your own preference 5 | indent_style = space 6 | indent_size = 2 7 | 8 | # We recommend you to keep these unchanged 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.js] 18 | max_line_length = 120 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "outDir": "dist", 8 | "experimentalDecorators": true, 9 | "noImplicitAny": true, 10 | "suppressImplicitAnyIndexErrors": true, 11 | "declaration": true, 12 | "noUnusedParameters": true, 13 | "noUnusedLocals": true 14 | }, 15 | "exclude": [ 16 | "dist", 17 | "node_modules" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | *.pid.lock 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # nyc test coverage 19 | .nyc_output 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directories 31 | node_modules 32 | jspm_packages 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional eslint cache 38 | .eslintcache 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Output of 'npm pack' 44 | *.tgz 45 | 46 | # Yarn Integrity file 47 | .yarn-integrity 48 | 49 | # TypeScript files 50 | dist 51 | typings 52 | index.d.ts 53 | docs 54 | 55 | # Other files 56 | test.db* 57 | .vscode 58 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2017 Creative Curiosity 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/where.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-magic-numbers */ 2 | import 'should'; 3 | 4 | // import { attribute } from './queryable'; 5 | 6 | describe('Where', () => { 7 | // let attr = attribute('name'); 8 | // let ageAttr = attribute('age'); 9 | 10 | /* FIXME doesn't work with symbols anymore 11 | describe('#and', () => { 12 | it('should compile', () => { 13 | ageAttr.eq(40).and(attr.like('%Bruce%')).compile().should.deepEqual({ 14 | age: 40, 15 | name: { $like: '%Bruce%' } 16 | }); 17 | 18 | ageAttr.eq(40).and(attr.like('%Bruce%').or(attr.like('%Willis%'))).compile().should.deepEqual({ 19 | age: 40, 20 | $or: [ 21 | { 22 | name: { $like: '%Bruce%' } 23 | }, 24 | 25 | { 26 | name: { $like: '%Willis%' } 27 | } 28 | ] 29 | }); 30 | }); 31 | }); 32 | 33 | describe('#or', () => { 34 | it('should compile', () => { 35 | ageAttr.eq(40).or(attr.like('%Bruce%')).compile().should.deepEqual({ 36 | $or: [ 37 | { 38 | age: 40 39 | }, 40 | 41 | { 42 | name: { $like: '%Bruce%' } 43 | } 44 | ] 45 | }); 46 | }); 47 | }); 48 | */ 49 | }); 50 | -------------------------------------------------------------------------------- /src/database.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-magic-numbers */ 2 | import 'should'; 3 | import * as modelsafe from 'modelsafe'; 4 | 5 | import { Database } from './database'; 6 | import { attr } from './metadata'; 7 | 8 | /* tslint:disable:completed-docs */ 9 | @modelsafe.model() 10 | class BadActor extends modelsafe.Model { 11 | @attr({ autoIncrement: true }) 12 | @modelsafe.attr(modelsafe.INTEGER, { primary: true }) 13 | @modelsafe.optional 14 | id: number; 15 | 16 | @modelsafe.attr(modelsafe.STRING) 17 | name: string; 18 | 19 | @modelsafe.attr(modelsafe.INTEGER) 20 | age: number; 21 | 22 | @modelsafe.assoc(modelsafe.HAS_ONE, () => BadActor) 23 | mentee: BadActor; 24 | 25 | @modelsafe.assoc(modelsafe.HAS_ONE, () => BadActor) 26 | duplicate: BadActor; 27 | } 28 | /* tslint:enable-completed-docs */ 29 | 30 | let db = new Database('sqlite://root:root@localhost/squell_test', { 31 | storage: ':memory:', 32 | logging: !!process.env.LOG_TEST_SQL 33 | }); 34 | 35 | describe('Database', () => { 36 | describe('#sync', () => { 37 | it('should reject duplicate has-one foreign keys', async () => { 38 | db.define(BadActor); 39 | // Must throw an exception to succeed 40 | return db.sync({ force: true }).then( 41 | () => { throw new Error('Should fail'); }, 42 | () => null); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/where.ts: -------------------------------------------------------------------------------- 1 | /** Contains the where class. */ 2 | import { AnyWhereOptions, Op } from 'sequelize'; 3 | 4 | /* tslint:disable-next-line:no-var-requires */ 5 | let cloner = require('./cloner'); 6 | 7 | /** 8 | * Represents a type-safe where query that maps directly to a Sequelize query. 9 | */ 10 | export class Where { 11 | /** The Sequelize representation of the where query. */ 12 | private repr: AnyWhereOptions; 13 | 14 | /** 15 | * Construct a where query from an internal Sequelize representation. 16 | * 17 | * @param repr The internal Sequelize query. 18 | */ 19 | constructor(repr: AnyWhereOptions) { 20 | this.repr = repr; 21 | } 22 | 23 | /** 24 | * Construct a where query from AND'ing two queries together. 25 | * 26 | * @param other The other where query to AND with. 27 | * @returns The new where query. 28 | */ 29 | and(other: Where): Where { 30 | return new Where(cloner.deep.merge({}, this.compile(), other.compile())); 31 | } 32 | 33 | /** 34 | * Construct a where query from OR'ing two queries together. 35 | * 36 | * @param other The other where query to OR with. 37 | * @returns The new where query. 38 | */ 39 | or(other: Where): Where { 40 | return new Where({ 41 | [Op.or]: [this.compile(), other.compile()], 42 | }); 43 | } 44 | 45 | /** 46 | * Compile a where query to its internal Sequelize representation. 47 | * 48 | * @returns The compiled where query. 49 | */ 50 | compile(): AnyWhereOptions { 51 | return this.repr; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/sequelize.d.ts: -------------------------------------------------------------------------------- 1 | import { Hooks } from "sequelize"; 2 | 3 | declare module "sequelize" { 4 | export interface Model extends Hooks, Associations { 5 | /** 6 | * A hashmap of associations defined for this model. 7 | * @param {string} key - The name of the association 8 | * @return {Association} The association definition. 9 | */ 10 | associations: { [key: string]: Association }; 11 | } 12 | 13 | 14 | /** 15 | * Defines an association between models. 16 | */ 17 | export interface Association { 18 | /** 19 | * The field on the source instance used to access the associated target instance. 20 | */ 21 | associationAccessor: string; 22 | 23 | /** 24 | * The type of the association. One of `HasMany`, `BelongsTo`, `HasOne`, `BelongsToMany` 25 | */ 26 | associationType: string; 27 | 28 | /** 29 | * The foreign key options specified for this association, if any. 30 | */ 31 | foreignKey: AssociationForeignKeyOptions; 32 | } 33 | 34 | /** 35 | * Defines a BelongsTo association between two models, where the source model as a foreign key to the target. 36 | */ 37 | export interface BelongsToAssociation extends Association { 38 | /** 39 | * The name of the attribute that contains the identifier on the source. 40 | */ 41 | identifier: string; 42 | 43 | /** 44 | * The name of the attribute that contains the identifier on the target. 45 | */ 46 | targetIdentifier: string; 47 | } 48 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "squell", 3 | "version": "2.0.9", 4 | "bugs": "https://github.com/creativecuriositystudio/squell/issues", 5 | "keywords": [ 6 | "sequelize", 7 | "postgres", 8 | "mysql", 9 | "sqlite", 10 | "orm", 11 | "typescript" 12 | ], 13 | "homepage": "https://github.com/creativecuriositystudio/squell", 14 | "license": "MIT", 15 | "description": "A type-safe wrapper around the Sequelize ORM for TypeScript", 16 | "contributors": [ 17 | "Zack Corr (http://z0w0.me)", 18 | "Daniel Cavanagh ", 19 | "Sorakthun Ly " 20 | ], 21 | "scripts": { 22 | "prepush": "npm run lint && npm run test", 23 | "prepare": "npm run build", 24 | "build": "tsc && cp src/*.js dist", 25 | "test": "mocha --opts mocha.opts", 26 | "test:auto": "mocha --opts mocha.opts --watch", 27 | "lint": "tslint --format stylish --project tsconfig.json", 28 | "clean": "rm -rf dist" 29 | }, 30 | "main": "dist/index.js", 31 | "types": "./dist/index.d.ts", 32 | "peerDependencies": { 33 | "@types/sequelize": "^4.0.76", 34 | "modelsafe": "^2.0.0", 35 | "sequelize": "^4.12.0" 36 | }, 37 | "dependencies": { 38 | "@types/lodash": "^4.14.62", 39 | "@types/reflect-metadata": "0.0.5", 40 | "lodash": "^4.17.4", 41 | "reflect-metadata": "^0.1.8", 42 | "typescript": "^2.3.4" 43 | }, 44 | "devDependencies": { 45 | "@types/mocha": "^2.2.33", 46 | "@types/sequelize": "^4.0.76", 47 | "@types/should": "^8.1.30", 48 | "gh-pages": "^1.0.0", 49 | "husky": "^0.13.2", 50 | "mocha": "^3.2.0", 51 | "modelsafe": "^2.0.0", 52 | "sequelize": "^4.12.0", 53 | "should": "^11.1.2", 54 | "sqlite": "^2.2.4", 55 | "ts-node": "^3.3.0", 56 | "tslint": "^5.0.0", 57 | "tslint-config-ccs": "^0.6.1" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Squell 2 | 3 | ## Introduction 4 | 5 | Squell is a type-safe wrapper for the Sequelize library, usable in TypeScript 2.1+ projects. 6 | Squell takes the Sequelize type definitions a step further by allowing models to be designed 7 | using [ModelSafe](https://github.com/creativecuriositystudio/modelsafe). 8 | Each model is defined as a class with all of its properties being decorated 9 | with the relevant ModelSafe data types, which will be in turned mapped to Sequelize types. 10 | 11 | Additionally to serializing ModelSafe models to SQL databases, Squell provides what is 12 | essentially a type-safe query language that compiles down to Sequelize queries. 13 | This means that any queries on the database are partially checked at compile time, 14 | which obviously can't capture all errors, but stops small issues like type inconsistencies 15 | and typos. 16 | 17 | ## Installation 18 | 19 | ```sh 20 | npm install --save squell 21 | ``` 22 | 23 | ## Usage 24 | 25 | Model definitions (including associations/relationships) are written using the ModelSafe library. 26 | [See the ModelSafe documentation](https://github.com/creativecuriositystudio/modelsafe) 27 | for more information on how to define models. 28 | 29 | An example model definition for a basic user in our application might look like: 30 | 31 | ```typescript 32 | @modelsafe.model('user') 33 | class User extends modelsafe.Model { 34 | @modelsafe.attr(modelsafe.STRING) 35 | public username: string; 36 | 37 | @modelsafe.attr(modelsafe.STRING) 38 | public email: string; 39 | } 40 | ``` 41 | 42 | Squell also provides its own `@model`, `@attr` and `@assoc` decorators that 43 | are companion pieces to the ModelSafe decorators. These can be used to provide 44 | specific Sequelize options, such as attribute options like `autoIncrement` 45 | which is not captured in ModelSafe. Take a look at the documentation for more information. 46 | 47 | To query that model, you might do something like this: 48 | 49 | ```typescript 50 | let db = new squell.Database('mysql://username:password@localhost/db'); 51 | 52 | db.query(User) 53 | .where(m => m.email.eq('test@example.com').or(m.id.lt(5))) 54 | .find() 55 | .then((users: User[]) => { 56 | // Do something with `users`. 57 | }); 58 | ``` 59 | 60 | This query would find a user with the email of exactly `test@example.com`, 61 | or an ID larger than 5, but with the benefit of the query being checked 62 | at compile time. Take a look at the API documentation for more information 63 | on the query operators available - but for the most part they are the same 64 | as the Sequelize operators. 65 | 66 | ## Documentatation 67 | 68 | The API documentation generated using [TypeDoc](https://github.com/TypeStrong/typedoc) 69 | is [available online](http://creativecuriositystudio.github.io/squell). 70 | 71 | To generate API documentation from the code into the `docs` directory, run: 72 | 73 | ```sh 74 | npm run docs 75 | ``` 76 | 77 | ## Testing 78 | 79 | First install the library dependencies and the SQLite3 library: 80 | 81 | ```sh 82 | npm install 83 | npm install sqlite3 84 | ``` 85 | 86 | To execute the test suite using SQLite as the backend, run: 87 | 88 | ```sh 89 | npm run test 90 | ``` 91 | 92 | By default, the tests will not log the SQL queries performed to keep the output sane. 93 | If a test is giving particular trouble, run the tests with `LOG_TEST_SQL` turned on 94 | to inspect the generated SQL queries: 95 | 96 | ```sh 97 | LOG_TEST_SQL=1 npm run test 98 | ``` 99 | 100 | ## License 101 | 102 | This project is licensed under the MIT license. Please see `LICENSE.md` for more details. 103 | 104 | ## Limitations 105 | 106 | * Any static functions or methods on the model classes are not yet transferred over to Sequelize. 107 | This means when trying to call them they will be undefined. 108 | * Calling update will do both an `update` and `findAll` call instead of just the single update call. This is because relationships have to be automatically assigned by Squell, which requires the updated instance model. 109 | * Associations will always be updated every `create`/`update` call, even if the association hasn't changed. These associations will only be updated if an `include` for that association model has be set, however. 110 | * `bulkCreate` cannot create objects with relationships. 111 | -------------------------------------------------------------------------------- /src/cloner.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Copyright (C) 2015 by Andrea Giammarchi - @WebReflection 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | 22 | */ 23 | var cloner = (function (O) {'use strict'; 24 | 25 | // (C) Andrea Giammarchi - Mit Style 26 | 27 | var 28 | 29 | // constants 30 | VALUE = 'value', 31 | PROTO = '__proto__', // to avoid jshint complains 32 | 33 | // shortcuts 34 | isArray = Array.isArray, 35 | create = O.create, 36 | dP = O.defineProperty, 37 | dPs = O.defineProperties, 38 | gOPD = O.getOwnPropertyDescriptor, 39 | gOPN = O.getOwnPropertyNames, 40 | gOPS = O.getOwnPropertySymbols || 41 | function (o) { return Array.prototype; }, 42 | gPO = O.getPrototypeOf || 43 | function (o) { return o[PROTO]; }, 44 | hOP = O.prototype.hasOwnProperty, 45 | oKs = (typeof Reflect !== typeof oK) && 46 | Reflect.ownKeys || 47 | function (o) { return gOPS(o).concat(gOPN(o)); }, 48 | set = function (descriptors, key, descriptor) { 49 | if (key in descriptors) dP(descriptors, key, { 50 | configurable: true, 51 | enumerable: true, 52 | value: descriptor 53 | }); 54 | else descriptors[key] = descriptor; 55 | }, 56 | 57 | // used to avoid recursions in deep copy 58 | index = -1, 59 | known = null, 60 | blown = null, 61 | clean = function () { known = blown = null; }, 62 | 63 | // utilities 64 | New = function (source, descriptors) { 65 | var out = isArray(source) ? [] : create(gPO(source)); 66 | return descriptors ? Object.defineProperties(out, descriptors) : out; 67 | }, 68 | 69 | // deep copy and merge 70 | deepCopy = function deepCopy(source) { 71 | var result = New(source); 72 | known = [source]; 73 | blown = [result]; 74 | deepDefine(result, source); 75 | clean(); 76 | return result; 77 | }, 78 | deepMerge = function (target) { 79 | known = []; 80 | blown = []; 81 | for (var i = 1; i < arguments.length; i++) { 82 | known[i - 1] = arguments[i]; 83 | blown[i - 1] = target; 84 | } 85 | merge.apply(true, arguments); 86 | clean(); 87 | return target; 88 | }, 89 | 90 | // shallow copy and merge 91 | shallowCopy = function shallowCopy(source) { 92 | clean(); 93 | for (var 94 | key, 95 | descriptors = {}, 96 | keys = oKs(source), 97 | i = keys.length; i--; 98 | set(descriptors, key, gOPD(source, key)) 99 | ) key = keys[i]; 100 | return New(source, descriptors); 101 | }, 102 | shallowMerge = function () { 103 | clean(); 104 | return merge.apply(false, arguments); 105 | }, 106 | 107 | // internal methods 108 | isObject = function isObject(value) { 109 | /*jshint eqnull: true */ 110 | return value != null && typeof value === 'object'; 111 | }, 112 | shouldCopy = function shouldCopy(value) { 113 | /*jshint eqnull: true */ 114 | index = -1; 115 | if (isObject(value)) { 116 | if (known == null) return true; 117 | index = known.indexOf(value); 118 | if (index < 0) return 0 < known.push(value); 119 | } 120 | return false; 121 | }, 122 | deepDefine = function deepDefine(target, source) { 123 | for (var 124 | key, descriptor, 125 | descriptors = {}, 126 | keys = oKs(source), 127 | i = keys.length; i--; 128 | ) { 129 | key = keys[i]; 130 | descriptor = gOPD(source, key); 131 | if (VALUE in descriptor) deepValue(descriptor); 132 | set(descriptors, key, descriptor); 133 | } 134 | dPs(target, descriptors); 135 | }, 136 | deepValue = function deepValue(descriptor) { 137 | var value = descriptor[VALUE]; 138 | if (shouldCopy(value)) { 139 | descriptor[VALUE] = New(value); 140 | deepDefine(descriptor[VALUE], value); 141 | blown[known.indexOf(value)] = descriptor[VALUE]; 142 | } else if (-1 < index && index in blown) { 143 | descriptor[VALUE] = blown[index]; 144 | } 145 | }, 146 | merge = function merge(target) { 147 | for (var 148 | source, 149 | keys, key, 150 | value, tvalue, 151 | descriptor, 152 | deep = this.valueOf(), 153 | descriptors = {}, 154 | i, a = 1; 155 | a < arguments.length; a++ 156 | ) { 157 | source = arguments[a]; 158 | keys = oKs(source); 159 | for (i = 0; i < keys.length; i++) { 160 | key = keys[i]; 161 | descriptor = gOPD(source, key); 162 | if (hOP.call(target, key)) { 163 | if (VALUE in descriptor) { 164 | value = descriptor[VALUE]; 165 | if (shouldCopy(value)) { 166 | descriptor = gOPD(target, key); 167 | if (VALUE in descriptor) { 168 | tvalue = descriptor[VALUE]; 169 | if (isObject(tvalue)) { 170 | merge.call(deep, tvalue, value); 171 | } 172 | } 173 | } 174 | } 175 | } else { 176 | if (deep && VALUE in descriptor) { 177 | deepValue(descriptor); 178 | } 179 | set(descriptors, key, descriptor); 180 | } 181 | } 182 | } 183 | return dPs(target, descriptors); 184 | } 185 | ; 186 | 187 | return { 188 | deep: { 189 | copy: deepCopy, 190 | merge: deepMerge 191 | }, 192 | shallow: { 193 | copy: shallowCopy, 194 | merge: shallowMerge 195 | } 196 | }; 197 | 198 | }(Object)); 199 | module.exports = cloner; -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:ban-types */ 2 | import 'reflect-metadata'; 3 | import { Model, AssociationTarget } from 'modelsafe'; 4 | import { DefineAttributeColumnOptions, DefineOptions, AssociationOptionsBelongsTo, 5 | AssociationOptionsHasOne, AssociationOptionsHasMany, AssociationOptionsManyToMany, 6 | Model as SequelizeModel, AssociationScope, AssociationForeignKeyOptions } from 'sequelize'; 7 | 8 | /** The meta key for a model's options on a model class. */ 9 | export const MODEL_OPTIONS_META_KEY = 'squell:options'; 10 | 11 | /** The meta key for attribute options on a model class. */ 12 | export const MODEL_ATTR_OPTIONS_META_KEY = 'squell:attrOptions'; 13 | 14 | /** The meta key for association options on a model class. */ 15 | export const MODEL_ASSOC_OPTIONS_META_KEY = 'squell:assocOptions'; 16 | 17 | /** 18 | * Association options for a belongs to many. This is the same 19 | * as the Sequelize options with the added ability to 20 | * specify a ModelSafe model to use as the join/through. 21 | * 22 | * @see Sequelize 23 | */ 24 | export interface AssociationOptionsBelongsToMany extends AssociationOptionsManyToMany { 25 | /** 26 | * The target to use as a through/join table. 27 | * 28 | * If it's a ModelSafe model, then it must be defined on the Squell database. 29 | * The other options are based off Sequelize's belongs to many options. 30 | * 31 | * @see Sequelize 32 | */ 33 | through: AssociationTarget | SequelizeModel | string | ThroughOptions; 34 | 35 | /** 36 | * The name of the foreign key in the join table (representing the target model). 37 | * This is the same as the Sequelize definition of this option. 38 | * 39 | * @see Sequelize 40 | */ 41 | otherKey?: string | AssociationForeignKeyOptions; 42 | } 43 | 44 | /** 45 | * Used for a association table in n:m associations. 46 | * This is the same as the Sequelize options with the added ability to 47 | * specify a ModelSafe model to use as the join/through. 48 | * 49 | * @see Sequelize 50 | */ 51 | export interface ThroughOptions { 52 | /** 53 | * The model used to join both sides of the N:M association. 54 | */ 55 | model: AssociationTarget | SequelizeModel; 56 | 57 | /** 58 | * A key/value set that will be used for association create and find defaults on the through model. 59 | * (Remember to add the attributes to the through model) 60 | */ 61 | scope?: AssociationScope; 62 | 63 | /** 64 | * If true a unique key will be generated from the foreign keys used (might want to turn this off and create 65 | * specific unique keys when using scopes) 66 | * 67 | * Defaults to true 68 | */ 69 | unique?: boolean; 70 | } 71 | 72 | /** 73 | * Define any extra Sequelize model options on a model constructor. 74 | * 75 | * @param ctor The model constructor. 76 | * @param options The model options. 77 | */ 78 | export function defineModelOptions(ctor: Function, options: Partial>) { 79 | // We extend the existing options so that other options defined on the prototype get inherited. 80 | options = { 81 | ... Reflect.getMetadata(MODEL_OPTIONS_META_KEY, ctor.prototype), 82 | 83 | ... options 84 | }; 85 | 86 | Reflect.defineMetadata(MODEL_OPTIONS_META_KEY, options, ctor.prototype); 87 | } 88 | 89 | /** 90 | * Define any extra Sequelize association options on the model constructor. 91 | * 92 | * @param ctor The model constructor. 93 | * @param key The association's property key. 94 | * @param options The association options. 95 | */ 96 | export function defineAssociationOptions( 97 | ctor: object, 98 | key: string | symbol, 99 | options: Partial 103 | ) { 104 | options = { 105 | ... Reflect.getMetadata(MODEL_ASSOC_OPTIONS_META_KEY, ctor, key), 106 | ... options 107 | }; 108 | 109 | Reflect.defineMetadata(MODEL_ASSOC_OPTIONS_META_KEY, options, ctor, key); 110 | } 111 | 112 | /** 113 | * Define any extra Sequelize attribute options on the model constructor. 114 | * 115 | * @param ctor The model constructor. 116 | * @param key The attribute's property key. 117 | * @param options The attribute options. 118 | */ 119 | export function defineAttributeOptions(ctor: object, key: string | symbol, options: Partial) { 120 | options = { 121 | ... Reflect.getMetadata(MODEL_ATTR_OPTIONS_META_KEY, ctor, key), 122 | ... options 123 | }; 124 | 125 | Reflect.defineMetadata(MODEL_ATTR_OPTIONS_META_KEY, options, ctor, key); 126 | } 127 | 128 | /** 129 | * Get the model options for a model constructor. 130 | * 131 | * @param ctor The model constructor. 132 | * @returns The model options. 133 | */ 134 | export function getModelOptions(ctor: Function): DefineOptions { 135 | return { ... Reflect.getMetadata(MODEL_OPTIONS_META_KEY, ctor.prototype) }; 136 | } 137 | 138 | /** 139 | * Get the association options for an association on a model constructor. 140 | * 141 | * @param ctor The model constructor. 142 | * @param key The association key. 143 | * @returns The model associations. 144 | */ 145 | export function getAssociationOptions(ctor: Function, key: string | symbol): AssociationOptionsBelongsTo | 146 | AssociationOptionsHasOne | 147 | AssociationOptionsHasMany | 148 | AssociationOptionsBelongsToMany { 149 | return { 150 | as: key, 151 | ... Reflect.getMetadata(MODEL_ASSOC_OPTIONS_META_KEY, ctor.prototype, key) 152 | }; 153 | } 154 | 155 | /** 156 | * Get the attribute options for an attribute on a model constructor. 157 | * 158 | * @param ctor The model constructor. 159 | * @param key The attribute key. 160 | * @returns The model attributes. 161 | */ 162 | export function getAttributeOptions(ctor: Function, key: string | symbol): DefineAttributeColumnOptions { 163 | return { ... Reflect.getMetadata(MODEL_ATTR_OPTIONS_META_KEY, ctor.prototype, key) }; 164 | } 165 | 166 | /** 167 | * A decorator for Sequelize-specific model options. 168 | * This should be used in conjuction with the relevant @model decorator 169 | * from ModelSafe, not on its own. 170 | * 171 | * @param options The Sequelize model options. 172 | */ 173 | export function model(options: Partial>) { 174 | return (ctor: Function) => defineModelOptions(ctor, options); 175 | } 176 | 177 | /** 178 | * A decorator for Sequelize-specific attribute options. 179 | * This should be used in conjuction with the relevant @attr decorator 180 | * from ModelSafe, not on its own. 181 | * 182 | * @param options The Sequelize attribute options. 183 | */ 184 | export function attr(options: Partial) { 185 | return (ctor: object, key: string | symbol) => defineAttributeOptions(ctor, key, options); 186 | } 187 | 188 | /** 189 | * A decorator for Sequelize-specific association options. 190 | * This should be used in conjuction with the relevant @assoc decorator 191 | * from ModelSafe, not on its own. 192 | * 193 | * @param options The Sequelize association options. 194 | */ 195 | export function assoc(options: Partial) { 199 | return (ctor: object, key: string | symbol) => defineAssociationOptions(ctor, key, options); 200 | } 201 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.0.8 2 | 3 | * [UPDATE] allow for untyped ordering query for nested associations. Will add typing in the next patch 4 | 5 | * Previous versions were skipped due to accidental publishing 6 | 7 | # 2.0.4 8 | 9 | * [UPDATE] allow for database raw query 10 | 11 | # 2.0.3 12 | 13 | * [FIX] compile cols with `compileRight()` in group bys and fns 14 | 15 | # 2.0.2 16 | 17 | * [UPDATE] return more specific types from queryables 18 | 19 | # 2.0.1 20 | 21 | * [UPDATE] add groupBy 22 | 23 | # 2.0.0 24 | 25 | * [UPDATE] update to modelsafe 2 26 | 27 | # 1.0.0-alpha.36 28 | 29 | * [FIX] account for includes in `count()` 30 | 31 | # 1.0.0-alpha.35 32 | 33 | * [FIX] stop `compileWheres()` from breaking dates 34 | 35 | # 1.0.0-alpha.34 36 | 37 | * [FIX] don't put all attribute errors into the `$constraint` group 38 | 39 | # 1.0.0-alpha.33 40 | 41 | * [FIX] when merging queries, don't set existing query onto new one by default. leads to duplicate rules 42 | 43 | # 1.0.0-alpha.32 44 | 45 | * [FIX] when merging queries, don't assume option params are always set 46 | 47 | # 1.0.0-alpha.31 48 | 49 | * [FEATURE] coerce constraint validations also (put into errors.$constraints) 50 | 51 | # 1.0.0-alpha.30 52 | 53 | * [FIX] remove typedoc due to security issue 54 | 55 | # 1.0.0-alpha.29 56 | 57 | * [FIX] don't `associate()` if no includes are pass at all 58 | 59 | # 1.0.0-alpha.28 60 | 61 | * [FIX] make the validator happier when associating in create() 62 | 63 | # 1.0.0-alpha.27 64 | 65 | * [FIX] pick() exact include sub-query attrs to avoid a sequelize bug 66 | 67 | # 1.0.0-alpha.26 68 | 69 | * [FIX] use nodejs version of cloner 70 | 71 | # 1.0.0-alpha.25 72 | 73 | * [FIX] import cloner file into repo, since it's causing a package issue i don't want to work out" 74 | 75 | # 1.0.0-alpha.24 76 | 77 | * [FIX] switch an extend to use new cloner 78 | 79 | # 1.0.0-alpha.23 80 | 81 | * [FIX] use a cloner that can handle symbol keys when merging wheres 82 | 83 | # 1.0.0-alpha.22 84 | 85 | * [FIX] set `as` in `getAssociation` so all assocs get it 86 | 87 | # 1.0.0-alpha.21 88 | 89 | * [FIX] allow target assoc to be set on an assoc for cases where the target name is different to the default 90 | 91 | # 1.0.0-alpha.19 92 | 93 | * [FIX] compile attributes so they aren't ambiguous 94 | 95 | # 1.0.0-alpha.18 96 | 97 | * [FIX] merge duplicate includes properly 98 | * [FIX] explicitly save new assocs regardless of associatedOnly option 99 | 100 | # 1.0.0-alpha.17 101 | 102 | * [FIX] calculate include depth and use for de/serialisation, to prevent circ deps from causing issues 103 | * [CHANGE] set associateOnly to true by default as this is the expected behaviour 104 | 105 | # 1.0.0-alpha.16 106 | 107 | * [CHANGE] upgrade sequelize and fix new typing issues 108 | 109 | # 1.0.0-alpha.15 110 | 111 | * [FEATURE] add `Query.merge` to allow two queries to be merged 112 | * [FEATURE] store rich includes to allow for recursively mergable includes 113 | * [FEATURE] add `associateOnly` as include option that doesn't save the data on included associations, just sets the association 114 | * [FIX] use transaction in all sequelize calls in `update()` 115 | * [FIX] coerce `associate` save errors into a `ValidationError` and prefix the property keys with the assoc key 116 | 117 | # 1.0.0-alpha.14 118 | 119 | * [FIX] when associating belongs-tos in update, check original data for direct id attr, not data from db 120 | 121 | # 1.0.0-alpha.13 122 | 123 | * [FIX] map sequelize errors into new modelsafe validation error format 124 | 125 | # 1.0.0-alpha.12 126 | 127 | * [FIX] account for target model when determining duplicate foreign keys 128 | 129 | # 1.0.0-alpha.11 130 | 131 | * [FIX] pass a default foreign key to sequelize for has-one associations. otherwise it does the wrong thing 132 | * [FIX] reject models that have duplicate has-one foreign keys 133 | * [FEATURE] tests 134 | 135 | # 1.0.0-alpha.10 136 | 137 | * [FIX] when de/serialising instances, use an infinite depth (or near enough) so associations aren't wiped 138 | 139 | # 1.0.0-alpha.9 140 | 141 | * [CHANGE] Add custom `ThroughOptions` to support defining a through as non-unique 142 | 143 | # 1.0.0-alpha.8 144 | 145 | * [FIX] Resolve coerced Sequelizes instances not being saveable without a primary key 146 | 147 | # 1.0.0-alpha.7 148 | 149 | * [FIX] allow validation to succeed if all errors are filtered out for required default value attrs 150 | 151 | # 1.0.0-alpha.6 152 | 153 | * [FIX] Resolve bug with default-value require validation exclusion 154 | 155 | # 1.0.0-alpha.5 156 | 157 | * [FIX] Resolve bug with auto-increment require validation exclusion 158 | 159 | # 1.0.0-alpha.4 160 | 161 | * [CHANGE] Upgrade to TypeScript 2.3 162 | * [CHANGE] Require ModelSafe 1.0.0-alpha.8 163 | * [FIX] ModelSafe required attribute validations are now correctly ignored for auto-ncrement fields 164 | 165 | # 1.0.0-alpha.3 166 | 167 | * [FIX] `findOne` and `findById` should only deserialize if they succeeded, to prevent a null dereference 168 | 169 | # 1.0.0-alpha.2 170 | 171 | * [FIX] 'through' models should be lazy to avoid null-class references 172 | 173 | # 1.0.0-alpha.1 174 | 175 | * [CHANGE] Moved to ModelSafe alpha 1.0.0-alpha.1 176 | * [CHANGE] Sequelize models are now correctly serialized to regular ModelSafe model class instances. 177 | The ModelSafe models were originally only being used as types and not as proper classes, 178 | which means that helper methods and getters/setters defined were not available. 179 | This is quite a big change and may break existing code. 180 | * [CHANGE] The `drop` method of queries has been renamed to `skip` in order 181 | to avoid confusion with the database `drop` term (for destroying a database table/schema) 182 | * [CHANGE] The truncate method of query can now take optional Sequelize truncate options 183 | * [CHANGE] `through` for belongs to many is no longer auto-generated and must be manually defined 184 | * [FEATURE] Allow for providing a ModelSafe model as `through` for belongs to many, allowing 185 | for more complicated join/through models 186 | * [FEATURE] There is now a new `drop` command on queries for dropping database tables 187 | 188 | # 0.9.0 189 | 190 | * Added `transaction` param to `associate()` to allow association calls to be transacted 191 | * Transacted `associate` and `reload` calls in `create()` and `update()` 192 | 193 | # 0.8.1 194 | 195 | * Added includeAll() for including all associations on a query 196 | * Fixed an include bug that caused shadowing of assoc ids and thus notNull validation errors 197 | * Lost a minor version along the way somewhere 198 | 199 | # 0.7.0 200 | 201 | * Bump to ModelSafe `0.7.1` 202 | * Improve the error stack generated by coerced validation errors to allow for easier debugging 203 | of Sequelize validation errors 204 | 205 | # 0.6.2 206 | 207 | * Treat plain attributes passed to `order` as a path, and map the path into a 208 | fully-disambiguous model path. Prevents disambiguous attr names, and allows ordering 209 | on child attrs 210 | * Add `plain` for creating `PlainAttribute`s easily 211 | 212 | # 0.6.1 213 | 214 | * Fix include options broken compilation to Sequelize include options 215 | 216 | # 0.6.0 217 | 218 | * Fix casing of default `through` setting 219 | * Support providing include options in `include` for setting things like `required` off 220 | or manually overriding the `as` of a eager-load 221 | 222 | # 0.5.2 223 | 224 | * Fix association saving to support setting associations with an include flag 225 | even if the association target hasn't come directly from the database (i.e. 226 | it's a plain JS object instead of a Sequelize instance) 227 | 228 | # 0.5.1 229 | 230 | * Bump to ModelSafe 0.5.2 for `ValidationError` fixes 231 | 232 | # 0.5.0 233 | 234 | * Add `findById` operation 235 | 236 | # 0.4.0 237 | 238 | * Support ModelSafe's new lazy loading association declaration feature 239 | * Add `save` operation to querying that either updates or creates a model instance 240 | automatically based off the primary key of the model 241 | 242 | # 0.3.1 243 | 244 | * Re-add `getModelPrimary` as `getInternalModePrimary` to the database class 245 | 246 | # 0.3.0 247 | 248 | * Migrate to using ModelSafe for model definitions 249 | * Simplify existing code / improve code quality 250 | -------------------------------------------------------------------------------- /src/queryable.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-magic-numbers */ 2 | import * as sequelize from 'sequelize'; 3 | import 'should'; 4 | 5 | import { attribute, constant, fn, col } from './queryable'; 6 | 7 | describe('Queryable', () => { 8 | let attr = attribute('name'); 9 | let ageAttr = attribute('age'); 10 | 11 | describe('#eq', () => { 12 | it('should compile', () => { 13 | attr.eq('Bruce Willis').compile().should.deepEqual({ 14 | name: 'Bruce Willis' 15 | }); 16 | 17 | attr.eq(ageAttr).compile().should.deepEqual({ 18 | name: sequelize.col('age') 19 | }); 20 | }); 21 | }); 22 | 23 | /* FIXME no longer work after switch to symbols. rework these 24 | describe('#ne', () => { 25 | it('should compile', () => { 26 | attr.ne('Bruce Willis').compile().should.deepEqual({ 27 | name: { $ne: 'Bruce Willis' } 28 | }); 29 | 30 | attr.ne(ageAttr).compile().should.deepEqual({ 31 | name: { $ne: sequelize.col('age') } 32 | }); 33 | }); 34 | }); 35 | 36 | let networthAttr = attribute('networth'); 37 | 38 | describe('#gt', () => { 39 | it('should compile', () => { 40 | networthAttr.gt(123).compile().should.deepEqual({ 41 | networth: { $gt: 123 } 42 | }); 43 | 44 | networthAttr.gt(ageAttr).compile().should.deepEqual({ 45 | networth: { $gt: sequelize.col('age') } 46 | }); 47 | }); 48 | }); 49 | 50 | describe('#gte', () => { 51 | it('should compile', () => { 52 | networthAttr.gte(123).compile().should.deepEqual({ 53 | networth: { $gte: 123 } 54 | }); 55 | 56 | networthAttr.gte(ageAttr).compile().should.deepEqual({ 57 | networth: { $gte: sequelize.col('age') } 58 | }); 59 | }); 60 | }); 61 | 62 | describe('#lt', () => { 63 | it('should compile', () => { 64 | networthAttr.lt(123).compile().should.deepEqual({ 65 | networth: { $lt: 123 } 66 | }); 67 | 68 | networthAttr.lt(ageAttr).compile().should.deepEqual({ 69 | networth: { $lt: sequelize.col('age') } 70 | }); 71 | }); 72 | }); 73 | 74 | describe('#lte', () => { 75 | it('should compile', () => { 76 | networthAttr.lte(123).compile().should.deepEqual({ 77 | networth: { $lte: 123 } 78 | }); 79 | 80 | networthAttr.lte(ageAttr).compile().should.deepEqual({ 81 | networth: { $lte: sequelize.col('age') } 82 | }); 83 | }); 84 | }); 85 | 86 | describe('#like', () => { 87 | it('should compile', () => { 88 | attr.like('%Bruce%').compile().should.deepEqual({ 89 | name: { $like: '%Bruce%' } 90 | }); 91 | 92 | attr.like(ageAttr).compile().should.deepEqual({ 93 | name: { $like: sequelize.col('age') } 94 | }); 95 | }); 96 | }); 97 | 98 | describe('#notLike', () => { 99 | it('should compile', () => { 100 | attr.notLike('%Bruce%').compile().should.deepEqual({ 101 | name: { $notLike: '%Bruce%' } 102 | }); 103 | 104 | attr.notLike(ageAttr).compile().should.deepEqual({ 105 | name: { $notLike: sequelize.col('age') } 106 | }); 107 | }); 108 | }); 109 | 110 | describe('#iLike', () => { 111 | it('should compile', () => { 112 | attr.iLike('%Bruce%').compile().should.deepEqual({ 113 | name: { $iLike: '%Bruce%' } 114 | }); 115 | 116 | attr.iLike(ageAttr).compile().should.deepEqual({ 117 | name: { $iLike: sequelize.col('age') } 118 | }); 119 | }); 120 | }); 121 | 122 | describe('#notILike', () => { 123 | it('should compile correctly', () => { 124 | attr.notILike('%Bruce%').compile().should.deepEqual({ 125 | name: { $notILike: '%Bruce%' } 126 | }); 127 | 128 | attr.notILike(ageAttr).compile().should.deepEqual({ 129 | name: { $notILike: sequelize.col('age') } 130 | }); 131 | }); 132 | }); 133 | 134 | describe('#not', () => { 135 | it('should compile', () => { 136 | attr.not(false).compile().should.deepEqual({ 137 | name: { $not: false } 138 | }); 139 | 140 | attr.not(ageAttr).compile().should.deepEqual({ 141 | name: { $not: sequelize.col('age') } 142 | }); 143 | }); 144 | }); 145 | 146 | describe('#in', () => { 147 | it('should compile', () => { 148 | ageAttr.in([1, 2, 3]).compile().should.deepEqual({ 149 | age: { $in: [1, 2, 3] } 150 | }); 151 | }); 152 | }); 153 | 154 | describe('#notIn', () => { 155 | it('should compile', () => { 156 | ageAttr.notIn([1, 2, 3]).compile().should.deepEqual({ 157 | age: { $notIn: [1, 2, 3] } 158 | }); 159 | }); 160 | }); 161 | 162 | describe('#between', () => { 163 | it('should compile', () => { 164 | ageAttr.between([1, 70]).compile().should.deepEqual({ 165 | age: { $between: [1, 70] } 166 | }); 167 | }); 168 | }); 169 | 170 | describe('#notBetween', () => { 171 | it('should compile', () => { 172 | ageAttr.notBetween([1, 70]).compile().should.deepEqual({ 173 | age: { $notBetween: [1, 70] } 174 | }); 175 | }); 176 | }); 177 | 178 | describe('#overlap', () => { 179 | it('should compile', () => { 180 | ageAttr.overlap([1, 70]).compile().should.deepEqual({ 181 | age: { $overlap: [1, 70] } 182 | }); 183 | }); 184 | }); 185 | 186 | describe('#contains', () => { 187 | it('should compile', () => { 188 | ageAttr.contains([1, 70]).compile().should.deepEqual({ 189 | age: { $contains: [1, 70] } 190 | }); 191 | 192 | ageAttr.contains(1).compile().should.deepEqual({ 193 | age: { $contains: 1 } 194 | }); 195 | }); 196 | }); 197 | 198 | describe('#contained', () => { 199 | it('should compile', () => { 200 | ageAttr.contained([1, 70]).compile().should.deepEqual({ 201 | age: { $contained: [1, 70] } 202 | }); 203 | }); 204 | }); 205 | 206 | describe('#any', () => { 207 | it('should compile', () => { 208 | ageAttr.any([1, 70]).compile().should.deepEqual({ 209 | age: { $any: [1, 70] } 210 | }); 211 | }); 212 | }); 213 | 214 | describe('#adjacent', () => { 215 | it('should compile', () => { 216 | ageAttr.adjacent([1, 70]).compile().should.deepEqual({ 217 | age: { $adjacent: [1, 70] } 218 | }); 219 | }); 220 | }); 221 | 222 | describe('#strictLeft', () => { 223 | it('should compile', () => { 224 | ageAttr.strictLeft([1, 70]).compile().should.deepEqual({ 225 | age: { $strictLeft: [1, 70] } 226 | }); 227 | }); 228 | }); 229 | 230 | describe('#strictRight', () => { 231 | it('should compile', () => { 232 | ageAttr.strictRight([1, 70]).compile().should.deepEqual({ 233 | age: { $strictRight: [1, 70] } 234 | }); 235 | }); 236 | }); 237 | 238 | describe('#noExtendRight', () => { 239 | it('should compile', () => { 240 | ageAttr.noExtendRight([1, 70]).compile().should.deepEqual({ 241 | age: { $noExtendRight: [1, 70] } 242 | }); 243 | }); 244 | }); 245 | 246 | describe('#noExtendLeft', () => { 247 | it('should compile', () => { 248 | ageAttr.noExtendLeft([1, 70]).compile().should.deepEqual({ 249 | age: { $noExtendLeft: [1, 70] } 250 | }); 251 | }); 252 | }); 253 | */ 254 | }); 255 | 256 | describe('AttributeQueryable', () => { 257 | let attr = attribute('name'); 258 | 259 | describe('#compileLeft', () => { 260 | it('should compile', () => { 261 | attr.compileLeft().should.equal('name'); 262 | }); 263 | }); 264 | 265 | describe('#compileRight', () => { 266 | it('should compile', () => { 267 | attr.compileRight().should.deepEqual(sequelize.col('name')); 268 | }); 269 | }); 270 | }); 271 | 272 | describe('ConstantQueryable', () => { 273 | let attr = constant(1234); 274 | 275 | describe('#compileLeft', () => { 276 | it('should compile', () => { 277 | attr.compileLeft().should.equal(1234); 278 | }); 279 | }); 280 | 281 | describe('#compileRight', () => { 282 | it('should compile', () => { 283 | attr.compileRight().should.equal(1234); 284 | }); 285 | }); 286 | }); 287 | 288 | describe('ColumnQueryable', () => { 289 | let attr = col('name'); 290 | 291 | describe('#compileLeft', () => { 292 | it('should not compile', () => { 293 | attr.compileLeft.should.throw(); 294 | }); 295 | }); 296 | 297 | describe('#compileRight', () => { 298 | it('should compile', () => { 299 | attr.compileRight().should.deepEqual(sequelize.col('name')); 300 | }); 301 | }); 302 | }); 303 | 304 | describe('FunctionQueryable', () => { 305 | let attr = fn('COUNT', constant('name')); 306 | 307 | describe('#compileLeft', () => { 308 | it('should not compile', () => { 309 | attr.compileLeft.should.throw(); 310 | }); 311 | }); 312 | 313 | describe('#compileRight', () => { 314 | it('should compile', () => { 315 | attr.compileRight().should.deepEqual(sequelize.fn('COUNT', 'name')); 316 | }); 317 | }); 318 | }); 319 | 320 | describe('AliasQueryable', () => { 321 | let attr = attribute('name').as('renamed'); 322 | 323 | describe('#compileLeft', () => { 324 | it('should not compile', () => { 325 | attr.compileLeft.should.throw(); 326 | }); 327 | }); 328 | 329 | describe('#compileRight', () => { 330 | it('should compile', () => { 331 | attr.compileRight().should.deepEqual([sequelize.col('name'), 'renamed']); 332 | }); 333 | }); 334 | }); 335 | -------------------------------------------------------------------------------- /src/query.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-magic-numbers */ 2 | import 'should'; 3 | import * as modelsafe from 'modelsafe'; 4 | 5 | import { Database } from './database'; 6 | import { assoc, attr } from './metadata'; 7 | import { ASC, DESC } from './query'; 8 | 9 | /* tslint:disable:completed-docs */ 10 | @modelsafe.model() 11 | class Actor extends modelsafe.Model { 12 | @attr({ autoIncrement: true }) 13 | @modelsafe.attr(modelsafe.INTEGER, { primary: true }) 14 | @modelsafe.optional 15 | id: number; 16 | 17 | @modelsafe.attr(modelsafe.STRING) 18 | public name: string; 19 | 20 | @modelsafe.attr(modelsafe.INTEGER) 21 | public age: number; 22 | 23 | @modelsafe.assoc(modelsafe.BELONGS_TO, () => Actor) 24 | public mentor: Actor; 25 | 26 | @modelsafe.assoc(modelsafe.HAS_ONE, () => Actor) 27 | @assoc({ foreignKey: 'mentorId' }) 28 | public mentee: Actor; 29 | } 30 | 31 | @modelsafe.model() 32 | class List extends modelsafe.Model { 33 | @attr({ autoIncrement: true }) 34 | @modelsafe.attr(modelsafe.INTEGER, { primary: true }) 35 | @modelsafe.optional 36 | id: number; 37 | 38 | @modelsafe.attr(modelsafe.STRING) 39 | public name: string; 40 | 41 | @assoc({ foreignKey: { allowNull: false, name: 'parentId' } }) 42 | @modelsafe.assoc(modelsafe.HAS_MANY, () => ListItem) 43 | public items: ListItem[]; 44 | } 45 | 46 | @modelsafe.model() 47 | class ListItem extends modelsafe.Model { 48 | @attr({ autoIncrement: true }) 49 | @modelsafe.attr(modelsafe.INTEGER, { primary: true }) 50 | @modelsafe.optional 51 | id: number; 52 | 53 | @modelsafe.attr(modelsafe.STRING) 54 | public value: string; 55 | 56 | @assoc({ foreignKey: { allowNull: false, name: 'parentId' } }) 57 | @modelsafe.assoc(modelsafe.BELONGS_TO, List) 58 | public parent: List; 59 | } 60 | /* tslint:enable-completed-docs */ 61 | 62 | let db = new Database('sqlite://root:root@localhost/squell_test', { 63 | storage: ':memory:', 64 | logging: !!process.env.LOG_TEST_SQL 65 | }); 66 | 67 | db.define(Actor); 68 | db.define(List); 69 | db.define(ListItem); 70 | 71 | describe('Query', () => { 72 | beforeEach(async () => { 73 | return db.sync({ force: true }) 74 | .then(async () => { 75 | let bruce = new Actor(); 76 | let milla = new Actor(); 77 | let chris = new Actor(); 78 | 79 | bruce.name = 'Bruce Willis'; 80 | bruce.age = 61; 81 | 82 | milla.name = 'Milla Jojovich'; 83 | milla.age = 40; 84 | 85 | chris.name = 'Chris Tucker'; 86 | chris.age = 45; 87 | 88 | [bruce, milla, chris] = await db 89 | .query(Actor) 90 | .bulkCreate([bruce, milla, chris]); 91 | 92 | bruce.mentor = milla; 93 | milla.mentor = chris; 94 | chris.mentor = bruce; 95 | chris.mentee = milla; 96 | 97 | await db 98 | .query(Actor) 99 | .include(Actor, m => m.mentor) 100 | .include(Actor, m => m.mentee) 101 | .save(bruce); 102 | 103 | await db 104 | .query(Actor) 105 | .include(Actor, m => m.mentor) 106 | .include(Actor, m => m.mentee) 107 | .save(milla); 108 | 109 | await db 110 | .query(Actor) 111 | .include(Actor, m => m.mentor) 112 | .include(Actor, m => m.mentee) 113 | .save(chris); 114 | }); 115 | }); 116 | 117 | describe('#compileFindOptions', () => { 118 | it('should compile', () => { 119 | let options = db.query(Actor) 120 | .where(m => m.name.eq('Bruce Willis')) 121 | .attributes(m => [m.name]) 122 | .order(m => [[m.name, DESC]]) 123 | .take(5) 124 | .skip(5) 125 | .compileFindOptions(); 126 | 127 | options.should.deepEqual({ 128 | where: { name: 'Bruce Willis' }, 129 | order: [['name', 'DESC']], 130 | attributes: ['name'], 131 | limit: 5, 132 | offset: 5 133 | }); 134 | }); 135 | }); 136 | 137 | describe('#findOne', () => { 138 | it('should find by name', async () => { 139 | return db.query(Actor) 140 | .where(m => m.name.eq('Bruce Willis')) 141 | .findOne() 142 | .then((actor) => { 143 | actor.name.should.equal('Bruce Willis'); 144 | }); 145 | }); 146 | }); 147 | 148 | describe('#find', () => { 149 | it('should find less than 50 yos', async () => { 150 | return db.query(Actor) 151 | .where(m => m.age.lt(50)) 152 | .find() 153 | .then((actors) => { 154 | actors.should.have.length(2); 155 | }); 156 | }); 157 | 158 | it('should find one 50 yo when taken', async () => { 159 | return db.query(Actor) 160 | .where(m => m.age.lt(50)) 161 | .take(1) 162 | .find() 163 | .then((actors) => { 164 | actors.should.have.length(1); 165 | }); 166 | }); 167 | 168 | it('should find ordered correctly', async () => { 169 | return db.query(Actor) 170 | .order(m => [[m.age, ASC]]) 171 | .find() 172 | .then((actors) => { 173 | actors[0].age.should.equal(40); 174 | actors[2].age.should.equal(61); 175 | }); 176 | }); 177 | }); 178 | 179 | describe('#count', () => { 180 | it('should count all actors', async () => { 181 | return db.query(Actor) 182 | .count() 183 | .then((num) => { 184 | num.should.equal(3); 185 | }); 186 | }); 187 | 188 | it('should count two actors under 50 yo', async () => { 189 | return db.query(Actor) 190 | .where(m => m.age.lt(50)) 191 | .count() 192 | .then((num) => { 193 | num.should.equal(2); 194 | }); 195 | }); 196 | }); 197 | 198 | describe('#aggregate', () => { 199 | it('should average ages correctly', async () => { 200 | return db.query(Actor) 201 | .aggregate('AVG', m => m.age) 202 | .then((num) => { 203 | Math.ceil(num).should.equal(48); 204 | }); 205 | }); 206 | }); 207 | 208 | describe('#min', () => { 209 | it('should find the minimum age', async () => { 210 | return db.query(Actor) 211 | .min(m => m.age) 212 | .then((num) => { 213 | num.should.equal(40); 214 | }); 215 | }); 216 | }); 217 | 218 | describe('#max', () => { 219 | it('should find the maximum age', async () => { 220 | return db.query(Actor) 221 | .max(m => m.age) 222 | .then((num) => { 223 | num.should.equal(61); 224 | }); 225 | }); 226 | }); 227 | 228 | describe('#sum', () => { 229 | it('should find the total age', async () => { 230 | return db.query(Actor) 231 | .sum(m => m.age) 232 | .then((num) => { 233 | num.should.equal(146); 234 | }); 235 | }); 236 | }); 237 | 238 | describe('#truncate', () => { 239 | it('should clear the database table', async () => { 240 | return db.query(Actor) 241 | .truncate() 242 | .then(async () => { 243 | return db.query(Actor) 244 | .count() 245 | .then((num) => { 246 | num.should.equal(0); 247 | }); 248 | }); 249 | }); 250 | }); 251 | 252 | describe('#destroy', () => { 253 | it('should clear the database table', async () => { 254 | return db.query(Actor) 255 | .destroy() 256 | .then(async () => { 257 | return db.query(Actor) 258 | .count() 259 | .then((num) => { 260 | num.should.equal(0); 261 | }); 262 | }); 263 | }); 264 | }); 265 | 266 | describe('#save', () => { 267 | it('should create instances if no primary is set', async () => { 268 | let actor = new Actor(); 269 | 270 | actor.name = 'Gary Oldman'; 271 | actor.age = 58; 272 | 273 | return db.query(Actor) 274 | .save(actor) 275 | .then(created => { 276 | created.id.should.equal(4); 277 | }); 278 | }); 279 | 280 | it('should update instances if a primary is set', async () => { 281 | let actor = new Actor(); 282 | 283 | actor.name = 'Gary Oldman'; 284 | actor.age = 58; 285 | 286 | let created = await db.query(Actor).create(actor); 287 | 288 | created.id.should.equal(4); 289 | created.name = 'Barry Boldman'; 290 | 291 | let updated = await db.query(Actor).save(created); 292 | 293 | updated.id.should.equal(4); 294 | updated.name.should.equal('Barry Boldman'); 295 | }); 296 | }); 297 | 298 | describe('#create', () => { 299 | it('should create instances correctly', async () => { 300 | let actor = new Actor(); 301 | 302 | actor.name = 'Gary Oldman'; 303 | actor.age = 58; 304 | 305 | return db.query(Actor) 306 | .create(actor) 307 | .then(created => { 308 | created.name.should.equal('Gary Oldman'); 309 | }); 310 | }); 311 | 312 | it('should create instances with manual associations correctly', async () => { 313 | let bruce = await db.query(Actor).where(m => m.name.eq('Bruce Willis')).findOne(); 314 | let actor = new Actor(); 315 | 316 | actor.name = 'Gary Oldman'; 317 | actor.age = 58; 318 | 319 | // Setting with an ID only should work. 320 | actor.mentor = { id: bruce.id } as Actor; 321 | 322 | return db.query(Actor) 323 | .include(Actor, m => m.mentor) 324 | .create(actor) 325 | .then(created => { 326 | created.name.should.equal('Gary Oldman'); 327 | created.mentor.name.should.equal('Bruce Willis'); 328 | }); 329 | }); 330 | 331 | it('should create instances with not null foreign key', async () => { 332 | let list = new List({ id: 1, name: 'Test List' }); 333 | await db.query(List).create(list); 334 | 335 | let item = new ListItem({ value: 'testing' }); 336 | item.parent = { id: 1 } as List; 337 | 338 | return db.query(ListItem) 339 | .include(List, item => item.parent) 340 | .create(item) 341 | .then(created => { 342 | created.value.should.equal(item.value); 343 | created.parent.name.should.equal(list.name); 344 | }); 345 | }); 346 | }); 347 | 348 | describe('#update', () => { 349 | it('should update instances correctly', async () => { 350 | let actor = new Actor(); 351 | 352 | actor.age = 62; 353 | 354 | return db.query(Actor) 355 | .where(m => m.name.eq('Bruce Willis')) 356 | .update(actor) 357 | .then((result) => { 358 | result[0].should.equal(1); 359 | }); 360 | }); 361 | 362 | it('should update with not null primary key', async () => { 363 | let parent = await db.query(List).create(new List({ name: 'Test List' })); 364 | let item = new ListItem({ value: 'test value' }); 365 | item.parent = parent; 366 | 367 | let created = await db.query(ListItem).include(List, m => m.parent).create(item); 368 | 369 | created.value = 'updated'; 370 | 371 | let updated = await db.query(ListItem).save(created); 372 | 373 | updated.id.should.equal(created.id); 374 | updated.value.should.equal('updated'); 375 | }); 376 | }); 377 | 378 | describe('#findOrCreate', () => { 379 | it('should find existing records', async () => { 380 | return db.query(Actor) 381 | .where(m => m.name.eq('Bruce Willis').and(m.age.eq(61))) 382 | .findOrCreate() 383 | .then((result) => { 384 | result[0].age.should.equal(61); 385 | result[1].should.equal(false); 386 | }); 387 | }); 388 | 389 | it('should create non-existing records', async () => { 390 | return db.query(Actor) 391 | .where(m => m.name.eq('Gary Oldman').and(m.age.eq(58))) 392 | .findOrCreate() 393 | .then((result) => { 394 | result[0].age.should.equal(58); 395 | result[1].should.equal(true); 396 | }); 397 | }); 398 | }); 399 | 400 | describe('#findById', () => { 401 | it('should find existing records by ID', async () => { 402 | let [actor] = await db.query(Actor) 403 | .where(m => m.name.eq('Gary Oldman').and(m.age.eq(58))) 404 | .findOrCreate(); 405 | 406 | return db.query(Actor) 407 | .findById(actor.id) 408 | .then(result => { 409 | result.name.should.equal('Gary Oldman'); 410 | result.age.should.equal(58); 411 | }); 412 | }); 413 | }); 414 | 415 | describe('#include', () => { 416 | it('should include associated models', async () => { 417 | return db.query(Actor) 418 | .where(m => m.name.eq('Bruce Willis')) 419 | .include(Actor, m => m.mentor) 420 | .findOne() 421 | .then((result) => { 422 | result.age.should.equal(61); 423 | result.mentor.name.should.equal('Milla Jojovich'); 424 | }); 425 | }); 426 | 427 | it('should include lazy-loaded associated models', async () => { 428 | return db.query(Actor) 429 | .where(m => m.name.eq('Chris Tucker')) 430 | .include(Actor, m => m.mentee) 431 | .findOne() 432 | .then((result) => { 433 | result.mentee.name.should.equal('Milla Jojovich'); 434 | }); 435 | }); 436 | 437 | it('should inludes sub-association', async () => { 438 | return db.query(Actor) 439 | .where(m => m.name.eq('Chris Tucker')) 440 | .include(Actor, m => m.mentee, 441 | _ => _.include(Actor, _ => _.mentee)) 442 | .findOne() 443 | .then((result) => { 444 | result.mentee.mentee.name.should.equal('Bruce Willis'); 445 | }); 446 | }); 447 | }); 448 | }); 449 | -------------------------------------------------------------------------------- /src/database.ts: -------------------------------------------------------------------------------- 1 | /** Contains the database connection class. */ 2 | import 'reflect-metadata'; 3 | import * as Sequelize from 'sequelize'; 4 | import * as _ from 'lodash'; 5 | import { Model, ModelConstructor, AttributeType, InternalAttributeType, AssociationType, 6 | ArrayAttributeTypeOptions, EnumAttributeTypeOptions, isLazyLoad, 7 | getModelOptions, getAttributes, getAssociations, AssociationTarget, 8 | HAS_ONE, HAS_MANY, BELONGS_TO, BELONGS_TO_MANY } from 'modelsafe'; 9 | import { DestroyOptions, DropOptions, Options as SequelizeOptions, 10 | Sequelize as Connection, SyncOptions, Transaction, Model as SequelizeModel, 11 | DefineAttributeColumnOptions, DefineAttributes, 12 | DataTypeAbstract, STRING, CHAR, TEXT, INTEGER, BIGINT, 13 | DOUBLE, BOOLEAN, TIME, DATE, DATEONLY, JSON, BLOB, ENUM, ARRAY, 14 | AssociationOptionsBelongsToMany as SequelizeAssociationOptionsBelongsToMany 15 | } from 'sequelize'; 16 | 17 | import { Queryable, attribute } from './queryable'; 18 | import { getModelOptions as getSquellOptions, getAttributeOptions, 19 | getAssociationOptions, AssociationOptionsBelongsToMany, ThroughOptions } from './metadata'; 20 | import { Query } from './query'; 21 | 22 | /** 23 | * Map a ModelSafe attribute type to a Sequelize data type. 24 | * 25 | * @param type The attribute type. 26 | * @returns The Sequelize data type, or null if no equivalent type was found. 27 | */ 28 | function mapType(type: AttributeType): DataTypeAbstract { 29 | switch (type.type) { 30 | case InternalAttributeType.STRING: return STRING; 31 | case InternalAttributeType.CHAR: return CHAR; 32 | case InternalAttributeType.TEXT: return TEXT; 33 | case InternalAttributeType.INTEGER: return INTEGER; 34 | case InternalAttributeType.BIGINT: return BIGINT; 35 | case InternalAttributeType.REAL: return DOUBLE; 36 | case InternalAttributeType.BOOLEAN: return BOOLEAN; 37 | case InternalAttributeType.TIME: return TIME; 38 | case InternalAttributeType.DATETIME: return DATE; 39 | case InternalAttributeType.DATE: return DATEONLY; 40 | case InternalAttributeType.OBJECT: return JSON; 41 | case InternalAttributeType.BLOB: return BLOB; 42 | case InternalAttributeType.ENUM: { 43 | if (!type.options || !(type.options as EnumAttributeTypeOptions).values) { 44 | return null; 45 | } 46 | 47 | return ENUM.apply(ENUM, (type.options as EnumAttributeTypeOptions).values); 48 | } 49 | 50 | case InternalAttributeType.ARRAY: { 51 | if (!type.options || !(type.options as ArrayAttributeTypeOptions).contained) { 52 | return null; 53 | } 54 | 55 | return ARRAY(mapType((type.options as ArrayAttributeTypeOptions).contained)); 56 | } 57 | 58 | default: return null; 59 | } 60 | } 61 | 62 | interface AssocOptions { 63 | type: AssociationType; 64 | model: string; 65 | as: string; 66 | target: string; 67 | foreignKey: string | { name: string }; 68 | } 69 | 70 | /** 71 | * The database connection, wrapping a Sequelize connection. 72 | * All models defined on a connection before they can be used 73 | * for querying a database, as the models are defined separately 74 | * to the database via extending the abstract model class. 75 | */ 76 | export class Database { 77 | /** The Sequelize connection. */ 78 | conn: Connection; 79 | 80 | /** The ModelSafe models to be used with Sequelize. */ 81 | protected models: { [key: string]: ModelConstructor; }; 82 | 83 | /** 84 | * The internal Sequelize models. 85 | * This should act like the models property existing 86 | * on a safe, except map to the internal Sequelize model 87 | * instead of a ModelSafe model. 88 | * 89 | * This will not be populated until synced. 90 | */ 91 | protected internalModels: { [index: string]: SequelizeModel; }; 92 | 93 | /** 94 | * Connect to a database using Sequelize. 95 | * 96 | * @param url The database URL/URI. 97 | * @param options Any additional Sequelize options, e.g. connection pool count. 98 | */ 99 | constructor(url: string, options?: SequelizeOptions) { 100 | this.conn = new Sequelize(url, options); 101 | this.models = {}; 102 | this.internalModels = {}; 103 | } 104 | 105 | /** 106 | * Checks if a ModelSafe model has already been defined on the database. 107 | * 108 | * @param model The model constructor. 109 | * @returns Whether the model has been defined on the database. 110 | */ 111 | isDefined(model: ModelConstructor): boolean { 112 | let options = getModelOptions(model); 113 | 114 | return options.name && !!this.models[options.name]; 115 | } 116 | 117 | /** 118 | * Define a ModelSafe model on the database. 119 | * A model must be defined on the database in order for it 120 | * to be synced to the database and then queried. 121 | * 122 | * @param model The model constructor. 123 | * @returns The database with the model defined, allowing for chaining definition calls. 124 | * The database is still mutated. 125 | */ 126 | define(model: ModelConstructor): Database { 127 | let options = getModelOptions(model); 128 | 129 | // We need a model name in order to use the provided model 130 | if (!options.name) { 131 | throw new Error('Models must have a model name and be decorated with @model to be defined on a safe'); 132 | } 133 | 134 | // Only define a model once 135 | if (this.isDefined(model)) { 136 | return this; 137 | } 138 | 139 | this.models[options.name] = model; 140 | 141 | return this; 142 | } 143 | 144 | /** 145 | * Sync all defined model tables to the database using Sequelize. 146 | * 147 | * @param options Extra Sequelize sync options, if required. 148 | * @returns A promise that resolves when the table syncing is completed. 149 | */ 150 | async sync(options?: SyncOptions): Promise { 151 | // Translate the ModelSafe model into Sequelize form. 152 | for (let name of Object.keys(this.models)) { 153 | let model = this.models[name]; 154 | let attrs = getAttributes(model); 155 | let mappedAttrs: DefineAttributes = {}; 156 | let mappedOptions = { ... getSquellOptions(model) }; 157 | 158 | for (let key of Object.keys(attrs)) { 159 | let attrOptions = attrs[key]; 160 | let mappedType = mapType(attrOptions.type); 161 | 162 | if (typeof (mappedType) === 'undefined') { 163 | throw new Error(`Cannot define the ${name} model without a type on the ${key} attribute`); 164 | } 165 | 166 | let mappedAttr: DefineAttributeColumnOptions = { 167 | type: mappedType, 168 | 169 | ... getAttributeOptions(model, key) 170 | }; 171 | 172 | if (!attrOptions.optional) { 173 | mappedAttr.allowNull = false; 174 | } 175 | 176 | if (attrOptions.primary) { 177 | mappedAttr.primaryKey = true; 178 | 179 | delete mappedAttr.allowNull; 180 | } 181 | 182 | if (attrOptions.unique) { 183 | mappedAttr.unique = true; 184 | } 185 | 186 | mappedAttrs[key] = mappedAttr; 187 | } 188 | 189 | this.internalModels[name] = this.conn.define(name, mappedAttrs, mappedOptions) as SequelizeModel; 190 | } 191 | 192 | // Associate the Sequelize models as a second-step. 193 | // We do this separately because we want all models 194 | // defined before we attempt to associate them. 195 | for (let name of Object.keys(this.models)) { 196 | let model = this.models[name]; 197 | let internalModel = this.internalModels[name]; 198 | let assocs = getAssociations(model); 199 | 200 | const allAssocs = Object.keys(assocs).map(key => { 201 | let assoc = assocs[key]; 202 | let type = assoc.type; 203 | let target = assoc.target; 204 | let assocOptions = getAssociationOptions(model, key); 205 | 206 | if (isLazyLoad(target)) { 207 | target = (target as () => ModelConstructor)(); 208 | } 209 | 210 | if (typeof (type) === 'undefined') { 211 | throw new Error(`Cannot associate the ${name} model without a type for association ${key}`); 212 | } 213 | 214 | if (!target) { 215 | throw new Error(`Cannot associate the ${name} model without a target for association ${key}`); 216 | } 217 | 218 | let targetOptions = getModelOptions(target); 219 | 220 | if (!targetOptions.name) { 221 | throw new Error(`Cannot associate the ${name} model without a correctly decorated target for association ${key}`); 222 | } 223 | 224 | let targetModel = this.internalModels[targetOptions.name]; 225 | 226 | if ((type === HAS_ONE || type === HAS_MANY) && !assocOptions.foreignKey) { 227 | const targetAssoc = assoc.targetAssoc ? assoc.targetAssoc(getAssociations(target)) : null; 228 | const targetAssocOptions = targetAssoc ? getAssociationOptions(target.constructor, targetAssoc.key) : null; 229 | 230 | assocOptions.foreignKey = targetAssocOptions ? 231 | targetAssocOptions.foreignKey || targetAssocOptions.as + 'Id' : 232 | name + 'Id'; 233 | } 234 | 235 | if (type === BELONGS_TO_MANY) { 236 | let manyAssoc = assocOptions as AssociationOptionsBelongsToMany; 237 | let through = manyAssoc.through; 238 | 239 | // FIXME: Don't throw here and make it required somehow during decoration 240 | // Not currently possible because there is one @assoc decorator 241 | // and not a separate one for belongs to many. 242 | if (typeof (through) === 'undefined') { 243 | throw new Error(`Cannot associate the ${name} model without a decorated through for association ${key}`); 244 | } 245 | 246 | if (typeof (through) !== 'string') { 247 | let throughModel = through; 248 | let throughObj: ThroughOptions = { model: null, unique: true }; 249 | 250 | // Check if it looks like a through options object 251 | if (_.isPlainObject(through)) { 252 | throughModel = (through as ThroughOptions).model; 253 | throughObj = { 254 | ... throughObj, 255 | ... _.pick(through, ['scope', 'unique']) as ThroughOptions 256 | }; 257 | } 258 | 259 | // Check if it's a lazy-loadable association target 260 | if (isLazyLoad(throughModel as AssociationTarget)) { 261 | throughModel = (throughModel as () => ModelConstructor)(); 262 | } 263 | 264 | let throughOptions; 265 | 266 | // FIXME: Test this in a better way somehow 267 | try { 268 | throughOptions = getModelOptions(throughModel as ModelConstructor); 269 | } catch (err) { 270 | // Not a ModelSafe model 271 | throughOptions = {}; 272 | } 273 | 274 | // Rejig to use the already defined internal model if it's a ModelSafe model 275 | // If it's a Sequelize model we leave as-is 276 | if (throughOptions.name) { 277 | let internalThroughModel = this.internalModels[throughOptions.name]; 278 | 279 | // If there's no internal model, they haven't called `define`. 280 | if (!internalThroughModel) { 281 | throw new Error(`Cannot associate the ${name} model without a database-defined through for association ${key}`); 282 | } 283 | 284 | throughModel = internalThroughModel; 285 | } 286 | 287 | throughObj.model = throughModel as SequelizeModel; 288 | manyAssoc.through = throughObj; 289 | } 290 | } 291 | 292 | switch (type) { 293 | case HAS_ONE: internalModel.hasOne(targetModel, assocOptions); break; 294 | 295 | case HAS_MANY: 296 | internalModel.hasMany(targetModel, assocOptions); 297 | break; 298 | 299 | case BELONGS_TO: internalModel.belongsTo(targetModel, assocOptions); break; 300 | 301 | // FIXME: Any cast required because our belongs to many options aren't type equivalent (but function the same) 302 | case BELONGS_TO_MANY: 303 | internalModel.belongsToMany(targetModel, assocOptions as any as SequelizeAssociationOptionsBelongsToMany); 304 | 305 | break; 306 | } 307 | 308 | return { 309 | type, 310 | model: name, 311 | target: targetOptions.name, 312 | ...assocOptions, 313 | } as AssocOptions; 314 | }); 315 | 316 | // Check for duplicate foreign keys on the same model 317 | const hasOnes = _.filter(allAssocs, _ => _.type === HAS_ONE); 318 | const duplicates = _.xor(hasOnes, 319 | _.uniqWith(hasOnes, 320 | (a: AssocOptions, b: AssocOptions) => 321 | a.model === b.model && 322 | a.target === b.target && 323 | (_.isObject(a.foreignKey) ? (a.foreignKey as any).name : a.foreignKey) === 324 | (_.isObject(b.foreignKey) ? (b.foreignKey as any).name : b.foreignKey) 325 | )) as AssocOptions[]; 326 | 327 | if (duplicates.length) { 328 | throw new Error('Duplicate foreign keys found:\n' + 329 | duplicates.map(_ => ` ${_.model}.${_.as} -> ${_.target}.${_.foreignKey || (_.foreignKey as any).name}`).join('\n') + 330 | '\nSpecify non-conflicting foreign keys on the associations'); 331 | } 332 | } 333 | 334 | return Promise.resolve(this.conn.sync(options)); 335 | } 336 | 337 | /** 338 | * Drop all defined model tables from the database. 339 | * 340 | * @param options 341 | * @returns Returns a promise that resolves when the table dropping is completed. 342 | */ 343 | async drop(options?: DropOptions): Promise { 344 | return Promise.resolve(this.conn.drop(options)); 345 | } 346 | 347 | /** 348 | * Truncate all defined model tables in the database. 349 | * 350 | * @param options Extra Sequelize truncate options, if required. 351 | */ 352 | async truncate(options?: DestroyOptions): Promise { 353 | return Promise.resolve(this.conn.truncate(options)); 354 | } 355 | 356 | /** 357 | * Creates a transaction and passes it to a callback, working 358 | * exactly the same as the Sequelize function of the same name. 359 | * 360 | * @param cb The callback that will be passed the transaction and should return a promise using 361 | * the transaction. 362 | * @returns The promise result that resolves when the transaction is completed. 363 | */ 364 | async transaction(cb: (tx: Transaction) => Promise): Promise { 365 | // FIXME: Kinda hacky, but we cast any here so that we don't have to have 366 | // the Bluebird dependency just for this single function. The promise 367 | // should behave exactly the same as a Bluebird promise. 368 | return Promise.resolve(this.conn.transaction(cb as any)); 369 | } 370 | 371 | /** 372 | * Close the database connection. 373 | * Once closed, the database cannot be queried again. 374 | */ 375 | close() { 376 | this.conn.close(); 377 | } 378 | 379 | /** 380 | * Start a query on a specific model. The model must have 381 | * been defined and the database synced for the query to be performed. 382 | * 383 | * @param model The model class to query. 384 | * @returns A new query of the model. 385 | */ 386 | query(model: ModelConstructor): Query { 387 | return new Query(this, model); 388 | } 389 | 390 | /** 391 | * Start a native query to the database. 392 | * 393 | * @param query The native query string. 394 | * @returns Query result as formatted by Sequelize @see http://docs.sequelizejs.com/manual/tutorial/raw-queries.html 395 | */ 396 | async rawQuery(query: string | { query: string; values: any[] }, options?: Sequelize.QueryOptions) { 397 | return this.conn.query(query, options); 398 | } 399 | 400 | /** 401 | * Get the internal Sequelize model for a ModelSafe model. 402 | * Throws an error if the model has not been decorated correctly using 403 | * the ModelSafe decorators. 404 | * 405 | * @throws Error 406 | * @param model The model constructor. 407 | * @returns The internal Sequelize model. 408 | */ 409 | getInternalModel(model: ModelConstructor): SequelizeModel { 410 | let options = getModelOptions(model); 411 | let internalModel = this.internalModels[options.name] as SequelizeModel; 412 | 413 | if (!internalModel) { 414 | throw new Error('The database must be synced and a model must be defined before it can be queried'); 415 | } 416 | 417 | return internalModel; 418 | } 419 | 420 | /** 421 | * Get the internal Sequelize model's primary attribute. 422 | * Throws an error if the model has not been decorated correctly using 423 | * the ModelSafe decorators. 424 | * 425 | * @throws Error 426 | * @param model The model class to get the primary attribute for. 427 | * The model must be defined on the database before it can be queried. 428 | */ 429 | getInternalModelPrimary(model: ModelConstructor): Queryable { 430 | // FIXME: Wish we didn't have to cast any here, but primaryKeyAttribute isn't exposed 431 | // by the Sequelize type definitions. 432 | return attribute((this.getInternalModel(model) as any).primaryKeyAttribute); 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/queryable.ts: -------------------------------------------------------------------------------- 1 | /** Contains all of the queryable querying types. */ 2 | import * as _ from 'lodash'; 3 | import { Model } from 'modelsafe'; 4 | import { col as sequelizeCol, fn as sequelizeFn, Op } from 'sequelize'; 5 | 6 | import { Where } from './where'; 7 | 8 | /** 9 | * Construct an attribute queryable from an absolute attribute path. 10 | * 11 | * This should be used for path-specific queries on model attributes 12 | * that cannot easily be constructed using the query DSL. 13 | */ 14 | export function attribute(name: string): AttributeQueryable { 15 | return new AttributeQueryable(name); 16 | } 17 | 18 | /** 19 | * Construct a column queryable. 20 | * 21 | * This will function the same as the Sequelize function 22 | * with the same name when used in a query. 23 | */ 24 | export function col(name: string): ColumnQueryable { 25 | return new ColumnQueryable(name); 26 | } 27 | 28 | /** 29 | * Construct a function queryable. 30 | * 31 | * This will function in a type-safe manner on the Squell end, 32 | * but compile down to the function of the same name on 33 | * the Sequelize end. 34 | * 35 | * Once a function is wrapped as a queryable, 36 | * its result can be aliased using the `as` method to store 37 | * it's result into a specific queryable path. 38 | * 39 | * @see as 40 | */ 41 | export function fn(name: string, ...args: Queryable[]): FunctionQueryable { 42 | return new FunctionQueryable(name, args); 43 | } 44 | 45 | /** 46 | * Construct a constant queryable. 47 | * 48 | * When this value is used internally as a Sequelize value, 49 | * it will be represented exactly as the value given. For the most 50 | * part, the Squell query DSL should completely facilitate 51 | * this sort of functionality on its own, but this function may be necessary in some cases. 52 | */ 53 | export function constant(value: any): ConstantQueryable { 54 | return new ConstantQueryable(value); 55 | } 56 | 57 | /** 58 | * Represents a queryable property of a model or other non-model specific 59 | * queryable operations, like SQL functions, constants, etc. 60 | * 61 | * Squell abstracts this out so that where queries can be built 62 | * up in a type-safe way. To perform a certain comparison on a queryable, 63 | * you simply call the relevant comparison method. These comparisons 64 | * can then be composed by using the `Where` classes `and`/`or` methods. 65 | * 66 | * @param T The underlying type of the queryable. 67 | */ 68 | export abstract class Queryable { 69 | /** 70 | * Check if a queryable is equal to another queryable or constant value. 71 | * Equivalent to the $eq operator in Sequelize. 72 | * 73 | * @param other The queryable or constant value to compare. 74 | * @returns The generated where query. 75 | */ 76 | eq(other: T | Queryable): Where { 77 | return this.build(other); 78 | } 79 | 80 | /** 81 | * Check if a queryable is not equal to another queryable or constant value. 82 | * Equivalent to the $ne operator in Sequelize. 83 | * 84 | * @param other The queryable or constant value to compare. 85 | * @returns The generated where query. 86 | */ 87 | ne(other: T | Queryable): Where { 88 | return this.build(other, Op.ne); 89 | } 90 | 91 | /** 92 | * Check if a queryable is not equal to another queryable or constant value. 93 | * Equivalent to the $gt operator in Sequelize. 94 | * 95 | * @param other The queryable or constant value to compare. 96 | * @returns The generated where query. 97 | */ 98 | gt(other: T | Queryable): Where { 99 | return this.build(other, Op.gt); 100 | } 101 | 102 | /** 103 | * Check if a queryable is not equal to another queryable or constant value. 104 | * Equivalent to the $gte operator in Sequelize. 105 | * 106 | * @param other The queryable or constant value to compare. 107 | * @returns The generated where query. 108 | */ 109 | gte(other: T | Queryable): Where { 110 | return this.build(other, Op.gte); 111 | } 112 | 113 | /** 114 | * Check if a queryable is not equal to another queryable or constant value. 115 | * Equivalent to the $lt operator in Sequelize. 116 | * 117 | * @param other The queryable or constant value to compare. 118 | * @returns The generated where query. 119 | */ 120 | lt(other: T | Queryable): Where { 121 | return this.build(other, Op.lt); 122 | } 123 | 124 | /** 125 | * Check if a queryable is not equal to another queryable or constant value. 126 | * Equivalent to the $lte operator in Sequelize. 127 | * 128 | * @param other The queryable or constant value to compare. 129 | * @returns The generated where query. 130 | */ 131 | lte(other: T | Queryable): Where { 132 | return this.build(other, Op.lte); 133 | } 134 | 135 | /** 136 | * Check if a queryable is not equal to another queryable or constant string. 137 | * Equivalent to the $like operator in Sequelize. 138 | * 139 | * @param other The queryable or constant string to compare. 140 | * @returns The generated where query. 141 | */ 142 | like(other: string | Queryable): Where { 143 | return this.build(other, Op.like); 144 | } 145 | 146 | /** 147 | * Check if a queryable is not equal to another queryable or constant string. 148 | * Equivalent to the $notLike operator in Sequelize. 149 | * 150 | * @param other The queryable or constant string to compare. 151 | * @returns The generated where query. 152 | */ 153 | notLike(other: string | Queryable): Where { 154 | return this.build(other, Op.notLike); 155 | } 156 | 157 | /** 158 | * Check if a queryable is not equal to another queryable or constant string. 159 | * Equivalent to the $iLike operator in Sequelize. 160 | * 161 | * @param other The queryable or constant string to compare. 162 | * @returns The generated where query. 163 | */ 164 | iLike(other: string | Queryable): Where { 165 | return this.build(other, Op.iLike); 166 | } 167 | 168 | /** 169 | * Check if a queryable is not equal to another queryable or constant string. 170 | * Equivalent to the $notILike operator in Sequelize. 171 | * 172 | * @param other The queryable or constant string to compare. 173 | * @returns The generated where query. 174 | */ 175 | notILike(other: string | Queryable): Where { 176 | return this.build(other, Op.notILike); 177 | } 178 | 179 | /** 180 | * Check if a queryable is not equal to another queryable or constant bool. 181 | * Equivalent to the $not operator in Sequelize. 182 | * 183 | * @param other The queryable or constant bool to compare. 184 | * @returns The generated where query. 185 | */ 186 | not(other: boolean | Queryable): Where { 187 | return this.build(other, Op.not); 188 | } 189 | 190 | /** 191 | * Cast a queryable from one contained type to another. This will ignore 192 | * any type constraints and generally goes against what Squell was designed for (type-safe queries). 193 | * Nonetheless, there may be a situation where this is necessary due to typing issues. 194 | */ 195 | cast(): Queryable { 196 | return this; 197 | } 198 | 199 | /** 200 | * Alias a queryable under a different name. 201 | * 202 | * If you provide a string it will use that as a name, 203 | * otherwise it will use the underlying name of a provided queryable. 204 | * The more type-safe option is to alias to another queryable object, as that will let you 205 | * store a function queryable under an actual queryable of the model. The queryable should be set to virtual 206 | * if it's not meant to be synced to the database. 207 | */ 208 | as(aliased: string | Queryable): Queryable { 209 | return new AliasQueryable(typeof (aliased) !== 'string' ? aliased.compileLeft() : aliased, this); 210 | } 211 | 212 | /** 213 | * Check if a queryable is one of the values in a constant array. 214 | * Equivalent to the $in operator in Sequelize. 215 | * 216 | * @param other The constant array to check. 217 | * @returns The generated where query. 218 | */ 219 | in(other: T[]): Where { 220 | return this.build(other, Op.in); 221 | } 222 | 223 | /** 224 | * Check if a queryable is not one of the values in a constant array. 225 | * Equivalent to the $notIn operator in Sequelize. 226 | * 227 | * @param other The constant array to check. 228 | * @returns The generated where query. 229 | */ 230 | notIn(other: T[]): Where { 231 | return this.build(other, Op.notIn); 232 | } 233 | 234 | /** 235 | * Check if a queryable is between two constant values. 236 | * This is a bound inclusive check. 237 | * Equivalent to the $between operator in Sequelize. 238 | * 239 | * @param other The lower and upper bound constant to check. 240 | * @returns The generated where query. 241 | */ 242 | between(other: [T, T]): Where { 243 | return this.build(other, Op.between); 244 | } 245 | 246 | /** 247 | * Check if a queryable is not between two constant values. 248 | * This is a bound exclusive check. 249 | * Equivalent to the $notBetween operator in Sequelize. 250 | * 251 | * @param other The constant array to check. 252 | * @returns The generated where query. 253 | */ 254 | notBetween(other: [T, T]): Where { 255 | return this.build(other, Op.notBetween); 256 | } 257 | 258 | /** 259 | * Check if a queryable overlaps a constant array. 260 | * Equivalent to the $overlap operator in Sequelize, which only works in Postgres. 261 | * 262 | * @param other The constant array to check. 263 | * @returns The generated where query. 264 | */ 265 | overlap(other: T[]): Where { 266 | return this.build(other, Op.overlap); 267 | } 268 | 269 | /** 270 | * Check if a queryable contains a constant array. 271 | * Equivalent to the $contains operator in Sequelize, which only works in Postgres. 272 | * 273 | * @param other The constant array to check. 274 | * @returns The generated where query. 275 | */ 276 | contains(other: T | T[]): Where { 277 | return this.build(other, Op.contains); 278 | } 279 | 280 | /** 281 | * Check if a queryable is contained in a constant array. 282 | * Equivalent to the $contained operator in Sequelize, which only works in Postgres. 283 | * 284 | * @param other The constant array to check. 285 | * @returns The generated where query. 286 | */ 287 | contained(other: T[]): Where { 288 | return this.build(other, Op.contained); 289 | } 290 | 291 | /** 292 | * Check if a queryable has any common elements with a constant array. 293 | * Equivalent to the $any operator in Sequelize, which only works in Postgres. 294 | * 295 | * @param other The constant array to check. 296 | * @returns The generated where query. 297 | */ 298 | any(other: T[]): Where { 299 | return this.build(other, Op.any); 300 | } 301 | 302 | /** 303 | * Check if a queryable is adjacent to a constant array. 304 | * Equivalent to the $adjacent operator in Sequelize, which only works in Postgres. 305 | * 306 | * @param other The constant array to check. 307 | * @returns The generated where query. 308 | */ 309 | adjacent(other: T[]): Where { 310 | return this.build(other, Op.adjacent); 311 | } 312 | 313 | /** 314 | * Check if a queryable is strictly left of a constant array. 315 | * Equivalent to the $strictLeft operator in Sequelize, which only works in Postgres. 316 | * 317 | * @param other The constant array to check. 318 | * @returns The generated where query. 319 | */ 320 | strictLeft(other: T[]): Where { 321 | return this.build(other, Op.strictLeft); 322 | } 323 | 324 | /** 325 | * Check if a queryable is strictly right of a constant array. 326 | * Equivalent to the $strictRight operator in Sequelize, which only works in Postgres. 327 | * 328 | * @param other The constant array to check. 329 | * @returns The generated where query. 330 | */ 331 | strictRight(other: T[]): Where { 332 | return this.build(other, Op.strictRight); 333 | } 334 | 335 | /** 336 | * Check if a queryable extends right of a constant array. 337 | * Equivalent to the $noExtendRight operator in Sequelize, which only works in Postgres. 338 | * 339 | * @param other The constant array to check. 340 | * @returns The generated where query. 341 | */ 342 | noExtendRight(other: T[]): Where { 343 | return this.build(other, Op.noExtendRight); 344 | } 345 | 346 | /** 347 | * Check if a queryable extends left of a constant array. 348 | * Equivalent to the $noExtendLeft operator in Sequelize, which only works in Postgres. 349 | * 350 | * @param other The constant array to check. 351 | * @returns The generated where query. 352 | */ 353 | noExtendLeft(other: T[]): Where { 354 | return this.build(other, Op.noExtendLeft); 355 | } 356 | 357 | /** 358 | * An abstract method that should compile the queryable 359 | * into an equivalent left-side format to be used in. 360 | * The left-side vs right-side distinction is to do with what 361 | * side of the colon the queryable would normally be used in Sequelize options. 362 | * 363 | * For example, the queryable name username would translate to the object key 364 | * `username` in the statement `{ where: { username: 'hunter2' }}`. But if it was 365 | * being used as a right side, it would need to be wrapped in a `Sequelize.col` 366 | * call in order to properly filter by the `username` queryable value ( 367 | * otherwise it would be interpreted as a string). This distinction handles that 368 | * automatically for all sub-classes of a queryable. 369 | */ 370 | abstract compileRight(): any; 371 | 372 | /** 373 | * An abstract method that should compile the queryable into its 374 | * right-side format. 375 | */ 376 | abstract compileLeft(): any; 377 | 378 | /** 379 | * A generic where query builder, that will compare the queryable 380 | * to another queryable or constant value using a specific Sequelize operator. 381 | * 382 | * @param other The queryable or constant value. 383 | * @param operator The Sequelize operator to use in the comparison. 384 | */ 385 | protected build(other: any | Queryable, operator: symbol = Op.eq): Where { 386 | let value = other instanceof Queryable 387 | ? other.compileRight() 388 | : other; 389 | 390 | let operation = operator === Op.eq 391 | ? value 392 | : _.fromPairs([[operator, value]]); 393 | 394 | return new Where(_.fromPairs([[this.compileLeft(), operation]])); 395 | } 396 | } 397 | 398 | /** 399 | * A queryable representing the attribute of a model. 400 | * 401 | * @param T The underlying type of the queryable. 402 | */ 403 | export class AttributeQueryable extends Queryable { 404 | /** The underlying attribute name. */ 405 | private name: string; 406 | 407 | /** 408 | * Construct a queryable from an attribute name. 409 | * 410 | * @param name The queryable name. 411 | */ 412 | constructor(name: string) { 413 | super(); 414 | 415 | this.name = name; 416 | } 417 | 418 | /** Compiles into the right side format. */ 419 | compileRight(): any { 420 | return sequelizeCol(this.name); 421 | } 422 | 423 | /** Compiles into the left side format. */ 424 | compileLeft(): any { 425 | return this.name; 426 | } 427 | } 428 | 429 | /** 430 | * A queryable representing an arbitary column. 431 | * This could be a column selected manually, and might 432 | * not necessarily be a column from a model. 433 | * 434 | * @param T The underlying type of the queryable. 435 | */ 436 | export class ColumnQueryable extends Queryable { 437 | /** The column name. */ 438 | private name: string; 439 | 440 | /** 441 | * Construct a column queryable from a column name. 442 | * 443 | * @param name The column name. 444 | */ 445 | constructor(name: string) { 446 | super(); 447 | 448 | this.name = name; 449 | } 450 | 451 | /** Compiles into the right side format. */ 452 | compileRight(): any { 453 | return sequelizeCol(this.name); 454 | } 455 | 456 | /** Compiles into the left side format. */ 457 | compileLeft(): never { 458 | throw new SyntaxError('A column queryable cannot be used as a left operator in a Squell query'); 459 | } 460 | } 461 | 462 | /** 463 | * A queryable representing a call to a SQL function. 464 | * A function queryable contains both the name of a function 465 | * being called, and the queryable arguments to it. 466 | * 467 | * @param T The underlying type of the queryable. 468 | */ 469 | export class FunctionQueryable extends Queryable { 470 | /** The function name. */ 471 | private name: string; 472 | 473 | /** The function arguments, represented as queryables. */ 474 | private args: Queryable[]; 475 | 476 | /** 477 | * Construct a function queryable from a function name and arguments. 478 | * 479 | * @param name The function name. 480 | * @param args The function arguments as queryables. 481 | */ 482 | constructor(name: string, args: Queryable[]) { 483 | super(); 484 | 485 | this.name = name; 486 | this.args = args; 487 | } 488 | 489 | /** Compiles into the right side format. */ 490 | compileRight(): any { 491 | return sequelizeFn.apply(sequelizeFn, [this.name].concat(this.args.map(a => a.compileRight()))); 492 | } 493 | 494 | /** Compiles into the left side format. */ 495 | compileLeft(): never { 496 | throw new SyntaxError('A function queryable cannot be used as a left operator in a Squell query'); 497 | } 498 | } 499 | 500 | /** 501 | * A queryable representing an arbitary constant value. 502 | * 503 | * @param T The underlying type of the queryable. 504 | */ 505 | export class ConstantQueryable extends Queryable { 506 | /** The constant value. */ 507 | private value: any; 508 | 509 | /** 510 | * Construct a constant queryable from some value. 511 | * 512 | * @param value The constant value. 513 | */ 514 | constructor(value: any) { 515 | super(); 516 | 517 | this.value = value; 518 | } 519 | 520 | /** Compiles into the right side format. */ 521 | compileRight(): any { 522 | return this.value; 523 | } 524 | 525 | /** Compiles into the left side format. */ 526 | compileLeft(): any { 527 | return this.value; 528 | } 529 | } 530 | 531 | /** 532 | * A queryable that has been aliased. 533 | * An aliased queryable wraps another queryable under a new name, 534 | * or in the case of function queryables, specifies the name 535 | * the function result will be output under. 536 | * 537 | * @param T The underlying type of the queryable. 538 | */ 539 | export class AliasQueryable extends Queryable { 540 | /** The aliased name. */ 541 | private name: string; 542 | 543 | /** The original queryable. */ 544 | private aliased: Queryable; 545 | 546 | /** 547 | * Construct an alias queryable from an alias name and an original queryable. 548 | * 549 | * @param name The alias name. 550 | * @param aliased The original queryable. 551 | */ 552 | constructor(name: string, aliased: Queryable) { 553 | super(); 554 | 555 | this.name = name; 556 | this.aliased = aliased; 557 | } 558 | 559 | /** Compiles into the right side format. */ 560 | compileRight(): any { 561 | return [this.aliased.compileRight(), this.name]; 562 | } 563 | 564 | /** Compiles into the left side format. */ 565 | compileLeft(): never { 566 | throw new SyntaxError('An aliased queryable cannot be used as a left operator in a Squell query'); 567 | } 568 | } 569 | 570 | /** 571 | * A queryable representing the association of a model. 572 | * 573 | * @param T The underlying type of the queryable. 574 | */ 575 | export class AssociationQueryable extends AttributeQueryable {} 576 | 577 | /** 578 | * A mapped type that maps all of a model properties 579 | * to Squell queryables to support type-safe queries. 580 | */ 581 | export type ModelQueryables = { 582 | [P in keyof T]: Queryable; 583 | }; 584 | 585 | /** 586 | * A mapped type that maps all of a model properties 587 | * to Squell attribute queryables to support type-safe queries. 588 | */ 589 | export type ModelAttributeQueryables = { 590 | [P in keyof T]: AttributeQueryable; 591 | }; 592 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | /** Contains the type-safe querying interface that wraps Sequelize queries. */ 2 | import * as _ from 'lodash'; 3 | import { Model, ModelConstructor, ModelErrors, ValidationError, isLazyLoad, 4 | getModelOptions, getAttributes as getModelAttributes, 5 | getAssociations as getModelAssociations } from 'modelsafe'; 6 | import { FindOptions, WhereOptions, BelongsToAssociation, 7 | FindOptionsAttributesArray, DestroyOptions, RestoreOptions, 8 | CountOptions, AggregateOptions, CreateOptions as SequelizeCreateOptions, 9 | UpdateOptions as SequelizeUpdateOptions, 10 | BulkCreateOptions, UpsertOptions, FindOrInitializeOptions, 11 | IncludeOptions as SequelizeIncludeOptions, Utils, ValidationError as SequelizeValidationError, 12 | Model as SequelizeModel, Transaction, TruncateOptions, DropOptions, Instance 13 | } from 'sequelize'; 14 | 15 | import { Queryable, AttributeQueryable, AssociationQueryable, ModelQueryables, FunctionQueryable, 16 | ModelAttributeQueryables } from './queryable'; 17 | import { getAttributeOptions, getAssociationOptions } from './metadata'; 18 | import { Where } from './where'; 19 | import { Database } from './database'; 20 | 21 | /** A type alias for a sorting order. */ 22 | export type Order = string; 23 | 24 | /** Represents descending order. */ 25 | export const DESC: Order = 'DESC'; 26 | 27 | /** Represents ascending order. */ 28 | export const ASC: Order = 'ASC'; 29 | 30 | /** Represents the ordering of a queryable. */ 31 | export type QueryableOrder = [Queryable, Order]; 32 | 33 | /** 34 | * Query options for a Squell query. 35 | */ 36 | export interface QueryOptions { 37 | /** The array of where queries for the query. */ 38 | wheres: Where[]; 39 | 40 | /** The array of attribute filters for the query. */ 41 | attrs: (AttributeQueryable | [FunctionQueryable, AttributeQueryable])[]; 42 | 43 | /** The array of associations for the query. */ 44 | includes: IncludeOptions[]; 45 | 46 | /** The array of attribute orderings for the query. */ 47 | orderings: QueryableOrder[]; 48 | 49 | /** The array of groups used for the query. */ 50 | groupBys: Queryable[]; 51 | 52 | /** The number of records to skip (i.e. OFFSET). */ 53 | skipped: number; 54 | 55 | /** The number of records to take (i.e. LIMIT). */ 56 | taken: number; 57 | } 58 | 59 | /** Include options for a Squell association include. */ 60 | export interface IncludeOptions { 61 | /** Association model. */ 62 | model: ModelConstructor; 63 | 64 | /** Association key. */ 65 | as: string; 66 | 67 | /** Association query that could contain wheres, includes, etc. */ 68 | query?: Query; 69 | 70 | /** Whether the association must exist for the whole query to succeed. */ 71 | required?: boolean; 72 | 73 | /** Whether the association should also be saved during a top-level save/update/upsert */ 74 | associateOnly?: boolean; 75 | } 76 | 77 | /** Extra options for updating model instances. */ 78 | export interface UpdateOptions extends SequelizeUpdateOptions { 79 | /** Whether or not to automatically update associated instances. */ 80 | associate?: boolean; 81 | } 82 | 83 | /** Extra options for creating model instances. */ 84 | export interface CreateOptions extends SequelizeCreateOptions { 85 | /** Whether or not to automatically update associated instances. */ 86 | associate?: boolean; 87 | } 88 | 89 | /** 90 | * Coerces a Sequelize validation error into ModelSafe's form. 91 | * 92 | * @param ctor The model constructor. 93 | * @param err The Sequelize validation error. 94 | * @returns The coerced error. 95 | */ 96 | export function coerceValidationError( 97 | ctor: ModelConstructor, 98 | err: SequelizeValidationError, 99 | prefix?: string, 100 | ): ValidationError { 101 | let errors = {}; 102 | let attrs = getModelAttributes(ctor); 103 | let assocs = getModelAssociations(ctor); 104 | 105 | // Preset each attr and assoc error to an empty array 106 | const attrKeys = Object.keys(attrs); 107 | attrKeys.concat(Object.keys(assocs)).forEach(key => { 108 | errors[(prefix ? prefix + '.' : '') + key] = []; 109 | }); 110 | 111 | // Loop through errors from Sequelize 112 | err.errors.map(e => e.path).forEach(key => { 113 | let errs = errors; 114 | 115 | // If this key isn't an attr then it's a constraint. Record them separately 116 | if (!_.includes(attrKeys, key)) { 117 | errors['$constraints'] = errors['$constraints'] || {}; 118 | errs = errors['$constraints']; 119 | } 120 | 121 | errs[(prefix ? prefix + '.' : '') + key] = _.map(err.get(key), item => { 122 | switch (item.type.toLowerCase()) { 123 | case 'notnull violation': 124 | return { 125 | type: 'attribute.required', 126 | message: 'Required', 127 | }; 128 | case 'unique violation': 129 | return { 130 | type: 'attribute.unique', 131 | message: 'Not unique', 132 | }; 133 | case 'string violation': 134 | return { 135 | type: 'attribute.string', 136 | message: 'Not a string', 137 | }; 138 | } 139 | }); 140 | }); 141 | 142 | let result = new ValidationError(ctor, 'Validation error', errors as ModelErrors); 143 | 144 | // Merge the stack. 145 | result.stack = `${result.stack}\ncaused by ${err.stack}`; 146 | 147 | return result; 148 | } 149 | 150 | /** Calculate the max include depth on the given query */ 151 | export function calcIncludeDepth(query: Query, depth: number = 0): number { 152 | return query && query.options.includes ? 153 | _.max(_.map(query.options.includes, _ => calcIncludeDepth(_.query, depth + 1))) || depth : 154 | depth; 155 | } 156 | 157 | /** 158 | * Coerces a ModelSafe model instance to a Sequelize model instance. 159 | * 160 | * @param internalModel The Sequelize model. 161 | * @param data The ModelSafe model instance or a partial object of values. 162 | * @returns The Sequelize model instance coerced. 163 | */ 164 | export async function coerceInstance(internalModel: SequelizeModel, 165 | data: T | Partial, 166 | query: Query, 167 | isNewRecord: boolean = false): Promise> { 168 | if (!_.isPlainObject(data)) { 169 | data = await data.serialize({ depth: calcIncludeDepth(query) }); 170 | } 171 | 172 | return internalModel.build(data as T, { isNewRecord }) as any as Instance; 173 | } 174 | 175 | /** 176 | * Removes any required attribute errors if the attribute has a default value or is auto-incremented. 177 | * 178 | * @param err The validation error to filter. 179 | * @returns The validation error without required errors for default values. 180 | */ 181 | export async function preventRequiredDefaultValues(err: ValidationError) { 182 | let errors = err.errors; 183 | 184 | if (!errors) { 185 | return Promise.reject(err); 186 | } 187 | 188 | let attrs = getModelAttributes(err.ctor); 189 | 190 | // Look for default values then filter out required attribute errors 191 | for (let key of Object.keys(attrs)) { 192 | let options = getAttributeOptions(err.ctor, key); 193 | 194 | // Ignore non-auto increment and non-default-value or things that have no errors 195 | if (!options || (!options.autoIncrement && !options.defaultValue) || 196 | !_.isArray(errors[key]) || errors[key].length < 1) { 197 | continue; 198 | } 199 | 200 | if (options && (options.autoIncrement || options.defaultValue) && errors[key]) { 201 | errors[key] = _.filter(errors[key], x => x.type !== 'attribute.required'); 202 | if (errors[key].length === 0) delete errors[key]; 203 | } 204 | } 205 | 206 | if (Object.keys(errors).length === 0) return; 207 | 208 | err.errors = errors; 209 | 210 | return Promise.reject(err); 211 | } 212 | 213 | /** 214 | * Get the queryable properties of a model as a mapped type. 215 | * 216 | * @param ctor The model constructor. 217 | * @returns The mapped queryable properties. 218 | */ 219 | export function getQueryables(ctor: ModelConstructor): ModelQueryables { 220 | let result = {}; 221 | let attrs = getModelAttributes(ctor); 222 | let assocs = getModelAssociations(ctor); 223 | 224 | for (let key of Object.keys(attrs)) { 225 | result[key] = new AttributeQueryable(key); 226 | } 227 | 228 | for (let key of Object.keys(assocs)) { 229 | result[key] = new AssociationQueryable(key); 230 | } 231 | 232 | return result as ModelQueryables; 233 | } 234 | 235 | /** 236 | * Get the attribute queryable properties of a model as a mapped type. 237 | * 238 | * @param ctor The model constructor. 239 | * @returns The mapped queryable properties. 240 | */ 241 | export function getAttributeQueryables(ctor: ModelConstructor): ModelAttributeQueryables { 242 | let result = {}; 243 | let attrs = getModelAttributes(ctor); 244 | 245 | for (let key of Object.keys(attrs)) { 246 | result[key] = new AttributeQueryable(key); 247 | } 248 | 249 | return result as ModelAttributeQueryables; 250 | } 251 | 252 | /** 253 | * A type-safe query on a ModelSafe model. 254 | * This is the main interaction with the library, and every Squell query compiles 255 | * down to a relevant Sequelize query. 256 | */ 257 | export class Query { 258 | /** The Squell database that generated the query relates to. */ 259 | db: Database; 260 | 261 | /** The model being queried. */ 262 | model: ModelConstructor; 263 | 264 | /** The options for the query, include wheres, includes, etc. */ 265 | options: QueryOptions; 266 | 267 | /** The internal Sequelize representation of the model. */ 268 | protected internalModel: SequelizeModel; 269 | 270 | /** 271 | * Construct a query. This generally should not be done by user code, 272 | * rather the query function on a database connection should be used. 273 | * 274 | * @param model The model class. 275 | * @param options The query options. 276 | */ 277 | constructor(db: Database, model: ModelConstructor, options?: QueryOptions) { 278 | this.db = db; 279 | this.model = model; 280 | this.internalModel = db.getInternalModel(model); 281 | this.options = { 282 | wheres: [], 283 | skipped: 0, 284 | taken: 0, 285 | 286 | ... options 287 | }; 288 | } 289 | 290 | /** 291 | * Merge two queries into a new one, preferencing the given query. 292 | * Wheres, attrs, and orderings are concatenated; 293 | * Includes are recursively merged; and 294 | * Skipped and taken are overridden 295 | * 296 | * @param other The other query to merge into the first 297 | * @returns The new merged query. 298 | */ 299 | merge(other: Query): Query { 300 | let options: QueryOptions = { 301 | ... this.options, 302 | ... other.options, 303 | wheres: (this.options.wheres || []).concat(other.options.wheres || []), 304 | attrs: (this.options.attrs || []).concat(other.options.attrs || []), 305 | orderings: (this.options.orderings || []).concat(other.options.orderings || []), 306 | includes: (this.options.includes || []).concat(other.options.includes || []), 307 | }; 308 | 309 | // Unionise includes and recursively merge their subqueries 310 | options.includes = 311 | _.chain(options.includes) 312 | .groupBy(_ => _.as) 313 | .map((_: IncludeOptions[]) => ({ 314 | ... _[0], 315 | ... _[1], 316 | query: _[0].query.merge(_[1].query), 317 | } as IncludeOptions)) 318 | .value(); 319 | 320 | return new Query(this.db, this.model, options); 321 | } 322 | 323 | /** 324 | * Filter a query using a where query. 325 | * 326 | * @param map A function that will take the queryable model properties 327 | * and produce the where query to filter the query with. 328 | * @returns The filtered query. 329 | */ 330 | where(map: (queryables: ModelQueryables) => Where): Query { 331 | let options = { ... this.options, wheres: this.options.wheres.concat(map(getQueryables(this.model))) }; 332 | 333 | return new Query(this.db, this.model, options); 334 | } 335 | 336 | /** 337 | * Select the attributes to be included in a query result. 338 | * 339 | * @param map A function that will take the queryable model properties 340 | * and produce an array of attributes to be included in the result. 341 | * @returns The new query with the selected attributes only. 342 | */ 343 | attributes(map: (queryable: ModelAttributeQueryables) => 344 | (AttributeQueryable | [FunctionQueryable, AttributeQueryable])[]): Query { 345 | 346 | let attrs = this.options.attrs || []; 347 | let options = { ... this.options, attrs: attrs.concat(map(getAttributeQueryables(this.model))) }; 348 | 349 | return new Query(this.db, this.model, options); 350 | } 351 | 352 | /** 353 | * Eager load an association model with the query. 354 | * An optional function can be provided to change the query on the association 355 | * model in order to do things like where queries on association query. 356 | * 357 | * @param map A function that will take the queryable model properties 358 | * and produce the attribute the association is under. 359 | * @param query A function that will take the default query on the association model 360 | * and return a custom one, i.e. allows for adding where queries 361 | * to the association query. 362 | * @returns The eagerly-loaded query. 363 | */ 364 | include(model: ModelConstructor, 365 | map: (queryables: ModelQueryables) => Queryable | [Queryable, Partial>], 366 | query?: (query: Query) => Query): Query { 367 | let includes = this.options.includes || []; 368 | let attrResult = map(getQueryables(this.model)); 369 | let assocKey: string; 370 | let extraOptions = {} as IncludeOptions; 371 | 372 | if (Array.isArray(attrResult)) { 373 | let [assocAttr, assocIncludeOptions] = attrResult as [Queryable, IncludeOptions]; 374 | 375 | assocKey = assocAttr.compileLeft(); 376 | extraOptions = assocIncludeOptions; 377 | } else { 378 | assocKey = (attrResult as Queryable).compileLeft(); 379 | } 380 | 381 | let assocOptions = getAssociationOptions(this.model, assocKey); 382 | if (assocOptions && assocOptions.as) assocKey = assocOptions.as as string; 383 | 384 | const existingIncludeIndex = includes.findIndex(_ => _.as === assocKey); 385 | const existingInclude = includes[existingIncludeIndex] as IncludeOptions; 386 | 387 | let includeOptions: IncludeOptions = { 388 | model, 389 | as: assocKey, 390 | associateOnly: true, 391 | ...existingInclude, 392 | ...extraOptions, 393 | query: null, 394 | }; 395 | 396 | if (query) includeOptions.query = query(new Query(this.db, model)); 397 | 398 | if (existingInclude && existingInclude.query) 399 | includeOptions.query = includeOptions.query ? existingInclude.query.merge(includeOptions.query) : existingInclude.query; 400 | 401 | let options = { 402 | ... this.options, 403 | 404 | includes: (existingInclude ? 405 | includes.slice(0, existingIncludeIndex).concat(includes.slice(existingIncludeIndex + 1)) : 406 | includes).concat([includeOptions]) 407 | }; 408 | 409 | return new Query(this.db, this.model, options); 410 | } 411 | 412 | /** 413 | * Eager load all association models with the query. 414 | * 415 | * @returns The eagerly-loaded query. 416 | */ 417 | includeAll(options?: Partial>): Query { 418 | let query: Query = this; 419 | const assocs = getModelAssociations(this.model); 420 | 421 | for (let key of Object.keys(assocs)) { 422 | let target = assocs[key].target; 423 | 424 | // Lazily load the target if required. 425 | if (isLazyLoad(target)) { 426 | target = (target as () => ModelConstructor)(); 427 | } 428 | 429 | query = query.include(target as ModelConstructor, _ => [new AssociationQueryable(key), options]); 430 | } 431 | 432 | return query; 433 | } 434 | 435 | /** 436 | * Order the future results of a query. 437 | * 438 | * @param map A function that will take the queryable model properties 439 | * and produce an array of attribute orders for the result to be ordered by. 440 | * @returns The ordered query. 441 | */ 442 | order(map: (queryables: ModelQueryables) => QueryableOrder[]): Query { 443 | let orderings = this.options.orderings || []; 444 | let options = { ... this.options, orderings: orderings.concat(map(getQueryables(this.model))) }; 445 | 446 | return new Query(this.db, this.model, options); 447 | } 448 | 449 | /** 450 | * Group a query by an provided grouping function 451 | * 452 | * @param map A function that will take the queryable model properties 453 | * and produce a function for grouping to used for find/count method 454 | * 455 | * @returns The grouped query 456 | */ 457 | groupBy(map: (queryables: ModelQueryables) => Queryable[]): Query { 458 | let groupBys = this.options.groupBys || []; 459 | let options = { ... this.options, groupBys: groupBys.concat(map(getQueryables(this.model))) }; 460 | 461 | return new Query(this.db, this.model, options); 462 | } 463 | 464 | /** 465 | * Skip a number of future results from the query. 466 | * This essentially increases the OFFSET amount, 467 | * and will only affect the find and findOne queries. 468 | */ 469 | skip(num: number): Query { 470 | let options = { ... this.options, skipped: this.options.skipped + num }; 471 | 472 | return new Query(this.db, this.model, options); 473 | } 474 | 475 | /** 476 | * Take a number of future results from the query. 477 | * This increases the LIMIT amount and will affect find, 478 | * findOne, restore, destroy, aggregation and updates. 479 | */ 480 | take(num: number): Query { 481 | let options = { ... this.options, taken: this.options.taken + num }; 482 | 483 | return new Query(this.db, this.model, options); 484 | } 485 | 486 | /** 487 | * Find a list of model instances using the built query. 488 | * 489 | * @param options Any extra Sequelize find options required. 490 | * @returns A promise that resolves to a list of found instances if successful. 491 | */ 492 | async find(options?: FindOptions): Promise { 493 | let model = this.model; 494 | let data = await Promise.resolve(this.internalModel.findAll({ ... options, ... this.compileFindOptions() })); 495 | 496 | // Deserialize all returned Sequelize models 497 | return Promise.all(data.map(async (item: T): Promise => { 498 | // FIXME: The any cast is required here to turn the plain T into a Sequelize instance T 499 | return await model.deserialize((item as any as Instance).toJSON(), { validate: false, depth: null }) as T; 500 | })); 501 | } 502 | 503 | /** 504 | * Find a single model instance using the built query. 505 | * 506 | * @param options Any extra Sequelize find options required. 507 | * @returns A promise that resolves to the found instance if successful. 508 | */ 509 | async findOne(options?: FindOptions): Promise { 510 | let data = await Promise.resolve(this.internalModel.findOne({ ... options, ... this.compileFindOptions() })); 511 | 512 | // FIXME: The any cast is required here to turn the plain T into a Sequelize instance T 513 | return data ? 514 | await this.model.deserialize((data as any as Instance).toJSON(), { validate: false, depth: null }) as T : 515 | null; 516 | } 517 | 518 | /** 519 | * Find a single model instance by a primary key/ID. 520 | * 521 | * @param id The primary key/ID value. 522 | * @param options Any extra Sequelize find options required. 523 | * @returns A promise that resolves to the found instance if successful. 524 | */ 525 | async findById(id: any, options?: FindOptions): Promise { 526 | let data = await Promise.resolve(this.internalModel.findById(id, options)); 527 | 528 | // FIXME: The any cast is required here to turn the plain T into a Sequelize instance T 529 | return data ? 530 | await this.model.deserialize((data as any as Instance).toJSON(), { validate: false, depth: null }) as T : 531 | null; 532 | } 533 | 534 | /** 535 | * Truncate the model table in the database. 536 | * 537 | * @param options Any extra truncate options to truncate with. 538 | * @returns A promise that resolves when the model table has been truncated. 539 | */ 540 | async truncate(options?: TruncateOptions): Promise { 541 | return Promise.resolve(this.internalModel.truncate(options)); 542 | } 543 | 544 | /** 545 | * Drop the model table in the database. 546 | * 547 | * @param options Any extra drop options to drop with. 548 | * @returns A promise that resolves when the model table has been dropped. 549 | */ 550 | async drop(options?: DropOptions): Promise { 551 | return Promise.resolve(this.internalModel.drop(options)); 552 | } 553 | 554 | /** 555 | * Restore deleted model instances to the database using 556 | * the built query. This will only work on paranoid deletion 557 | * models. 558 | * 559 | * @param options Any extra Sequelize restore options required. 560 | * @returns A promise that resolves when the instances have been restored successfully. 561 | */ 562 | async restore(options?: RestoreOptions): Promise { 563 | return Promise.resolve(this.internalModel.restore({ 564 | ... options, 565 | 566 | where: this.compileWheres(), 567 | limit: this.options.taken > 0 ? this.options.taken : undefined, 568 | })); 569 | } 570 | 571 | /** 572 | * Destroys model instances from the database using the 573 | * built query. This can destroy multiple instances if the query 574 | * isn't specific enough. 575 | * 576 | * @param options Any extra Sequelize destroy options required. 577 | * @returns A promise that resolves when the instances have been destroyed successfully. 578 | */ 579 | async destroy(options?: DestroyOptions): Promise { 580 | return Promise.resolve(this.internalModel.destroy({ 581 | ... options, 582 | 583 | where: this.compileWheres(), 584 | limit: this.options.taken > 0 ? this.options.taken : undefined, 585 | })); 586 | } 587 | 588 | /** 589 | * Counts the number of the model instances returned from the database 590 | * for the built query. 591 | * 592 | * @param options Any extra Sequelize count options required. 593 | * @returns A promise that resolves with the number of records found if successful. 594 | */ 595 | async count(options?: CountOptions): Promise { 596 | return Promise.resolve(this.internalModel.count({ 597 | ... options, 598 | 599 | where: this.compileWheres(), 600 | include: this.options.includes ? this.compileIncludes() : undefined, 601 | })); 602 | } 603 | 604 | /** 605 | * Aggregate the model instances in the database using a specific 606 | * aggregation function and model queryable. 607 | * 608 | * @param fn The aggregate function to use. 609 | * @param map A lambda function that will take the queryable model properties 610 | * and produce the attribute to aggregate by. 611 | * @returns A promise that resolves with the aggregation value if successful. 612 | */ 613 | async aggregate(fn: string, map: (attrs: ModelQueryables) => Queryable, 614 | options?: AggregateOptions): Promise { 615 | return Promise.resolve(this.internalModel.aggregate(map(getQueryables(this.model)).compileLeft(), fn, { 616 | ... options, 617 | 618 | where: this.compileWheres(), 619 | })); 620 | } 621 | 622 | /** 623 | * Aggregate the model instances in the database using the min 624 | * aggregation function and model attribute. 625 | * 626 | * @param map A lambda function that will take the queryable model properties 627 | * and produce the attribute to aggregate by. 628 | * @returns A promise that resolves with the aggregation value if successful. 629 | */ 630 | async min(map: (attrs: ModelQueryables) => Queryable, 631 | options?: AggregateOptions): Promise { 632 | return Promise.resolve(this.aggregate('min', map, options)); 633 | } 634 | 635 | /** 636 | * Aggregate the model instances in the database using the max 637 | * aggregation function and model attribute. 638 | * 639 | * @param map A lambda function that will take the queryable model properties 640 | * and produce the attribute to aggregate by. 641 | * @returns A promise that resolves with the aggregation value if successful. 642 | */ 643 | async max(map: (attrs: ModelQueryables) => Queryable, 644 | options?: AggregateOptions): Promise { 645 | return Promise.resolve(this.aggregate('max', map, options)); 646 | } 647 | 648 | /** 649 | * Aggregate the model instances in the database using the sum 650 | * aggregation function and model attribute. 651 | * 652 | * @param map A lambda function that will take the queryable model properties 653 | * and produce the attribute to aggregate by. 654 | * @returns A promise that resolves with the aggregation value if successful. 655 | */ 656 | async sum(map: (attrs: ModelQueryables) => Queryable, 657 | options?: AggregateOptions): Promise { 658 | return Promise.resolve(this.aggregate('sum', map, options)); 659 | } 660 | 661 | /** 662 | * Find or create a model instance in the database using the built query. 663 | * The created instance will use any values set in the query, 664 | * hence you will need to build the query using the eq attribute 665 | * method so that the instance is built with the right attribute values. 666 | * 667 | * This *will not* update any associations. 668 | * 669 | * @param defaults The default values to be used alongside the search parameters 670 | * when the instance is being created. 671 | * @param options Any extra Sequelize find or initialize options required. 672 | * @returns A promise that resolves with the found/created instance and 673 | * a bool that is true when an instance was created. 674 | */ 675 | async findOrCreate(defaults?: Partial, options?: FindOrInitializeOptions): Promise<[T, boolean]> { 676 | let model = this.model; 677 | let [data, created] = await Promise.resolve( 678 | this.internalModel 679 | .findOrCreate({ 680 | ... options, 681 | 682 | defaults: defaults as T, 683 | where: this.compileWheres() as any as WhereOptions, 684 | }) 685 | .catch(SequelizeValidationError, async (err: SequelizeValidationError) => { 686 | return Promise.reject(coerceValidationError(model, err)); 687 | }) 688 | ); 689 | 690 | // Turn the Sequelize data into a ModelSafe model class 691 | return [ 692 | // FIXME: The any cast is required here to turn the plain T into a Sequelize instance T 693 | await model.deserialize((data as any as Instance).toJSON(), { validate: false }) as T, 694 | created 695 | ]; 696 | } 697 | 698 | /** 699 | * Prepare an instance to be stored in the database. 700 | * This serialises the ModelSafe instance to a plain JS 701 | * object and then also adds on any foreign keys, if found. 702 | * 703 | * If the `associations` parameter is set to `true`, then this 704 | * also serializes associations to JS. 705 | * 706 | * @param instance The instance to prepare to be stored. 707 | * @param associations Whether to add associations to the object result too. 708 | * @returns A promise that resolves with the instance in plain object form. 709 | */ 710 | protected async prepare(instance: T, associations: boolean = false): Promise { 711 | let includes = this.options.includes || []; 712 | 713 | let data = await this.model.serialize(instance, { associations, depth: calcIncludeDepth(this) }); 714 | 715 | // No point doing anything extra if no includes were set. 716 | if (includes.length < 1) { 717 | return data; 718 | } 719 | 720 | // Add all foreign keys to the data, if it's an early association (belongs-to). 721 | for (let include of includes) { 722 | let key = include.as; 723 | let internalAssoc = this.internalModel.associations[key]; 724 | let value = instance[key]; 725 | 726 | if (_.isNil(value)) { 727 | continue; 728 | } 729 | 730 | // Add any foreign keys that are set outside of association values 731 | if (internalAssoc.associationType === 'BelongsTo') { 732 | let identifier = (internalAssoc as BelongsToAssociation).identifier; 733 | let targetIdentifier = (internalAssoc as BelongsToAssociation).targetIdentifier; 734 | let id; 735 | 736 | // If they've provided an association value, then pull the id off that. 737 | if (value) { 738 | id = value[targetIdentifier]; 739 | } 740 | 741 | // If there's still no ID, try getting it of a foreign key value 742 | if (!id) { 743 | id = instance[identifier]; 744 | } 745 | 746 | if (id) { 747 | data[identifier] = id; 748 | } 749 | } 750 | } 751 | 752 | return data; 753 | } 754 | 755 | /** 756 | * Saves any associations that have been included 757 | * onto a Sequelize internal model instance, and then 758 | * reloads that internal model data. 759 | * 760 | * @param internalInstance The internal Sequelize model instance. 761 | * @param data The data that the model originally had in its ModelSafe instance form 762 | * This is used to get the original values of the model instance and its associations. 763 | * @param transaction An optional transaction to use with associate calls. 764 | * @returns A promise that resolves with an associated & reloaded instance, or rejects 765 | * if there was an error. 766 | */ 767 | // FIXME this is not recursive! big issue as deep includes will get ignored 768 | protected async associate(type: 'create' | 'update', model: ModelConstructor, internalInstance: Instance, data: Partial, 769 | includes: IncludeOptions[], transaction?: Transaction): Promise> { 770 | // No point doing any operations/reloading if no includes were set. 771 | if (!includes || includes.length < 1) return internalInstance; 772 | 773 | const modelName = getModelOptions(model).name; 774 | const internalModel = this.db.getInternalModel(model); 775 | const assocs = getModelAssociations(model); 776 | 777 | for (let include of includes) { 778 | let key = include.as; 779 | let value = (data as object)[key]; 780 | let internalAssoc = internalModel.associations[key]; 781 | let assoc = assocs[key]; 782 | const assocModel = isLazyLoad(assoc.target) ? 783 | (assoc.target as () => ModelConstructor)() : 784 | assoc.target as ModelConstructor; 785 | 786 | // We need the internal and ModelSafe association in order to see how to save the value. 787 | // We also ignore undefined values since that means they haven't been 788 | // loaded/shouldn't be touched. 789 | if (!internalAssoc || !assocModel || typeof (value) === 'undefined') { 790 | continue; 791 | } 792 | 793 | let internalAssocModel = this.db.getInternalModel(assocModel); 794 | let internalAssocPrimary = this.db.getInternalModelPrimary(assocModel).compileLeft(); 795 | 796 | // Don't attempt to do anything else if the association's 797 | // foreign value was set - it would have already been associated 798 | // during the update or create call. 799 | if (internalAssoc.associationType === 'BelongsTo' && (data as any)[(internalAssoc as BelongsToAssociation).identifier]) 800 | continue; 801 | 802 | // When creating objects for a has-one/has-many relationship, set the foreign key to make the validator happy 803 | if (type === 'create' && (internalAssoc.associationType === 'HasOne' || internalAssoc.associationType === 'HasMany')) { 804 | const targetAssoc = assoc.targetAssoc ? assoc.targetAssoc(getModelAssociations(assocModel)) : null; 805 | const targetAssocOptions = targetAssoc ? getAssociationOptions(assocModel.constructor, targetAssoc.key) : null; 806 | const targetAssocForeignKey = targetAssocOptions ? 807 | targetAssocOptions.foreignKey as string || targetAssocOptions.as + 'Id' : 808 | modelName + 'Id'; 809 | 810 | (_.isArray(value) ? value : [value]).forEach((obj: Partial) => { 811 | if (getAttributeOptions(assocModel.constructor, targetAssocForeignKey)) { 812 | obj[targetAssocForeignKey] = internalInstance.get(this.db.getInternalModelPrimary(model).compileLeft()); 813 | } 814 | if (targetAssocOptions) delete obj[targetAssocOptions.as as string]; 815 | }); 816 | } 817 | 818 | // This is the same across all associations. 819 | let method = internalInstance['set' + Utils.uppercaseFirst(key)]; 820 | 821 | if (typeof (method) !== 'function') { 822 | continue; 823 | } 824 | 825 | method = method.bind(internalInstance); 826 | 827 | if (_.isNil(value)) { 828 | // The value is null like (but not undefined, as that was ignored earlier). 829 | // Clear any existing association value and continue. 830 | await Promise.resolve(method(null, { transaction })); 831 | 832 | continue; 833 | } 834 | 835 | let coerced: Instance | Instance[]; 836 | 837 | // The value is either a serialized JS plain object, or an array of them. 838 | // Build them into Sequelize instances then save the association if required. 839 | let coerceSave = async (values: object): Promise> => { 840 | const isNewRecord = !values[internalAssocPrimary]; 841 | let coerced = await coerceInstance(internalAssocModel, values, include.query, isNewRecord); 842 | 843 | // If we have any keys other than the association's primary key, 844 | // then save the instance. The logic being that if it's 845 | // just an ID then the user is only trying to update the association 846 | // and doesn't care if the association instance is updated. 847 | let keys = Object.keys(values); 848 | if (isNewRecord || (!include.associateOnly && keys.filter(key => key !== internalAssocPrimary).length > 0)) { 849 | return Promise.resolve( 850 | coerced.save({ transaction }) 851 | .catch(SequelizeValidationError, async (err: SequelizeValidationError) => { 852 | return Promise.reject(coerceValidationError(assocModel, err, key)); 853 | })) as any as Promise; 854 | } 855 | 856 | return coerced; 857 | }; 858 | 859 | if (_.isArray(value)) { 860 | coerced = await Promise.all(_.map(value, async (item: object) => coerceSave(item))); 861 | } else { 862 | coerced = await coerceSave(value); 863 | } 864 | 865 | // Now set the association 866 | // TODO: Only set associations if they've changed. 867 | await Promise.resolve(method(coerced, { transaction })); 868 | } 869 | 870 | return await Promise.resolve(internalInstance.reload({ 871 | include: this.compileIncludes(null, includes), 872 | transaction 873 | })) as any as Instance; 874 | } 875 | 876 | /** 877 | * Automatically update or create a model instance 878 | * depending on whether it has already been created. 879 | * 880 | * This is done by inspecting the primary key and seeing 881 | * if it's set. If it's set, a where query with 882 | * that ID is added and then update is called. 883 | * If no primary key was set, the model instance is created then returned. 884 | * 885 | * By default ModelSafe validations will be run. 886 | * 887 | * @param instance The model instance. 888 | * @param options The create or update options. The relevant will be used depending 889 | * on the value of the primary key. 890 | * @returns A promise that resolves with the saved instance if successful. 891 | */ 892 | async save(instance: T, options?: CreateOptions | UpdateOptions): Promise { 893 | options = { 894 | validate: true, 895 | 896 | ... options 897 | }; 898 | 899 | // If the value is provided looks like a T instance but isn't actually, 900 | // coerce it. 901 | if (_.isPlainObject(instance)) { 902 | instance = await this.model.deserialize(instance, { validate: false, depth: calcIncludeDepth(this) }) as T; 903 | } 904 | 905 | let primary = this.db.getInternalModelPrimary(this.model); 906 | let primaryValue = instance[primary.compileLeft()]; 907 | 908 | // If the primary key is set, update. 909 | // Otherwise create. 910 | if (primaryValue) { 911 | if (options.validate) { 912 | // We validate here. The update call only validates non-required validations, 913 | // whereas we want to do required validations too since we know that the instance 914 | // should be a full valid instance (whereas what is passed into update may be a partial, 915 | // hence why not every field should be required) 916 | await instance.validate(); 917 | } 918 | 919 | let [num, instances] = await this 920 | .where(_m => primary.eq(primaryValue)) 921 | .update(instance, options as UpdateOptions); 922 | 923 | // Handle this just in case. 924 | // Kind of unexpected behaviour, so we just return null. 925 | if (num < 1 || instances.length < 1) { 926 | return null; 927 | } 928 | 929 | return instances[0]; 930 | } else { 931 | // Create will perform validations for us as well 932 | return this.create(instance, options as CreateOptions); 933 | } 934 | } 935 | 936 | /** 937 | * Creates a model instance in the database. 938 | * 939 | * By default ModelSafe validations will be run and 940 | * associations will be updated if they have been included before hand. 941 | * 942 | * @rejects ValidationError 943 | * @param instance The model instance to create. 944 | * @param option Any extra Sequelize create options required. 945 | * @returns A promise that resolves with the created instance if successful. 946 | */ 947 | async create(instance: T, options?: CreateOptions): Promise { 948 | options = { 949 | associate: true, 950 | validate: true, 951 | 952 | ... options, 953 | }; 954 | 955 | // If the value is provided looks like a T instance but isn't actually, 956 | // coerce it. 957 | if (_.isPlainObject(instance)) { 958 | instance = await this.model.deserialize(instance, { validate: false }) as T; 959 | } 960 | 961 | // Validate the instance if required 962 | if (options.validate) { 963 | await instance.validate().catch(preventRequiredDefaultValues); 964 | } 965 | 966 | let model = this.model; 967 | let values = await this.prepare(instance, true); 968 | let data = await Promise.resolve( 969 | this.internalModel 970 | .create(values as T, options) 971 | .catch(SequelizeValidationError, async (err: SequelizeValidationError) => { 972 | return Promise.reject(coerceValidationError(model, err)); 973 | }) 974 | ); 975 | 976 | if (options.associate) { 977 | // Save associations of the Sequelize data and reload 978 | data = await this.associate('create', this.model, (data as any as Instance), values, this.options.includes, 979 | options.transaction) as any as T; 980 | } 981 | 982 | // Turn the Sequelize data into a ModelSafe class instance 983 | return await model.deserialize((data as any as Instance).toJSON(), { validate: false, depth: null }) as T; 984 | } 985 | 986 | /** 987 | * Creates multiple instances in the database. 988 | * Note, that like Sequelize, there's no guaranteed that 989 | * the returned values here are the exact values that have been created. 990 | * If you want those values, you should re-query the database 991 | * once the bulk create has succeeded. 992 | * 993 | * By default ModelSafe validations will be run. 994 | * This *will not* update any associations, unless those associations 995 | * are set by a foreign key (ie. belongs-to). 996 | * 997 | * @rejects ValidationError 998 | * @param instances The array of model instances to create. 999 | * @param options Any extra Sequelize bulk create options required. 1000 | * @returns The array of instances created. See above. 1001 | */ 1002 | async bulkCreate(instances: T[], options?: BulkCreateOptions): Promise { 1003 | options = { 1004 | validate: true, 1005 | 1006 | ... options 1007 | }; 1008 | 1009 | let model = this.model; 1010 | 1011 | // If any is provided looks like a T instance but isn't actually, 1012 | // coerce it. 1013 | instances = await Promise.all(instances.map(async (instance: T) => { 1014 | if (_.isPlainObject(instance)) { 1015 | return await model.deserialize(instance, { validate: false }) as T; 1016 | } 1017 | 1018 | return instance; 1019 | })); 1020 | 1021 | // Validate all instances if required 1022 | if (options.validate) { 1023 | for (let instance of instances) { 1024 | await instance.validate().catch(preventRequiredDefaultValues); 1025 | } 1026 | } 1027 | 1028 | let data = await Promise.resolve( 1029 | this.internalModel 1030 | .bulkCreate( 1031 | await Promise.all(_.map(instances, async (instance) => await this.prepare(instance))) as T[], 1032 | options 1033 | ) 1034 | .catch(SequelizeValidationError, async (err: SequelizeValidationError) => { 1035 | return Promise.reject(coerceValidationError(model, err)); 1036 | }) 1037 | ); 1038 | 1039 | // Deserialize all returned Sequelize models 1040 | return Promise.all(data.map(async (item: T): Promise => { 1041 | // FIXME: The any cast is required here to turn the plain T into a Sequelize instance T 1042 | return await model.deserialize((item as any as Instance).toJSON(), { validate: false }) as T; 1043 | })); 1044 | } 1045 | 1046 | /** 1047 | * Update one or more instances in the database with the partial 1048 | * property values provided. 1049 | * 1050 | * By default ModelSafe validations will be run (on the properties provided, only) and 1051 | * associations will be updated if they have been included before hand. 1052 | * 1053 | * @rejects ValidationError 1054 | * @param values The instance data to update with. 1055 | * @param options Any extra Sequelize update options required. 1056 | * @return A promise that resolves to a tuple with the number of instances updated and 1057 | * an array of the instances updated, if successful. 1058 | */ 1059 | async update(values: Partial, options?: UpdateOptions): Promise<[number, T[]]> { 1060 | options = { 1061 | associate: true, 1062 | validate: true, 1063 | 1064 | ... options 1065 | }; 1066 | 1067 | let model = this.model; 1068 | 1069 | // Validate the values partial if required 1070 | if (options.validate) { 1071 | // Ignore required validation since values is a partial 1072 | await (await model.deserialize(values, { 1073 | validate: false, 1074 | associations: false 1075 | })).validate({ required: false }); 1076 | } 1077 | 1078 | let [num, data] = await Promise.resolve( 1079 | this.internalModel 1080 | .update(values as T, { 1081 | ... options as SequelizeUpdateOptions, 1082 | 1083 | where: this.compileWheres(), 1084 | limit: this.options.taken > 0 ? this.options.taken : undefined, 1085 | }) 1086 | .catch(SequelizeValidationError, async (err: SequelizeValidationError) => { 1087 | return Promise.reject(coerceValidationError(model, err)); 1088 | }) 1089 | ); 1090 | 1091 | // FIXME: The update return value is only supported in Postgres, 1092 | // so we findAll here to emulate this on other databases. 1093 | // Could be detrimental to performance. 1094 | data = await Promise.resolve(this.internalModel.findAll({ 1095 | where: this.compileWheres(), 1096 | limit: this.options.taken > 0 ? this.options.taken : undefined, 1097 | transaction: options.transaction, 1098 | })); 1099 | 1100 | if (options.associate) { 1101 | // Save associations of each Sequelize data and reload all 1102 | data = await Promise.all(data.map(async (item: T) => { 1103 | return await this.associate('update', this.model, (item as any as Instance), values, this.options.includes, 1104 | options.transaction) as any as T; 1105 | })); 1106 | } 1107 | 1108 | // Deserialize all returned Sequelize models 1109 | return [ 1110 | num, 1111 | 1112 | await Promise.all(data.map(async (item: T): Promise => { 1113 | // FIXME: The any cast is required here to turn the plain T into a Sequelize instance T 1114 | return await model.deserialize((item as any as Instance).toJSON(), { validate: false, depth: null }) as T; 1115 | })) 1116 | ]; 1117 | } 1118 | 1119 | /** 1120 | * Update or insert a model using the provided model as a query. 1121 | * This will update an instance from the provided partial, or insert/create one 1122 | * if none exists matching the provided model instance. 1123 | * The model provided will be used as a partial, which will mean that all 1124 | * attributes are optional and non-defined ones will be excluded from 1125 | * the query/insertion. 1126 | * 1127 | * ModelSafe validations will run by default, 1128 | * but this *will not* update any associations. 1129 | * 1130 | * @param values The property values to upsert using. 1131 | * @param options Any extra Sequelize upsert options required. 1132 | * @returns A promise that will upsert and contain true if an instance was inserted. 1133 | */ 1134 | async upsert(values: Partial, options?: UpsertOptions): Promise { 1135 | options = { 1136 | validate: true, 1137 | 1138 | ... options 1139 | }; 1140 | 1141 | let model = this.model; 1142 | 1143 | // Validate the values partial if required 1144 | if (options.validate) { 1145 | // Ignore required validation since values is a partial 1146 | await (await model.deserialize(values, { 1147 | validate: false, 1148 | associations: false 1149 | })).validate({ required: false }); 1150 | } 1151 | 1152 | return Promise.resolve( 1153 | this.internalModel 1154 | .upsert(values as T, options) 1155 | .catch(SequelizeValidationError, async (err: SequelizeValidationError) => { 1156 | return Promise.reject(coerceValidationError(model, err)); 1157 | }) 1158 | ); 1159 | } 1160 | 1161 | /** 1162 | * Compile the find options for a find/findOne call, as expected by Sequelize. 1163 | * 1164 | * @returns The Sequelize representation. 1165 | */ 1166 | compileFindOptions(): FindOptions { 1167 | let options: FindOptions = { where: this.compileWheres() }; 1168 | 1169 | if (this.options.attrs) options.attributes = this.compileAttributes(); 1170 | if (this.options.includes) options.include = this.compileIncludes(); 1171 | if (this.options.orderings) options.order = this.compileOrderings(); 1172 | if (this.options.groupBys) options.group = this.compileGroupBys(); 1173 | if (this.options.skipped > 0) options.offset = this.options.skipped; 1174 | if (this.options.taken > 0) options.limit = this.options.taken; 1175 | 1176 | return options; 1177 | } 1178 | 1179 | /** 1180 | * Compile the where query to a representation expected by Sequelize. 1181 | * 1182 | * @returns The Sequelize representation. 1183 | */ 1184 | compileWheres(): WhereOptions { 1185 | return Object.assign({}, ...this.options.wheres.map(w => w.compile())); 1186 | } 1187 | 1188 | /** 1189 | * Compile the attributes to a representation expected by Sequelize. 1190 | * 1191 | * @returns The Sequelize representation. 1192 | */ 1193 | compileAttributes(): FindOptionsAttributesArray { 1194 | return this.options.attrs.map(w => 1195 | _.isArray(w) ? w.map(x => x instanceof AttributeQueryable ? x.compileLeft() : x.compileRight()) : w.compileLeft()); 1196 | } 1197 | 1198 | /** 1199 | * Compile the group to a representation expected by Sequelize. 1200 | * 1201 | * @returns The Sequelize representation. 1202 | */ 1203 | compileGroupBys(): any { 1204 | return this.options.groupBys.map(w => w.compileRight()); 1205 | } 1206 | 1207 | /** 1208 | * Compile the orderings to a representation expected by Sequelize. 1209 | * 1210 | * @returns The Sequelize representation. 1211 | */ 1212 | compileOrderings(): any { 1213 | // FIX ME Allow Sequelize string ordering to compile at the moment. This needs to be properly typed. 1214 | return this.options.orderings.map(ordering => { 1215 | return ordering.length > 2 && ordering.every(i => typeof i === 'string') ? 1216 | ordering : [ordering[0].compileLeft(), ordering[1].toString()]; 1217 | }); 1218 | } 1219 | 1220 | /** 1221 | * Compile the includes to a representation expected by Sequelize, including nested includes 1222 | * 1223 | * @params overrides Option overrides to enforce certain option fields to be a consistent value 1224 | * @returns The Sequelize representation. 1225 | */ 1226 | compileIncludes(overrides?: { required?: boolean }, includes?: IncludeOptions[]): SequelizeIncludeOptions[] { 1227 | return (includes || this.options.includes || []).map(include => { 1228 | const findOpts = include.query ? include.query.compileFindOptions() : null; 1229 | return { 1230 | model: this.db.getInternalModel(include.model), 1231 | as: include.as, 1232 | required: include.required, 1233 | ... _.pick(findOpts, ['where', 'attributes', 'include']), 1234 | ... overrides, 1235 | } as SequelizeIncludeOptions; 1236 | }); 1237 | } 1238 | } 1239 | --------------------------------------------------------------------------------