├── .babelrc ├── codecov.yml ├── src ├── index.js ├── enums │ └── relation-type.js ├── config.js ├── utils.js ├── plugin-base.js ├── errors.js ├── plugins.js ├── query-builder.js ├── relation.js └── model-base.js ├── .editorconfig ├── .gitattributes ├── example ├── model.js ├── models │ ├── employee.js │ └── company.js └── index.js ├── .eslintrc.json ├── knexfile.js ├── jsconfig.json ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── db ├── seeds │ ├── companies.js │ └── employees.js └── migrations │ └── v1.js ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── .travis.yml ├── package.json ├── tests └── index.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": [ 4 | "transform-async-to-generator", 5 | "transform-class-properties", 6 | "transform-runtime" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | token: 991a42ba-3685-4fe0-b1cb-861e07689870 3 | branch: master 4 | coverage: 5 | notify: 6 | gitter: 7 | default: 8 | url: https://webhooks.gitter.im/e/691a25c5ed68cc18043d 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ModelBase from './model-base'; 2 | import PluginBase from './plugin-base'; 3 | import * as Plugins from './plugins'; 4 | import * as Errors from './errors'; 5 | 6 | export { ModelBase, PluginBase, Plugins, Errors }; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Top-most EditorConfig file 2 | root = true 3 | 4 | # Defaults 5 | [*] 6 | charset = utf-8 7 | insert_final_newline = true 8 | 9 | # Set the default indentation for file types 10 | [*.{js,json,yml}] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Automatically detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Git-related files 5 | .gitattributes text 6 | .gitignore text 7 | 8 | # Documentation 9 | *.md text 10 | LICENSE text 11 | 12 | # Source code 13 | *.js text 14 | *.json text 15 | -------------------------------------------------------------------------------- /src/enums/relation-type.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a database relation type. 3 | * @enum {number} 4 | * @private 5 | */ 6 | const RelationType = { 7 | ONE_TO_MANY: 1, 8 | ONE_TO_ONE: 2, 9 | MANY_TO_ONE: 3, 10 | MANY_TO_MANY: 4, 11 | }; 12 | 13 | export default RelationType; 14 | -------------------------------------------------------------------------------- /example/model.js: -------------------------------------------------------------------------------- 1 | import knex from 'knex'; 2 | import { ModelBase, Plugins } from './../src'; 3 | import knexConfig from './../knexfile'; 4 | 5 | export default class Model extends ModelBase { 6 | static knex = knex(knexConfig.development); 7 | static plugins = [new Plugins.CaseConverterPlugin()]; 8 | } 9 | -------------------------------------------------------------------------------- /example/models/employee.js: -------------------------------------------------------------------------------- 1 | import Model from './../model'; 2 | 3 | class Employee extends Model { 4 | static get tableName() { return 'employees'; } 5 | 6 | static get related() { 7 | return { 8 | company: this.belongsTo('Company'), 9 | }; 10 | } 11 | } 12 | 13 | export default Employee.register(); 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "max-len": [2, 80], 6 | "valid-jsdoc": [2, { 7 | "prefer": { 8 | "return": "returns" 9 | }, 10 | "requireReturn": false, 11 | "requireReturnDescription": false 12 | }] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | development: { 3 | client: 'sqlite3', 4 | useNullAsDefault: false, 5 | connection: { 6 | filename: './dev.sqlite3', 7 | }, 8 | migrations: { 9 | directory: './db/migrations', 10 | }, 11 | seeds: { 12 | directory: './db/seeds', 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=759670 3 | // for the documentation about the jsconfig.json format 4 | "compilerOptions": { 5 | "target": "es6", 6 | "module": "commonjs", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "lib" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import knexDefaultMethods from 'knex/lib/query/methods'; 2 | 3 | const KNEX_IGNORED_QUERY_METHODS = [ 4 | 'from', 5 | 'fromJS', 6 | 'into', 7 | 'table', 8 | 'queryBuilder', 9 | ]; 10 | 11 | const Config = { 12 | KNEX_ALLOWED_QUERY_METHODS: knexDefaultMethods.filter((item) => 13 | KNEX_IGNORED_QUERY_METHODS.indexOf(item) < 0 14 | ), 15 | }; 16 | 17 | export default Config; 18 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function flattenArray(value) { 2 | return [].concat.apply([], value); 3 | } 4 | 5 | export function modelize(obj, Model) { 6 | // Support recursive array transformation 7 | if (Array.isArray(obj)) { 8 | return obj.map((item) => modelize(item, Model)); 9 | } 10 | 11 | // Don't modelize non-objects 12 | return obj instanceof Object ? new Model(obj, false) : obj; 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch example", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "program": "${workspaceRoot}/example/index.js", 10 | "runtimeArgs": [ 11 | "${workspaceRoot}/node_modules/babel-cli/bin/babel-node" 12 | ], 13 | "env": { 14 | "NODE_ENV": "development" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use ESLint instead of the integrated JS validator of Visual Studio Code 3 | "eslint.enable": true, 4 | "javascript.validate.enable": false, 5 | 6 | // Configure glob patterns for excluding files and folders in searches 7 | "search.exclude": { 8 | "**/coverage": true, 9 | "**/docs": true, 10 | "**/lib": true, 11 | "**/node_modules": true 12 | }, 13 | 14 | // Define special file associations for syntax highlighting 15 | "files.associations": { 16 | ".babelrc": "json" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /db/seeds/companies.js: -------------------------------------------------------------------------------- 1 | exports.seed = (knex, Promise) => { 2 | const tableName = 'companies'; 3 | 4 | return Promise.join( 5 | // Delete every existing entry 6 | knex(tableName).del(), 7 | 8 | knex(tableName).insert({ 9 | rank: 1, 10 | name: 'Lectus Quis Massa LLP', 11 | email: 'vulputate@enimnon.edu', 12 | }), 13 | 14 | knex(tableName).insert({ 15 | rank: 2, 16 | name: 'Ultrices Consulting', 17 | email: 'duis@ipsum.com', 18 | }), 19 | 20 | knex(tableName).insert({ 21 | rank: 3, 22 | name: 'Eu Turpis Nulla Corporation', 23 | }) 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "0.1.0", 5 | "command": "npm", 6 | "isShellCommand": true, 7 | "showOutput": "always", 8 | "suppressTaskName": true, 9 | "tasks": [ 10 | { 11 | "taskName": "build", 12 | "args": ["run", "build"], 13 | "isBuildCommand": true 14 | }, 15 | { 16 | "taskName": "watch", 17 | "args": ["run", "watch"], 18 | "isWatching": true 19 | }, 20 | { 21 | "taskName": "test", 22 | "args": ["test"], 23 | "isTestCommand": true 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project-specific 2 | lib 3 | docs 4 | *.sqlite* 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directory 32 | node_modules 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | -------------------------------------------------------------------------------- /example/models/company.js: -------------------------------------------------------------------------------- 1 | import Model from './../model'; 2 | 3 | class Company extends Model { 4 | // The 'tableName' property is omitted on purpose 5 | static get primaryKey() { return 'rank'; } 6 | static get whitelistedProps() { return ['rank', 'name', 'email']; } 7 | 8 | static get jsonSchema() { 9 | return { 10 | type: 'object', 11 | properties: { 12 | rank: { type: 'integer' }, 13 | name: { type: 'string' }, 14 | email: { 15 | type: 'string', 16 | format: 'email', 17 | }, 18 | }, 19 | required: ['name'], 20 | }; 21 | } 22 | 23 | static get related() { 24 | return { 25 | employees: this.hasMany('Employee'), 26 | }; 27 | } 28 | } 29 | 30 | export default Company.register(); 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.2.0 - 2016-04-24 4 | - Added support for Model validation 5 | 6 | ## 1.1.0 - 2016-04-21 7 | - Added support for whitelisting/blacklisting Model properties 8 | - Fixed automatic camelization of query results: No unnecessary (and invalid) 9 | conversions should happen from now 10 | - Fixed query results of single Model instances, making them non-arrays 11 | - Fixed the database initializer script for Node <4 12 | - Improved test coverage 13 | - Deprecated `Model.idAttribute` in favor of `Model.primaryKey` 14 | 15 | ## 1.0.2 - 2016-04-15 16 | - Added a test for checking compatibility with vanilla Node 17 | - Fixed compatibility with vanilla Node 18 | 19 | ## 1.0.1 - 2016-04-14 20 | - Improved documentation 21 | 22 | ## 1.0.0 - 2016-04-13 23 | - Initial stable release 24 | -------------------------------------------------------------------------------- /db/migrations/v1.js: -------------------------------------------------------------------------------- 1 | exports.up = (knex, Promise) => 2 | Promise.all([ 3 | knex.schema.createTable('employees', (table) => { 4 | table.increments().primary(); 5 | table.timestamps(); 6 | 7 | table.integer('company_id').unsigned().references('companies.rank'); 8 | 9 | table.string('name').notNullable(); 10 | table.date('birth_date').notNullable(); 11 | table.integer('zip_code').unsigned(); 12 | }), 13 | 14 | knex.schema.createTable('companies', (table) => { 15 | table.increments('rank').primary(); 16 | 17 | table.string('name').notNullable(); 18 | table.string('email').unique(); 19 | }), 20 | ]); 21 | 22 | exports.down = (knex, Promise) => 23 | Promise.all([ 24 | knex.schema.dropTable('employees'), 25 | knex.schema.dropTable('companies'), 26 | ]); 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kristóf Poduszló 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | - '5' 5 | - '4' 6 | - '0.12' 7 | - '0.10' 8 | before_script: 9 | - npm run init-db 10 | after_success: 11 | - bash <(curl -s https://codecov.io/bash) 12 | cache: 13 | directories: 14 | - node_modules 15 | deploy: 16 | provider: npm 17 | email: kripod@protonmail.com 18 | api_key: 19 | secure: ycFkwt5WgNcDWTD6Mbr332L/xUtNZaBOe2xjBbcapPt8GbHikH++3T2b8eoeLdIh0pTB95ZtXLjcA8ln4aqpAWqdvMdRtEg44e+snwQDnUTvm76UmLTi/6yFl156CKJ5mJ/fBCvgHjjdHhyFDuUvMmKfVKVGVHt5IQBWNmxvYFVdFh3+IXRzIWTz3WCvt7H/AJP3eFUVlz8Qoo8sWYJj4nhNLz93C8LnViB10ZkQormF0zJVtNIIk/pPuZNxgK9OG1/di7ZGLXDs1epReY6+k/0i8hzJ456cVDdj8g1fUzDnk9wxl6me2n87ps6TbqIwsQ9+x+gGz/80LDCRNrDwrS6JYlJp9CNj550odWFafwCyidL9Tkolsl2LAyukanXH8oBgY0D9hSUZ09ZawH/hViEWSiE8K+W0c9hiiIUVMrfO+y5/u2Em4PZWtaBz2N7gF0r7whU9hunW+c8Avx7cQmsJO+lEhjmcirWvMMKq03KW4gegwzUt7y/72BdvBYxXaAxv+zV2+k2hM237dOKYnz5DTo/pfzcMhad/l5Owm3m1JadNGU1OixueWrp1f5r7IoT9oNzo2qKcJ/G1HgCX8dyzbaMsIUURNnJmLo2yAJzAK1MAvzjDkWE0vkgA60hhxYXVgXTJ8/u93bh/qkmvg8ttwHm4XSwlXPJe9YPMRsQ= 20 | on: 21 | tags: true 22 | notifications: 23 | webhooks: 24 | urls: 25 | - https://webhooks.gitter.im/e/ce58f2688d67dbcd287f 26 | -------------------------------------------------------------------------------- /src/plugin-base.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A base class for Plugins. 3 | */ 4 | export default class PluginBase { 5 | /** 6 | * Creates a new plugin instance with the given options. 7 | * @param {Object} [options] Options to be used for the plugin. 8 | * @param {boolean} [options.beforeQuery=true] Set to false to disable the 9 | * execution of the 'beforeQuery' function. 10 | * @param {boolean} [options.afterQuery=true] Set to false to disable the 11 | * execution of the 'afterQuery' function. 12 | */ 13 | constructor(options = {}) { 14 | const defaultOptions = { 15 | beforeQuery: true, 16 | afterQuery: true, 17 | }; 18 | 19 | this.options = Object.assign(defaultOptions, options); 20 | } 21 | 22 | /** 23 | * Initializes the plugin. 24 | * @param {ModelBase} BaseModel Base Model class of the plugin's corresponding 25 | * ORM instance. 26 | * @returns {PluginBase} The current plugin after initialization. 27 | */ 28 | /* eslint-disable no-unused-vars */ 29 | init(BaseModel) { return this; } 30 | 31 | /** 32 | * A function which triggers before query execution. 33 | * @param {QueryBuilder} qb QueryBuilder which corresponds to the query. 34 | * @returns {QueryBuilder} The modified QueryBuilder instance. 35 | */ 36 | beforeQuery(qb) { return qb; } 37 | 38 | /** 39 | * A function which triggers after query execution, but before returning a 40 | * response object. 41 | * @param {Object} res Response object which can be modified. 42 | * @returns {Object} The modified response object. 43 | */ 44 | afterQuery(res) { return res; } 45 | } 46 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import Company from './models/company'; 2 | import Employee from './models/employee'; 3 | 4 | /* 5 | Company.where({ rank: 3 }).orderBy('name').then((res) => { 6 | console.log(res); 7 | }); 8 | */ 9 | 10 | async function test() { 11 | console.log(Company.query() 12 | .withRelated('employees') 13 | .orderBy('name') 14 | .where('companyId', 1) 15 | .first().toString()); 16 | console.log(Employee.query() 17 | .where({ id: 3 }) 18 | .withRelated('company') 19 | .toString() 20 | ); 21 | 22 | const companies = await Company.query() 23 | .withRelated('employees') 24 | .orderBy('name') 25 | .first(); 26 | console.log('Companies:'); 27 | console.log(companies); 28 | 29 | const employee = await Employee.query() 30 | .where({ id: 3 }) 31 | .withRelated('company'); 32 | console.log('Employee:'); 33 | console.log(employee); 34 | } 35 | 36 | test(); 37 | 38 | // console.log(Company.where({ id: 3 }).toString()); 39 | 40 | /* Company.where({ rank: 3 }).then((company) => { 41 | console.log(company); 42 | }); */ 43 | 44 | /* 45 | console.log(Employee.first().withRelated('company').toString()); 46 | console.log(Company.first().withRelated('employee').toString()); 47 | console.log(); 48 | 49 | Employee.withRelated('company').then((employee) => { 50 | console.log('Employee:'); 51 | console.log(employee); 52 | console.log(); 53 | }); 54 | 55 | Company.first().withRelated('employee').then((company) => { 56 | console.log('Company:'); 57 | console.log(company); 58 | console.log(); 59 | }); 60 | 61 | // The 2 lines below equal to Employee.withRelated('company') 62 | Database.knex.from('employees') 63 | .join('companies', 'employees.company_id', 'companies.rank') 64 | .select('companies.*') 65 | .then((employee) => { 66 | console.log('Knex employee:'); 67 | console.log(employee); 68 | }); 69 | */ 70 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | import ErrorBase from 'es6-error'; 2 | 3 | /** 4 | * An error which gets thrown when an attempt is made to register a Model 5 | * multiple times. 6 | */ 7 | export class DuplicateModelError extends ErrorBase { 8 | /** 9 | * Name of the Model in question. 10 | * @type {string} 11 | * @memberof DuplicateModelError 12 | * @instance 13 | */ 14 | name; 15 | 16 | constructor(name) { 17 | super(`Model with name '${name}' cannot be registered multiple times`); 18 | this.name = name; 19 | } 20 | } 21 | 22 | /** 23 | * An error which gets thrown when an attempt is made to store an empty Model. 24 | */ 25 | export class EmptyModelError extends ErrorBase { 26 | constructor() { 27 | super('Empty Model cannot be stored'); 28 | } 29 | } 30 | 31 | /** 32 | * An error which gets thrown when a Relation does not behave as expected. 33 | */ 34 | export class RelationError extends ErrorBase { 35 | constructor() { 36 | super('One-to-one and many-to-one Relations cannot be re-assigned'); 37 | } 38 | } 39 | 40 | /** 41 | * An error which gets thrown when an attempt is made to modify a Model instance 42 | * without specifying its primary key. 43 | */ 44 | export class UnidentifiedModelError extends ErrorBase { 45 | constructor() { 46 | super('Model cannot be identified without specifying a primary key value'); 47 | } 48 | } 49 | 50 | /** 51 | * An error which gets thrown when a Model cannot be successfully validated 52 | * against its JSON Schema. 53 | */ 54 | export class ValidationError extends ErrorBase { 55 | /** 56 | * Detailed information about why the validation has failed. 57 | * @type {?Object} 58 | * @memberof ValidationError 59 | * @instance 60 | */ 61 | data; 62 | 63 | constructor(data) { 64 | super('Model could not be successfully validated against its JSON Schema'); 65 | this.data = data; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "knex-orm", 3 | "version": "1.2.0", 4 | "description": "Knex-based object-relational mapping for JavaScript.", 5 | "author": "Kristóf Poduszló ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "orm", 9 | "knex" 10 | ], 11 | "main": "lib/index.js", 12 | "files": [ 13 | "lib/" 14 | ], 15 | "directories": { 16 | "lib": "./lib" 17 | }, 18 | "scripts": { 19 | "start": "babel-node ./example", 20 | "build": "babel ./src -d ./lib -s", 21 | "watch": "npm run build -- -w", 22 | "prepublish": "npm run build", 23 | "init-db": "npm run babel-knex -- migrate:latest && npm run babel-knex -- seed:run", 24 | "test": "babel-node ./node_modules/babel-istanbul/lib/cli cover ./tests && eslint ./src", 25 | "doc": "documentation build ./src/index.js -o ./docs -f html", 26 | "babel-knex": "babel-node ./node_modules/knex/lib/bin/cli" 27 | }, 28 | "dependencies": { 29 | "ajv": "^4.0.5", 30 | "babel-runtime": "^6.6.1", 31 | "es6-error": "^3.0.0", 32 | "inflection": "^1.10.0" 33 | }, 34 | "peerDependencies": { 35 | "knex": ">=0.6.10 <0.12.0" 36 | }, 37 | "devDependencies": { 38 | "babel-cli": "^6.8.0", 39 | "babel-eslint": "^6.0.4", 40 | "babel-istanbul": "^0.8.0", 41 | "babel-plugin-transform-async-to-generator": "^6.8.0", 42 | "babel-plugin-transform-class-properties": "^6.8.0", 43 | "babel-plugin-transform-runtime": "^6.8.0", 44 | "babel-preset-es2015": "^6.6.0", 45 | "documentation": "^4.0.0-beta2", 46 | "eslint": "^2.10.2", 47 | "eslint-config-airbnb-base": "^3.0.1", 48 | "eslint-plugin-import": "^1.8.0", 49 | "istanbul": "^0.4.3", 50 | "knex": "^0.11.0", 51 | "sqlite3": "^3.1.4", 52 | "tape": "^4.5.1" 53 | }, 54 | "repository": { 55 | "type": "git", 56 | "url": "https://github.com/kripod/knex-orm.git" 57 | }, 58 | "bugs": { 59 | "url": "https://github.com/kripod/knex-orm/issues" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /db/seeds/employees.js: -------------------------------------------------------------------------------- 1 | exports.seed = (knex, Promise) => { 2 | const tableName = 'employees'; 3 | 4 | return Promise.join( 5 | // Delete every existing entry 6 | knex(tableName).del(), 7 | 8 | knex(tableName).insert({ 9 | id: 1, 10 | company_id: 3, 11 | name: 'Eaton Sanchez', 12 | birth_date: new Date(1963, 10, 28), 13 | zip_code: 11596, 14 | }), 15 | 16 | knex(tableName).insert({ 17 | id: 2, 18 | company_id: 1, 19 | name: 'Jana Conley', 20 | birth_date: new Date(1984, 4, 1), 21 | }), 22 | 23 | knex(tableName).insert({ 24 | id: 3, 25 | company_id: 1, 26 | name: 'Pandora McCarty', 27 | birth_date: new Date(1977, 3, 7), 28 | zip_code: 4634, 29 | }), 30 | 31 | knex(tableName).insert({ 32 | id: 4, 33 | company_id: 2, 34 | name: 'Keiko Russell', 35 | birth_date: new Date(1979, 1, 3), 36 | zip_code: 14712, 37 | }), 38 | 39 | knex(tableName).insert({ 40 | id: 5, 41 | company_id: 1, 42 | name: 'Alexa Buckner', 43 | birth_date: new Date(1982, 7, 16), 44 | zip_code: 391311, 45 | }), 46 | 47 | knex(tableName).insert({ 48 | id: 6, 49 | company_id: 3, 50 | name: 'Xaviera Park', 51 | birth_date: new Date(1964, 3, 7), 52 | }), 53 | 54 | knex(tableName).insert({ 55 | id: 7, 56 | company_id: 2, 57 | name: 'Victor Frank', 58 | birth_date: new Date(1984, 7, 31), 59 | zip_code: 10226, 60 | }), 61 | 62 | knex(tableName).insert({ 63 | id: 8, 64 | company_id: 1, 65 | name: 'Emerald Chang', 66 | birth_date: new Date(1960, 7, 6), 67 | zip_code: 6262, 68 | }), 69 | 70 | knex(tableName).insert({ 71 | id: 9, 72 | company_id: 1, 73 | name: 'Victoria Ayers', 74 | birth_date: new Date(1988, 3, 22), 75 | zip_code: 59897, 76 | }), 77 | 78 | knex(tableName).insert({ 79 | id: 10, 80 | company_id: 2, 81 | name: 'James Conner', 82 | birth_date: new Date(1972, 6, 6), 83 | zip_code: 9059, 84 | }) 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/plugins.js: -------------------------------------------------------------------------------- 1 | import { camelize, underscore } from 'inflection'; 2 | import ModelBase from './model-base'; 3 | import PluginBase from './plugin-base'; 4 | 5 | export class CaseConverterPlugin extends PluginBase { 6 | init(BaseModel) { 7 | const formatterPrototype = BaseModel.knex.client.Formatter.prototype; 8 | 9 | // Override a Knex query formatter function by extending it 10 | /* eslint-disable no-underscore-dangle */ 11 | const originalFunction = formatterPrototype._wrapString; 12 | formatterPrototype._wrapString = function _wrapString(value) { 13 | return underscore(originalFunction.call(this, value)); 14 | }; 15 | /* eslint-enable */ 16 | 17 | return this; 18 | } 19 | 20 | afterQuery(res) { 21 | if (!this.options.afterQuery) return res; 22 | 23 | return this.transformKeys(res, (key) => camelize(key, true)); 24 | } 25 | 26 | /** 27 | * Transforms the keys of the given object. 28 | * @param {Object} obj Object to be transformed. 29 | * @param {Function} transformer Transformation function to be used. 30 | * @returns {Object} The transformed object. 31 | * @private 32 | */ 33 | transformKeys(obj, transformer) { 34 | // Don't transform the keys of non-objects 35 | if (!(obj instanceof ModelBase)) return obj; 36 | 37 | // Support recursive array transformation 38 | if (Array.isArray(obj)) { 39 | return obj.map((item) => this.transformKeys(item, transformer)); 40 | } 41 | 42 | const result = {}; 43 | for (const [key, value] of Object.entries(obj)) { 44 | result[transformer(key)] = value; 45 | } 46 | 47 | // Assign the appropriate prototype to the result 48 | return Object.create(Object.getPrototypeOf(obj), result); 49 | } 50 | } 51 | 52 | export class ValidationPlugin extends PluginBase { 53 | beforeQuery(qb) { 54 | if (!this.options.beforeQuery) return qb; 55 | 56 | const model = qb.modelInstance; 57 | if (model) { 58 | model.validate(); 59 | } 60 | 61 | return qb; 62 | } 63 | 64 | afterQuery(res) { 65 | if (!this.options.afterQuery) return res; 66 | 67 | if (res instanceof ModelBase) { 68 | res.validate(); 69 | } 70 | 71 | return res; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import Company from './../example/models/company'; 3 | import Employee from './../example/models/employee'; 4 | import Model from './../example/model'; 5 | import { 6 | DuplicateModelError, 7 | EmptyModelError, 8 | UnidentifiedModelError, 9 | ValidationError, 10 | } from './../src/errors'; 11 | 12 | const NEW_EMPLOYEE_PROPS = { 13 | companyId: 2, 14 | name: 'Olympia Pearson', 15 | birthDate: new Date('1982-08-20 00:00'), 16 | }; 17 | 18 | const newEmployee = new Employee(NEW_EMPLOYEE_PROPS); 19 | const oldEmployee = new Employee({ id: 5, name: 'Alexa Buckner' }, false); 20 | 21 | // console.log(Company.where({ id: 3 }).orderBy('id').withRelated().toString()); 22 | 23 | test('orm instance methods', (t) => { 24 | t.throws(() => Company.register(), DuplicateModelError); 25 | 26 | t.end(); 27 | }); 28 | 29 | test('static model property defaults', (t) => { 30 | t.equal(Company.tableName, 'companies'); 31 | 32 | t.end(); 33 | }); 34 | 35 | test('static model methods', (t) => { 36 | t.equal(Company.query().where({ id: 3 }).toString(), 37 | 'select * from "companies" where "id" = 3' 38 | ); 39 | 40 | t.end(); 41 | }); 42 | 43 | test('creating new models', (t) => { 44 | // Ensure that no private Model property gets exposed 45 | for (const key of Object.keys(newEmployee)) { 46 | t.equal(newEmployee[key], NEW_EMPLOYEE_PROPS[key]); 47 | } 48 | 49 | t.equals(newEmployee.save().toString(), 50 | 'insert into "employees" ("birth_date", "company_id", "name") ' + 51 | 'values (\'1982-08-20 00:00:00.000\', 2, \'Olympia Pearson\')' 52 | ); 53 | 54 | t.end(); 55 | }); 56 | 57 | test('modifying existing models', (t) => { 58 | newEmployee.birthDate = new Date('1982-08-20 00:00'); 59 | newEmployee.zipCode = 5998; 60 | 61 | t.equals(newEmployee.save().toString(), 62 | 'insert into "employees" ("birth_date", "zip_code") ' + 63 | 'values (\'1982-08-20 00:00:00.000\', 5998)' 64 | ); 65 | 66 | // Test modifying an existing employee 67 | oldEmployee.zipCode = 4674; 68 | t.equals(oldEmployee.save().toString(), 69 | 'update "employees" set "zip_code" = 4674 where "id" = 5' 70 | ); 71 | 72 | // Cover the avoidance of unnecessary queries 73 | t.equals(oldEmployee.save().toString(), 74 | 'select * from "employees" where "id" = 5 limit 1' 75 | ); 76 | t.throws(() => newEmployee.save(), EmptyModelError); 77 | t.end(); 78 | }); 79 | 80 | test('deleting existing models', (t) => { 81 | t.throws(() => newEmployee.del(), UnidentifiedModelError); 82 | 83 | t.equals(oldEmployee.del().toString(), 84 | 'delete from "employees" where "id" = 5' 85 | ); 86 | 87 | t.end(); 88 | }); 89 | 90 | test('validating models', (t) => { 91 | const invalidCompany = new Company(); 92 | t.throws(() => invalidCompany.validate(), ValidationError); 93 | 94 | invalidCompany.save() 95 | .then(() => t.fail()) 96 | .catch(() => t.pass()) 97 | .then(t.end); 98 | }); 99 | 100 | test('relations', (t) => { 101 | const qb = oldEmployee.fetchRelated('company'); 102 | t.equals(qb.toString('\t').split('\t')[1], 103 | 'select * from "companies" where "rank" in (\'originInstance.company_id\')' 104 | ); 105 | 106 | qb.then((employee) => { 107 | t.ok(employee.company instanceof Company); 108 | }).then(t.end); 109 | }); 110 | 111 | test('destroying knex instance', (t) => { 112 | // Destroy the Knex instance being used to exit from the test suite 113 | Model.knex.destroy(); 114 | t.end(); 115 | }); 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [knex-orm](http://kripod.github.io/knex-orm) 2 | 3 | Knex-based object-relational mapping for JavaScript. 4 | 5 | [![Version (npm)](https://img.shields.io/npm/v/knex-orm.svg)](https://npmjs.com/package/knex-orm) 6 | [![Build Status](https://img.shields.io/travis/kripod/knex-orm/master.svg)](https://travis-ci.org/kripod/knex-orm) 7 | [![Code Coverage](https://img.shields.io/codecov/c/github/kripod/knex-orm/master.svg)](https://codecov.io/gh/kripod/knex-orm) 8 | [![Gitter](https://img.shields.io/gitter/room/kripod/knex-orm.svg)](https://gitter.im/kripod/knex-orm) 9 | 10 | ## Introduction 11 | 12 | The motivation behind this project is to combine the simplicity of [Bookshelf][] 13 | with the power of [Knex][] and modern ECMAScript features. 14 | 15 | Knex-ORM aims to provide a wrapper for every significant method of [Knex][], 16 | while keeping the ORM code overhead as low as possible. 17 | 18 | [bookshelf]: http://bookshelfjs.org 19 | 20 | [knex]: http://knexjs.org 21 | 22 | ## Getting started 23 | 24 | Installing [Knex][] and at least one of its supported database drivers as peer 25 | dependencies is mandatory. 26 | 27 | ```bash 28 | $ npm install knex --save 29 | $ npm install knex-orm --save 30 | 31 | # Then add at least one of the following: 32 | $ npm install pg --save 33 | $ npm install mysql --save 34 | $ npm install mariasql --save 35 | $ npm install sqlite3 --save 36 | ``` 37 | 38 | An instance of the Knex-ORM library can be created by passing a [Knex][] client 39 | instance to the entry class. 40 | 41 | ```js 42 | const knex = require('knex'); 43 | const KnexOrm = require('knex-orm'); 44 | 45 | const Database = new KnexOrm( 46 | knex({ 47 | client: 'sqlite3', 48 | connection: { 49 | filename: './dev.sqlite3', 50 | }, 51 | }) 52 | ); 53 | 54 | class Employee extends Database.Model { 55 | static get tableName() { return 'employees'; } // Redundant 56 | 57 | // Specify related Models which can optionally be fetched 58 | static get related() { 59 | return { 60 | company: this.belongsTo('Company'), // No Model cross-referencing 61 | }; 62 | } 63 | } 64 | 65 | class Company extends Database.Model { 66 | // The 'tableName' property is omitted on purpose, as it gets assigned 67 | // automatically based on the Model's class name. 68 | 69 | static get primaryKey() { return 'rank'; } 70 | 71 | static get related() { 72 | return { 73 | employees: this.hasMany('Employee'), 74 | }; 75 | } 76 | } 77 | 78 | // Register Models to make them relatable without cross-referencing each other 79 | Database.register(Employee); 80 | Database.register(Company); 81 | ``` 82 | 83 | ## Examples 84 | 85 | Creating and storing a new Model: 86 | 87 | ```js 88 | const famousCompany = new Company({ 89 | name: 'A Really Famous Company', 90 | email: 'info@famouscompany.example' 91 | }); 92 | 93 | famousCompany.save() 94 | .then((ids) => { 95 | // An ordinary response of a Knex 'insert' query 96 | // (See http://knexjs.org/#Builder-insert) 97 | console.log(ids); 98 | }); 99 | ``` 100 | 101 | Modifying an existing Model gathered by a query: 102 | 103 | ```js 104 | Company.query().where({ email: 'info@famouscompany.example' }).first() 105 | .then((company) => { 106 | // Response of a Knex 'where' query, with results parsed as Models 107 | // (See http://knexjs.org/#Builder-where) 108 | console.log(company); // Should be equal with 'famousCompany' (see above) 109 | 110 | company.name = 'The Most Famous Company Ever'; 111 | return company.save(); 112 | }) 113 | .then((rowsCount) => { 114 | // An ordinary response of a Knex 'update' query 115 | // (See http://knexjs.org/#Builder-update) 116 | console.log(rowsCount); // Should be 1 117 | }); 118 | ``` 119 | -------------------------------------------------------------------------------- /src/query-builder.js: -------------------------------------------------------------------------------- 1 | import Config from './config'; 2 | import { flattenArray, modelize } from './utils'; 3 | 4 | /** 5 | * Represents a query builder which corresponds to a static Model reference. 6 | * Inherits every query method of the Knex query builder. 7 | */ 8 | export default class QueryBuilder { 9 | constructor(StaticModel, modelInstance) { 10 | this.StaticModel = StaticModel; 11 | this.modelInstance = modelInstance; 12 | 13 | this.includedRelations = new Set(); 14 | this.knexInstance = StaticModel.knex.from(StaticModel.tableName); 15 | if (modelInstance) { 16 | const props = {}; 17 | if (Array.isArray(StaticModel.primaryKey)) { 18 | // Handle composite primary keys 19 | for (const prop of StaticModel.primaryKey) { 20 | props[prop] = modelInstance.oldProps[prop] || modelInstance[prop]; 21 | } 22 | } else { 23 | // Handle single primary key 24 | const prop = StaticModel.primaryKey; 25 | props[prop] = modelInstance.oldProps[prop] || modelInstance[prop]; 26 | } 27 | 28 | // Filter to the given model instance 29 | this.knexInstance = this.knexInstance.where(props).first(); 30 | } 31 | } 32 | 33 | /** 34 | * Queues fetching the given related Models of the queryable instance(s). 35 | * @param {...string} props Relation attributes to be fetched. 36 | * @returns {QueryBuilder} 37 | */ 38 | withRelated(...props) { 39 | const relationNames = flattenArray(props); 40 | const relationEntries = Object.entries(this.StaticModel.related); 41 | 42 | // Filter the given relations by name if necessary 43 | if (relationNames.length > 0) { 44 | relationEntries.filter(([name]) => relationNames.indexOf(name) >= 0); 45 | } 46 | 47 | // Store the filtered relations 48 | for (const [name, relation] of relationEntries) { 49 | relation.name = name; 50 | this.includedRelations.add(relation); 51 | } 52 | 53 | return this; 54 | } 55 | 56 | /** 57 | * Executes the query. 58 | * @param {Function} [onFulfilled] Success handler function. 59 | * @param {Function} [onRejected] Error handler function. 60 | * @returns {Promise} 61 | */ 62 | then(onFulfilled = () => {}, onRejected = () => {}) { 63 | // Apply the effect of plugins 64 | let qb = this; 65 | for (const plugin of this.StaticModel.plugins) { 66 | qb = plugin.beforeQuery(qb); 67 | } 68 | 69 | let result; 70 | return qb.knexInstance 71 | .then((res) => { 72 | const awaitableQueries = []; 73 | result = res; 74 | 75 | // Convert the result to a specific Model type if necessary 76 | result = modelize(result, qb.StaticModel); 77 | 78 | // Apply each desired relation to the original result 79 | for (const relation of qb.includedRelations) { 80 | awaitableQueries.push(relation.applyAsync(result)); 81 | } 82 | 83 | return Promise.all(awaitableQueries); 84 | }) 85 | .then(() => { 86 | // Apply the effect of plugins 87 | for (const plugin of qb.StaticModel.plugins) { 88 | result = plugin.afterQuery(result); 89 | } 90 | 91 | return result; 92 | }) 93 | .then(onFulfilled, onRejected); 94 | } 95 | 96 | /** 97 | * Gets the list of raw queries to be executed, joined by a string separator. 98 | * @param {string} [separator=\n] Separator string to be used for joining 99 | * multiple raw query strings. 100 | * @returns {string} 101 | */ 102 | toString(separator = '\n') { 103 | // Apply the effect of plugins 104 | let qb = this; 105 | for (const plugin of this.StaticModel.plugins) { 106 | qb = plugin.beforeQuery(qb); 107 | } 108 | 109 | // Return a list of query strings to be executed, including Relations 110 | const result = [qb.knexInstance.toString()]; 111 | for (const relation of qb.includedRelations) { 112 | // Create the relation query with an empty array of Models 113 | result.push(relation.createQuery([]).toString()); 114 | } 115 | 116 | return result.join(separator); 117 | } 118 | } 119 | 120 | // Inherit Knex query methods 121 | for (const method of Config.KNEX_ALLOWED_QUERY_METHODS) { 122 | QueryBuilder.prototype[method] = function queryMethod(...args) { 123 | // Update Knex state 124 | this.knexInstance = this.knexInstance[method](...args); 125 | return this; 126 | }; 127 | } 128 | -------------------------------------------------------------------------------- /src/relation.js: -------------------------------------------------------------------------------- 1 | import { underscore } from 'inflection'; 2 | import RelationType from './enums/relation-type'; 3 | import { RelationError } from './errors'; 4 | import { flattenArray } from './utils'; 5 | 6 | /** 7 | * Represents a relation between Models. 8 | * @private 9 | */ 10 | export default class Relation { 11 | /** 12 | * Static Model object which shall be joined with the target. 13 | * @type {Model} 14 | * @memberof Relation 15 | * @instance 16 | */ 17 | Origin; 18 | 19 | /** 20 | * Static Model object which corresponds to the origin. 21 | * @type {Model} 22 | * @memberof Relation 23 | * @instance 24 | */ 25 | Target; 26 | 27 | /** 28 | * Type of the relation between Origin and Target. 29 | * @type {RelationType} 30 | * @memberof Relation 31 | * @instance 32 | */ 33 | type; 34 | 35 | /** 36 | * Name of the Relation. 37 | * @type {string} 38 | * @memberof Relation 39 | * @instance 40 | */ 41 | name; 42 | 43 | constructor(Origin, Target, type, foreignKey) { 44 | this.Origin = Origin; 45 | 46 | // Get the target's registered Model if target is a string 47 | const modelRegistry = Origin.registry; 48 | this.Target = typeof Target === 'string' ? modelRegistry[Target] : Target; 49 | 50 | this.type = type; 51 | if (foreignKey) this.foreignKey = foreignKey; 52 | } 53 | 54 | /** 55 | * The attribute which points to the primary key of the joinable database 56 | * table. 57 | * @type {string} 58 | */ 59 | get foreignKey() { 60 | // Set the foreign key deterministically 61 | return this.isTypeFromOne ? 62 | `${underscore(this.Origin.name)}_id` : 63 | `${underscore(this.Target.name)}_id`; 64 | } 65 | 66 | get OriginAttribute() { 67 | return this.isTypeFromOne ? 68 | this.foreignKey : 69 | this.Target.primaryKey; 70 | } 71 | 72 | get TargetAttribute() { 73 | return this.isTypeFromOne ? 74 | this.Origin.primaryKey : 75 | this.foreignKey; 76 | } 77 | 78 | get isTypeFromOne() { 79 | return [ 80 | RelationType.MANY_TO_ONE, 81 | RelationType.MANY_TO_MANY, 82 | ].indexOf(this.type) < 0; 83 | } 84 | 85 | /** 86 | * Creates a many-to-many Relation from a one-to many Relation. 87 | * @param {string|Model} Interim Name or static reference to the pivot Model. 88 | * @param {string} [foreignKey] Foreign key in this Model. 89 | * @param {string} [otherKey] Foreign key in the Interim Model. 90 | * @returns {Relation} 91 | */ 92 | through(Interim, foreignKey, otherKey) { // eslint-disable-line 93 | // TODO 94 | return this; 95 | } 96 | 97 | /** 98 | * Creates a query based on the given origin Model instances. 99 | * @param {Object[]} originInstances Origin Model instances. 100 | * @returns {QueryBuilder} 101 | */ 102 | createQuery(originInstances) { 103 | const { OriginAttribute, TargetAttribute } = this; 104 | 105 | return this.Target.query() 106 | .whereIn( 107 | OriginAttribute, 108 | originInstances.length > 0 ? // Pass a mock value if necessary 109 | originInstances.map((model) => model[TargetAttribute]) : 110 | [`originInstance.${TargetAttribute}`] 111 | ); 112 | } 113 | 114 | /** 115 | * Applies the relation by executing subqueries on the origin Model instances. 116 | * @param {...Object} originInstances Origin Model instances. 117 | * @throws {RelationError} 118 | * @returns {Promise} 119 | */ 120 | applyAsync(...originInstances) { 121 | const models = flattenArray(originInstances); 122 | const { OriginAttribute, TargetAttribute } = this; 123 | 124 | // Create and then execute the query, handling Model bindings 125 | return this.createQuery(models) 126 | .then((relatedModels) => { 127 | for (const relatedModel of relatedModels) { 128 | // Pair up the related Model with its origin 129 | const foreignValue = relatedModel[OriginAttribute]; 130 | const originInstance = models.find((model) => 131 | model[TargetAttribute] === foreignValue 132 | ); 133 | 134 | if (originInstance) { 135 | if (originInstance[this.name] === undefined) { 136 | // Initially set the origin's related property 137 | if (this.type === RelationType.ONE_TO_MANY) { 138 | originInstance[this.name] = [relatedModel]; 139 | } else { 140 | originInstance[this.name] = relatedModel; 141 | } 142 | } else { 143 | // Modify the origin instance's related property if possible 144 | if (this.type === RelationType.ONE_TO_MANY) { 145 | originInstance[this.name].push(relatedModel); 146 | } else { 147 | throw new RelationError(); 148 | } 149 | } 150 | } 151 | } 152 | }); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/model-base.js: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import { tableize } from 'inflection'; 3 | import QueryBuilder from './query-builder'; 4 | import Relation from './relation'; 5 | import RelationType from './enums/relation-type'; 6 | import { 7 | DuplicateModelError, 8 | EmptyModelError, 9 | UnidentifiedModelError, 10 | ValidationError, 11 | } from './errors'; 12 | 13 | /** 14 | * Base Model class which should be used as an extension for database entities. 15 | */ 16 | export default class ModelBase { 17 | /** 18 | * Knex client corresponding to the current ORM instance. 19 | * @type {Object} 20 | * @memberof ModelBase 21 | * @static 22 | */ 23 | static knex; 24 | 25 | /** 26 | * Plugins to be used for the current ORM instance. 27 | * @type {Object[]} 28 | * @memberof ModelBase 29 | * @static 30 | */ 31 | static plugins = []; 32 | 33 | static registry = []; 34 | 35 | /** 36 | * Case-sensitive name of the database table which corresponds to the Model. 37 | * @type {string} 38 | */ 39 | static get tableName() { return tableize(this.name); } 40 | 41 | /** 42 | * Primary key of the Model, used for instance identification. 43 | * @type {string} 44 | */ 45 | static get primaryKey() { return 'id'; } 46 | 47 | /** 48 | * List of properties which should exclusively be present in database 49 | * entities. If the list is empty, then every enumerable property of the 50 | * instance are considered to be database entities. 51 | * @type {string[]} 52 | */ 53 | static get whitelistedProps() { return []; } 54 | 55 | /** 56 | * List of properties which shall not be present in database entities. The 57 | * blacklist takes precedence over any whitelist rule. 58 | * @type {string[]} 59 | */ 60 | static get blacklistedProps() { return []; } 61 | 62 | /** 63 | * JSON Schema to be used for validating instances of the Model. Validation 64 | * happens automatically before executing queries. 65 | * @type{?Object} 66 | */ 67 | static get jsonSchema() { return null; } 68 | 69 | /** 70 | * Registers this static Model object to the list of database objects. 71 | * @param {string} [name] Name under which the Model shall be registered. 72 | * @throws {DuplicateModelError} 73 | * @returns {Model} The current Model. 74 | */ 75 | static register(name) { 76 | // Clone Knex and initialize plugins 77 | this.knex = Object.assign({}, this.knex); 78 | for (const plugin of this.plugins) { 79 | plugin.init(this); 80 | } 81 | 82 | // Determine the Model's name and then check if it's already registered 83 | const modelName = name || this.name; 84 | if (Object.keys(this.registry).indexOf(modelName) >= 0) { 85 | throw new DuplicateModelError(modelName); 86 | } 87 | 88 | this.registry[modelName] = this; 89 | return this; 90 | } 91 | 92 | /** 93 | * Returns a new QueryBuilder instance which corresponds to the current Model. 94 | * @returns {QueryBuilder} 95 | */ 96 | static query() { 97 | return new QueryBuilder(this); 98 | } 99 | 100 | /** 101 | * Creates a one-to-one relation between the current Model and a target. 102 | * @param {string|Model} Target Name or static reference to the joinable 103 | * table's Model. 104 | * @param {string} [foreignKey] Foreign key in the target Model. 105 | * @returns {Relation} 106 | */ 107 | static hasOne(Target, foreignKey) { 108 | return new Relation(this, Target, RelationType.ONE_TO_ONE, foreignKey); 109 | } 110 | 111 | /** 112 | * Creates a one-to-many relation between the current Model and a target. 113 | * @param {string|Model} Target Name or static reference to the joinable 114 | * table's Model. 115 | * @param {string} [foreignKey] Foreign key in the target Model. 116 | * @returns {Relation} 117 | */ 118 | static hasMany(Target, foreignKey) { 119 | return new Relation(this, Target, RelationType.ONE_TO_MANY, foreignKey); 120 | } 121 | 122 | /** 123 | * Creates a many-to-one relation between the current Model and a target. 124 | * @param {string|Model} Target Name or static reference to the joinable 125 | * table's Model. 126 | * @param {string} [foreignKey] Foreign key in this Model. 127 | * @returns {Relation} 128 | */ 129 | static belongsTo(Target, foreignKey) { 130 | return new Relation(this, Target, RelationType.MANY_TO_ONE, foreignKey); 131 | } 132 | 133 | /** 134 | * Creates a new Model instance. 135 | * @param {Object} [props={}] Initial properties of the instance. 136 | * @param {boolean} [isNew=true] True if the instance is not yet stored 137 | * persistently in the database. 138 | */ 139 | constructor(props = {}, isNew = true) { 140 | // Set the initial properties of the instance 141 | Object.assign(this, props); 142 | 143 | // Initialize a store for old properties of the instance 144 | Object.defineProperties(this, { 145 | isNew: { 146 | value: isNew, 147 | }, 148 | oldProps: { 149 | value: isNew ? {} : Object.assign({}, props), 150 | writable: true, 151 | }, 152 | }); 153 | } 154 | 155 | /** 156 | * Validates all the enumerable properties of the current instance. 157 | * @throws {ValidationError} 158 | */ 159 | validate() { 160 | const schema = this.constructor.jsonSchema; 161 | if (!schema) return; // The Model is valid if no schema is given 162 | 163 | const ajv = new Ajv(); 164 | if (!ajv.validate(schema, this)) { 165 | throw new ValidationError(ajv.errors); 166 | } 167 | } 168 | 169 | /** 170 | * Queues fetching the given related Models of the current instance. 171 | * @param {...string} props Relation attributes to be fetched. 172 | * @returns {QueryBuilder} 173 | */ 174 | fetchRelated(...props) { 175 | const qb = this.getQueryBuilder(); 176 | if (!qb) throw new UnidentifiedModelError(); 177 | 178 | return qb.withRelated(...props); 179 | } 180 | 181 | /** 182 | * Queues the deletion of the current Model from the database. 183 | * @throws {UnidentifiedModelError} 184 | * @returns {QueryBuilder} 185 | */ 186 | del() { 187 | const qb = this.getQueryBuilder(); 188 | if (!qb) throw new UnidentifiedModelError(); 189 | 190 | return qb.del(); 191 | } 192 | 193 | /** 194 | * Queues saving (creating or updating) the current Model in the database. 195 | * @throws {EmptyModelError} 196 | * @returns {QueryBuilder} 197 | */ 198 | save() { 199 | const qb = this.getQueryBuilder(); 200 | const changedProps = {}; 201 | 202 | // By default, save only the whitelisted properties, but if none is present, 203 | // then save every property. Use the blacklist for filtering the results. 204 | const savablePropNames = ( 205 | this.constructor.whitelistedProps.length > 0 ? 206 | this.constructor.whitelistedProps : 207 | Object.keys(this) 208 | ).filter((propName) => 209 | this.constructor.blacklistedProps.indexOf(propName) < 0 210 | ); 211 | 212 | for (const propName of savablePropNames) { 213 | const oldValue = this.oldProps[propName]; 214 | const newValue = this[propName]; 215 | 216 | // New and modified properties must be updated 217 | if (oldValue === undefined || newValue !== oldValue) { 218 | changedProps[propName] = newValue; 219 | } 220 | } 221 | 222 | // Don't run unnecessary queries 223 | if (Object.keys(changedProps).length === 0) { 224 | if (!qb) throw new EmptyModelError(); 225 | 226 | return qb; 227 | } 228 | 229 | // Update the Model's old properties with the new ones 230 | Object.assign(this.oldProps, changedProps); 231 | 232 | // Insert or update the current instance in the database 233 | return qb ? 234 | qb.update(changedProps) : 235 | this.constructor.query().insert(changedProps); 236 | } 237 | 238 | /** 239 | * @returns {?QueryBuilder} 240 | * @private 241 | */ 242 | getQueryBuilder() { 243 | if (this.isNew) return null; 244 | 245 | return new QueryBuilder(this.constructor, this); 246 | } 247 | } 248 | --------------------------------------------------------------------------------