diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..2077e44 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +.idea +coverage +node_modules diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..17672a9 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,194 @@ +{ + "parserOptions": { + "ecmaVersion": 6 + }, + "env": { + "amd": false, + "browser": false, + "jasmine": false, + "mocha": true, + "node": true + }, + "globals": { + "Promise": false, + "console": true + }, + "rules": { + "comma-dangle": [ 2, "never" ], + "no-cond-assign": [ 2, "always" ], + "no-console": 1, + "no-constant-condition": 2, + "no-control-regex": 1, + "no-debugger": 1, + "no-dupe-args": 2, + "no-dupe-keys": 2, + "no-duplicate-case": 2, + "no-empty-character-class": 2, + "no-empty": 2, + "no-ex-assign": 2, + "no-extra-boolean-cast": 2, + "no-extra-parens": 0, + "no-extra-semi": 2, + "no-func-assign": 2, + "no-inner-declarations": [ 2, "functions" ], + "no-invalid-regexp": 2, + "no-irregular-whitespace": 2, + "no-negated-in-lhs": 2, + "no-obj-calls": 2, + "no-regex-spaces": 2, + "no-sparse-arrays": 2, + "no-unexpected-multiline": 2, + "no-unreachable": 2, + "use-isnan": 2, + "valid-jsdoc": 0, + "valid-typeof": 2, + "accessor-pairs": 1, + "block-scoped-var": 2, + "complexity": [ 0, 11 ], + "consistent-return": 2, + "curly": [ 2, "all" ], + "default-case": 2, + "dot-location": [ 2, "property" ], + "dot-notation": [ 2, { "allowKeywords": true } ], + "eqeqeq": 2, + "guard-for-in": 0, + "no-alert": 2, + "no-caller": 2, + "no-case-declarations": 2, + "no-div-regex": 0, + "no-else-return": 2, + "no-empty-pattern": 2, + "no-eq-null": 2, + "no-eval": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-fallthrough": 2, + "no-floating-decimal": 2, + "no-implicit-coercion": 1, + "no-implied-eval": 2, + "no-invalid-this": 0, + "no-iterator": 2, + "no-labels": 2, + "no-lone-blocks": 2, + "no-loop-func": 2, + "no-magic-numbers": 0, + "no-multi-spaces": 2, + "no-multi-str": 2, + "no-native-reassign": 2, + "no-new-func": 2, + "no-new-wrappers": 2, + "no-new": 2, + "no-octal-escape": 2, + "no-octal": 2, + "no-param-reassign": 0, + "no-process-env": 0, + "no-proto": 2, + "no-redeclare": 2, + "no-return-assign": 2, + "no-script-url": 0, + "no-self-compare": 2, + "no-sequences": 2, + "no-throw-literal": 2, + "no-unused-expressions": 2, + "no-useless-call": 1, + "no-useless-concat": 2, + "no-void": 2, + "no-warning-comments": [ 0, { "terms": [ "todo", "fixme", "xxx" ], "location": "start" } ], + "no-with": 2, + "radix": 0, + "vars-on-top": 0, + "wrap-iife": 1, + "yoda": [ 2, "never" ], + "strict": [ 2, "global" ], + "init-declarations": [ 0, "always" ], + "no-catch-shadow": 2, + "no-delete-var": 2, + "no-label-var": 2, + "no-shadow-restricted-names": 2, + "no-shadow": 2, + "no-undef-init": 2, + "no-undef": 2, + "no-undefined": 2, + "no-unused-vars": [ 2, { "vars": "all", "args": "after-used" } ], + "no-use-before-define": 2, + "array-bracket-spacing": [ 2, "always", { "objectsInArrays": false } ], + "block-spacing": [ 2, "always" ], + "brace-style": [ 2, "1tbs", { "allowSingleLine": false } ], + "camelcase": [ 2, { "properties": "never" } ], + "comma-spacing": [ 2, { "before": false, "after": true } ], + "comma-style": 2, + "computed-property-spacing": [2, "never"], + "consistent-this": [ 2, "self" ], + "eol-last": 0, + "func-names": 0, + "func-style": [ 2, "expression" ], + "id-length": 0, + "id-match": 0, + "indent": [ 2, 2, { "SwitchCase": 1 } ], + "key-spacing": [ 2, { "beforeColon": false, "afterColon": true } ], + "keyword-spacing": 2, + "linebreak-style": [ 2, "unix" ], + "lines-around-comment": 0, + "max-depth": [ 1, 4 ], + "max-len": [ 1, 120 ], + "max-nested-callbacks": 0, + "max-params": [ 0, 4 ], + "max-statements": [ 0, 10 ], + "new-cap": 2, + "new-parens": 2, + "newline-after-var": 0, + "no-array-constructor": 2, + "no-bitwise": 0, + "no-continue": 1, + "no-inline-comments": 0, + "no-lonely-if": 2, + "no-mixed-spaces-and-tabs": [ 2, false ], + "no-multiple-empty-lines": [ 2, { "max": 1 } ], + "no-negated-condition": 2, + "no-nested-ternary": 0, + "no-new-object": 2, + "no-plusplus": 0, + "no-restricted-syntax": 0, + "no-spaced-func": 2, + "no-ternary": 0, + "no-trailing-spaces": 2, + "no-underscore-dangle": 0, + "no-unneeded-ternary": 2, + "object-curly-spacing": [ 2, "always" ], + "one-var": [ 2, "never" ], + "operator-assignment": [ 0, "always" ], + "operator-linebreak": [ 1, "before" ], + "padded-blocks": [ 2, "never" ], + "quote-props": [ 2, "as-needed" ], + "quotes": [ 2, "single" ], + "require-jsdoc": 0, + "semi-spacing": 2, + "semi": 2, + "sort-vars": [ 2, { "ignoreCase" : true } ], + "space-before-blocks": 2, + "space-before-function-paren": [ 2, "never" ], + "space-in-parens": 2, + "space-infix-ops": 2, + "space-unary-ops": [ 2, { "words": true, "nonwords": false } ], + "spaced-comment": [ 2, "always" ], + "wrap-regex": 0, + "arrow-body-style": [ 0, "as-needed" ], + "arrow-parens": [ 2, "always" ], + "arrow-spacing": [ 2, { "before": true, "after": true } ], + "constructor-super": 2, + "generator-star-spacing": 2, + "no-class-assign": 2, + "no-const-assign": 2, + "no-confusing-arrow": 0, + "no-dupe-class-members": 2, + "no-this-before-super": 2, + "no-var": 2, + "object-shorthand": [ 2, "methods" ], + "prefer-arrow-callback": 2, + "prefer-const": [ 2, { "ignoreReadBeforeAssign": true } ], + "prefer-reflect": 0, + "prefer-spread": 0, + "prefer-template": 2, + "require-yield": 2 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d63bfd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +coverage +node_modules +npm-debug.log diff --git a/.istanbul.yml b/.istanbul.yml new file mode 100644 index 0000000..437a177 --- /dev/null +++ b/.istanbul.yml @@ -0,0 +1,6 @@ +check: + global: + branches: 100 + functions: 100 + lines: 100 + statements: 100 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cae23d6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Mathew Gardner + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..37acad8 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +## knex-supermodel + +[![circle](https://circleci.com/gh/mathewdgardner/knex-supermodel.svg?style=svg)](https://circleci.com/gh/mathewdgardner/knex-supermodel) +[![coverage](https://coveralls.io/repos/github/mathewdgardner/knex-supermodel/badge.svg?branch=master)](https://coveralls.io/github/mathewdgardner/knex-supermodel?branch=master) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/mathewdgardner/knex-supermodel/master/LICENSE) + +## Description + +knex-supermodel is meant to be a very lite but not quite ORM for knex. This is accomplished by providing a base model that is simply an ES6 class that you extend in your own models. You can override the provided methods or even add to them to make your own. Each method will always return your model back to you, except in the obvious case of `collection`! + +This package requires ES6 features only available in node 6. + +## Examples + +##### Saving + +Any added properties will become part of the resultant query. + +```javascript +class User extends require('knex-supermodel') { + constructor(opts) { + super(opts); + } +} + +User.knex = knex; +const user = new User({ foo: 'bar', bar: 'baz' }); + +console.log(user.foo); // bar +console.log(user.bar); // baz + +user.save(); // performs insert +``` + +##### Fetching + +When fetching, the provided object becomes the `where` clause with a limit of 1. This results in an instantaion of your class whose properties are loaded from the database. + +```javascript +let user; + +User.fetch({ id: '123' }) + .then((u) => { + user = u; + }); +``` + +##### Collection + +When getting a collection, the provided object becomes the `where` clause. Each member in the collection is an instantation of your class. + +```javascript +let users; + +User.collection({ foo: 'bar' }) + .then((u) => { + users = u; + }); +``` + +##### Create + +When creating, the provided object becomes the properties. After inserting into the database, an instantation of your class is returned. + +```javascript +let user; + +User.create({ foo: 'bar' }) + .then((u) => { + user = u; + }); +``` + +##### Update + +When updating, the provided object is used to update the record. The same instantation of your class is return after update with its properties updated. + +```javascript +User.fetch({ id: '123' }) + .then((user) => { + console.log(user.foo); // bar + + return user.update({ foo: 'baz' }); + }) + .then((user) => { + console.log(user.foo); // baz + }); +``` + +##### Destroy + +When deleting, it assumed you want to delete by `id` unless you provide an id object that is in the format of a knex where object. + +```javascript +User.fetch({ id: '123' }) + .then((user) => {} + return user.destroy(); + }); + +User.fetch({ id: '123' }) + .then((user) => {} + return user.destroy({ foo: 'bar', bar: 'baz' }); + }); +``` + +##### Transacting + +You may either provide a transacting knex to each method or chain it. + +```javascript +let user; + +knex.transaction((trx) => { + User.fetch({ id: '123' }, { trx }) + .then((u) => { + user = u; + + return user.update({ foo: 'baz' }); + }); +}); + +knex.transaction((trx) => { + user.transaction(trx) + .update({ trx }) + .then((user) => { + return user.update({ foo: 'baz' }); + }); +}); +``` + +## License + +This software is licensed under [the MIT license](LICENSE.md). diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..9b3401f --- /dev/null +++ b/circle.yml @@ -0,0 +1,26 @@ +machine: + pre: + - curl -sSL https://s3.amazonaws.com/circle-downloads/install-circleci-docker.sh | bash -s -- 1.10.0 + services: + - docker + node: + version: 6 + +dependencies: + cache_directories: + - node_modules + pre: + - sudo bash -c "curl -L https://github.com/docker/compose/releases/download/1.8.0-rc2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose" + - sudo chmod +x /usr/local/bin/docker-compose + - docker-compose pull + override: + - npm install && npm update + +test: + override: + - npm run dc:test:setup + - npm run dc:test:coverage + - npm run ci:coveralls + post: + - sudo mv coverage $CIRCLE_ARTIFACTS || true + - sudo mv ./npm-debug.log $CIRCLE_ARTIFACTS || true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bfcf800 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '2' +services: + app: + image: mhart/alpine-node:6 + volumes: + - ./:/app + working_dir: /app + ports: + - 3000 + links: + - pg + environment: + NODE_ENV: development + PG_HOST: pg + PG_PASSWORD: docker + PG_PORT: 5432 + PG_USER: postgres + PORT: 3000 + + pg: + image: kiasaki/alpine-postgres:9.5 + ports: + - 5432 + environment: + POSTGRES_PASSWORD: docker + POSTGRES_USER: postgres diff --git a/index.js b/index.js new file mode 100644 index 0000000..6b535b6 --- /dev/null +++ b/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./lib/base'); diff --git a/knexfile.js b/knexfile.js new file mode 100644 index 0000000..ec2938b --- /dev/null +++ b/knexfile.js @@ -0,0 +1,18 @@ +'use strict'; + +module.exports = { + client: 'pg', + connection: { + charset: 'utf8', + host: process.env.PG_HOST, + port: process.env.PG_PORT, + user: process.env.PG_USER, + password: process.env.PG_PASSWORD, + database: 'base_test' + }, + pool: { + min: 2, + max: 10, + bailAfter: 10000 + } +}; diff --git a/lib/base.js b/lib/base.js new file mode 100644 index 0000000..a73941c --- /dev/null +++ b/lib/base.js @@ -0,0 +1,193 @@ +'use strict'; + +const _ = require('lodash'); +const Config = require('./config'); +const proxyHandler = require('./proxy_handler'); +const Util = require('./util'); + +/** + * Base model class to be extended that basically wraps around Knex. Holds helpful static methods. + * + * @class + */ +class Base { + /** + * Get the knex to be used. + * + * @returns {Function} The knex to be used. + */ + static get knex() { + return Config.knex; + } + + /** + * Set the knex to be used and save it for future use. + * + * @param k {Function} Knex to be used. + */ + static set knex(k) { + Config.knex = k; + } + + /** + * Inserts a new model into the database then returns an instantiation of the model. + * + * @param {Object} properties The Model properties. + * @param {Object} opts Options. + * @returns {*} An instantiation of the model. + */ + static create(properties, opts = {}) { + const knex = Util.assertKnex(opts.trx || opts.knex || Config.knex); + + return knex(Util.table(this)) + .insert(Util.toSnake(properties), '*') + .spread((res) => new this(Util.toCamel(res), opts)); + } + + /** + * Selects the first record then returns an instantiation of the model. + * + * @param {Object} query The Query object. + * @param {Object} opts Options. + * @returns {*} An instantiation of the model. + */ + static fetch(query, opts = {}) { + const knex = Util.assertKnex(opts.trx || opts.knex || Config.knex); + + return knex(Util.table(this)) + .first('*') + .where(Util.toSnake(query)) + .then((res) => new this(Util.toCamel(res), opts)); + } + + /** + * Get a collection of models matching a given query. + * + * @param {Object} query The query to match against. + * @param {Object} opts Options. + * @returns {Array} An array holding resultant models. + */ + static collection(query, opts = {}) { + const knex = Util.assertKnex(opts.trx || opts.knex || Config.knex); + + return knex(Util.table(this)) + .select() + .where(Util.toSnake(query)) + .map((res) => new this(Util.toCamel(res), opts)); + } + + /** + * Forges a new object but does not persist to DB. Does not need to be overridden. + * + * @constructor + * @param {Object} properties Additional properties to set. + * @param {Object} config A configuration object for options. + * @returns {*} An instantiation of the model with given properties set. + */ + constructor(properties = {}, config = {}) { + Config.knex = config.knex || Config.knex; + this._trx = config.trx; + this._properties = properties; + this._safeProperties = Util.toSnake(properties); + + return new Proxy(this, proxyHandler); // eslint-disable-line no-undef + } + + /** + * Saves the properties currently set on the model. + * + * @param {Object} opts Options for saving. + * @return {*} An updated copy of the model. + */ + save(opts = {}) { + const knex = Util.assertKnex(opts.trx || opts.knex || this._trx || Config.knex); + const method = opts.method || 'insert'; + + _.transform(this._properties, (result, value, key) => { + result[_.snakeCase(key)] = value; + }, this._safeProperties); + + return knex(Util.table(this))[method](this._safeProperties, '*') + .spread((res) => { + // Save computed values + _.transform(res, (result, value, key) => { + result[key] = value; + }, this._properties); + + this._safeProperties = res; + + return this; + }); + } + + /** + * Saves the properties currently set on the model. + * + * @param {Object} properties The properties to update. + * @param {Object} opts Options for saving. + * @return {*} An updated copy of the model. + */ + update(properties, opts = {}) { + const knex = Util.assertKnex(opts.trx || opts.knex || this._trx || Config.knex); + + // Save computed values + _.transform(properties, (result, value, key) => { + result[key] = value; + }, this._properties); + + // Save computed values + _.transform(properties, (result, value, key) => { + result[_.snakeCase(key)] = value; + }, this._safeProperties); + + return knex(Util.table(this)) + .update(properties, '*') + .where('id', this.id) + .spread((res) => { + _.transform(res, (result, value, key) => { + result[key] = value; + }, this._properties); + + this._safeProperties = res; + + return this; + }); + } + + /** + * Delete the model. + * + * @param {Object} opts Options for saving. + * @returns {*} The deleted model. + */ + destroy(opts = {}) { + const knex = Util.assertKnex(opts.trx || opts.knex || this._trx || Config.knex); + const id = opts.id || { id: this.id }; + + return knex(Util.table(this)) + .del() + .where(id) + .return(this); + } + + transaction(trx) { + this._trx = trx; + + return this; + } + + /** + * Serializes the model into JSON. + * + * @returns {String} The model stringified. + */ + toString() { + return JSON.stringify(this._properties); + } +} + +/** + * @module + * @type {Base} + */ +module.exports = Base; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..326f980 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,30 @@ +'use strict'; + +/** + * Singleton used for configurations. + */ +class Config { + /** + * Get the knex to be used. + * + * @returns {Function} The knex to be used. + */ + static get knex() { + return this._knex; + } + + /** + * Set the knex to be used and save it for future use. + * + * @param k {Function} Knex to be used. + */ + static set knex(k) { + this._knex = k; + } +} + +/** + * @module + * @type {Config} + */ +module.exports = Config; diff --git a/lib/proxy_handler.js b/lib/proxy_handler.js new file mode 100644 index 0000000..e9d9afd --- /dev/null +++ b/lib/proxy_handler.js @@ -0,0 +1,68 @@ +'use strict'; + +const _ = require('lodash'); + +/** + * Proxy handler for keeping user defined properties of a model on a dedicated object, _properties. + */ +module.exports = { + /** + * Own keys trap for the proxy. + * + * @param {Object} target The model. + * @returns {Array} User defined property keys on the model. + */ + ownKeys(target) { + return Object.keys(target._properties); + }, + + /** + * Get trap for the proxy. + * + * @param {Object} target The model. + * @param {String} property The requested property name. + * @returns {*} The property requested. + */ + get(target, property) { + if (typeof target[property] !== 'undefined') { + return target[property]; + } + + return target._properties[property]; + }, + + /** + * Set trap for the proxy. + * + * @param {Object} target The model. + * @param {String} property The requested property name. + * @param {*} value The value to set. + * @returns {boolean} The value is accepted or rejected. + */ + set(target, property, value) { + if (property.startsWith('_')) { + target[property] = value; + + return true; + } + + target._properties[property] = value; + target._safeProperties[_.snakeCase(property)] = value; + + return true; + }, + + /** + * Delete trap for the proxy. + * + * @param {Object} target The model. + * @param {String} property The requested property name. + * @returns {boolean} The value was deleted or not. + */ + deleteProperty(target, property) { + delete target._properties[property]; + delete target._safeProperties[property]; + + return true; + } +}; diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..34a39b3 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,61 @@ +'use strict'; + +const _ = require('lodash'); +const assert = require('assert'); +const inflection = require('inflection'); + +/** + * Static utility class. + */ +class Util { + /** + * Static Helper method to return a pluralized table name for the model in both a static and nonstatic context. + * + * @returns {String} The pluralized database table's name. + */ + static table(thiz) { + if (thiz.constructor.name === 'Function') { + // static context + return inflection.tableize(thiz.name); + } + + // non-static context + return inflection.tableize(thiz.constructor.name); + } + + /** + * Helper method to convert an object into a snake_case format for use with knex. + * + * @param {Object} obj The object to convert. + * @returns {Object} The resultant snake_case object. + */ + static toSnake(obj) { + return _.transform(obj, (result, value, key) => { + result[_.snakeCase(key)] = value; + }, {}); + } + + /** + * Helper method to convert an object into a camelCase format for use with after knex. + * + * @param {Object} obj The object to convert. + * @returns {Object} The resultant camelCase object. + */ + static toCamel(obj) { + return _.transform(obj, (result, value, key) => { + result[_.camelCase(key)] = value; + }, {}); + } + + static assertKnex(knex) { + assert(knex, 'You must provide a knex object!'); + + return knex; + } +} + +/** + * @module + * @type {Util} + */ +module.exports = Util; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f89fc3c --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "knex-supermodel", + "version": "0.1.0", + "description": "A thin Knex wrapper that provides a small base model that can be extended and act like a lite ORM.", + "main": "index.js", + "scripts": { + "ci:coveralls": "docker-compose run -e COVERALLS_REPO_TOKEN=$COVERALLS_REPO_TOKEN app npm run coveralls", + "coveralls": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", + "dc:test": "docker-compose run app npm run test", + "dc:test:coverage": "docker-compose run app npm run test:coverage", + "dc:test:create-db": "docker-compose run app npm run test:create-db", + "dc:test:migrate": "docker-compose run app npm run test:migrate", + "dc:test:setup": "npm run dc:test:create-db; npm run dc:test:migrate", + "lint": "eslint . --fix", + "posttest": "npm run lint", + "test": "NODE_ENV=test mocha", + "test:coverage": "istanbul cover _mocha", + "test:create-db": "NODE_ENV=test node ./scripts/setup.js", + "test:migrate": "NODE_ENV=test node ./scripts/migrate.js", + "test:setup": "npm run test:create-db; npm run test:migrate" + }, + "keywords": [ + "base", + "es6", + "knex", + "model", + "node", + "orm", + "supermodel" + ], + "author": "Mathew Gardner ", + "license": "MIT", + "dependencies": { + "inflection": "^1.10.0", + "lodash": "^4.14.1" + }, + "devDependencies": { + "bluebird": "^3.4.1", + "chai": "^3.5.0", + "coveralls": "^2.11.12", + "eslint": "^3.2.2", + "eslint-plugin-mocha": "^4.3.0", + "istanbul": "^0.4.4", + "knex": "^0.11.9", + "mocha": "^3.0.0", + "mocha-lcov-reporter": "^1.2.0", + "pg": "^6.0.3", + "sinon": "^1.17.5" + }, + "peerDependencies": { + "knex": "^0.11.0" + }, + "private": true +} diff --git a/scripts/migrate.js b/scripts/migrate.js new file mode 100644 index 0000000..e242370 --- /dev/null +++ b/scripts/migrate.js @@ -0,0 +1,25 @@ +/* eslint-disable no-console */ + +'use strict'; + +const config = require('../knexfile'); +const knex = require('knex')(config); + +knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + .then(() => { + return knex.schema.createTable('models', (t) => { + t.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()')); + t.string('foo'); + t.string('bar'); + t.timestamp('created_at').notNullable().defaultTo(knex.raw('now()')); + t.timestamp('updated_at').notNullable().defaultTo(knex.raw('now()')); + t.timestamp('deleted_at').nullable().defaultTo(null); + t.boolean('is_deleted').notNullable().defaultTo(false); + }); + }) + .then(() => { + console.log('Done migrating'); + + return knex.destroy(); + }) + .done(); diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000..c36171a --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,13 @@ +/* eslint-disable no-console */ + +'use strict'; + +const config = require('../knexfile'); +delete config.connection.database; +const knex = require('knex')(config); + +console.log('Creating database'); + +knex.raw('CREATE DATABASE base_test;') + .then(() => knex.destroy()) + .done(); diff --git a/test/.eslintrc b/test/.eslintrc new file mode 100644 index 0000000..e3574db --- /dev/null +++ b/test/.eslintrc @@ -0,0 +1,9 @@ +{ + "plugins": [ "mocha" ], + "rules": { + "max-depth": 0, + "max-len": 0, + "mocha/no-exclusive-tests": 2, + "no-unused-expressions": 0 + } +} diff --git a/test/base_tests.js b/test/base_tests.js new file mode 100644 index 0000000..abc0c65 --- /dev/null +++ b/test/base_tests.js @@ -0,0 +1,425 @@ +'use strict'; + +const Base = require('../lib/base'); +const expect = require('chai').expect; +const knex = require('knex')(require('../knexfile')); +const Promise = require('bluebird'); +const Sinon = require('sinon'); +const Utils = require('./helpers/utils'); + +describe('Base', () => { + class Model extends Base {} + + const properties = { foo: 'bar', bar: 'baz' }; + + beforeEach(() => { + Base.knex = null; + return Utils.clear(knex); + }); + + describe('constructor', () => { + it('should instantiate a model', () => { + expect(new Model({}, { knex })).to.be.instanceOf(Model); + }); + + it('should instantiate a model with properties', () => { + const model = new Model(properties, { knex }); + + expect(model).to.be.instanceOf(Model); + expect(model.foo).to.equal(properties.foo); + expect(model.bar).to.equal(properties.bar); + }); + + it('should instantiate a model without a knex object', () => { + expect(() => new Model()).to.not.throw(/knex/); + }); + + it('should instantiate a model with a knex object', () => { + expect(() => new Model({}, { knex })).to.not.throw(/knex/); + }); + }); + + describe('properties', () => { + it('should set a property', () => { + const model = new Model(); + model.foo = 'bar'; + + expect(model._properties.foo).to.equal('bar'); + expect(model._safeProperties.foo).to.equal('bar'); + expect(model.foo).to.equal('bar'); + }); + + it('should set a property and keep a snake cased safe property', () => { + const model = new Model(); + model.fooBar = 'baz'; + + expect(model._properties.fooBar).to.equal('baz'); + expect(model._safeProperties.foo_bar).to.equal('baz'); + expect(model.fooBar).to.equal('baz'); + }); + + it('should enter ownKeys trap and show user defined properties on the model', () => { + const model = new Model({ foo: 'bar' }); + + expect(Object.getOwnPropertyNames(model)).to.deep.equal([ 'foo' ]); + }); + + it('should enter deleteProperty trap and delete user defined properties on the model', () => { + const model = new Model({ foo: 'bar' }); + delete model.foo; + + expect(model.foo).to.be.undefined; + }); + }); + + describe('static methods', () => { + describe('get / set knex', () => { + it('should get knex', () => { + Model.knex = knex; + const k = Model.knex; + expect(k).to.equal(knex); + }); + + it('should set knex', () => { + Model.knex = knex; + expect(Model.knex).to.equal(knex); + }); + }); + + describe('create', () => { + it('should create a new model and insert it into the database', () => { + let model; + + return Model.create(properties, { knex }) + .then((m) => { + model = m; + + expect(model).to.be.instanceOf(Model); + + return knex('models') + .first() + .where('id', model.id); + }) + .then((m) => { + expect(m).to.have.property('id', model.id); + expect(m).to.have.property('foo', properties.foo); + expect(m).to.have.property('bar', properties.bar); + }); + }); + + it('should create a new model and insert it into the database using a previously set knex', () => { + Model.knex = knex; + let model; + + return Model.create(properties) + .then((m) => { + model = m; + + expect(model).to.be.instanceOf(Model); + + return knex('models') + .first() + .where('id', model.id); + }) + .then((m) => { + expect(m).to.have.property('id', model.id); + expect(m).to.have.property('foo', properties.foo); + expect(m).to.have.property('bar', properties.bar); + }); + }); + + it('should not create a new model if no knex object is given', () => { + expect(() => Model.create(properties)).to.throw(/knex/); + }); + }); + + describe('fetch', () => { + it('should get a model', () => { + return knex('models') + .insert(properties, '*') + .spread((m) => Model.fetch({ id: m.id }, { knex })) + .then((model) => { + expect(model).to.be.instanceOf(Model); + expect(model.id).to.equal(model.id); + expect(model.foo).to.equal(properties.foo); + expect(model.bar).to.equal(properties.bar); + }); + }); + + it('should get a model using a previously set knex', () => { + Model.knex = knex; + + return knex('models') + .insert(properties, '*') + .spread((m) => Model.fetch({ id: m.id })) + .then((model) => { + expect(model).to.be.instanceOf(Model); + expect(model.id).to.equal(model.id); + expect(model.foo).to.equal(properties.foo); + expect(model.bar).to.equal(properties.bar); + }); + }); + + it('should not get model if no knex object is given', () => { + expect(() => Model.fetch({ id: 'uuid' })).to.throw(/knex/); + }); + }); + + describe('collection', () => { + beforeEach(() => { + return knex('models') + .insert([{ + foo: 'bar', + bar: 'baz' + }, { + foo: 'bar2', + bar: 'baz2' + }, { + foo: 'bar3', + bar: 'baz3' + }]); + }); + + it('should return a collection of models', () => { + return Model.collection({}, { knex }) + .tap((models) => expect(models).to.have.property('length', 3)) + .map((model) => expect(model).to.be.instanceOf(Model)); + }); + + it('should return a collection of models using previously set knex', () => { + Model.knex = knex; + + return Model.collection({}) + .tap((models) => expect(models).to.have.property('length', 3)) + .map((model) => expect(model).to.be.instanceOf(Model)); + }); + + it('should return a collection of models matching a given query', () => { + return Model.collection({ foo: 'bar' }, { knex }) + .then((models) => { + expect(models).to.have.property('length', 1); + }); + }); + + it('should return a collection of models with an empty query', () => { + return Model.collection({}, { knex }) + .then((models) => { + expect(models).to.have.property('length', 3); + }); + }); + + it('should return a collection of models with a null query', () => { + return Model.collection(null, { knex }) + .then((models) => { + expect(models).to.have.property('length', 3); + }); + }); + }); + }); + + describe('Instance methods', () => { + describe('update', () => { + const props = { foo: 'bar' }; + const newProperties = { foo: 'baz' }; + + it('should update a model', () => { + let model; + + return Model.create(properties, { knex }) + .then((m) => { + model = m; + + return model.update(newProperties); + }) + .then((m) => { + expect(m).to.be.instanceOf(Model); + expect(m.id).to.equal(model.id); + expect(m.foo).to.equal(newProperties.foo); + + return knex('models') + .first() + .where('id', model.id); + }) + .then((res) => { + expect(res).to.have.property('id', model.id); + expect(res).to.have.property('foo', newProperties.foo); + }); + }); + + it('should not update model if no knex object is given', () => { + const model = new Model(props); + + expect(() => model.update(newProperties)).to.throw(/knex/); + }); + }); + + describe('save', () => { + const props = { foo: 'bar' }; + + it('should save a model', () => { + const model = new Model(props); + + return model.save({ knex }) + .then((m) => { + expect(m).to.be.instanceOf(Model); + expect(m.id).to.exist; + expect(m.created_at).to.exist; + expect(m.updated_at).to.exist; + expect(m.deleted_at).to.be.null; + expect(m.foo).to.equal(props.foo); + + return knex('models') + .first() + .where('id', model.id); + }) + .then((res) => { + expect(res).to.have.property('id', model.id); + expect(res).to.have.property('foo', props.foo); + }); + }); + + it('should save a model using previously set knex', () => { + Model.knex = knex; + const model = new Model(props); + + return model.save() + .then((m) => { + expect(m).to.be.instanceOf(Model); + expect(m.id).to.exist; + expect(m.created_at).to.exist; + expect(m.updated_at).to.exist; + expect(m.deleted_at).to.be.null; + expect(m.foo).to.equal(props.foo); + + return knex('models') + .first() + .where('id', model.id); + }) + .then((res) => { + expect(res).to.have.property('id', model.id); + expect(res).to.have.property('foo', props.foo); + }); + }); + + it('should not save model if no knex object is given', () => { + const model = new Model(props); + + expect(() => model.save()).to.throw(/knex/); + }); + }); + + describe('destroy', () => { + it('should delete a model', () => { + return Model.create(properties, { knex }) + .then((model) => model.destroy()) + .then((model) => { + expect(model).to.be.instanceOf(Model); + + return knex('models') + .select() + .where('id', model.id); + }) + .then((res) => { + expect(res).to.have.property('length', 0); + }); + }); + + it('should delete a model with provided query', () => { + return Model.create(properties, { knex }) + .then((model) => model.destroy({ foo: 'bar' })) + .then((model) => { + expect(model).to.be.instanceOf(Model); + + return knex('models') + .select() + .where('id', model.id); + }) + .then((res) => { + expect(res).to.have.property('length', 0); + }); + }); + + it('should not delete a model if no knex object is given', () => { + const model = new Model(); + expect(() => model.destroy()).to.throw(/knex/); + }); + }); + + describe('transaction', () => { + it('should use a transaction', () => { + return knex.transaction((trx) => { + const model = new Model(properties, { trx }); + + expect(model._trx).to.equal(trx); + + return model.save(); + }) + .then(() => { + return knex('models') + .select(); + }) + .then((res) => { + expect(res).to.have.property('length', 1); + expect(res[0]).to.have.property('foo', 'bar'); + expect(res[0]).to.have.property('bar', 'baz'); + }); + }); + + it('should use a transaction through transaction method', () => { + return knex.transaction((trx) => { + const model = new Model(properties) + .transaction(trx); + + expect(model._trx).to.equal(trx); + + return model.save(); + }) + .then(() => { + return knex('models') + .select(); + }) + .then((res) => { + expect(res).to.have.property('length', 1); + expect(res[0]).to.have.property('foo', 'bar'); + expect(res[0]).to.have.property('bar', 'baz'); + }); + }); + + it('should rollback a transaction', () => { + let saveStub; + + return knex.transaction((trx) => { + const model = new Model(properties, { trx }); + saveStub = Sinon.stub(model, 'save') + .returns(Promise.reject(new Error('rejected'))); + + expect(model._trx).to.equal(trx); + + return model.save(); + }) + .then(() => { + throw new Error('Should not get here!'); + }) + .catch((err) => { + expect(err.message).to.equal('rejected'); + + return knex('models') + .select(); + }) + .then((res) => { + expect(res).to.have.property('length', 0); + }) + .finally(() => { + saveStub.restore(); + }); + }); + }); + + describe('toString', () => { + it('should convert the model into a JSON string', () => { + const model = new Model({ foo: 'bar', bar: 'baz' }); + expect(model.toString()).to.equal(JSON.stringify(model._properties)); + }); + }); + }); +}); diff --git a/test/helpers/utils.js b/test/helpers/utils.js new file mode 100644 index 0000000..571a6e0 --- /dev/null +++ b/test/helpers/utils.js @@ -0,0 +1,5 @@ +'use strict'; + +exports.clear = (knex) => { + return knex.raw('TRUNCATE TABLE models CASCADE'); +}; diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..5c138c2 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,3 @@ +--check-leaks +--recursive +--reporter spec diff --git a/test/util_tests.js b/test/util_tests.js new file mode 100644 index 0000000..6702e49 --- /dev/null +++ b/test/util_tests.js @@ -0,0 +1,26 @@ +'use strict'; + +const expect = require('chai').expect; +const Util = require('../lib/util'); + +describe('Util', () => { + describe('toCamel', () => { + it('should convert an object to camelCase', () => { + expect(Util.toCamel({ foo_bar: 'baz' })).to.deep.equal({ fooBar: 'baz' }); + }); + + it('should preserve a camelCase object as camelCase', () => { + expect(Util.toCamel({ fooBar: 'baz' })).to.deep.equal({ fooBar: 'baz' }); + }); + }); + + describe('toSnake', () => { + it('should convert an object to snake_case', () => { + expect(Util.toSnake({ fooBar: 'baz' })).to.deep.equal({ foo_bar: 'baz' }); + }); + + it('should preserve a snake_case object as snake_case', () => { + expect(Util.toSnake({ foo_bar: 'baz' })).to.deep.equal({ foo_bar: 'baz' }); + }); + }); +});