diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..51a8ae4 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015", "stage-2"], + "plugins": ["transform-runtime"] +} diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..7fe9686 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1 @@ +extends: seegno diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8cfba4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/test/coverage +/test/knexfile.js diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d6e0be0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: node_js + +before_script: + - cp test/knexfile.js.dist test/knexfile.js + - psql -U postgres -c 'create database "bookshelf-cascade-delete";' + +node_js: + - "0.10" + - "4" + - "5" + +after_success: + - npm run coveralls + +sudo: false diff --git a/README.md b/README.md index 74993e5..9a49e7f 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ # bookshelf-cascade-delete + +This [Bookshelf.js](https://github.com/tgriesser/bookshelf) plugin provides cascade delete with a simple configuration on your models. + +## Status + +[![npm version][npm-image]][npm-url] [![build status][travis-image]][travis-url] [![coverage status][coveralls-image]][coveralls-url] + +## Installation + +Install the package via `npm`: + +```sh +$ npm install --save bookshelf-cascade-delete +``` + +## Usage + +Require and register the `bookshelf-cascade-delete` plugin: + +```js +var bookshelf = require('bookshelf')(knex); +var cascadeDelete = require('bookshelf-cascade-delete'); + +// You need to access the `default` property since the plugin is transpilled from es6 modules syntax. +bookshelf.plugin(cascadeDelete.default); +``` + +Define which relations depend on your model when it's destroyed with the `dependents` prototype property: + +```js +var Post = bookshelf.Model.extend({ + tableName: 'Post' +}); + +var Author = bookshelf.Model.extend({ + tableName: 'Author', + posts: function() { + return this.hasMany(Post); + } +}, { + dependents: ['posts'] +}); +``` + +**NOTE:** This plugin extends the `destroy` method of Bookshelf's `Model`, so if you are extending or overriding it on your models make sure to call its prototype after your work is done: + +```js +var Author = bookshelf.Model.extend({ + tableName: 'Author', + posts: function() { + return this.hasMany(Post); + }, + destroy: function() { + // Do some stuff. + sendDeleteAccountEmail(this); + + // Call the destroy prototype method. + bookshelf.Model.prototype.destroy.apply(this, arguments); + } +}, { + dependents: ['posts'] +}); +``` + +## Contributing + +Feel free to fork this repository and submit pull requests. To run the tests, duplicate the `test/knexfile.js.dist` file, update it to your needs and run: + +```sh +$ npm test +``` + +## Credits + +This plugin's code is heavily inspired on the [tkellen](https://github.com/tkellen) contribution for this [issue](https://github.com/tgriesser/bookshelf/issues/135), so cheers to him for making our job really easy! + +## License + +MIT + +[coveralls-image]: https://coveralls.io/repos/github/seegno/bookshelf-cascade-delete/badge.svg?branch=master +[coveralls-url]: https://coveralls.io/github/seegno/bookshelf-cascade-delete?branch=master +[npm-image]: https://img.shields.io/npm/v/bookshelf-cascade-delete.svg?style=flat-square +[npm-url]: https://npmjs.org/package/bookshelf-cascade-delete +[travis-image]: https://img.shields.io/travis/seegno/bookshelf-cascade-delete.svg?style=flat-square +[travis-url]: https://travis-ci.org/seegno/bookshelf-cascade-delete diff --git a/package.json b/package.json new file mode 100644 index 0000000..c84feb2 --- /dev/null +++ b/package.json @@ -0,0 +1,73 @@ +{ + "name": "bookshelf-cascade-delete", + "version": "0.0.0", + "description": "Cascade delete with Bookshelf.js", + "license": "MIT", + "author": { + "name": "Ricardo Gama", + "email": "ricardo@seegno.com", + "url": "https://github.com/ricardogama" + }, + "homepage": "http://seegno.github.io/bookshelf-cascade-delete", + "bugs": { + "url": "https://github.com/seegno/bookshelf-cascade-delete/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/seegno/bookshelf-cascade-delete.git" + }, + "main": "./dist/index.js", + "keywords": [ + "bookshelf", + "cascade", + "delete" + ], + "options": { + "isparta": "--dir test/coverage", + "mocha": "--compilers js:babel-register --bail test/index.js" + }, + "scripts": { + "build": "rm -rf dist && babel src --out-dir dist", + "changelog": "github_changelog_generator --bug-labels --enhancement-labels --header-label='# Changelog'", + "cover": "babel-node node_modules/.bin/isparta cover $npm_package_options_isparta _mocha -- $npm_package_options_mocha", + "coveralls": "npm run cover && cat ./test/coverage/lcov.info | coveralls", + "lint": "git diff --cached --name-only --diff-filter=ACMRTUXB | grep -E '\\.(js)(\\..+)?$' | xargs eslint", + "test": "mocha $npm_package_options_mocha" + }, + "dependencies": { + "bluebird": "^3.3.5", + "lodash": "^4.11.1" + }, + "peerDependencies": { + "bookshelf": ">= 0.8" + }, + "devDependencies": { + "babel-cli": "^6.7.5", + "babel-eslint": "^6.0.2", + "babel-plugin-transform-runtime": "^6.7.5", + "babel-preset-es2015": "^6.6.0", + "babel-preset-stage-2": "^6.5.0", + "babel-register": "^6.7.2", + "babel-runtime": "^6.6.1", + "bookshelf": "^0.9.4", + "coveralls": "^2.11.9", + "eslint": "^2.8.0", + "eslint-config-seegno": "^4.0.0", + "eslint-plugin-babel": "^3.2.0", + "eslint-plugin-sort-class-members": "^1.0.1", + "isparta": "^4.0.0", + "knex": "^0.10.0", + "mocha": "^2.4.5", + "pg": "^4.5.3", + "pre-commit": "^1.1.2", + "should": "^8.3.1", + "sinon": "^1.17.3" + }, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.10" + }, + "pre-commit": [ + "lint" + ] +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..6304a9b --- /dev/null +++ b/src/index.js @@ -0,0 +1,76 @@ + +/** + * Module dependencies. + */ + +import Promise from 'bluebird'; +import { compact, flatten, reduce } from 'lodash'; + +/** + * Export `bookshelf-cascade-delete` plugin. + */ + +export default Bookshelf => { + const Model = Bookshelf.Model.prototype; + + Bookshelf.Model = Bookshelf.Model.extend({ + cascadeDelete(transaction, options) { + return Promise.map(this.constructor.recursiveDeletes(this.get('id'), options), query => query(transaction)) + .then(() => Model.destroy.call(this, { + ...options, + transacting: transaction + })); + }, + destroy(options) { + options = options || {}; + + if (options.cascadeDelete === false) { + return Model.destroy.call(this, options); + } + + if (options.transacting) { + return this.cascadeDelete(options.transacting, options); + } + + return Bookshelf.knex.transaction(transaction => this.cascadeDelete(transaction, options)); + } + }, { + dependencyMap() { + if (!this.dependents) { + return; + } + + return reduce(this.dependents, (result, dependent) => { + const { foreignKey, target } = this.prototype[dependent]().relatedData; + + return { + ...result, + [dependent]: { + dependents: target.dependencyMap(), + key: foreignKey, + model: target + } + }; + }, {}); + }, + recursiveDeletes(parent, options) { + // Stringify in case of parent being an instance of query. + const parentValue = typeof parent === 'number' || typeof parent === 'string' ? `'${parent}'` : parent.toString(); + + // Build delete queries for each dependent. + const queries = reduce(this.dependencyMap(), (result, dependent) => { + const tableName = dependent.model.prototype.tableName; + const whereClause = `"${dependent.key}" IN (${parentValue})`; + const selectQuery = Bookshelf.knex(tableName).column('id').whereRaw(whereClause); + + return [ + ...result, + transaction => transaction(tableName).del().whereRaw(whereClause), + dependent.model.recursiveDeletes(selectQuery, options) + ]; + }, []); + + return flatten(compact(queries)).reverse(); + } + }); +}; diff --git a/test/fixtures/collections.js b/test/fixtures/collections.js new file mode 100644 index 0000000..38e93eb --- /dev/null +++ b/test/fixtures/collections.js @@ -0,0 +1,31 @@ + +/** + * Module dependencies. + */ + +import { repository } from './repository'; +import { Account, Author, Comment, Post } from './models'; + +/** + * Export `Accounts`. + */ + +export const Accounts = repository.Collection.extend({ model: Account }); + +/** + * Export `Authors`. + */ + +export const Authors = repository.Collection.extend({ model: Author }); + +/** + * Export `Comments`. + */ + +export const Comments = repository.Collection.extend({ model: Comment }); + +/** + * Export `Posts`. + */ + +export const Posts = repository.Collection.extend({ model: Post }); diff --git a/test/fixtures/models.js b/test/fixtures/models.js new file mode 100644 index 0000000..9f7ad49 --- /dev/null +++ b/test/fixtures/models.js @@ -0,0 +1,47 @@ + +/** + * Module dependencies. + */ + +import { repository } from './repository'; + +/** + * Export `Account`. + */ + +export const Account = repository.Model.extend({ tableName: 'Account' }); + +/** + * Export `Comment`. + */ + +export const Comment = repository.Model.extend({ tableName: 'Comment' }); + +/** + * Export `Post`. + */ + +export const Post = repository.Model.extend({ + comments() { + return this.hasMany(Comment, 'postId'); + }, + tableName: 'Post' +}, { + dependents: ['comments'] +}); + +/** + * Export `Author`. + */ + +export const Author = repository.Model.extend({ + account() { + return this.hasOne(Account, 'authorId'); + }, + posts() { + return this.hasMany(Post, 'authorId'); + }, + tableName: 'Author' +}, { + dependents: ['account', 'posts'] +}); diff --git a/test/fixtures/repository.js b/test/fixtures/repository.js new file mode 100644 index 0000000..178cf82 --- /dev/null +++ b/test/fixtures/repository.js @@ -0,0 +1,27 @@ + +/** + * Module dependencies. + */ + +import Bookshelf from 'bookshelf'; +import cascadeDelete from '../../src'; +import knex from 'knex'; +import knexfile from '../knexfile'; + +/** + * Export `repository`. + */ + +export const repository = Bookshelf(knex(knexfile)); + +/** + * Export `Model`. + */ + +export const Model = repository.Model.prototype; + +/** + * Register `bookshelf-cascade-delete` plugin. + */ + +repository.plugin(cascadeDelete); diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..cd121e5 --- /dev/null +++ b/test/index.js @@ -0,0 +1,167 @@ + +/** + * Module dependencies. + */ + +import should from 'should'; +import sinon from 'sinon'; +import { Account, Author, Comment, Post } from './fixtures/models'; +import { Accounts, Authors, Comments, Posts } from './fixtures/collections'; +import { Model, repository } from './fixtures/repository'; +import { clearTables, dropTables, recreateTables } from './utils'; + +/** + * Test `bookshelf-cascade-delete` plugin. + */ + +describe('bookshelf-cascade-delete', () => { + before(async () => { + await recreateTables(); + }); + + beforeEach(async () => { + await clearTables(); + }); + + after(async () => { + await dropTables(); + }); + + it('should throw an error if model has no registered dependents', async () => { + const author = await repository.Model.extend({ tableName: 'Author' }).forge().save(); + + await Account.forge().save({ authorId: author.get('id') }); + + try { + await author.destroy(); + + should.fail(); + } catch(e) { + e.code.should.equal('23503'); + } + }); + + it('should throw an error if model has dependents and `cascadeDelete` option is given as `false`', async () => { + const author = await Author.forge().save(); + + await Account.forge().save({ authorId: author.get('id') }); + + try { + await author.destroy({ cascadeDelete: false }); + + should.fail(); + } catch(e) { + e.code.should.equal('23503'); + } + }); + + it('should not delete model and its dependents if an error is thrown on destroy', async () => { + const author = await Author.forge().save(); + const post = await Post.forge().save({ authorId: author.get('id') }); + + await Account.forge().save({ authorId: author.get('id') }); + await Comment.forge().save({ postId: post.get('id') }); + + sinon.stub(Model, 'destroy').throws(new Error('foobar')); + + try { + await author.destroy(); + + should.fail(); + } catch(e) { + e.message.should.equal('foobar'); + } + + const accounts = await Accounts.query(); + const authors = await Authors.query(); + const comments = await Comments.query(); + const posts = await Posts.query(); + + accounts.length.should.equal(1); + authors.length.should.equal(1); + comments.length.should.equal(1); + posts.length.should.equal(1); + + sinon.restore(Model); + }); + + it('should rollback any query on given `transaction` if an error is thrown on model destroy', async () => { + sinon.stub(Model, 'destroy').throws(new Error('foobar')); + + try { + await repository.knex.transaction(transaction => Author.forge().save(null, { transacting: transaction }) + .then(() => Author.forge().save(null, { transacting: transaction })) + .then(author => author.destroy({ transacting: transaction })) + ); + + should.fail(); + } catch(e) { + e.message.should.equal('foobar'); + } + + const authors = await Authors.query(); + + authors.length.should.equal(0); + + sinon.restore(Model); + }); + + it('should delete model and all its dependents', async () => { + const author = await Author.forge().save(); + const post1 = await Post.forge().save({ authorId: author.get('id') }); + const post2 = await Post.forge().save({ authorId: author.get('id') }); + + await Account.forge().save({ authorId: author.get('id') }); + await Comment.forge().save({ postId: post1.get('id') }); + await Comment.forge().save({ postId: post2.get('id') }); + + await author.destroy(); + + const accounts = await Accounts.query(); + const authors = await Authors.query(); + const comments = await Comments.query(); + const posts = await Posts.query(); + + accounts.length.should.equal(0); + authors.length.should.equal(0); + comments.length.should.equal(0); + posts.length.should.equal(0); + }); + + it('should not delete models which are not dependent', async () => { + const author1 = await Author.forge().save(); + const author2 = await Author.forge().save(); + const post1 = await Post.forge().save({ authorId: author1.get('id') }); + const post2 = await Post.forge().save({ authorId: author2.get('id') }); + + await Account.forge().save({ authorId: author1.get('id') }); + await Account.forge().save({ authorId: author2.get('id') }); + await Comment.forge().save({ postId: post1.get('id') }); + await Comment.forge().save({ postId: post2.get('id') }); + + await author1.destroy(); + + const accounts = await Accounts.query(); + const authors = await Authors.query(); + const comments = await Comments.query(); + const posts = await Posts.query(); + + accounts.length.should.equal(1); + authors.length.should.equal(1); + comments.length.should.equal(1); + posts.length.should.equal(1); + }); + + it('should call destroy prototype method with given `options`', async () => { + sinon.spy(Model, 'destroy'); + + const author = await Author.forge().save(); + + await author.destroy({ foo: 'bar' }); + + Model.destroy.callCount.should.equal(1); + Model.destroy.firstCall.args[0].should.have.properties({ foo: 'bar' }); + + sinon.restore(Model); + }); +}); diff --git a/test/knexfile.js.dist b/test/knexfile.js.dist new file mode 100644 index 0000000..0a8e444 --- /dev/null +++ b/test/knexfile.js.dist @@ -0,0 +1,13 @@ + +/** + * Export `knexfile`. + */ + +export default { + client: 'postgres', + connection: { + charset: 'utf8', + database: 'bookshelf-cascade-delete', + host: 'localhost' + } +}; diff --git a/test/utils/index.js b/test/utils/index.js new file mode 100644 index 0000000..5c349c3 --- /dev/null +++ b/test/utils/index.js @@ -0,0 +1,56 @@ + +/** + * Module dependencies. + */ + +import { repository } from '../fixtures/repository'; + +/** + * Export `recreateTables`. + */ + +export function recreateTables() { + return repository.knex.schema + .dropTableIfExists('Account') + .dropTableIfExists('Comment') + .dropTableIfExists('Post') + .dropTableIfExists('Author') + .createTable('Author', table => { + table.increments('id').primary(); + }) + .createTable('Account', table => { + table.increments('id').primary(); + table.integer('authorId').references('Author.id'); + }) + .createTable('Post', table => { + table.increments('id').primary(); + table.integer('authorId').references('Author.id'); + }) + .createTable('Comment', table => { + table.increments('id').primary(); + table.integer('postId').references('Post.id'); + }); +} + +/** + * Export `clearTables`. + */ + +export async function clearTables() { + await repository.knex('Account').del(); + await repository.knex('Comment').del(); + await repository.knex('Post').del(); + await repository.knex('Author').del(); +} + +/** + * Export `dropTables`. + */ + +export function dropTables() { + return repository.knex.schema + .dropTable('Account') + .dropTable('Comment') + .dropTable('Post') + .dropTable('Author'); +}