Working with Static Query Hooks in Objection.js

February 4, 2022

Objection.js prvoides two types of query hooks that enable us to customise CRUD operations:

  1. Static query hooks — fire always
  2. Instance query hooks — fired only when the query is executed on an instance (with $query() syntax)

When you need to customise some query type (e.g. delete) regardless of whether it was called on an instance or not, you can turn to static query hooks.

Problem context

I needed a way to prevent delete queries that are performed directly on the model instead of a model instance with the following as the aim:

// ⛔ do not allow this
MyModel.query().delete();

// ✅ allow this
const myModelInstance = await MyModel.query().findById(1);

await myModelInstance.$query().delete();

Solution

Fortunately, objection.js provides static query hooks that allow us to hook into a query whenever it executes. As static hooks, these belong to the model itself. They are different from instance query hooks, which only run when called directly on a model instance and are prefixed with $, e.g. $beforeDelete().

Since the static hooks are called on the model, they are guaranteed to execute on every query they've been defined for. This way, we can impose custom controls on the query and do useful things such as:

  • conditionally cancel queries
  • query other models
  • implement dry run functionality

For this use case, all queries that were not called on a model instance needed to be cancelled.

Implementation

The relevant hook for customising delete logic is beforeDelete, and it can be implemented as follows:

public static async beforeDelete(args: StaticHookArguments<TemplateProject>) {
  const { cancelQuery, context, items } = args;

  // nb: items is only populated when the query has been explicitly started for a set of model instances.
  const isCalledOnAnInstance = items.length > 0;

  if (!isCalledOnAnInstance) {
    // the argument to cancelQuery is returned to the caller
    cancelQuery("The delete query was not called on a model instance.");
  }
}

This makes it possible to check whether the delete query was called on a model instance or the model itself and, based on that, cancel the ones not called on an instance.

Making the static query hooks more flexible through an escape hatch

There was also a need to bypass the static hook on demand and perform the query directly on the model. In objection.js this can be done by creating a custom query builder, which enables you to extend the native QueryBuilder and do something like:

MyModel.query().myExtension().findById(1);

In my case, I needed:

MyModel.query().withForce().delete();

which would enable force deletion on the model, i.e. to override the conditional in beforeDelete specified above. For this to work, two things are required:

  1. creating a custom QueryBuilder
  2. adding the custom method(s) to the custom QueryBuilder

All of that involves a fair amount of boilerplate, and looks something like the following:

import { Model, Page } from 'objection';

class MyQueryBuilder<M extends Model, R= M[]> extends QueryBuilder<M, R> {
  // copy and paste boilerplate here from docs:
  // https://vincit.github.io/objection.js/recipes/custom-query-builder.html#custom-query-builder-extending-the-query-builder

  // add custom methods here:
  withForce(): this {
    // when query is called with withForce(), set that to the model context
    this.context().withForce = true;
    return this;
  }
}

// Our Model class
class BaseModel extends Model {
  // Specify model to use your custom query builder
  // Both of these are needed.
  QueryBuilderType!: MyQueryBuilder<this>;
  static QueryBuilder = MyQueryBuilder;
}

Now, non-instance delete operations are cancelled unless withForce is added to the query:

// ⛔ this gets cancelled
MyModel.query().delete();

// ✅ this gets executed
MyModel.query().withForce().delete();

NB: the withForce() must be added prior to the delete call since the context needs to be updated before delete() is called.

Gotchas

With updateAnd* and patchAnd* methods, the items array in the StaticHookArguments is empty in the beforeUpdate query hook.

Conclusion

There are other customisations made possible by these static methods and the documentation for the hook arguments is a good place to start exploring what's possible.