index.js
'use strict'
let Promise = require('bluebird')
let result = require('lodash.result')
let merge = require('lodash.merge')
/**
* A function that can be used as a plugin for bookshelf
* @param {Object} bookshelf The main bookshelf instance
* @param {Object} [settings] Additional settings for configuring this plugin
* @param {String} [settings.field=deleted_at] The name of the field that stores
* the soft delete information for that model
* @param {String?} [settings.sentinel=null] The name of the field that stores
* the model's active state as a boolean for unique indexing purposes, if any
*/
module.exports = (bookshelf, settings) => {
// Add default settings
settings = merge(
{
field: 'deleted_at',
nullValue: null,
sentinel: null,
events: {
destroying: true,
updating: false,
saving: false,
destroyed: true,
updated: false,
saved: false
}
},
settings
)
/**
* Check if the operation needs to be patched for not retrieving
* soft deleted rows
* @param {Object} model An instantiated bookshelf model
* @param {Object} attrs The attributes that's being queried
* @param {Object} options The operation option
* @param {Boolean} [options.withDeleted=false] Override the default behavior
* and allow querying soft deleted objects
*/
function skipDeleted (model, attrs, options) {
if (!options.isEager || options.parentResponse) {
let softDelete = this.model
? this.model.prototype.softDelete
: this.softDelete
if (softDelete && !options.withDeleted) {
if (settings.nullValue === null) {
options.query.whereNull(`${result(this, 'tableName')}.${settings.field}`)
} else {
options.query.where(`${result(this, 'tableName')}.${settings.field}`, settings.nullValue)
}
}
}
}
// Store prototypes for later
let modelPrototype = bookshelf.Model.prototype
let collectionPrototype = bookshelf.Collection.prototype
// Extends the default collection to be able to patch relational queries
// against a set of models
bookshelf.Collection = bookshelf.Collection.extend({
initialize: function () {
collectionPrototype.initialize.call(this)
this.on('fetching', skipDeleted.bind(this))
this.on('counting', (collection, options) =>
skipDeleted.call(this, null, null, options)
)
}
})
// Extends the default model class
bookshelf.Model = bookshelf.Model.extend({
initialize: function () {
modelPrototype.initialize.call(this)
if (this.softDelete && settings.sentinel) {
this.defaults = merge(
{
[settings.sentinel]: true
},
result(this, 'defaults')
)
}
this.on('fetching', skipDeleted.bind(this))
},
/**
* Override the default destroy method to provide soft deletion logic
* @param {Object} [options] The default options parameters from Model.destroy
* @param {Boolean} [options.hardDelete=false] Override the default soft
* delete behavior and allow a model to be hard deleted
* @param {Number|Date} [options.date=new Date()] Use a client supplied time
* @return {Promise} A promise that's fulfilled when the model has been
* hard or soft deleted
*/
destroy: function (options) {
options = options || {}
if (this.softDelete && !options.hardDelete) {
let query = this.query()
// Add default values to options
options = merge(
{
method: 'update',
patch: true,
softDelete: true,
query: query
},
options
)
const date = options.date ? new Date(options.date) : new Date()
// Attributes to be passed to events
let attrs = { [settings.field]: date }
// Null out sentinel column, since NULL is not considered by SQL unique indexes
if (settings.sentinel) {
attrs[settings.sentinel] = null
}
// Make sure the field is formatted the same as other date columns
attrs = this.format(attrs)
return Promise.resolve()
.then(() => {
// Don't need to trigger hooks if there's no events registered
if (!settings.events) return
let events = []
// Emulate all pre update events
if (settings.events.destroying) {
events.push(
this.triggerThen('destroying', this, options).bind(this)
)
}
if (settings.events.saving) {
events.push(
this.triggerThen('saving', this, attrs, options).bind(this)
)
}
if (settings.events.updating) {
events.push(
this.triggerThen('updating', this, attrs, options).bind(this)
)
}
// Resolve all promises in parallel like bookshelf does
return Promise.all(events)
})
.then(() => {
// Check if we need to use a transaction
if (options.transacting) {
query = query.transacting(options.transacting)
}
return query
.update(attrs, this.idAttribute)
.where(this.format(this.attributes))
.where(`${result(this, 'tableName')}.${settings.field}`, settings.nullValue)
})
.then((resp) => {
// Check if the caller required a row to be deleted and if
// events weren't totally disabled
if (resp === 0 && options.require) {
throw new this.constructor.NoRowsDeletedError('No Rows Deleted')
} else if (!settings.events) {
return
}
// Add previous attr for reference and reset the model to pristine state
this.set(attrs)
options.previousAttributes = this._previousAttributes
this._reset()
let events = []
// Emulate all post update events
if (settings.events.destroyed) {
events.push(
this.triggerThen('destroyed', this, options).bind(this)
)
}
if (settings.events.saved) {
events.push(
this.triggerThen('saved', this, resp, options).bind(this)
)
}
if (settings.events.updated) {
events.push(
this.triggerThen('updated', this, resp, options).bind(this)
)
}
return Promise.all(events)
})
.then(() => this)
} else {
return modelPrototype.destroy.call(this, options)
}
}
})
}