Objection.js prvoides two types of query hooks that enable us to customise CRUD operations:
- Static query hooks — fire always
- 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:
- creating a custom
QueryBuilder
- 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.