If you want to soft delete data in Adonis v5, unfortunately it is not built into the core architecture. If you aren't familiar with the concept of soft deletes, it is the concept that when you delete data from the database, you aren't actually deleting it, but rather setting a flag to indicate that it is in a deleted state. These rows shouldn't return in database queries, but still exist if needed in the future.
Soft deletes bring a few advantages to the table depending on your business requirements.
There are a lot of advantages, but know that you are making a decision to keep a lot of data and must understand the implications of doing such in the long run.
##Getting Started
The flag we'll be making use of in this tutorial is a column added to the tables we want called deleted_at
. This column will help us know which database rows are active vs deleted for future queries and updates. To start off, we should already have an Adonis project created with our choice of database. We'll use MySql as our baseline. We'll also be assuming those two steps for this tutorial are already complete. Once a project and database schema are set up, we'll need to create our first migration.
node ace make:migration posts
This will create a posts migration that we'll use to create and soft delete posts within our database. For soft deletes, we'll use deleted_at
with a column type of datetime
. This way we can track both the post being soft deleted and when it was soft deleted. Soft deletes can also be accomplished alternatively by using a column is_deleted
with a type of boolean
and tracking changes generally with the updated_at
column.
// <app-name>/database/migrations/012345678987654321_posts.ts
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
export default class Posts extends BaseSchema {
protected tableName = 'posts'
public async up () {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').primary()
table.string("name", 254).notNullable();
table.text("description");
table.dateTime("deleted_at").defaultTo(null);
table.timestamps(true)
})
}
public async down () {
this.schema.dropTable(this.tableName)
}
}
With our migration in place, we can now migrate our database and setup our posts table.
node ace migration:run
We need to create our model to define our column fields for Adonis' ORM. This will be critical to implementing soft deletes on the fly and programmatically within various functions and controllers. Without the model, doing soft deletes would require not only more lines of duplicated code, but more manually labor anywhere and everywhere we need to manage the soft delete paradigm.
The following command will initiate our Post model:
node ace make:model Post
// <app-name>/app/Models/Post.ts
import { DateTime } from 'luxon'
import { column, BaseModel } from '@ioc:Adonis/Lucid/Orm'
export default class Post extends BaseModel {
@column({ isPrimary: true })
public id: string
@column()
public name: string
@column()
public body: string
@column.dateTime({ serializeAs: null})
public deletedAt: DateTime
@column.dateTime({ autoCreate: true })
public createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
public updatedAt: DateTime
}
Since we want soft deletes to happen for any possible number of models, we're going to extract the implementation as an Adonis service. Since Adonis doesn't actively come with an ace command to create a service, we'll manually create our services folder inside the app folder and create a SoftDelete.ts
service file.
// <my-app>/app/Services/SoftDelete.ts
import { LucidRow } from '@ioc:Adonis/Lucid/Model'
import { DateTime } from 'luxon';
// Optional null check query
export const softDeleteQuery = (query: ModelQueryBuilderContract<typeof BaseModel>) => {
query.whereNull('deleted_at')
}
export const softDelete = async (row: LucidRow, column: string = 'deletedAt') => {
if(row[column]) {
if(row[column].isLuxonDateTime) {
// Deleted represented by a datetime
row[column] = DateTime.local();
} else {
// Deleted represented by a boolean
row[column] = true;
}
await row.save();
}
}
The softDelete
function is the most important part and is the engine to distributing the soft delete functionality at scale to any number of models. The softDeleteQuery
is optional that we'll be adding to our Post model queries next. Both functions need to be updated based on how you implement your soft delete column. Update both functions as needed to check against a column of boolean
or datetime
as well as update the column name the functions check against. As a reminder, the column name we're using in the examples in this tutorial is deleted_at
.
We're going to add the service we just created to the Post model. Adonis comes with hooks built in that allow us to intercept or override the model lifecycle. In our case, we'll be overriding the delete functionality and updating fetch and find to not include rows that have been soft deleted.
Required Imports:
import { beforeFind, beforeFetch } from '@ioc:Adonis/Lucid/Orm'
import { softDelete, softDeleteQuery } from '../Services/SoftDelete'
Below is a summarized Post model showing the imports and function implementations we have just created.
// Summarized Post.ts
import {
beforeFind,
beforeFetch
} from '@ioc:Adonis/Lucid/Orm'
import { softDelete, softDeleteQuery } from '../Services/SoftDelete';
export default class Post extends BaseModel {
// ... Additional model details here
@beforeFind()
public static softDeletesFind = softDeleteQuery;
@beforeFetch()
public static softDeletesFetch = softDeleteQuery;
public async softDelete(column?: string) {
await softDelete(this, column);
}
}
In the code above you have two choices. You can add in an additional method to the model like we did. This allows us to keep the native delete and also add in a soft delete. The danger here is if you implement soft delete, without proper documentation and code review, a different developer may use the main delete method without knowing that soft delete is the go to method. If this is something you want to avoid, then instead of adding a new method, you can override the delete method by reassignment.
public async delete = softDelete;
Let's go ahead and test this new soft delete method. We'll be skipping over route and controller creation and demonstrating controller functions that will call get and delete.
This first example shows a simple delete implementing our soft delete method.
public async delete ({ request, response, auth }: HttpContextContract) {
try {
const postId = request.input('id')
const post = await Post.findOrFail(postId)
await post.softDelete()
return response.json({})
} catch (error) {
return response.json(error)
}
}
The next example demonstrates implementing the beforeFetch
and the beforeFind
hooks. As a result, our queries will return all rows that have not been soft deleted.
public async getAll({ response }: HttpContextContract) {
try {
const posts = await Post.all()
return response.json(posts)
} catch (error) {
return response.json(error)
}
}
There you have it! We've not create a soft delete paradigm that can easily be scaled to any model in our system.
Implementing soft delete is a power feature that allows you to retain and control all the data in your database. It has both the advantages of data persistence and long term management, but also comes with the warning of data maintenance and exponential growth in the long run. As long as you are aware of all the options and consequences, implementing soft deletes can become a unique and powerful tool as your app or product expands.