diff --git a/README.md b/README.md index ea98ca60..1e996d96 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,19 @@ - [x] withPrefix - [x] transactions - [x] global transactions + + + +## Model + +- [ ] hooks +- [ ] getters +- [ ] setters +- [ ] helper static methods +- [ ] boot method ( ability to extend via BaseModel ) +- [ ] refresh model +- [ ] fill model with json data +- [ ] use traits +- [ ] computed properties +- [ ] visible/hidden attributes +- [ ] timestamps diff --git a/index.js b/index.js index ccacec30..124230b0 100644 --- a/index.js +++ b/index.js @@ -1 +1,23 @@ 'use strict' + +const path = require('path') +const knex = require('knex')({ + client: 'sqlite', + connection: ':memory:', + debug: true, + useNullAsDefault: true +}) + +knex + .schema + .createTableIfNotExists('users', function (table) { + table.integer('uuid') + table.string('username') + }) + .then(() => { + return knex + .table('users') + .insert({ uuid: 1100, username: 'virk' }) + }) + .then(console.log) + .catch(console.error) diff --git a/japaFile.js b/japaFile.js new file mode 100644 index 00000000..a6868eb1 --- /dev/null +++ b/japaFile.js @@ -0,0 +1,4 @@ +'use strict' + +const cli = require('japa/cli') +cli.run('test/**/*.spec.js') diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 00000000..36018342 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,37 @@ +'use strict' + +/* + * adonis-lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + + +const _ = require('lodash') +const pluralize = require('pluralize') + +const util = exports = module.exports = {} + +util.makeTableName = function (name) { + return _.snakeCase(pluralize(name)) +} + +util.getSetterName = function (name) { + return `set${_.upperFirst(_.camelCase(name))}` +} + +util.getGetterName = function (name) { + return `get${_.upperFirst(_.camelCase(name))}` +} + +util.getCycleAndEvent = function (name) { + const tokens = name.match(/^(before|after)(\w+)/) + if (!tokens) { + return [] + } + + return [ tokens[1], tokens[2].toLowerCase() ] +} diff --git a/package-lock.json b/package-lock.json index 19fbc64a..59f32006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3,12 +3,22 @@ "version": "1.0.0", "lockfileVersion": 1, "dependencies": { + "@adonisjs/fold": { + "version": "git+https://github.com/poppinss/adonis-fold.git#06e937ad37bbff961c0c168f1cac5a591d6d6b9e", + "dev": true + }, "@adonisjs/sink": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@adonisjs/sink/-/sink-1.0.8.tgz", "integrity": "sha512-SZ8NbC5BVn3JP9iY45dSrAux/Q9fq1saqcvd6lPypDffnfAeEPoFMU9xg2/O5/qntR9uuLajZILwTWQvCxlMRA==", "dev": true }, + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=", + "dev": true + }, "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", @@ -134,6 +144,12 @@ "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=" }, + "caller": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/caller/-/caller-1.0.1.tgz", + "integrity": "sha1-uFGGD3Dhlds9J3OVqhp+I+ow7PU=", + "dev": true + }, "caseless": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz", @@ -224,6 +240,11 @@ } } }, + "date-fns": { + "version": "1.28.5", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.28.5.tgz", + "integrity": "sha1-JXz8RdMi30XvVlhmWWfuhBzXP68=" + }, "debug": { "version": "2.6.8", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", @@ -729,6 +750,11 @@ } } }, + "moment": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz", + "integrity": "sha1-w2GT3Tzhwu7SrbfIAtu8d6gbHA8=" + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1895,6 +1921,11 @@ "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", "dev": true }, + "pluralize": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-5.0.0.tgz", + "integrity": "sha1-6LkHOvmgywLkwu+pW1W+u9vwEpk=" + }, "preserve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", @@ -1984,6 +2015,12 @@ "integrity": "sha1-Tf5b9r6LjNw3/Pk+BLZVd3InEN4=", "dev": true }, + "require-stack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/require-stack/-/require-stack-1.0.2.tgz", + "integrity": "sha1-4A7jSL+Wy1w+LUwntJ5BR24Ill0=", + "dev": true + }, "resolve": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.3.3.tgz", @@ -3013,6 +3050,12 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" }, + "syntax-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.3.0.tgz", + "integrity": "sha1-HtkmbE1AvnXcVb+bsct3Biu5bKE=", + "dev": true + }, "tildify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.0.0.tgz", diff --git a/package.json b/package.json index 27018cfe..9e429733 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,16 @@ }, "homepage": "https://github.com/adonisjs/adonis-lucid#readme", "dependencies": { + "date-fns": "^1.28.5", + "debug": "^2.6.8", "knex": "^0.13.0", "lodash": "^4.17.4", - "node-exceptions": "^2.0.2" + "moment": "^2.18.1", + "node-exceptions": "^2.0.2", + "pluralize": "^5.0.0" }, "devDependencies": { + "@adonisjs/fold": "git+https://github.com/poppinss/adonis-fold.git#dawn", "@adonisjs/sink": "^1.0.8", "chance": "^1.0.9", "coveralls": "^2.13.1", diff --git a/src/Database/Manager.js b/src/Database/Manager.js index b90e0f18..5125b7b9 100644 --- a/src/Database/Manager.js +++ b/src/Database/Manager.js @@ -49,10 +49,12 @@ class DatabaseManager { * * @return {Database} */ - connection (name = this.Config.get('database.connection')) { + connection (name) { + name = name || this.Config.get('database.connection') if (this._connectionPools[name]) { return this._connectionPools[name] } + const connectionSettings = this.Config.get(`database.${name}`) if (!connectionSettings) { throw CE.RuntimeException.missingDatabaseConnection(name) diff --git a/src/Exceptions/index.js b/src/Exceptions/index.js index 2beda91e..882ac112 100644 --- a/src/Exceptions/index.js +++ b/src/Exceptions/index.js @@ -27,4 +27,14 @@ class RuntimeException extends NE.RuntimeException { } } -module.exports = { RuntimeException } +class InvalidArgumentException extends NE.InvalidArgumentException { + static missingParameter (message) { + return new this(message, 500, 'E_MISSING_PARAMETER') + } + + static invalidParamter (message) { + return new this(message, 500, 'E_INVALID_PARAMETER') + } +} + +module.exports = { RuntimeException, InvalidArgumentException } diff --git a/src/Lucid/Hooks/index.js b/src/Lucid/Hooks/index.js new file mode 100644 index 00000000..c75221be --- /dev/null +++ b/src/Lucid/Hooks/index.js @@ -0,0 +1,152 @@ +'use strict' + +/* + * adonis-lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const _ = require('lodash') +const CE = require('../../Exceptions') + +class Hooks { + constructor () { + this._events = ['create', 'update', 'delete', 'restore', 'find'] + + /** + * The event aliases. Whenever a handler is saved for a alias, + * it will called when those events occurs. + * + * @type {Object} + */ + this._aliases = { + create: 'save', + update: 'save' + } + + /** + * A map of handlers to be called for each event + * + * @type {Object} + */ + this._handlers = {} + } + + /** + * Adds a new handler for an event. Make sure to give + * handler a unique name if planning to remove it + * later at runtime + * + * @method addHandler + * + * @param {String} event + * @param {Function|String} handler + * @param {String} [name] + * + * @return {void} + * + * @example + * ``` + * this.addHandler('create', async function () { + * }) + * ``` + */ + addHandler (event, handler, name) { + if (!this._events[event]) { + // error + } + this._handlers[event] = this._handlers[event] || [] + this._handlers[event].push({ handler, name }) + } + + /** + * Removes handler using it's name. This methods returns + * void when successfully executed, otherwise an + * exception is thrown. + * + * @method removeHandler + * + * @param {String} event + * @param {String} name + * + * @return {void} + * + * @example + * ```js + * this.removeHandler('create', 'updatePassword') + * ``` + * + * @throws {InvalidArgumentException} If `name` is missing + */ + removeHandler (event, name) { + if (!name) { + throw CE.InvalidArgumentException.missingParameter('Cannot remove hook without a name') + } + _.remove(this._handlers[event], (handler) => handler.name === name) + } + + /** + * Removes all handlers for a given event. This method + * returns void when successfully executed, otherwise + * an exception is thrown. + * + * @method removeAllHandlers + * + * @param {String} event + * + * @return {void} + * + * @example + * ``` + * this.removeAllHandlers('create') + * ``` + */ + removeAllHandlers (event) { + /** + * Don't create an empty array of events when there was + * not one. + */ + if (!this._handlers[event]) { + return + } + this._handlers[event] = [] + } + + /** + * Execute hooks in sequence. If this method doesn't + * throws an exception, means everything went fine. + * + * @method exec + * @async + * + * @param {String} event + * @param {Object} ctx + * + * @return {void} + */ + async exec (event, ctx) { + const handlers = this._handlers[event] || [] + const aliasesHandlers = this._aliases[event] ? this._handlers[this._aliases[event]] || [] : [] + const allHandlers = handlers.concat(aliasesHandlers) + + /** + * Return if there are no handlers for a given + * event + */ + if (!allHandlers.length) { + return + } + + /** + * Execute all handlers in sequence + */ + for (let handler of allHandlers) { + await handler.handler(ctx) + } + } +} + +module.exports = Hooks diff --git a/src/Lucid/Model/index.js b/src/Lucid/Model/index.js new file mode 100644 index 00000000..f9e83935 --- /dev/null +++ b/src/Lucid/Model/index.js @@ -0,0 +1,674 @@ +'use strict' + +/* + * adonis-lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const _ = require('lodash') +const moment = require('moment') + +const Hooks = require('../Hooks') +const QueryBuilder = require('../QueryBuilder') +const CollectionSerializer = require('../Serializers/Collection') +const CE = require('../../Exceptions') + +const util = require('../../../lib/util') + +/** + * Lucid model is a base model and supposed to be + * extended by other models. + * + * @class Model + */ +class Model { + constructor () { + this._instantiate() + return new Proxy(this, require('./proxyHandler')) + } + + /** + * Boot model if not booted. This method is supposed + * to be executed via IoC container hooks. + * + * @method _bootIfNotBooted + * + * @return {void} + * + * @private + * + * @static + */ + static _bootIfNotBooted () { + if (!this.$booted) { + this.boot() + } + } + + /** + * An array of methods to be called everytime + * a model in imported via boot method + * + * @attribute iocHooks + * + * @return {Array} + * + * @static + */ + static get iocHooks () { + return ['_bootIfNotBooted'] + } + + /** + * The primary key for the model. You can change it + * to anything you want. + * + * @attribute primaryKey + * + * @return {String} + * + * @static + */ + static get primaryKey () { + return 'id' + } + + /** + * Tell lucid whether primary key is supposed to be + * incrementing or not. If `false` is returned then + * users is responsible for setting the `primaryKey`. + * + * @attribute incrementing + * + * @return {Boolean} + * + * @static + */ + static get incrementing () { + return true + } + + /** + * Returns the value of primary key regardless of + * the key name + * + * @method primaryKeyValue + * + * @return {Mixed} + */ + get primaryKeyValue () { + return this.$attributes[this.constructor.primaryKey] + } + + /** + * Override primary key value. + * + * Note: You should know what you are doing, since primary + * keys are supposed to be fetched automatically from + * the database table. + * + * The only time you want to do is when `incrementing` is + * set to false + * + * @method primaryKeyValue + * + * @param {Mixed} value + * + * @return {void} + */ + set primaryKeyValue (value) { + this.$attributes[this.constructor.primaryKey] = value + } + + /** + * The database connection to be used for + * the model. + * + * @attribute connection + * + * @return {String} + * + * @static + */ + static get connection () { + return '' + } + + /** + * The date format to be used for formatting + * `dates`. + * + * @attribute dateFormat + * + * @return {String} + * + * @static + */ + static get dateFormat () { + return 'YYYY-MM-DD HH:mm:ss' + } + + /** + * The attributes to be considered as dates. By default + * `createdAtColumn` and `updatedAtColumn` are + * considered as dates. + * + * @attribute dates + * + * @return {Array} + * + * @static + */ + static get dates () { + const dates = [] + if (this.createdAtColumn) { dates.push(this.createdAtColumn) } + if (this.updatedAtColumn) { dates.push(this.updatedAtColumn) } + return dates + } + + /** + * The attribute name for created at timestamp + * + * @attribute createdAtColumn + * + * @return {String} + * + * @static + */ + static get createdAtColumn () { + return 'created_at' + } + + /** + * The attribute name for updated at timestamp + * + * @attribute updatedAtColumn + * + * @return {String} + * + * @static + */ + static get updatedAtColumn () { + return 'updated_at' + } + + /** + * The table name for the model + * + * @method table + * + * @return {String} + * + * @static + */ + static get table () { + return util.makeTableName(this.name) + } + + /** + * The serializer to be used for serializing + * data. The return value must always be a + * ES6 class. + * + * @method serializer + * + * @return {Class} + */ + static get serializer () { + return CollectionSerializer + } + + /** + * Executes the query listeners attached on the + * model + * + * @method _executeListeners + * + * @param {Object} query + * + * @return {void} + * + * @static + * + * @private + */ + static _executeListeners (query) { + _(this.$queryListeners) + .filter((listener) => typeof (listener) === 'function') + .each((listener) => listener(query)) + } + + /** + * Get fresh instance of query builder for + * this model + * + * @method query + * + * @return {QueryBuilder} + * + * @static + */ + static query () { + return new QueryBuilder(this, this.connection) + } + + /** + * Method to be called only once to boot + * the model + * + * @method boot + * + * @return {void} + * + * @static + */ + static boot () { + this.hydrate() + this.$booted = true + } + + /** + * Hydrates model static properties by re-setting + * them to their original value. + * + * @method hydrate + * + * @return {void} + * + * @static + */ + static hydrate () { + /** + * Whether or not model has been booted + * + * @type {Boolean} + */ + Model.$booted = false + + /** + * Model hooks for different lifecycle + * events + * + * @type {Object} + */ + Model.$hooks = { + before: new Hooks(), + after: new Hooks() + } + + /** + * List of global query listeners for the model. + * + * @type {Array} + */ + Model.$queryListeners = [] + } + + /** + * Adds a new hook for a given event type. + * + * @method addHook + * + * @param {String} forEvent + * @param {Function|String} handler + * + * @return {void} + * + * @static + */ + static addHook (forEvent, handler) { + const [cycle, event] = util.getCycleAndEvent(forEvent) + + /** + * If user has defined wrong hook cycle, do let them know + */ + if (!this.$hooks[cycle]) { + throw CE.InvalidArgumentException.invalidParamter(`Invalid hook event {${forEvent}}`) + } + + /** + * Add the handler + */ + this.$hooks[cycle].addHandler(event, handler) + } + + /** + * Attach a listener to be called everytime a query on + * the model is executed + * + * @method onQuery + * + * @param {Function} callback + * + * @chainable + */ + static onQuery (callback) { + this.$queryListeners.push(callback) + return this + } + + /** + * Formats all the dates set as `dates` on the model + * and exists in the values object. + * + * Note: This method will not mutate the original object + * and instead returns a new one. + * + * @method formatDates + * + * @param {Object} values + * + * @return {Object} + */ + static formatDates (values) { + /** + * Format dates properly when saving them to database + */ + return _.transform(values, (result, value, key) => { + if (this.dates.indexOf(key) > -1) { + result[key] = moment(value).format(this.dateFormat) + } else { + result[key] = value + } + return result + }, {}) + } + + /** + * Sets `created_at` column on the values object. + * + * Note: This method will mutate the original object + * by adding a new key/value pair. + * + * @method setCreatedAt + * + * @param {Object} values + */ + static setCreatedAt (values) { + if (this.createdAtColumn) { + values[this.createdAtColumn] = new Date() + } + } + + /** + * Sets `updated_at` column on the values object. + * + * Note: This method will mutate the original object + * by adding a new key/value pair. + * + * @method setUpdatedAt + * + * @param {Object} values + * + * @static + */ + static setUpdatedAt (values) { + if (this.updatedAtColumn) { + values[this.updatedAtColumn] = new Date() + } + } + + /** + * Tells whether model instance is new or + * persisted to database. + * + * @attribute isNew + * + * @return {Boolean} + */ + get isNew () { + return !this.$persisted + } + + /** + * Returns an object of values dirty after persisting to + * database or after fetching from database. + * + * @attribute dirty + * + * @return {Object} + */ + get dirty () { + return _.pickBy(this.$attributes, (value, key) => { + return _.isUndefined(this.$originalAttributes[key]) || this.$originalAttributes[key] !== value + }) + } + + /** + * Tells whether model is dirty or not + * + * @attribute isDirty + * + * @return {Boolean} + */ + get isDirty () { + return _.size(this.dirty) + } + + /** + * Instantiate the model by defining constructor properties + * and also setting `__setters__` to tell the proxy that + * these values should be set directly on the constructor + * and not on the `attributes` object. + * + * @method instantiate + * + * @return {void} + * + * @private + */ + _instantiate () { + this.__setters__ = ['$attributes', '$persisted', 'primaryKeyValue', '$originalAttributes'] + this.$attributes = {} + this.$originalAttributes = {} + this.$persisted = false + } + + /** + * Sync the original attributes with actual attributes. + * This is done after `save`, `update` and `find`. + * + * After this `isDirty` should return `false`. + * + * @method _syncOriginals + * + * @return {void} + * + * @private + */ + _syncOriginals () { + this.$originalAttributes = _.clone(this.$attributes) + } + + /** + * Insert values to the database. This method will + * call before and after hooks for `create` and + * `save` event. + * + * @method _insert + * + * @return {void} + * + * @private + */ + async _insert () { + /** + * Executing before hooks + */ + await this.constructor.$hooks.before.exec('create', this) + + /** + * Set timestamps + */ + this.constructor.setCreatedAt(this.$attributes) + this.constructor.setUpdatedAt(this.$attributes) + + const result = await this.constructor + .query() + .returning(this.constructor.$primaryKey) + .insert(this.constructor.formatDates(this.$attributes)) + + /** + * Only set the primary key value when incrementing is + * set to true on model + */ + if (this.constructor.incrementing) { + this.primaryKeyValue = result[0] + } + + this.$persisted = true + + /** + * Keep a clone copy of saved attributes, so that we can find + * a diff later when calling the update query. + */ + this._syncOriginals() + + /** + * Executing after hooks + */ + await this.constructor.$hooks.after.exec('create', this) + } + + /** + * Update model by updating dirty attributes to the database. + * + * @method _update + * + * @return {void} + */ + async _update () { + /** + * Executing before hooks + */ + await this.constructor.$hooks.before.exec('update', this) + + if (this.isDirty) { + /** + * Set proper timestamps + */ + const result = await this.constructor.query().update(this.dirty) + /** + * Sync originals to find a diff when updating for next time + */ + this._syncOriginals() + } + + /** + * Executing after hooks + */ + await this.constructor.$hooks.after.exec('update', this) + } + + /** + * Set attributes on model instance in bulk. Calling + * fill will remove the existing attributes. + * + * @method fill + * + * @param {Object} attributes + * + * @return {void} + */ + fill (attributes) { + this.$attributes = {} + _.each(attributes, (value, key) => this.set(key, value)) + } + + /** + * Set attribute on model instance. Setting properties + * manually or calling the `set` function has no + * difference. + * + * Note this method will call the setter + * + * @method set + * + * @param {String} name + * @param {Mixed} value + * + * @return {void} + */ + set (name, value) { + const setterName = util.getSetterName(name) + if (typeof (this[setterName]) === 'function') { + value = this[setterName](value) + } + return this.$attributes[name] = value + } + + /** + * Converts all date fields inside moment objects, so + * that you can transform them into something else. + * + * @method castDates + * + * @return {void} + */ + castDates () { + this.constructor.dates.forEach((field) => { + const value = this.$attributes[field] + if (value) { + this.$attributes[field] = moment(value) + } + }) + } + + /** + * Converts model to JSON. This method will call getters + * defined on the model and will attach `computed` + * properties to the JSON. + * + * @method toJSON + * + * @return {Object} + */ + toJSON () { + let evaluatedAttrs = _.transform(this.$attributes, (result, value, key) => { + const getter = this[util.getGetterName(key)] + result[key] = typeof (getter) === 'function' ? getter(value) : value + return result + }, {}) + + /** + * Set computed properties when defined + */ + _.each(this.constructor.computed || [], (key) => { + const getter = this[util.getGetterName(key)] + if (typeof (getter) === 'function') { + evaluatedAttrs[key] = getter(evaluatedAttrs) + } + }) + + /** + * Pick visible fields or remove hidden fields + */ + if (_.isArray(this.constructor.visible)) { + evaluatedAttrs = _.pick(evaluatedAttrs, this.constructor.visible) + } else if (_.isArray(this.constructor.hidden)) { + evaluatedAttrs = _.omit(evaluatedAttrs, this.constructor.hidden) + } + + return evaluatedAttrs + } + + /** + * Persist model to the database. It will create a new row + * when model has not been persisted already, otherwise + * will update it. + * + * @method save + * + * @return {void} + */ + async save () { + return this.isNew ? await this._insert() : await this._update() + } +} + +Model.hydrate() +module.exports = Model diff --git a/src/Lucid/Model/proxyHandler.js b/src/Lucid/Model/proxyHandler.js new file mode 100644 index 00000000..7d2d530a --- /dev/null +++ b/src/Lucid/Model/proxyHandler.js @@ -0,0 +1,18 @@ +'use strict' + +/* + * adonis-lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const proxyHandler = exports = module.exports = {} +proxyHandler.set = function (target, name, value) { + if (target.__setters__.indexOf(name) > -1) { + return target[name] = value + } + return target.set(name, value) +} diff --git a/src/Lucid/QueryBuilder/index.js b/src/Lucid/QueryBuilder/index.js new file mode 100644 index 00000000..b6e42a76 --- /dev/null +++ b/src/Lucid/QueryBuilder/index.js @@ -0,0 +1,51 @@ +'use strict' + +/* + * adonis-lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const { ioc } = require('@adonisjs/fold') + +const proxyHandler = { + get (target, name) { + if (target[name]) { + return target[name] + } + return target.query[name] + } +} + +class QueryBuilder { + constructor (model, connection) { + this.model = model + this.db = ioc.use('Adonis/Src/Database').connection(connection) + const table = this.model.prefix ? `${this.model.prefix}${this.model.table}` : this.model.table + this.query = this.db.table(table).on('query', this.model._executeListeners.bind(this.model)) + return new Proxy(this, proxyHandler) + } + + async fetch () { + const rows = await this.query + const collection = rows.map((row) => { + const modelInstance = new this.model() + modelInstance.$persisted = true + modelInstance.$attributes = row + modelInstance._syncOriginals() + modelInstance.castDates() + return modelInstance + }) + return new this.model.serializer(collection) + } + + async update (values) { + this.model.setUpdatedAt(values) + return this.query.update(this.model.formatDates(values)) + } +} + +module.exports = QueryBuilder diff --git a/src/Lucid/Serializers/Collection.js b/src/Lucid/Serializers/Collection.js new file mode 100644 index 00000000..24876be6 --- /dev/null +++ b/src/Lucid/Serializers/Collection.js @@ -0,0 +1,18 @@ +'use strict' + +class Collection { + constructor (rows, isOne = false) { + this.rows = rows + this.isOne = isOne + } + + first () { + return this.rows[0] + } + + toJSON () { + return this.rows.map((row) => row.toJSON()) + } +} + +module.exports = Collection diff --git a/test/unit/helpers/index.js b/test/unit/helpers/index.js index ff066795..b6ba6f04 100644 --- a/test/unit/helpers/index.js +++ b/test/unit/helpers/index.js @@ -14,6 +14,10 @@ module.exports = { } }, + formatBindings (bindings) { + return bindings + }, + getConfig () { if (process.env.DB === 'sqlite') { return _.cloneDeep({ @@ -43,13 +47,26 @@ module.exports = { table.increments() table.string('username') table.timestamps() + table.timestamp('login_at') + }), + db.schema.createTable('my_users', function (table) { + table.integer('uuid') + table.string('username') + table.timestamps() }) ]) }, dropTables (db) { return Promise.all([ - db.schema.dropTable('users') + db.schema.dropTable('users'), + db.schema.dropTable('my_users') ]) + }, + + sleep (time) { + return new Promise((resolve) => { + setTimeout(resolve, time) + }) } } diff --git a/test/unit/hooks.spec.js b/test/unit/hooks.spec.js new file mode 100644 index 00000000..19f90af2 --- /dev/null +++ b/test/unit/hooks.spec.js @@ -0,0 +1,149 @@ +'use strict' + +/* + * adonis-lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const test = require('japa') +const Hooks = require('../../src/Lucid/Hooks') +const helpers = require('./helpers') + +test.group('Hooks', () => { + test('it should add handler for a hook', (assert) => { + const hooks = new Hooks() + const fn = function () {} + hooks.addHandler('create', fn) + assert.deepEqual(hooks._handlers, { create: [{ handler: fn, name: undefined }] }) + }) + + test('it should add named handler for a hook', (assert) => { + const hooks = new Hooks() + const fn = function () {} + hooks.addHandler('create', fn, 'hashPassword') + assert.deepEqual(hooks._handlers, { create: [{ handler: fn, name: 'hashPassword' }] }) + }) + + test('it should remove a named handler', (assert) => { + const hooks = new Hooks() + const fn = function () {} + hooks.addHandler('create', fn, 'hashPassword') + hooks.addHandler('create', fn, 'doSomething') + assert.deepEqual(hooks._handlers, { + create: [{ handler: fn, name: 'hashPassword' }, { handler: fn, name: 'doSomething' }] + }) + + hooks.removeHandler('create', 'doSomething') + assert.deepEqual(hooks._handlers, { create: [{ handler: fn, name: 'hashPassword' }] }) + }) + + test('throw exception when trying to remove without name', (assert) => { + const hooks = new Hooks() + hooks.addHandler('create', function () {}, 'hashPassword') + const fn = () => hooks.removeHandler('create') + assert.throw(fn, 'E_MISSING_PARAMETER: Cannot remove hook without a name') + }) + + test('it should remove all handlers', (assert) => { + const hooks = new Hooks() + const fn = function () {} + hooks.addHandler('create', fn, 'hashPassword') + hooks.addHandler('create', fn, 'doSomething') + assert.deepEqual(hooks._handlers, { + create: [{ handler: fn, name: 'hashPassword' }, { handler: fn, name: 'doSomething' }] + }) + + hooks.removeAllHandlers('create') + assert.deepEqual(hooks._handlers, { create: [] }) + }) + + test('do not create empty array if there were no handlers ever', (assert) => { + const hooks = new Hooks() + const fn = function () {} + hooks.removeAllHandlers('create') + assert.deepEqual(hooks._handlers, {}) + }) + + test('execute plain functions in sequence', async (assert) => { + const hooks = new Hooks() + const stack = [] + + hooks.addHandler('create', function () { + stack.push(1) + }) + + hooks.addHandler('create', function () { + stack.push(2) + }) + + await hooks.exec('create') + assert.deepEqual(stack, [1, 2]) + }) + + test('execute async hooks in sequence', async (assert) => { + const hooks = new Hooks() + const stack = [] + + hooks.addHandler('create', async function () { + await helpers.sleep(200) + stack.push(1) + }) + + hooks.addHandler('create', async function () { + stack.push(2) + }) + + await hooks.exec('create') + assert.deepEqual(stack, [1, 2]) + }) + + test('abort execution when an handler throws an exception', async (assert) => { + assert.plan(2) + const hooks = new Hooks() + const stack = [] + + hooks.addHandler('create', async function () { + await helpers.sleep(200) + stack.push(1) + }) + + hooks.addHandler('create', async function () { + throw new Error('Stop') + }) + + hooks.addHandler('create', async function () { + stack.push(2) + }) + + try { + await hooks.exec('create') + } catch ({ message }) { + assert.equal(message, 'Stop') + assert.deepEqual(stack, [1]) + } + }) + + test('execute alias handlers', async (assert) => { + const hooks = new Hooks() + const stack = [] + + hooks.addHandler('save', async function () { + stack.push(1) + }) + + hooks.addHandler('create', async function () { + stack.push(2) + }) + + hooks.addHandler('create', async function () { + stack.push(3) + }) + + await hooks.exec('create') + assert.deepEqual(stack, [2, 3, 1]) + }) +}) diff --git a/test/unit/lucid.spec.js b/test/unit/lucid.spec.js new file mode 100644 index 00000000..5cf7c5aa --- /dev/null +++ b/test/unit/lucid.spec.js @@ -0,0 +1,505 @@ +'use strict' + +/* + * adonis-lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const test = require('japa') +const fs = require('fs-extra') +const path = require('path') +const moment = require('moment') +const { ioc } = require('@adonisjs/fold') +const { Config } = require('@adonisjs/sink') + +const helpers = require('./helpers') +const Model = require('../../src/Lucid/Model') +const DatabaseManager = require('../../src/Database/Manager') +const CollectionSerializer = require('../../src/Lucid/Serializers/Collection') + +test.group('Model', (group) => { + group.before(async () => { + ioc.bind('Adonis/Src/Database', function () { + const config = new Config() + config.set('database', { + connection: 'testing', + testing: helpers.getConfig() + }) + return new DatabaseManager(config) + }) + + await fs.ensureDir(path.join(__dirname, './tmp')) + await helpers.createTables(ioc.use('Adonis/Src/Database')) + }) + + group.beforeEach(() => { + Model.hydrate() + }) + + group.afterEach(async () => { + await ioc.use('Adonis/Src/Database').table('users').truncate() + await ioc.use('Adonis/Src/Database').table('my_users').truncate() + }) + + group.after(async () => { + await helpers.dropTables(ioc.use('Adonis/Src/Database')) + await fs.remove(path.join(__dirname, './tmp')) + }) + + test('run queries using query builder', (assert) => { + class User extends Model {} + const query = User.query().toSQL() + assert.equal(query.sql, helpers.formatQuery('select * from "users"')) + }) + + test('define different table for a model', (assert) => { + class User extends Model { + static get table () { + return 'my_users' + } + } + + const query = User.query().toSQL() + assert.equal(query.sql, helpers.formatQuery('select * from "my_users"')) + }) + + test('define table prefix for a model', (assert) => { + class User extends Model { + static get prefix () { + return 'my_' + } + } + + const query = User.query().toSQL() + assert.equal(query.sql, helpers.formatQuery('select * from "my_users"')) + }) + + test('call the boot method only once', (assert) => { + let callCounts = 0 + class User extends Model { + static boot () { + super.boot() + callCounts++ + } + } + + User._bootIfNotBooted() + User._bootIfNotBooted() + assert.equal(callCounts, 1) + }) + + test('should be able to define model attributes on model instance', (assert) => { + class User extends Model { + } + + const user = new User() + user.fill({ username: 'virk', age: 22 }) + assert.deepEqual(user.$attributes, { username: 'virk', age: 22 }) + }) + + test('remove existing attributes when calling fill', (assert) => { + class User extends Model { + } + + const user = new User() + user.fill({ username: 'virk', age: 22 }) + user.fill({ username: 'virk' }) + assert.deepEqual(user.$attributes, { username: 'virk' }) + }) + + test('call setters when defining attributes via fill', (assert) => { + class User extends Model { + setUsername (username) { + return username.toUpperCase() + } + } + + const user = new User() + user.fill({ username: 'virk', age: 22 }) + assert.deepEqual(user.$attributes, { username: 'VIRK', age: 22 }) + }) + + test('call setters when defining attributes manually', (assert) => { + class User extends Model { + setUsername (username) { + return username.toUpperCase() + } + } + + const user = new User() + user.username = 'virk' + assert.deepEqual(user.$attributes, { username: 'VIRK' }) + }) + + test('save attributes to the database and update model state', async (assert) => { + class User extends Model { + } + + const user = new User() + user.username = 'virk' + await user.save() + assert.isTrue(user.$persisted) + assert.isFalse(user.isNew) + }) + + test('return proper primary key value using primaryKeyValue getter', async (assert) => { + class User extends Model { + } + + const user = new User() + user.username = 'virk' + await user.save() + assert.equal(user.primaryKeyValue, 1) + }) + + test('define different primary key for a given model', async (assert) => { + class User extends Model { + static get primaryKey () { + return 'uuid' + } + + static get table () { + return 'my_users' + } + + static get incrementing () { + return false + } + } + + const user = new User() + user.username = 'virk' + user.uuid = 112000 + await user.save() + + assert.equal(user.primaryKeyValue, 112000) + assert.equal(user.primaryKeyValue, user.$attributes.uuid) + }) + + test('add hook for a given type', async (assert) => { + class User extends Model { + } + + User.addHook('beforeCreate', function () {}) + User.addHook('afterCreate', function () {}) + + assert.lengthOf(User.$hooks.before._handlers.create, 1) + assert.lengthOf(User.$hooks.after._handlers.create, 1) + }) + + test('throw exception when hook cycle is invalid', async (assert) => { + class User extends Model { + } + const fn = () => User.addHook('orCreate', function () { + }) + assert.throw(fn, 'E_INVALID_PARAMETER: Invalid hook event {orCreate}') + }) + + test('call before and after create hooks when saving the model for first time', async (assert) => { + class User extends Model { + } + + const stack = [] + User.addHook('beforeCreate', function () { + stack.push('before') + }) + + User.addHook('afterCreate', function () { + stack.push('after') + }) + + const user = new User() + await user.save() + assert.deepEqual(stack, ['before', 'after']) + }) + + test('abort insert if before create throws an exception', async (assert) => { + assert.plan(2) + class User extends Model { + } + + User.addHook('beforeCreate', function () { + throw new Error('Something bad happened') + }) + + User.addHook('afterCreate', function () { + stack.push('after') + }) + + const user = new User() + try { + await user.save() + } catch ({ message }) { + assert.equal(message, 'Something bad happened') + const users = await ioc.use('Adonis/Src/Database').table('users') + assert.lengthOf(users, 0) + } + }) + + test('update model when already persisted', async (assert) => { + class User extends Model { + } + + const user = new User() + user.username = 'virk' + await user.save() + user.username = 'nikk' + await user.save() + const users = await ioc.use('Adonis/Src/Database').table('users') + assert.lengthOf(users, 1) + assert.equal(users[0].username, user.$attributes.username) + assert.equal(users[0].id, user.primaryKeyValue) + }) + + test('only update when there are dirty values', async (assert) => { + class User extends Model { + } + + const queries = [] + User.onQuery((query) => queries.push(query)) + + const user = new User() + user.username = 'virk' + await user.save() + await user.save() + + assert.lengthOf(queries, 1) + assert.equal(queries[0].sql, helpers.formatQuery('insert into "users" ("created_at", "updated_at", "username") values (?, ?, ?)')) + }) + + test('update model for multiple times', async (assert) => { + class User extends Model { + } + + const queries = [] + User.onQuery((query) => queries.push(query)) + + const user = new User() + user.username = 'virk' + await user.save() + user.username = 'nikk' + await user.save() + user.username = 'virk' + await user.save() + + assert.lengthOf(queries, 3) + assert.equal(queries[0].sql, helpers.formatQuery('insert into "users" ("created_at", "updated_at", "username") values (?, ?, ?)')) + assert.equal(queries[1].sql, helpers.formatQuery('update "users" set "updated_at" = ?, "username" = ?')) + assert.deepEqual(queries[1].bindings[1], 'nikk') + assert.equal(queries[2].sql, helpers.formatQuery('update "users" set "updated_at" = ?, "username" = ?')) + assert.deepEqual(queries[2].bindings[1], 'virk') + assert.deepEqual(user.dirty, {}) + }) + + test('set timestamps automatically', async (assert) => { + class User extends Model { + } + + const user = new User() + user.username = 'virk' + await user.save() + assert.isDefined(user.$attributes.created_at) + assert.isDefined(user.$attributes.updated_at) + }) + + test('do not set timestamps when columns are not defined', async (assert) => { + class User extends Model { + static get createdAtColumn () { + return null + } + } + + const user = new User() + user.username = 'virk' + await user.save() + assert.isUndefined(user.$attributes.created_at) + assert.isDefined(user.$attributes.updated_at) + }) + + test('return serializer instance when calling fetch', async (assert) => { + class User extends Model { + } + await ioc.use('Adonis/Src/Database').insert({ username: 'virk' }).into('users') + const users = await User.query().fetch() + assert.instanceOf(users, CollectionSerializer) + }) + + test('cast all dates to moment objects after fetch', async (assert) => { + class User extends Model { + } + const user = new User() + user.username = 'virk' + await user.save() + + const users = await User.query().fetch() + assert.instanceOf(users.first().$attributes.created_at, moment) + }) + + test('collection toJSON should call model toJSON and getters', async (assert) => { + class User extends Model { + getCreatedAt (date) { + return date.fromNow() + } + } + const user = new User() + user.username = 'virk' + await user.save() + + const users = await User.query().fetch() + const json = users.toJSON() + assert.equal(json[0].created_at, 'a few seconds ago') + }) + + test('update model over insert when fetched from database', async (assert) => { + class User extends Model { + } + + let userQuery = null + User.onQuery(function (query) { + userQuery = query + }) + + await ioc.use('Adonis/Src/Database').table('users').insert({ username: 'virk' }) + const users = await User.query().fetch() + const user = users.first() + user.username = 'nikk' + await user.save() + assert.equal(userQuery.sql, helpers.formatQuery('update "users" set "updated_at" = ?, "username" = ?')) + }) + + test('format dates when saving model', async (assert) => { + class User extends Model { + static get dates () { + const dates = super.dates + dates.push('login_at') + return dates + } + + static get dateFormat () { + return 'DD' + } + } + + const user = new User() + user.username = 'nikk' + user.login_at = new Date() + await user.save() + const freshUser = await ioc.use('Adonis/Src/Database').table('users').first() + assert.equal(freshUser.login_at, new Date().getDate()) + }) + + test('call update hooks when updating model', async (assert) => { + const stack = [] + class User extends Model { + static boot () { + super.boot() + this.addHook('beforeUpdate', function () { + stack.push('before') + }) + + this.addHook('afterUpdate', function () { + stack.push('after') + }) + } + } + + User._bootIfNotBooted() + const user = new User() + user.username = 'nikk' + await user.save() + user.username = 'virk' + await user.save() + assert.deepEqual(stack, ['before', 'after']) + }) + + test('call save hooks when updating or creating model', async (assert) => { + const stack = [] + class User extends Model { + static boot () { + super.boot() + this.addHook('beforeSave', function (model) { + stack.push(`before:${model.$persisted}`) + }) + + this.addHook('afterSave', function () { + stack.push('after') + }) + } + } + + User._bootIfNotBooted() + const user = new User() + user.username = 'nikk' + await user.save() + user.username = 'virk' + await user.save() + assert.deepEqual(stack, ['before:false', 'after', 'before:true', 'after']) + }) + + test('update updated_at timestamp for mass updates', async (assert) => { + class User extends Model { + static get dates () { + const dates = super.dates + dates.push('login_at') + return dates + } + + static get dateFormat () { + return 'DD' + } + } + + User._bootIfNotBooted() + await ioc.use('Adonis/Src/Database').table('users').insert([{username: 'virk'}, { username: 'nikk' }]) + await User.query().where('username', 'virk').update({ login_at: new Date() }) + const users = await ioc.use('Adonis/Src/Database').table('users') + assert.equal(users[0].login_at, new Date().getDate()) + }) + + test('attach computed properties to the final output', async (assert) => { + class User extends Model { + static get computed () { + return ['full_name'] + } + + getFullName ({ username }) { + return `Mr. ${username}` + } + } + + User._bootIfNotBooted() + await ioc.use('Adonis/Src/Database').table('users').insert([{username: 'virk'}, { username: 'nikk' }]) + const users = await User.query().where('username', 'virk').fetch() + assert.equal(users.first().toJSON().full_name, 'Mr. virk') + }) + + test('only pick visible fields', async (assert) => { + class User extends Model { + static get visible () { + return ['created_at'] + } + } + + User._bootIfNotBooted() + await ioc.use('Adonis/Src/Database').table('users').insert([{username: 'virk'}, { username: 'nikk' }]) + const users = await User.query().where('username', 'virk').fetch() + assert.deepEqual(Object.keys(users.first().toJSON()), ['created_at']) + }) + + test('omit hidden fields', async (assert) => { + class User extends Model { + static get hidden () { + return ['created_at'] + } + } + + User._bootIfNotBooted() + await ioc.use('Adonis/Src/Database').table('users').insert([{username: 'virk'}, { username: 'nikk' }]) + const users = await User.query().where('username', 'virk').fetch() + assert.deepEqual(Object.keys(users.first().toJSON()), ['id', 'username', 'updated_at', 'login_at']) + }) +})