Model events
A model fires events when you save, delete or restore it, and you can run your own code on them.
There are two ways to listen:
- Hooks: methods in the model class. Use them for code that belongs to the model, like filling a value.
- Observers: separate classes. Use them for code that needs other services, like a mailer.
Hooks in the model
Override an on... method in the model. Then run dart run build_runner build.
@DoxModel()
class Post extends PostGenerator {
@Column()
String? title;
@Column()
String? slug;
@override
Future<void> onSaving() async {
slug = title?.trim().toLowerCase().replaceAll(' ', '-');
}
}
Post post = Post()..title = 'Hello World';
await post.save(); // onSaving runs first, so slug is 'hello-world'
All hooks come in pairs: one runs before the query, one after it.
| Before hook | After hook | Run on |
|---|---|---|
onSaving |
onSaved |
every save(): a new or a loaded model |
onCreating |
onCreated |
save() or create() on a new model (INSERT) |
onUpdating |
onUpdated |
save() on a loaded model (UPDATE) |
onDeleting |
onDeleted |
delete() |
onForceDeleting |
onForceDeleted |
forceDelete(): really removes a soft-deleted row |
onRestoring |
onRestored |
restore(): brings back a soft-deleted row |
- A before hook runs before the query. The row is not written yet. You can change values, or cancel the operation.
- An after hook runs after the query. The row is written, and a new model has its
id. Use it for side effects. - Declare the hooks in the model class itself. The generator looks there.
Order of events
| Call | Events, in order |
|---|---|
save() on a new model |
saving → creating → INSERT → created → saved |
save() on a loaded model |
saving → updating → UPDATE → updated → saved |
create({...}) |
the same as save() on a new model |
firstOrCreate(), updateOrCreate() |
the same as save() when they create or update a row |
delete() |
deleting → DELETE or soft delete → deleted |
forceDelete() |
forceDeleting → deleting → DELETE → deleted → forceDeleted |
restore() |
restoring → UPDATE deleted_at = NULL and updated_at → restored |
touch() |
saving → updating → UPDATE updated_at → updated → saved (touchQuietly() fires none) |
- The model hook runs first. Then the observers run, in the order you added them.
- When a model has events,
create({...})saves only the@Columnfields.
Query updates and deletes fire no events
Events fire only on one model instance.
Post().where('draft', true).update({...}), Post().where(...).delete(),
insertMultiple(), upsert(), increment(), decrement() and Post().where(...).touch() run one query and fire no events.
Examples
Set a value only on create
onCreating runs only for a new row. Updates do not change the value again.
@override
Future<void> onCreating() async {
status ??= 'draft';
publicId = Uuid().v4(); // from package:uuid
}
Stop an operation
Throw CancelModelEvent in a before hook. The query does not run.
save() and restore() return false. delete() and forceDelete() return 0.
@override
Future<void> onDeleting() async {
if (status == 'published') {
throw CancelModelEvent('unpublish the post first');
}
}
Future<Map<String, String>> destroy(DoxRequest req, int id) async {
Post? post = await Post().find(id);
if (post == null) throw NotFoundHttpException();
int deleted = await post.delete();
if (deleted == 0) {
throw BadRequestException(message: 'Unpublish the post first');
}
return <String, String>{'message': 'deleted'};
}
save()andrestore()returnfalsewhen a hook cancels.delete()andforceDelete()return the number of deleted rows. It is0when a hook cancels.create({...})returns the unsaved model. Itsidisnull.- Any other error from a hook is thrown to the caller.
Do something after a change
After hooks are for side effects. The row is already in the database.
@override
Future<void> onSaved() async {
await Cache().forget('post:$id'); // the next read gets the new data
}
@override
Future<void> onDeleted() async {
if (coverPath != null) {
await Storage().delete(coverPath!); // remove the file of the post
}
}
Observers
An observer is a class that listens to one model. Use it when the code needs other objects, like a mailer, or when you do not want it in the model.
The methods have the same names as the hooks. Each one gets the model. Override only the ones you need.
| Before method | After method | Run on |
|---|---|---|
onSaving(T model) |
onSaved(T model) |
every save(): a new or a loaded model |
onCreating(T model) |
onCreated(T model) |
save() or create() on a new model (INSERT) |
onUpdating(T model) |
onUpdated(T model) |
save() on a loaded model (UPDATE) |
onDeleting(T model) |
onDeleted(T model) |
delete() |
onForceDeleting(T model) |
onForceDeleted(T model) |
forceDelete() |
onRestoring(T model) |
onRestored(T model) |
restore() |
A before method can stop the operation with CancelModelEvent, like a before hook.
class UserObserver extends ModelObserver<User> {
final Mailer mailer;
UserObserver(this.mailer);
@override
Future<void> onCreated(User model) async {
await mailer.sendWelcome(model.email!);
}
}
Add it in the setup() of a provider.
Observers live in memory, and each isolate has its own memory, so every isolate must add them.
class ObserverProvider extends DoxProvider {
@override
void setup() {
Model.observe<User>(UserObserver(Mailer()));
}
}
Dox finds ObserverProvider by itself, like every DoxProvider. Run dart run build_runner build after you add it.
- You can add more than one observer to a model.
Model.forgetObservers<User>()removes the observers ofUser.Model.forgetObservers()removes all.
Skip events
Use this for imports, seed data or tests.
await user.saveQuietly(); // save without events
await Model.withoutEvents(() async {
await user.save();
await post.delete();
});
Transactions
save() does not start a transaction. If an after hook throws, the row stays written.
To undo the row when a hook fails, wrap the call in QueryBuilder.transaction():
await QueryBuilder.transaction(() async {
await user.save(); // if onCreated() throws, the INSERT is rolled back
});
A model with no hooks and no observers does no event work.