Soft Deletes, Hooks, and Scopes in Sutando
Sutando includes several powerful features that go beyond basic CRUD: soft deletes, model events (hooks), and query scopes. This guide covers all three…
Sutando includes several powerful features that go beyond basic CRUD: soft deletes, model events (hooks), and query scopes. This guide covers all three with practical examples.
Soft Deletes
Soft deletes allow you to “delete” records without actually removing them from the database. A deleted_at column marks records as deleted.
Setup
Add a deleted_at column to your table:
await sutando.schema().table('posts', table => {
table.timestamp('deleted_at').nullable();
});
Enable soft deletes on your model:
import { Model, compose, SoftDeletes } from 'sutando';
class Post extends compose(Model, SoftDeletes) {
table = 'posts';
}
Usage
// Soft delete — sets deleted_at instead of removing
const post = await Post.query().find(1);
await post.delete();
// Record still exists in the database with deleted_at set
// Normal queries exclude soft-deleted records
const posts = await Post.query().get(); // deleted records excluded
// Include soft-deleted records
const allPosts = await Post.query().withTrashed().get();
// Only soft-deleted records
const trashedPosts = await Post.query().onlyTrashed().get();
// Restore a soft-deleted record
const post = await Post.query().withTrashed().find(1);
await post.restore();
// Permanently delete
await post.forceDelete();
Model Events (Hooks)
Sutando fires events throughout a model’s lifecycle, letting you hook into create, update, and delete operations.
Available Events
| Event | When |
|---|---|
creating |
Before a new record is created |
created |
After a new record is created |
updating |
Before a record is updated |
updated |
After a record is updated |
saving |
Before save (create or update) |
saved |
After save (create or update) |
deleting |
Before a record is deleted |
deleted |
After a record is deleted |
restoring |
Before a soft-deleted record is restored |
restored |
After a soft-deleted record is restored |
Registering Hooks
// Hash password before creating a user
User.creating(async (user) => {
user.password = await bcrypt.hash(user.password, 10);
});
// Generate slug before saving a post
Post.saving(async (post) => {
if (post.isDirty('title')) {
post.slug = post.title.toLowerCase().replace(/\s+/g, '-');
}
});
// Cascade delete comments when a post is deleted
Post.deleting(async (post) => {
await post.related('comments').delete();
});
// Log after creation
Post.created(async (post) => {
console.log(`Post created: ${post.title}`);
});
Stopping an Operation
Return false from a creating or updating hook to abort:
User.creating(async (user) => {
const exists = await User.query().where('email', user.email).exists();
if (exists) return false; // prevents creation
});
Query Scopes
Scopes are reusable query constraints that you can chain together.
Defining Scopes
class Post extends Model {
table = 'posts';
scopePublished(query) {
return query.where('published', true);
}
scopePopular(query) {
return query.where('views', '>', 1000);
}
scopeRecent(query, days = 7) {
return query.where('created_at', '>', new Date(Date.now() - days * 86400000));
}
scopeByUser(query, userId) {
return query.where('user_id', userId);
}
}
Using Scopes
// Chain multiple scopes
const posts = await Post.query()
.published()
.popular()
.recent(30)
.orderBy('views', 'desc')
.limit(10)
.get();
// With parameters
const userPosts = await Post.query()
.byUser(5)
.published()
.get();
Global Scopes
Global scopes apply to all queries on a model automatically:
import { Model, Scope } from 'sutando';
class PublishedScope extends Scope {
apply(builder, model) {
builder.where('published', true);
}
}
class Post extends Model {
table = 'posts';
static booted() {
super.booted();
this.addGlobalScope('published', new PublishedScope());
}
}
// All queries now exclude unpublished posts
const posts = await Post.query().get();
// Skip global scope when needed
const allPosts = await Post.query().withoutGlobalScope('published').get();
Combining All Three
Here’s a real-world example combining soft deletes, hooks, and scopes:
import { Model, compose, SoftDeletes } from 'sutando';
class Post extends compose(Model, SoftDeletes) {
table = 'posts';
casts = { published: 'boolean' };
// Scopes
scopePublished(query) {
return query.where('published', true);
}
scopePopular(query) {
return query.where('views', '>', 1000);
}
// Hooks
static booted() {
super.booted();
this.creating(async (post) => {
post.slug = post.title.toLowerCase().replace(/\s+/g, '-');
});
this.deleting(async (post) => {
await post.related('comments').delete();
});
}
// Relationships
relationComments() {
return this.hasMany(Comment, 'post_id');
}
relationUser() {
return this.belongsTo(User, 'user_id');
}
}
// Usage
const popularPosts = await Post.query()
.with('user')
.published()
.popular()
.recent(30)
.orderBy('views', 'desc')
.limit(10)
.get();
Conclusion
Soft deletes, model events, and query scopes are three features that make Sutando more than just a query builder. They let you encode business logic directly into your models, keeping your controllers clean and your data consistent.
Learn more in the Sutando documentation.