A plugin that adds soft-delete functionality to Objection.js
npm i objection-soft-delete --save
yarn add objection-soft-delete
Mixin the plugin on an object representing a table that uses a boolean column as a flag for soft delete
// Import objection model.
const Model = require('objection').Model;
// Import the plugin
const softDelete = require('objection-soft-delete');
// Mixin the plugin and specify the column to to use. 'deleted' will be used if none is specified:
class User extends softDelete({ columnName: 'deleted' })(Model) {
static get tableName() {
return 'Users';
}
static get jsonSchema() {
return {
type: 'object',
required: [],
properties: {
id: { type: 'integer' },
// matches the columnName passed above
deleted: { type: 'boolean' },
// other columns
},
}
}
}
When .delete()
or .del()
is called for that model, the matching row(s) are flagged true
instead of deleted
await User.query().where('id', 1).delete(); // db now has: { User id: 1, deleted: true, ... }
const user = await User.query().where('id', 1).first();
await user.$query().delete(); // same
const deletedUser = await User.query().where('id', 1).first(); // => { User id: 1, deleted: true, ... }
const activeUsers = await User.query().whereNotDeleted();
const deletedUsers = await User.query().whereDeleted();
await User.query().where('id', 1).undelete(); // db now has: { User id: 1, deleted: false, ... }
await User.query.where('id', 1).hardDelete(); // => row with id:1 is permanently deleted
A notDeleted
and a deleted
filter will be added to the list of named filters for any model that mixes in the plugin. These filters use the .whereNotDeleted()
and .whereDeleted()
functions to filter records, and can be used without needing to remember the specific columnName for any model:
// some other Model with a relation to the `User` model:
const group = await UserGroup.query()
.where('id', 1)
.first()
.eager('users(notDeleted)'); // => now group.users contains only records that are not deleted
Or:
// some other Model with a relation to the `User` model:
const group = await UserGroup.query()
.where('id', 1)
.first()
.eager('users(deleted)'); // => now group.users contains only records that are deleted
With .joinRelation()
:
// some other Model with a relation to the `User` model:
const group = await UserGroup.query()
.where('id', 1)
.joinRelation('users(notDeleted)')
.where('users.firstName', 'like', 'a%'); // => all groups that have an undeleted user whose first name starts with 'a';
A filter can be applied directly to the relationship definition to ensure that deleted/undeleted rows never appear:
// some other class that has a FK to User:
class UserGroup extends Model {
static get tableName() {
return 'UserGroups';
}
...
static get relationMappings() {
return {
users: {
relation: Model.ManyToManyRelation,
modelClass: User,
join: {
from: 'UserGroups.id',
through: {
from: 'GroupUsers.groupId',
to: 'GroupUsers.userId',
},
to: 'Users.id',
},
filter: (f) => {
f.whereNotDeleted(); // or f.whereDeleted(), as needed.
},
},
}
}
}
then:
const group = await UserGroup.query()
.where('id', 1)
.first()
.eager('users'); // => `User` rows are filtered out automatically without having to specify the filter here
If for some reason you have to deal with different column names for different models (legacy code/schemas can be a bear!), all functionality is fully supported:
class User extends softDelete({ columnName: 'deleted' })(Model) {
...
}
class UserGroup extends softDelete({ columnName: 'inactive' })(Model) {
...
}
// everything will work as expected:
await User.query()
.whereNotDeleted(); // => all undeleted users
await UserGroup.query()
.whereNotDeleted(); // => all undeleted user groups
await UserGroup.query()
.whereNotDeleted()
.eager('users(notDeleted)'); // => all undeleted user groups, with all related undeleted users eagerly loaded
await User.query()
.whereDeleted()
.eager('groups(deleted)'); // => all deleted users, with all related deleted user groups eagerly loaded
await User.query()
.whereNotDeleted()
.joinRelation('groups(notDeleted)')
.where('groups.name', 'like', '%local%')
.eager('groups(notDeleted)'); // => all undeleted users that belong to undeleted user groups that have a name containing the string 'local', eagerly load all undeleted groups for said users.
// and so on...
This plugin was actually born out of a need to have .upsertGraph()
soft delete in some tables, and hard delete in others, so it plays nice with
.upsertGraph()
:
// a model with soft delete
class Phone extends softDelete(Model) {
static get tableName() {
return 'Phones';
}
}
// a model without soft delete
class Email extends Model {
static get tableName() {
return 'Emails';
}
}
// assume a User model that relates to both, and the following existing data:
User {
id: 1,
name: 'Johnny Cash',
phones: [
{
id: 6,
number: '+19195551234',
},
],
emails: [
{
id: 3,
address: '[email protected]',
},
]
}
// then:
await User.query().upsertGraph({
id: 1,
name: 'Johnny Cash',
phones: [],
emails: [],
}); // => phone id 6 will be flagged deleted (and will still be related to Johnny!), email id 3 will be removed from the database
columnName: the name of the column to use as the soft delete flag on the model (Default: 'deleted'
). The column must exist on the table for the model.
You can specify different column names per-model by using the options:
const softDelete = require('objection-soft-delete')({
columnName: 'inactive',
});
Tests can be run with:
npm test
or:
yarn test
The linter can be run with:
npm run lint
or:
yarn lint
The usual spiel: fork, fix/improve, write tests, submit PR. I try to maintain a (mostly) consistent syntax, but am open to suggestions for improvement. Otherwise, the only two rules are: do good work, and no tests = no merge.