Implementing Soft Delete in AdonisJS v5

mmcshinsky

Michael McShinsky

Posted on February 10, 2021

Implementing Soft Delete in AdonisJS v5

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.

  • You may legally be required to keep the data either in the database or in a backup, but you need a transition time for your backup implementation.
  • Related data depends on the deleted data to exist even if it won't be used by the user or other services anymore.
  • It can create a "trash can view" for users making data recovery faster and easier.
  • Creates trackable history for either in-house or customer auditing.

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
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

With our migration in place, we can now migrate our database and setup our posts table.

node ace migration:run
Enter fullscreen mode Exit fullscreen mode

Connecting the ORM

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
Enter fullscreen mode Exit fullscreen mode
// <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
}
Enter fullscreen mode Exit fullscreen mode

Implementing Soft Delete

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Adding Services to Models

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'
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Delete All The Things

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
Enter fullscreen mode Exit fullscreen mode

There you have it! We've not create a soft delete paradigm that can easily be scaled to any model in our system.

Final Thoughts

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.


If you found this helpful or useful, please share a 💓, 🦄, or 🔖. Thanks!

💖 💪 🙅 🚩
mmcshinsky
Michael McShinsky

Posted on February 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Implementing Soft Delete in AdonisJS v5
javascript Implementing Soft Delete in AdonisJS v5

February 10, 2021