├── circle.yml ├── test ├── db │ ├── models │ │ ├── session.js │ │ ├── article-or-tag.js │ │ ├── article-tag.js │ │ ├── comment.js │ │ ├── tag.js │ │ ├── user.js │ │ └── article.js │ ├── migrations │ │ ├── 20160414201953_users.js │ │ ├── 20160414203024_tags.js │ │ ├── 20160415000910_sessions.js │ │ ├── 20160414203128_articles_tags.js │ │ ├── 20160414202837_articles.js │ │ ├── 20160808170032_article_or_tag.js │ │ └── 20160414203355_comments.js │ ├── knexfile.js │ ├── seeds │ │ ├── 2_tags.js │ │ ├── 0_users.js │ │ ├── 3_articles_tags.js │ │ ├── 5_sessions.js │ │ ├── 6_article_or_tag.js │ │ ├── 1_articles.js │ │ └── 4_comments.js │ └── index.js ├── fixtures │ ├── timestamper.js │ └── custom-db.js └── spec │ ├── has-one.js │ ├── belongs-to.js │ ├── belongs-to-many.js │ ├── has-many.js │ ├── polymorphism.js │ ├── sentinel.js │ └── general.js ├── LICENSE ├── package.json ├── .gitignore ├── README.md └── index.js /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | environment: 3 | NODE_ENV: test 4 | node: 5 | version: v4.3 6 | test: 7 | override: 8 | - npm run test-cov 9 | post: 10 | - npm install -g codeclimate-test-reporter 11 | - codeclimate-test-reporter < coverage/lcov.info 12 | -------------------------------------------------------------------------------- /test/db/models/session.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let db = require('../') 4 | 5 | module.exports = db.bookshelf.model('Session', { 6 | tableName: 'sessions', 7 | softDelete: true, 8 | 9 | user: function () { 10 | return this.belongsToOne('User') 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /test/db/models/article-or-tag.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let db = require('../') 4 | 5 | module.exports = db.bookshelf.model('ArticleOrTag', { 6 | tableName: 'article_or_tag', 7 | softDelete: true, 8 | 9 | source: function () { 10 | return this.morphTo('source', 'Article', 'Tag') 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /test/db/migrations/20160414201953_users.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.up = (knex) => knex.schema.createTable('users', (table) => { 4 | table.increments() 5 | table.string('name') 6 | table.string('email') 7 | table.timestamps() 8 | }) 9 | 10 | exports.down = (knex) => knex.schema.dropTable('users') 11 | -------------------------------------------------------------------------------- /test/db/migrations/20160414203024_tags.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.up = (knex) => knex.schema.createTable('tags', (table) => { 4 | table.increments() 5 | table.string('name') 6 | table.timestamp('deleted_at') 7 | table.timestamps() 8 | }) 9 | 10 | exports.down = (knex) => knex.schema.dropTable('tags') 11 | -------------------------------------------------------------------------------- /test/db/models/article-tag.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let db = require('../') 4 | 5 | module.exports = db.bookshelf.model('ArticleTag', { 6 | tableName: 'articles_tags', 7 | 8 | tag: function () { 9 | return this.hasOne('Tag') 10 | }, 11 | 12 | article: function () { 13 | return this.hasOne('Article') 14 | } 15 | }) 16 | -------------------------------------------------------------------------------- /test/db/knexfile.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.development = { 4 | client: 'sqlite3', 5 | connection: { 6 | filename: `${__dirname}/db.sqlite` 7 | }, 8 | useNullAsDefault: true, 9 | migrations: { 10 | directory: `${__dirname}/migrations` 11 | }, 12 | seeds: { 13 | directory: `${__dirname}/seeds` 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/db/models/comment.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let db = require('../') 4 | 5 | module.exports = db.bookshelf.model('Comment', { 6 | tableName: 'comments', 7 | softDelete: true, 8 | 9 | user: function () { 10 | return this.belongsTo('User') 11 | }, 12 | 13 | article: function () { 14 | return this.belongsTo('Article') 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /test/db/migrations/20160415000910_sessions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.up = (knex) => knex.schema.createTable('sessions', (table) => { 4 | table.increments() 5 | table.integer('user_id').references('users.id') 6 | table.text('token') 7 | table.timestamp('deleted_at') 8 | table.timestamps() 9 | }) 10 | 11 | exports.down = (knex) => knex.schema.dropTable('sessions') 12 | -------------------------------------------------------------------------------- /test/db/migrations/20160414203128_articles_tags.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.up = (knex) => knex.schema.createTable('articles_tags', (table) => { 4 | table.increments() 5 | table.integer('tag_id').references('tags.id') 6 | table.integer('article_id').references('articles.id') 7 | table.timestamps() 8 | }) 9 | 10 | exports.down = (knex) => knex.schema.dropTable('articles_tags') 11 | -------------------------------------------------------------------------------- /test/db/models/tag.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let db = require('../') 4 | 5 | module.exports = db.bookshelf.model('Tag', { 6 | tableName: 'tags', 7 | softDelete: true, 8 | 9 | articles: function () { 10 | return this.belongsToMany('Article').through('ArticleTag') 11 | }, 12 | 13 | articleOrTag: function () { 14 | return this.morphOne('ArticleOrTag', 'source') 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /test/db/migrations/20160414202837_articles.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.up = (knex) => knex.schema.createTable('articles', (table) => { 4 | table.increments() 5 | table.integer('user_id').references('users.id') 6 | table.string('title') 7 | table.string('body') 8 | table.timestamp('deleted_at') 9 | table.timestamps() 10 | }) 11 | 12 | exports.down = (knex) => knex.schema.dropTable('articles') 13 | -------------------------------------------------------------------------------- /test/db/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let db = require('../') 4 | 5 | module.exports = db.bookshelf.model('User', { 6 | tableName: 'users', 7 | 8 | comments: function () { 9 | return this.hasMany('Comment') 10 | }, 11 | 12 | session: function () { 13 | return this.hasOne('Session') 14 | }, 15 | 16 | articles: function () { 17 | return this.hasMany('Article') 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /test/db/migrations/20160808170032_article_or_tag.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.up = (knex) => knex.schema.createTable('article_or_tag', (table) => { 4 | table.increments() 5 | table.string('source_type').notNullable() 6 | table.integer('source_id').notNullable() 7 | table.timestamp('deleted_at') 8 | table.timestamps() 9 | }) 10 | 11 | exports.down = (knex) => knex.schema.dropTable('article_or_tag') 12 | -------------------------------------------------------------------------------- /test/db/seeds/2_tags.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let timestamper = require('../../fixtures/timestamper') 4 | 5 | exports.seed = (knex, Promise) => { 6 | let tags = timestamper([ 7 | { id: 1, name: 'deposit' }, 8 | { id: 2, name: 'Costa Rican Colon' }, 9 | { id: 3, name: 'Bedfordshire' } 10 | ]) 11 | 12 | return Promise.join( 13 | knex('tags').del(), 14 | knex('tags').insert(tags) 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /test/db/migrations/20160414203355_comments.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.up = (knex) => knex.schema.createTable('comments', (table) => { 4 | table.increments() 5 | table.integer('user_id').references('users.id') 6 | table.integer('article_id').references('articles.id') 7 | table.text('text') 8 | table.timestamp('deleted_at') 9 | table.timestamps() 10 | }) 11 | 12 | exports.down = (knex) => knex.schema.dropTable('comments') 13 | -------------------------------------------------------------------------------- /test/db/seeds/0_users.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let timestamper = require('../../fixtures/timestamper') 4 | 5 | exports.seed = (knex, Promise) => { 6 | let users = timestamper([ 7 | { id: 1, name: 'Amira Dooley', email: 'Raina_Kunde14@hotmail.com' }, 8 | { id: 2, name: 'Joaquin Leffler', email: 'Brandyn_Collier44@yahoo.com' }, 9 | { id: 3, name: 'Chaim Herman', email: 'Emmie.Stehr@yahoo.com' } 10 | ]) 11 | 12 | return Promise.join( 13 | knex('users').del(), 14 | knex('users').insert(users) 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /test/db/models/article.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let db = require('../') 4 | 5 | module.exports = db.bookshelf.model('Article', { 6 | tableName: 'articles', 7 | softDelete: true, 8 | 9 | user: function () { 10 | return this.belongsToOne('User') 11 | }, 12 | 13 | comments: function () { 14 | return this.hasMany('Comment') 15 | }, 16 | 17 | tags: function () { 18 | return this.belongsToMany('Tag').through('ArticleTag') 19 | }, 20 | 21 | articlesOrTags: function () { 22 | return this.morphMany('ArticleOrTag', 'source') 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /test/fixtures/timestamper.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let timestamps = { 4 | created_at: new Date(), 5 | updated_at: new Date() 6 | } 7 | 8 | /** 9 | * Add a timestamp fields to an object or an array of objects 10 | * @param {Object[]} src An object or and array containing object 11 | * @return {Object[]} The provided object merged with timestamps or an array 12 | */ 13 | module.exports = (src) => { 14 | if (Array.isArray(src)) { 15 | return src.map((obj) => Object.assign(obj, timestamps)) 16 | } else { 17 | return Object.assign(src, timestamps) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/db/seeds/3_articles_tags.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let timestamper = require('../../fixtures/timestamper') 4 | 5 | exports.seed = (knex, Promise) => { 6 | let articlesTags = timestamper([ 7 | { id: 1, article_id: 1, tag_id: 1 }, 8 | { id: 2, article_id: 1, tag_id: 2 }, 9 | { id: 3, article_id: 2, tag_id: 3 }, 10 | { id: 4, article_id: 2, tag_id: 1 }, 11 | { id: 5, article_id: 3, tag_id: 2 }, 12 | { id: 6, article_id: 3, tag_id: 3 }, 13 | { id: 7, article_id: 3, tag_id: 1 } 14 | ]) 15 | 16 | return Promise.join( 17 | knex('articles_tags').del(), 18 | knex('articles_tags').insert(articlesTags) 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /test/db/seeds/5_sessions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let timestamper = require('../../fixtures/timestamper') 4 | 5 | exports.seed = (knex, Promise) => { 6 | let sessions = timestamper([ 7 | { 8 | id: 1, 9 | user_id: 1, 10 | token: 'UGVyc29uYWwgTG9hbiBBY2NvdW50IFNTTCBHbG9iYWwgQ29uY3JldGU=' 11 | }, 12 | { 13 | id: 2, 14 | user_id: 2, 15 | token: 'YXJjaGl2ZSBCdWNraW5naGFtc2hpcmUgQWdlbnQgcGluaw==' 16 | }, 17 | { 18 | id: 3, 19 | user_id: 3, 20 | token: 'SW52ZXN0bWVudCBBY2NvdW50IFJlZmluZWQgYmFja2dyb3VuZCBGb3JnZXM=' 21 | } 22 | ]) 23 | 24 | return Promise.join( 25 | knex('sessions').del(), 26 | knex('sessions').insert(sessions) 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /test/db/seeds/6_article_or_tag.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let timestamper = require('../../fixtures/timestamper') 4 | 5 | exports.seed = (knex, Promise) => { 6 | let articlesOrTags = timestamper([ 7 | { 8 | id: 1, 9 | source_id: 1, 10 | source_type: 'tags' 11 | }, 12 | { 13 | id: 2, 14 | source_id: 1, 15 | source_type: 'articles' 16 | }, 17 | { 18 | id: 3, 19 | source_id: 2, 20 | source_type: 'tags' 21 | }, 22 | { 23 | id: 4, 24 | source_id: 2, 25 | source_type: 'articles' 26 | }, 27 | { 28 | id: 5, 29 | source_id: 2, 30 | source_type: 'articles' 31 | } 32 | ]) 33 | 34 | return Promise.join( 35 | knex('article_or_tag').del(), 36 | knex('article_or_tag').insert(articlesOrTags) 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /test/db/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let fs = require('fs') 4 | let config = require('./knexfile') 5 | let knex = require('knex')(config.development) 6 | let bookshelf = require('bookshelf')(knex) 7 | 8 | // Install all necessary plugins 9 | bookshelf.plugin('registry') 10 | bookshelf.plugin(require('../../')) 11 | 12 | module.exports = { 13 | knex, 14 | bookshelf, 15 | reset: () => knex.raw('SELECT name FROM sqlite_master WHERE type = "table"') 16 | .then((tables) => { 17 | let promises = tables 18 | .filter((table) => !table.name.match(/^sqlite/)) 19 | .map((table) => knex.raw(`DROP TABLE IF EXISTS ${table.name}`)) 20 | 21 | return Promise.all(promises) 22 | }) 23 | .then(() => knex.migrate.latest()) 24 | } 25 | 26 | // Load all models 27 | fs.readdirSync(`${__dirname}/models`) 28 | .forEach((model) => require(`${__dirname}/models/${model}`)) 29 | -------------------------------------------------------------------------------- /test/db/seeds/1_articles.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let timestamper = require('../../fixtures/timestamper') 4 | 5 | exports.seed = (knex, Promise) => { 6 | let articles = timestamper([ 7 | { 8 | id: 1, 9 | user_id: 1, 10 | title: 'Payment PNG Cuban Peso Peso Convertible', 11 | body: 'IB silver synergistic clicks-and-mortar Pennsylvania action-items web-readiness Saudi Arabia Gorgeous Fresh Pizza holistic' 12 | }, 13 | { 14 | id: 2, 15 | user_id: 1, 16 | title: 'Needs-based intuitive', 17 | body: 'Generic Plastic Pants Nebraska Fresh Som Pataca override quantify COM Keyboard pixel' 18 | }, 19 | { 20 | id: 3, 21 | user_id: 2, 22 | title: 'Firewall Investor Iowa', 23 | body: 'Intelligent Frozen Keyboard Industrial yellow Auto Loan Account transmit red HDD Upgradable Electronics Unbranded' 24 | } 25 | ]) 26 | 27 | return Promise.join( 28 | knex('articles').del(), 29 | knex('articles').insert(articles) 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /test/spec/has-one.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let co = require('co') 4 | let lab = exports.lab = require('lab').script() 5 | let expect = require('code').expect 6 | 7 | let db = require('../db') 8 | let User = db.bookshelf.model('User') 9 | 10 | lab.experiment('hasOne relation', () => { 11 | lab.beforeEach(co.wrap(function * () { 12 | yield db.reset() 13 | yield db.knex.seed.run() 14 | })) 15 | 16 | lab.test('should work', co.wrap(function * () { 17 | let user = yield User.forge({ id: 1 }).fetch({ withRelated: 'session' }) 18 | 19 | // Soft delete that user 20 | yield user.related('session').destroy() 21 | 22 | // Try to query again 23 | user = yield User.forge({ id: 1 }).fetch({ withRelated: 'session' }) 24 | expect(user.related('session').has('id')).to.be.false() 25 | 26 | // Query with override 27 | user = yield User.forge({ id: 1 }).fetch({ 28 | withRelated: 'session', 29 | withDeleted: true 30 | }) 31 | 32 | expect(user.related('session').id).to.be.a.number() 33 | expect(user.related('session').get('deleted_at')).to.be.a.number() 34 | })) 35 | }) 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Estate 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 | -------------------------------------------------------------------------------- /test/spec/belongs-to.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let co = require('co') 4 | let lab = exports.lab = require('lab').script() 5 | let expect = require('code').expect 6 | 7 | let db = require('../db') 8 | let Comment = db.bookshelf.model('Comment') 9 | 10 | lab.experiment('belongsTo relation', () => { 11 | lab.beforeEach(co.wrap(function * () { 12 | yield db.reset() 13 | yield db.knex.seed.run() 14 | })) 15 | 16 | lab.test('should work', co.wrap(function * () { 17 | let comment = yield Comment.forge({ id: 1 }).fetch({ withRelated: 'article' }) 18 | 19 | // Soft delete that user 20 | yield comment.related('article').destroy() 21 | 22 | // Try to query again 23 | comment = yield Comment.forge({ id: 1 }).fetch({ withRelated: 'article' }) 24 | expect(comment.related('article').has('id')).to.be.false() 25 | 26 | // Query with override 27 | comment = yield Comment.forge({ id: 1 }).fetch({ 28 | withRelated: 'article', 29 | withDeleted: true 30 | }) 31 | 32 | expect(comment.related('article').id).to.be.a.number() 33 | expect(comment.related('article').get('deleted_at')).to.be.a.number() 34 | })) 35 | }) 36 | -------------------------------------------------------------------------------- /test/fixtures/custom-db.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let co = require('co') 4 | let db = require('../db') 5 | 6 | exports.generate = co.wrap(function * (tableName, tableSchema, bookshelfConfig) { 7 | // Setup the new table 8 | yield db.knex.schema.createTable(tableName, tableSchema) 9 | 10 | // Create a new bookshelf instance and hook it 11 | let bookshelf = require('bookshelf')(db.knex) 12 | bookshelfConfig.call(bookshelfConfig, bookshelf) 13 | 14 | return bookshelf 15 | }) 16 | 17 | exports.altFieldTable = co.wrap(function * (bookshelfConfig) { 18 | let bookshelf = yield exports.generate('test', (table) => { 19 | table.increments() 20 | table.timestamp('deleted') 21 | }, bookshelfConfig) 22 | 23 | // Create one row for testing 24 | yield bookshelf.knex('test').insert({ id: 1 }) 25 | 26 | return bookshelf 27 | }) 28 | 29 | exports.sentinelTable = co.wrap(function * (bookshelfConfig) { 30 | let bookshelf = yield exports.generate('test', (table) => { 31 | table.increments() 32 | table.integer('value') 33 | table.timestamp('deleted_at') 34 | table.boolean('active').nullable() 35 | table.unique(['value', 'active']) 36 | }, bookshelfConfig) 37 | 38 | // Create one row for testing 39 | yield bookshelf.knex('test').insert({ id: 1 }) 40 | 41 | return bookshelf 42 | }) 43 | -------------------------------------------------------------------------------- /test/spec/belongs-to-many.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let co = require('co') 4 | let lab = exports.lab = require('lab').script() 5 | let expect = require('code').expect 6 | 7 | let db = require('../db') 8 | let Article = db.bookshelf.model('Article') 9 | 10 | lab.experiment('belongsToMany relation', () => { 11 | lab.beforeEach(co.wrap(function * () { 12 | yield db.reset() 13 | yield db.knex.seed.run() 14 | })) 15 | 16 | lab.test('should work', co.wrap(function * () { 17 | let article = yield Article.forge({ id: 1 }).fetch({ withRelated: 'tags' }) 18 | let tags = article.related('tags') 19 | 20 | // Soft delete one tag 21 | yield article.related('tags').at(0).destroy() 22 | 23 | // Try to query again 24 | article = yield Article.forge({ id: 1 }).fetch({ withRelated: 'tags' }) 25 | expect(article.related('tags').length).to.be.below(tags.length) 26 | expect(article.related('tags').findWhere({ id: tags.at(0).id })).to.not.exist() 27 | 28 | // Query with override 29 | article = yield Article.forge({ id: 1 }).fetch({ 30 | withRelated: 'tags', 31 | withDeleted: true 32 | }) 33 | 34 | expect(article.related('tags').length).to.equal(tags.length) 35 | expect(article.related('tags').findWhere({ id: tags.at(0).id })).to.exist() 36 | expect(article.related('tags').at(0).get('deleted_at')).to.be.a.number() 37 | })) 38 | }) 39 | -------------------------------------------------------------------------------- /test/db/seeds/4_comments.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let timestamper = require('../../fixtures/timestamper') 4 | 5 | exports.seed = (knex, Promise) => { 6 | let comments = timestamper([ 7 | { 8 | id: 1, 9 | user_id: 1, 10 | article_id: 1, 11 | text: 'Try to reboot the AI port, maybe it will bypass the mobile card' 12 | }, 13 | { 14 | id: 2, 15 | user_id: 2, 16 | article_id: 1, 17 | text: 'If we compress the driver, we can get to the XSS feed through the virtual JBOD port!' 18 | }, 19 | { 20 | id: 3, 21 | user_id: 1, 22 | article_id: 2, 23 | text: 'Use the primary AGP firewall, then you can transmit the optical capacitor.' 24 | }, 25 | { 26 | id: 4, 27 | user_id: 3, 28 | article_id: 3, 29 | text: 'Use the open-source GB system, then you can input the optical system...' 30 | }, 31 | { 32 | id: 5, 33 | user_id: 2, 34 | article_id: 3, 35 | text: 'If we hack the pixel, we can get to the GB matrix through the online COM application!' 36 | }, 37 | { 38 | id: 6, 39 | user_id: 3, 40 | article_id: 2, 41 | text: 'I\'ll calculate the cross-platform AGP protocol, that should bandwidth the SAS firewall' 42 | } 43 | ]) 44 | 45 | return Promise.join( 46 | knex('comments').del(), 47 | knex('comments').insert(comments) 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /test/spec/has-many.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let co = require('co') 4 | let lab = exports.lab = require('lab').script() 5 | let expect = require('code').expect 6 | 7 | let db = require('../db') 8 | let Article = db.bookshelf.model('Article') 9 | 10 | lab.experiment('hasMany relation', () => { 11 | lab.beforeEach(co.wrap(function * () { 12 | yield db.reset() 13 | yield db.knex.seed.run() 14 | })) 15 | 16 | lab.test('should work', co.wrap(function * () { 17 | let article = yield Article.forge({ id: 1 }).fetch({ withRelated: 'comments' }) 18 | let comments = article.related('comments') 19 | 20 | // Soft delete one tag 21 | yield article.related('comments').at(0).destroy() 22 | 23 | // Try to query again 24 | article = yield Article.forge({ id: 1 }).fetch({ withRelated: 'comments' }) 25 | expect(article.related('comments').length).to.be.below(comments.length) 26 | expect(article.related('comments').findWhere({ id: comments.at(0).id })).to.not.exist() 27 | 28 | // Query with override 29 | article = yield Article.forge({ id: 1 }).fetch({ 30 | withRelated: 'comments', 31 | withDeleted: true 32 | }) 33 | 34 | expect(article.related('comments').length).to.equal(comments.length) 35 | expect(article.related('comments').findWhere({ id: comments.at(0).id })).to.exist() 36 | expect(article.related('comments').at(0).get('deleted_at')).to.be.a.number() 37 | })) 38 | }) 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookshelf-paranoia", 3 | "version": "0.13.1", 4 | "description": "Soft delete data from your database", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "standard && lab --verbose --colors --assert code --ignore __core-js_shared__", 8 | "test-cov": "npm test -- -r console -o stdout -r html -o coverage/coverage.html -r lcov -o coverage/lcov.info", 9 | "coveralls": "cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", 10 | "knex": "knex --knexfile test/db/knexfile.js", 11 | "migrate": "npm run knex -- migrate:latest", 12 | "seed": "npm run knex -- seed:run" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/estate/bookshelf-paranoia.git" 17 | }, 18 | "keywords": [ 19 | "bookshelf", 20 | "knex", 21 | "db", 22 | "delete", 23 | "safe", 24 | "paranoia", 25 | "database", 26 | "soft" 27 | ], 28 | "author": "Estate ", 29 | "contributors": ["David García (https://www.linkedin.com/in/davidgarciafdz/)"], 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/estate/bookshelf-paranoia/issues" 33 | }, 34 | "homepage": "https://github.com/estate/bookshelf-paranoia#readme", 35 | "dependencies": { 36 | "bluebird": "^3.4.7", 37 | "lodash.merge": "^4.3.5", 38 | "lodash.result": "^4.3.0" 39 | }, 40 | "devDependencies": { 41 | "bookshelf": "^0.9.4", 42 | "co": "^4.6.0", 43 | "code": "^2.2.0", 44 | "eslint": "^2.13.1", 45 | "knex": "^0.10.0", 46 | "lab": "^10.3.1", 47 | "sqlite": "0.0.4", 48 | "sqlite3": "^3.1.3", 49 | "standard": "^6.0.8" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/spec/polymorphism.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let co = require('co') 4 | let lab = exports.lab = require('lab').script() 5 | let expect = require('code').expect 6 | 7 | let db = require('../db') 8 | let ArticleOrTag = db.bookshelf.model('ArticleOrTag') 9 | let Tag = db.bookshelf.model('Tag') 10 | let Article = db.bookshelf.model('Article') 11 | 12 | lab.experiment('polymorphism', () => { 13 | lab.beforeEach(co.wrap(function * () { 14 | yield db.reset() 15 | yield db.knex.seed.run() 16 | })) 17 | 18 | lab.test('morph to one should work', co.wrap(function * () { 19 | let sources = yield ArticleOrTag.forge() 20 | .orderBy('id', 'ASC') 21 | .fetchAll({ withRelated: 'source' }) 22 | 23 | let copy = sources.clone() 24 | yield sources.at(0).destroy() 25 | 26 | sources = yield ArticleOrTag.forge() 27 | .orderBy('id', 'ASC') 28 | .fetchAll({ withRelated: 'source' }) 29 | 30 | let withDeleted = yield ArticleOrTag.forge() 31 | .orderBy('id', 'ASC') 32 | .fetchAll({ 33 | withRelated: 'source', 34 | withDeleted: true 35 | }) 36 | 37 | expect(sources.at(0).id).to.not.equal(copy.at(0).id) 38 | expect(sources.length).to.be.below(copy.length) 39 | expect(withDeleted.length).to.equal(copy.length) 40 | })) 41 | 42 | lab.test('morphMany should work', co.wrap(function * () { 43 | let article = yield Article.forge({ id: 2 }).fetch({ withRelated: 'articlesOrTags' }) 44 | let clone = article.clone() 45 | 46 | yield article.related('articlesOrTags').at(0).destroy() 47 | article = yield Article.forge({ id: 2 }).fetch({ withRelated: 'articlesOrTags' }) 48 | let withDeleted = yield Article.forge({ id: 2 }).fetch({ 49 | withRelated: 'articlesOrTags', 50 | withDeleted: true 51 | }) 52 | 53 | expect(article.related('articlesOrTags').length).to.be.below(clone.related('articlesOrTags').length) 54 | expect(withDeleted.related('articlesOrTags').length).to.equal(clone.related('articlesOrTags').length) 55 | })) 56 | 57 | lab.test('morphOne should work', co.wrap(function * () { 58 | let tag = yield Tag.forge({ id: 1 }).fetch({ withRelated: 'articleOrTag' }) 59 | expect(tag.related('articleOrTag').id).to.equal(1) 60 | 61 | yield tag.related('articleOrTag').destroy() 62 | tag = yield Tag.forge({ id: 1 }).fetch({ withRelated: 'articleOrTag' }) 63 | let withDeleted = yield Tag.forge({ id: 1 }).fetch({ 64 | withRelated: 'articleOrTag', 65 | withDeleted: true 66 | }) 67 | 68 | expect(tag.related('articleOrTag').id).to.be.undefined() 69 | expect(withDeleted.related('articleOrTag').id).to.equal(1) 70 | })) 71 | }) 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/linux,osx,windows,vim,sublimetext,node 2 | 3 | ### Linux ### 4 | *~ 5 | 6 | # temporary files which can be created if a process still has a handle open of a deleted file 7 | .fuse_hidden* 8 | 9 | # KDE directory preferences 10 | .directory 11 | 12 | # Linux trash folder which might appear on any partition or disk 13 | .Trash-* 14 | 15 | ### OSX ### 16 | .DS_Store 17 | .AppleDouble 18 | .LSOverride 19 | 20 | # Icon must end with two \r 21 | Icon 22 | 23 | # Thumbnails 24 | ._* 25 | 26 | # Files that might appear in the root of a volume 27 | .DocumentRevisions-V100 28 | .fseventsd 29 | .Spotlight-V100 30 | .TemporaryItems 31 | .Trashes 32 | .VolumeIcon.icns 33 | 34 | # Directories potentially created on remote AFP share 35 | .AppleDB 36 | .AppleDesktop 37 | Network Trash Folder 38 | Temporary Items 39 | .apdisk 40 | 41 | ### Windows ### 42 | # Windows image file caches 43 | Thumbs.db 44 | ehthumbs.db 45 | 46 | # Folder config file 47 | Desktop.ini 48 | 49 | # Recycle Bin used on file shares 50 | $RECYCLE.BIN/ 51 | 52 | # Windows Installer files 53 | *.cab 54 | *.msi 55 | *.msm 56 | *.msp 57 | 58 | # Windows shortcuts 59 | *.lnk 60 | 61 | ### Vim ### 62 | # swap 63 | [._]*.s[a-w][a-z] 64 | [._]s[a-w][a-z] 65 | # session 66 | Session.vim 67 | # temporary 68 | .netrwhist 69 | *~ 70 | 71 | ### SublimeText ### 72 | # cache files for sublime text 73 | *.tmlanguage.cache 74 | *.tmPreferences.cache 75 | *.stTheme.cache 76 | 77 | # workspace files are user-specific 78 | *.sublime-workspace 79 | 80 | # project files should be checked into the repository, unless a significant 81 | # proportion of contributors will probably not be using SublimeText 82 | # *.sublime-project 83 | 84 | # sftp configuration file 85 | sftp-config.json 86 | 87 | ### Node ### 88 | # Logs 89 | logs 90 | *.log 91 | npm-debug.log* 92 | 93 | # Runtime data 94 | pids 95 | *.pid 96 | *.seed 97 | 98 | # Directory for instrumented libs generated by jscoverage/JSCover 99 | lib-cov 100 | 101 | # Coverage directory used by tools like istanbul 102 | coverage 103 | 104 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 105 | .grunt 106 | 107 | # node-waf configuration 108 | .lock-wscript 109 | 110 | # Compiled binary addons (http://nodejs.org/api/addons.html) 111 | build/Release 112 | 113 | # Dependency directories 114 | node_modules 115 | jspm_packages 116 | 117 | # Optional npm cache directory 118 | .npm 119 | 120 | # Optional REPL history 121 | .node_repl_history 122 | 123 | ### Others ### 124 | temp 125 | tmp 126 | docs 127 | test/db/db.sqlite 128 | -------------------------------------------------------------------------------- /test/spec/sentinel.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let co = require('co') 4 | let lab = exports.lab = require('lab').script() 5 | let expect = require('code').expect 6 | 7 | let db = require('../db') 8 | let customDb = require('../fixtures/custom-db') 9 | 10 | lab.experiment('sentinel', () => { 11 | lab.beforeEach(co.wrap(function * () { 12 | yield db.reset() 13 | yield db.knex.seed.run() 14 | })) 15 | 16 | lab.test('should set the sentinel column to true on creation', co.wrap(function * () { 17 | let bookshelf = yield customDb.sentinelTable((bookshelf) => { 18 | bookshelf.plugin(require('../../'), { sentinel: 'active' }) 19 | }) 20 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 21 | 22 | let model = yield Model.forge().save() 23 | expect(model.get('active')).to.equal(true) 24 | })) 25 | 26 | lab.test('should null the sentinel column on deletion', co.wrap(function * () { 27 | let bookshelf = yield customDb.sentinelTable((bookshelf) => { 28 | bookshelf.plugin(require('../../'), { sentinel: 'active' }) 29 | }) 30 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 31 | 32 | let model = yield Model.forge().save().then((model) => model.destroy()) 33 | expect(model.get('active')).to.be.null() 34 | })) 35 | 36 | lab.test('should do nothing if the sentinel setting is null', co.wrap(function * () { 37 | let bookshelf = yield customDb.sentinelTable((bookshelf) => { 38 | bookshelf.plugin(require('../../'), { sentinel: null }) 39 | }) 40 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 41 | 42 | let model = yield Model.forge().save() 43 | expect(model.has('active')).to.be.false() 44 | })) 45 | 46 | lab.test('should do nothing if soft deletion is not enabled', co.wrap(function * () { 47 | let bookshelf = yield customDb.sentinelTable((bookshelf) => { 48 | bookshelf.plugin(require('../../'), { sentinel: 'active' }) 49 | }) 50 | let Model = bookshelf.Model.extend({ tableName: 'test' }) 51 | 52 | let model = yield Model.forge().save().then((m) => m.destroy()) 53 | expect(model.has('active')).to.be.false() 54 | })) 55 | 56 | lab.test('should error if the sentinel column does not exist', co.wrap(function * () { 57 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 58 | bookshelf.plugin(require('../../'), { sentinel: 'active' }) 59 | }) 60 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 61 | 62 | let err = yield Model.forge().save().catch((err) => err) 63 | expect(err).to.be.an.error(/has no column/) 64 | })) 65 | 66 | lab.experiment('with a unique constraint including the sentinel column', () => { 67 | lab.test('should enforce a single active row', co.wrap(function * () { 68 | let bookshelf = yield customDb.sentinelTable((bookshelf) => { 69 | bookshelf.plugin(require('../../'), { sentinel: 'active' }) 70 | }) 71 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 72 | 73 | let model = yield Model.forge({ value: 123 }).save() 74 | let err = yield Model.forge({ value: 123 }).save().catch((err) => err) 75 | expect(err).to.be.an.error(/UNIQUE constraint failed/) 76 | 77 | yield model.destroy() 78 | model = yield Model.forge({ value: 123 }).save() 79 | expect(model.get('value')).to.equal(123) 80 | 81 | let count = yield Model.where({ value: 123 }).count('*', { withDeleted: true }) 82 | expect(count).to.equal(2) 83 | })) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bookshelf-paranoia 2 | [![Build Status](https://circleci.com/gh/estate/bookshelf-paranoia.svg?style=shield)](https://circleci.com/gh/estate/bookshelf-paranoia) 3 | [![Code Climate](https://codeclimate.com/github/estate/bookshelf-paranoia/badges/gpa.svg)](https://codeclimate.com/github/estate/bookshelf-paranoia) 4 | [![Test Coverage](https://codeclimate.com/github/estate/bookshelf-paranoia/badges/coverage.svg)](https://codeclimate.com/github/estate/bookshelf-paranoia/coverage) 5 | [![Version](https://badge.fury.io/js/bookshelf-paranoia.svg)](http://badge.fury.io/js/bookshelf-paranoia) 6 | [![Downloads](http://img.shields.io/npm/dm/bookshelf-paranoia.svg)](https://www.npmjs.com/package/bookshelf-paranoia) 7 | 8 | Protect your database from data loss by soft deleting your rows. 9 | 10 | ### Unmaintained 11 | 12 | I don't use this package anymore so it's un-maintained. I still spend a little time managing small 13 | fixes but do so at a fairly slow pace. If you're interested in maintaining this project, please reach out to me. 14 | 15 | ### Installation 16 | 17 | After installing `bookshelf-paranoia` with `npm i --save bookshelf-paranoia`, 18 | all you need to do is add it as a bookshelf plugin and enable it on your models. 19 | The default field used to soft delete your models is `deleted_at` but you can override that. 20 | 21 | ```javascript 22 | let knex = require('knex')(require('./knexfile.js').development) 23 | let bookshelf = require('bookshelf')(knex) 24 | 25 | // Add the plugin 26 | bookshelf.plugin(require('bookshelf-paranoia')) 27 | 28 | // Enable it on your models 29 | let User = bookshelf.Model.extend({ tableName: 'users', softDelete: true }) 30 | ``` 31 | 32 | ### Usage 33 | 34 | You can call every method as usual and `bookshelf-paranoia` will handle soft 35 | deletes transparently for you. 36 | 37 | ```javascript 38 | // This user is indestructible 39 | yield User.forge({ id: 1000 }).destroy() 40 | 41 | // Now try to find it again 42 | let user = yield User.forge({ id: 1000 }).fetch() // null 43 | 44 | // It won't exist, even through eager loadings 45 | let user = yield User.forge({ id: 2000 }).fetch() // undefined 46 | 47 | let blog = yield Blog.forge({ id: 2000 }).fetch({ withRelated: 'users' }) 48 | blog.related('users').findWhere({ id: 1000 }) // also undefined 49 | 50 | // But we didn't delete it from the database 51 | let user = yield knex('users').select('*').where('id', 1000) 52 | console.log(user[0].deleted_at) // Fri Apr 15 2016 00:40:40 GMT-0300 (BRT) 53 | ``` 54 | 55 | ### Overrides 56 | 57 | `bookshelf-paranoia` provides a set of overrides so you can customize your 58 | experience while using it. 59 | 60 | ```javascript 61 | // Override the field name that holds the deletion date 62 | bookshelf.plugin(require('bookshelf-paranoia'), { field: 'deletedAt' }) 63 | 64 | // Override the null value if you're using a database that defaults values to 65 | // something other than null 66 | bookshelf.plugin(require('bookshelf-paranoia'), { 67 | nullValue: '0000-00-00 00:00:00' 68 | }) 69 | 70 | // If you want to delete something for good, even if the model has soft deleting on 71 | yield User.forge({ id: 1000 }).destroy({ hardDelete: true }) 72 | 73 | // Retrieve a soft deleted row even with the plugin enabled. Works for 74 | // eager loaded relations too 75 | let user = yield User.forge({ id: 1000 }).fetch({ withDeleted: true }) 76 | 77 | // By default soft deletes also emit "destroying" and "destroyed" events. You 78 | // can easily disable this behavior when setting the plugin 79 | bookshelf.plugin(require('bookshelf-paranoia'), { events: false }) 80 | 81 | // Disable only one event 82 | bookshelf.plugin(require('bookshelf-paranoia'), { 83 | events: { destroying: false } 84 | }) 85 | 86 | // Enable saving, updating, saved, and updated. This will turn on all events 87 | // since destroying and destroyed are already on by default 88 | bookshelf.plugin(require('bookshelf-paranoia'), { 89 | events: { 90 | saving: true, 91 | updating: true, 92 | saved: true, 93 | updated: true 94 | } 95 | }) 96 | ``` 97 | 98 | ### Sentinels/Uniqueness 99 | 100 | Due to limitations with some DBMSes, constraining a soft-delete-enabled 101 | model to "only one active instance" is difficult: any unique index will 102 | capture both undeleted and deleted rows. There are ways around this, 103 | e.g., scoped indexes (WHERE deleted_at IS NULL), but the most portable 104 | method involves adding a so-called sentinel column: a field that is true/1 105 | when the row is active and NULL when it has been deleted. Since unique 106 | indexes do not consider null fields, this allows a compound unique index 107 | to fulfill our needs: indexing ['email', 'active'] will ensure only one 108 | unique active email at a time, for example. 109 | 110 | To maintain backward compatibility, sentinel functionality is disabled 111 | by default. It can be enabled globally by setting the `sentinel` config 112 | value to the name of the sentinel column, nominally "active". The 113 | sentinel column should be added to all soft-deletable tables via 114 | migration as a nullable boolean field. 115 | 116 | ```javascript 117 | // Enable sentinel values stored under "active" 118 | bookshelf.plugin(require('bookshelf-paranoia'), { sentinel: 'active' }) 119 | 120 | let user = yield User.forge().save() 121 | user.get('active') // will be true 122 | 123 | yield user.destroy() 124 | user.get('active') // will be false 125 | ``` 126 | 127 | ### Detecting soft deletes 128 | 129 | By listening to the default events emitted by bookshelf when destroying a model 130 | you're able to detect if that model is being soft deleted. 131 | 132 | ```javascript 133 | let model = new User({ id: 1000 }) 134 | 135 | // Watch for deletes as usual 136 | model.on('destroying', (model, options) => { 137 | if (options.softDelete) console.log(`User ${model.id} is being soft deleted!`) 138 | }) 139 | 140 | model.on('destroyed', (model, options) => { 141 | if (options.softDelete) console.log(`User ${model.id} has been soft deleted!`) 142 | }) 143 | 144 | yield model.destroy() 145 | // User 1000 is being soft deleted! 146 | // User 1000 has been soft deleted! 147 | ``` 148 | 149 | ### Testing 150 | 151 | ```bash 152 | git clone git@github.com:bsiddiqui/bookshelf-paranoia.git 153 | cd bookshelf-paranoia && npm install && npm test 154 | ``` 155 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let Promise = require('bluebird') 4 | let result = require('lodash.result') 5 | let merge = require('lodash.merge') 6 | 7 | /** 8 | * A function that can be used as a plugin for bookshelf 9 | * @param {Object} bookshelf The main bookshelf instance 10 | * @param {Object} [settings] Additional settings for configuring this plugin 11 | * @param {String} [settings.field=deleted_at] The name of the field that stores 12 | * the soft delete information for that model 13 | * @param {String?} [settings.sentinel=null] The name of the field that stores 14 | * the model's active state as a boolean for unique indexing purposes, if any 15 | */ 16 | module.exports = (bookshelf, settings) => { 17 | // Add default settings 18 | settings = merge( 19 | { 20 | field: 'deleted_at', 21 | nullValue: null, 22 | sentinel: null, 23 | events: { 24 | destroying: true, 25 | updating: false, 26 | saving: false, 27 | destroyed: true, 28 | updated: false, 29 | saved: false 30 | } 31 | }, 32 | settings 33 | ) 34 | 35 | /** 36 | * Check if the operation needs to be patched for not retrieving 37 | * soft deleted rows 38 | * @param {Object} model An instantiated bookshelf model 39 | * @param {Object} attrs The attributes that's being queried 40 | * @param {Object} options The operation option 41 | * @param {Boolean} [options.withDeleted=false] Override the default behavior 42 | * and allow querying soft deleted objects 43 | */ 44 | function skipDeleted (model, attrs, options) { 45 | if (!options.isEager || options.parentResponse) { 46 | let softDelete = this.model 47 | ? this.model.prototype.softDelete 48 | : this.softDelete 49 | 50 | if (softDelete && !options.withDeleted) { 51 | if (settings.nullValue === null) { 52 | options.query.whereNull(`${result(this, 'tableName')}.${settings.field}`) 53 | } else { 54 | options.query.where(`${result(this, 'tableName')}.${settings.field}`, settings.nullValue) 55 | } 56 | } 57 | } 58 | } 59 | 60 | // Store prototypes for later 61 | let modelPrototype = bookshelf.Model.prototype 62 | let collectionPrototype = bookshelf.Collection.prototype 63 | 64 | // Extends the default collection to be able to patch relational queries 65 | // against a set of models 66 | bookshelf.Collection = bookshelf.Collection.extend({ 67 | initialize: function () { 68 | collectionPrototype.initialize.call(this) 69 | 70 | this.on('fetching', skipDeleted.bind(this)) 71 | this.on('counting', (collection, options) => 72 | skipDeleted.call(this, null, null, options) 73 | ) 74 | } 75 | }) 76 | 77 | // Extends the default model class 78 | bookshelf.Model = bookshelf.Model.extend({ 79 | initialize: function () { 80 | modelPrototype.initialize.call(this) 81 | 82 | if (this.softDelete && settings.sentinel) { 83 | this.defaults = merge( 84 | { 85 | [settings.sentinel]: true 86 | }, 87 | result(this, 'defaults') 88 | ) 89 | } 90 | 91 | this.on('fetching', skipDeleted.bind(this)) 92 | }, 93 | 94 | /** 95 | * Override the default destroy method to provide soft deletion logic 96 | * @param {Object} [options] The default options parameters from Model.destroy 97 | * @param {Boolean} [options.hardDelete=false] Override the default soft 98 | * delete behavior and allow a model to be hard deleted 99 | * @param {Number|Date} [options.date=new Date()] Use a client supplied time 100 | * @return {Promise} A promise that's fulfilled when the model has been 101 | * hard or soft deleted 102 | */ 103 | destroy: function (options) { 104 | options = options || {} 105 | if (this.softDelete && !options.hardDelete) { 106 | let query = this.query() 107 | // Add default values to options 108 | options = merge( 109 | { 110 | method: 'update', 111 | patch: true, 112 | softDelete: true, 113 | query: query 114 | }, 115 | options 116 | ) 117 | 118 | const date = options.date ? new Date(options.date) : new Date() 119 | 120 | // Attributes to be passed to events 121 | let attrs = { [settings.field]: date } 122 | // Null out sentinel column, since NULL is not considered by SQL unique indexes 123 | if (settings.sentinel) { 124 | attrs[settings.sentinel] = null 125 | } 126 | 127 | // Make sure the field is formatted the same as other date columns 128 | attrs = this.format(attrs) 129 | 130 | return Promise.resolve() 131 | .then(() => { 132 | // Don't need to trigger hooks if there's no events registered 133 | if (!settings.events) return 134 | 135 | let events = [] 136 | 137 | // Emulate all pre update events 138 | if (settings.events.destroying) { 139 | events.push( 140 | this.triggerThen('destroying', this, options).bind(this) 141 | ) 142 | } 143 | 144 | if (settings.events.saving) { 145 | events.push( 146 | this.triggerThen('saving', this, attrs, options).bind(this) 147 | ) 148 | } 149 | 150 | if (settings.events.updating) { 151 | events.push( 152 | this.triggerThen('updating', this, attrs, options).bind(this) 153 | ) 154 | } 155 | 156 | // Resolve all promises in parallel like bookshelf does 157 | return Promise.all(events) 158 | }) 159 | .then(() => { 160 | // Check if we need to use a transaction 161 | if (options.transacting) { 162 | query = query.transacting(options.transacting) 163 | } 164 | 165 | return query 166 | .update(attrs, this.idAttribute) 167 | .where(this.format(this.attributes)) 168 | .where(`${result(this, 'tableName')}.${settings.field}`, settings.nullValue) 169 | }) 170 | .then((resp) => { 171 | // Check if the caller required a row to be deleted and if 172 | // events weren't totally disabled 173 | if (resp === 0 && options.require) { 174 | throw new this.constructor.NoRowsDeletedError('No Rows Deleted') 175 | } else if (!settings.events) { 176 | return 177 | } 178 | 179 | // Add previous attr for reference and reset the model to pristine state 180 | this.set(attrs) 181 | options.previousAttributes = this._previousAttributes 182 | this._reset() 183 | 184 | let events = [] 185 | 186 | // Emulate all post update events 187 | if (settings.events.destroyed) { 188 | events.push( 189 | this.triggerThen('destroyed', this, options).bind(this) 190 | ) 191 | } 192 | 193 | if (settings.events.saved) { 194 | events.push( 195 | this.triggerThen('saved', this, resp, options).bind(this) 196 | ) 197 | } 198 | 199 | if (settings.events.updated) { 200 | events.push( 201 | this.triggerThen('updated', this, resp, options).bind(this) 202 | ) 203 | } 204 | 205 | return Promise.all(events) 206 | }) 207 | .then(() => this) 208 | } else { 209 | return modelPrototype.destroy.call(this, options) 210 | } 211 | } 212 | }) 213 | } 214 | -------------------------------------------------------------------------------- /test/spec/general.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | let co = require('co') 4 | let lab = exports.lab = require('lab').script() 5 | let expect = require('code').expect 6 | 7 | let db = require('../db') 8 | let customDb = require('../fixtures/custom-db') 9 | let Comment = db.bookshelf.model('Comment') 10 | let User = db.bookshelf.model('User') 11 | 12 | lab.experiment('general tests', () => { 13 | lab.beforeEach(co.wrap(function * () { 14 | yield db.reset() 15 | yield db.knex.seed.run() 16 | })) 17 | 18 | lab.test('should work', co.wrap(function * () { 19 | let model = yield Comment.forge({ id: 1 }).destroy() 20 | 21 | let comment = yield Comment.forge({ id: 1 }).fetch() 22 | expect(comment).to.be.null() 23 | 24 | comment = yield db.knex('comments').select('*').where('id', 1) 25 | expect(comment[0].deleted_at).to.be.a.number() 26 | expect(model.get('deleted_at')).to.be.a.date() 27 | })) 28 | 29 | lab.test('works with nullValue override', co.wrap(function * () { 30 | const nullValue = '0000-00-00 00:00:00' 31 | 32 | const bookshelf = yield customDb.sentinelTable((bookshelf) => { 33 | bookshelf.plugin(require('../../'), { nullValue }) 34 | }) 35 | 36 | const Model = bookshelf.Model.extend({ 37 | tableName: 'test', 38 | softDelete: true 39 | }) 40 | 41 | const model = yield Model.forge({ 42 | id: 1, 43 | deleted_at: nullValue 44 | }).save() 45 | 46 | yield model.destroy() 47 | 48 | const deletedModel = yield Model.forge({ id: 1 }).fetch() 49 | expect(deletedModel).to.be.null() 50 | 51 | const knexModels = yield db.knex('test').select('*').where('id', 1) 52 | expect(knexModels[0].deleted_at).to.be.a.number() 53 | expect(model.get('deleted_at')).to.be.a.date() 54 | })) 55 | 56 | lab.test('should not be able to delete twice', co.wrap(function * () { 57 | yield Comment.forge({ id: 1 }).destroy() 58 | 59 | const error = yield Comment.forge({ id: 1 }) 60 | .destroy({ require: true }) 61 | .catch((err) => err) 62 | 63 | expect(error).to.be.instanceOf(Comment.NoRowsDeletedError) 64 | })) 65 | 66 | lab.test('should work with user-provided time as Date', co.wrap(function * () { 67 | const now = new Date() 68 | let model = yield Comment.forge({ id: 1 }).destroy({ date: now }) 69 | 70 | let comment = yield Comment.forge({ id: 1 }).fetch() 71 | expect(comment).to.be.null() 72 | 73 | comment = yield db.knex('comments').select('*').where('id', 1) 74 | expect(comment[0].deleted_at).to.be.a.number() 75 | expect(model.get('deleted_at')).to.be.a.date() 76 | expect(model.get('deleted_at').getTime()).to.equal(now.getTime()) 77 | })) 78 | 79 | lab.test('should work with user-provided time as milliseconds', co.wrap(function * () { 80 | const now = Date.now() 81 | let model = yield Comment.forge({ id: 1 }).destroy({ date: now }) 82 | 83 | let comment = yield Comment.forge({ id: 1 }).fetch() 84 | expect(comment).to.be.null() 85 | 86 | comment = yield db.knex('comments').select('*').where('id', 1) 87 | expect(comment[0].deleted_at).to.be.a.number() 88 | expect(model.get('deleted_at')).to.be.a.date() 89 | expect(model.get('deleted_at').getTime()).to.equal(now) 90 | })) 91 | 92 | lab.test('should work with transactions', co.wrap(function * () { 93 | let err = yield db.bookshelf.transaction((transacting) => { 94 | return Comment.forge({ id: 1 }) 95 | .destroy({ transacting }) 96 | .then(() => { throw new Error('Rollback this transaction') }) 97 | }) 98 | .catch((err) => err) 99 | 100 | expect(err.message).to.equal('Rollback this transaction') 101 | 102 | let comment = yield Comment.forge({ id: 1 }).fetch() 103 | expect(comment.get('deleted_at')).to.be.null() 104 | })) 105 | 106 | lab.experiment('errors', () => { 107 | lab.test('should throw when required', co.wrap(function * () { 108 | let err = yield Comment.forge({ id: 12345 }) 109 | .destroy({ require: true }) 110 | .catch((err) => err) 111 | 112 | expect(err).to.be.an.error('No Rows Deleted') 113 | })) 114 | 115 | lab.test('should not throw when required if a row was soft deleted', co.wrap(function * () { 116 | yield Comment.forge({ id: 1 }).destroy({ require: true }) 117 | 118 | let comment = yield Comment.forge({ id: 1 }).fetch() 119 | expect(comment).to.be.null() 120 | })) 121 | 122 | lab.test('allows for filtered catch', co.wrap(function * () { 123 | let err = yield Comment.forge({ id: 12345 }) 124 | .destroy({ require: true }) 125 | .catch(db.bookshelf.Model.NoRowsDeletedError, (err) => err) 126 | 127 | expect(err).to.be.an.error('No Rows Deleted') 128 | })) 129 | }) 130 | 131 | lab.test('should preserve original query object', co.wrap(function * () { 132 | yield Comment.forge({ article_id: 1 }).query((qb) => qb.where('id', 1)).destroy() 133 | let comment1 = yield Comment.forge({ id: 1 }).fetch() 134 | let comment2 = yield Comment.forge({ id: 2 }).fetch() 135 | expect(comment1).to.be.null() 136 | expect(comment2).to.not.be.null() 137 | })) 138 | 139 | lab.test('should delete according to query object', co.wrap(function * () { 140 | yield Comment.query((qb) => qb.where('id', 2)).destroy() 141 | let comment = yield Comment.forge({ id: 1 }).fetch() 142 | expect(comment).to.not.be.null() 143 | })) 144 | 145 | lab.test('should allow override when destroying', co.wrap(function * () { 146 | yield Comment.forge({ id: 1 }).destroy() 147 | 148 | let comment = yield db.knex('comments').select('*').where('id', 1) 149 | expect(comment[0].deleted_at).to.be.a.number() 150 | 151 | yield Comment.forge({ id: 1 }).destroy({ hardDelete: true }) 152 | 153 | comment = yield db.knex('comments').select('*').where('id', 1) 154 | expect(comment[0]).to.be.undefined() 155 | })) 156 | 157 | lab.test('should properly update the document when using query', co.wrap(function * () { 158 | yield Comment.where({ id: 1 }).destroy() 159 | 160 | let comment = yield db.knex('comments').select('*').where('id', 1) 161 | expect(comment[0].deleted_at).to.be.a.number() 162 | 163 | yield Comment.forge({ id: 1 }).destroy({ hardDelete: true }) 164 | 165 | comment = yield db.knex('comments').select('*').where('id', 1) 166 | expect(comment[0]).to.be.undefined() 167 | })) 168 | 169 | lab.test('should allow querying soft deleted models', co.wrap(function * () { 170 | yield Comment.forge({ id: 1 }).destroy() 171 | 172 | let comment = yield Comment.forge({ id: 1 }).fetch() 173 | expect(comment).to.be.null() 174 | 175 | comment = yield Comment.forge({ id: 1 }).fetch({ withDeleted: true }) 176 | expect(comment.id).to.equal(1) 177 | expect(comment.get('deleted_at')).to.be.a.number() 178 | })) 179 | 180 | lab.test('should correctly emit events', co.wrap(function * () { 181 | let events = [] 182 | let model = Comment.forge({ id: 1 }) 183 | 184 | model.on('destroying', (model, options) => events.push(['destroying', model, options])) 185 | model.on('destroyed', (model, options) => events.push(['destroyed', model, options])) 186 | 187 | yield model.destroy({ customOption: true }) 188 | 189 | expect(events).to.have.length(2) 190 | expect(events[0][0]).to.equal('destroying') 191 | expect(events[0][1].get('id')).to.equal(1) 192 | expect(events[0][2]).to.deep.contain({ 193 | softDelete: true, 194 | customOption: true 195 | }) 196 | expect(events[1][0]).to.equal('destroyed') 197 | expect(events[1][1].get('id')).to.equal(1) 198 | expect(events[1][2]).to.deep.contain({ 199 | softDelete: true, 200 | customOption: true 201 | }) 202 | })) 203 | 204 | lab.test('should hard delete when models do not have soft delete configured', co.wrap(function * () { 205 | yield User.forge({ id: 1 }).destroy() 206 | 207 | expect(yield db.knex('users').select('*').where('id', 1)).to.have.length(0) 208 | })) 209 | 210 | lab.test('should allow overriding the field name', co.wrap(function * () { 211 | // Create a new bookshelf instance 212 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 213 | bookshelf.plugin(require('../../'), { field: 'deleted' }) 214 | }) 215 | 216 | // Create the model that corresponds to our newly created table 217 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 218 | yield Model.forge({ id: 1 }).destroy() 219 | 220 | // Try to fetch it trough the model 221 | let test = yield Model.forge({ id: 1 }).fetch() 222 | expect(test).to.be.null() 223 | 224 | // Now try to fetch it directly though knex 225 | test = yield db.knex('test').select('*').where('id', 1) 226 | expect(test[0].deleted).to.be.a.number() 227 | })) 228 | 229 | lab.test('should allow overriding the destroyed event', co.wrap(function * () { 230 | // Create a new bookshelf instance 231 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 232 | bookshelf.plugin(require('../../'), { 233 | field: 'deleted', 234 | events: { destroyed: false } 235 | }) 236 | }) 237 | 238 | // Create the model that corresponds to our newly created table 239 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 240 | let events = [] 241 | 242 | let model = Model.forge({ id: 1 }) 243 | model.on('destroying', (model, options) => events.push(['destroying', model, options])) 244 | model.on('destroyed', (model, options) => events.push(['destroyed', model, options])) 245 | 246 | yield model.destroy() 247 | 248 | expect(events).to.have.length(1) 249 | expect(events[0][0]).to.equal('destroying') 250 | })) 251 | 252 | lab.test('should allow overriding the destroying event', co.wrap(function * () { 253 | // Create a new bookshelf instance 254 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 255 | bookshelf.plugin(require('../../'), { 256 | field: 'deleted', 257 | events: { destroying: false } 258 | }) 259 | }) 260 | 261 | // Create the model that corresponds to our newly created table 262 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 263 | let events = [] 264 | 265 | let model = Model.forge({ id: 1 }) 266 | model.on('destroying', (model, options) => events.push(['destroying', model, options])) 267 | model.on('destroyed', (model, options) => events.push(['destroyed', model, options])) 268 | 269 | yield model.destroy() 270 | 271 | expect(events).to.have.length(1) 272 | expect(events[0][0]).to.equal('destroyed') 273 | })) 274 | 275 | lab.test('should allow overriding the saving event', co.wrap(function * () { 276 | // Create a new bookshelf instance 277 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 278 | bookshelf.plugin(require('../../'), { 279 | field: 'deleted', 280 | events: { 281 | destroying: false, 282 | updating: false, 283 | saving: true 284 | } 285 | }) 286 | }) 287 | 288 | // Create the model that corresponds to our newly created table 289 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 290 | let events = [] 291 | 292 | let model = Model.forge({ id: 1 }) 293 | model.on('saving', (model, attrs, options) => events.push([model, attrs, options])) 294 | 295 | yield model.destroy() 296 | 297 | expect(events).to.have.length(1) 298 | expect(events[0][0].id).to.equal(1) 299 | expect(events[0][1].deleted).to.be.a.date() 300 | expect(events[0][2].previousAttributes.deleted).to.not.exist() 301 | })) 302 | 303 | lab.test('should allow overriding the updating event', co.wrap(function * () { 304 | // Create a new bookshelf instance 305 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 306 | bookshelf.plugin(require('../../'), { 307 | field: 'deleted', 308 | events: { 309 | destroying: false, 310 | updating: true, 311 | saving: true 312 | } 313 | }) 314 | }) 315 | 316 | // Create the model that corresponds to our newly created table 317 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 318 | let events = [] 319 | 320 | let model = Model.forge({ id: 1 }) 321 | model.on('updating', (model, attrs, options) => events.push([model, attrs, options])) 322 | 323 | yield model.destroy() 324 | 325 | expect(events).to.have.length(1) 326 | expect(events[0][0].id).to.equal(1) 327 | expect(events[0][1].deleted).to.be.a.date() 328 | expect(events[0][2].previousAttributes.deleted).to.not.exist() 329 | })) 330 | 331 | lab.test('should allow overriding the saved event', co.wrap(function * () { 332 | // Create a new bookshelf instance 333 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 334 | bookshelf.plugin(require('../../'), { 335 | field: 'deleted', 336 | events: { 337 | destroyed: false, 338 | updated: false, 339 | saved: true 340 | } 341 | }) 342 | }) 343 | 344 | // Create the model that corresponds to our newly created table 345 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 346 | let events = [] 347 | 348 | let model = Model.forge({ id: 1 }) 349 | model.on('saved', (model, attrs, options) => events.push([model, attrs, options])) 350 | 351 | yield model.destroy() 352 | 353 | expect(events).to.have.length(1) 354 | expect(events[0][0].id).to.equal(1) 355 | expect(events[0][1]).to.equal(1) 356 | expect(events[0][2].previousAttributes.deleted).to.not.exist() 357 | })) 358 | 359 | lab.test('should allow overriding the updated event', co.wrap(function * () { 360 | // Create a new bookshelf instance 361 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 362 | bookshelf.plugin(require('../../'), { 363 | field: 'deleted', 364 | events: { 365 | destroyed: false, 366 | updated: true, 367 | saved: false 368 | } 369 | }) 370 | }) 371 | 372 | // Create the model that corresponds to our newly created table 373 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 374 | let events = [] 375 | 376 | let model = Model.forge({ id: 1 }) 377 | model.on('updated', (model, attrs, options) => events.push([model, attrs, options])) 378 | 379 | yield model.destroy() 380 | 381 | expect(events).to.have.length(1) 382 | expect(events[0][0].id).to.equal(1) 383 | expect(events[0][1]).to.equal(1) 384 | expect(events[0][2].previousAttributes.deleted).to.not.exist() 385 | })) 386 | 387 | lab.test('should allow disabling events', co.wrap(function * () { 388 | // Create a new bookshelf instance 389 | let bookshelf = yield customDb.altFieldTable((bookshelf) => { 390 | bookshelf.plugin(require('../../'), { 391 | field: 'deleted', 392 | events: false 393 | }) 394 | }) 395 | 396 | // Create the model that corresponds to our newly created table 397 | let Model = bookshelf.Model.extend({ tableName: 'test', softDelete: true }) 398 | let events = [] 399 | 400 | let model = Model.forge({ id: 1 }) 401 | model.on('destroying', (model, options) => events.push(['destroying', model, options])) 402 | model.on('destroyed', (model, options) => events.push(['destroyed', model, options])) 403 | 404 | yield model.destroy() 405 | 406 | expect(events).to.have.length(0) 407 | })) 408 | 409 | lab.test('should pass query to events like bookshelf', co.wrap(function * () { 410 | let model = Comment.forge({ id: 3 }) 411 | 412 | model.on('destroying', (model, options) => { 413 | expect(options.query).to.exist() 414 | }) 415 | 416 | yield model.destroy() 417 | })) 418 | }) 419 | --------------------------------------------------------------------------------