Soft deletes
A soft delete keeps the row but hides it, so a mistake can be undone. Add a field of type ant.DeletedAt, and the rest follows on its own.
Turning it on
type Note struct {
ant.Base
Text string
DeletedAt ant.DeletedAt
}
var N = ant.Fields[Note]()
From then on, Delete, DeleteByID, and ant.Remove mark the row instead of removing it. Every query skips marked rows, including Find, Count, Include, Has, joins, and subqueries.
n, err := db.Notes.Where(ant.Eq(&N.Text, "draft")).Delete(ctx) // marks it
all, err := db.Notes.All(ctx) // live rows only
A second delete changes nothing.
Seeing deleted rows
every, err := db.Notes.WithDeleted().All(ctx)
gone, err := db.Notes.OnlyDeleted().All(ctx)
Undoing, and really deleting
n, err := db.Notes.Where(ant.Eq(&N.Text, "draft")).Restore(ctx)
n, err = db.Notes.WithDeleted().Where(ant.Eq(&N.Text, "draft")).ForceDelete(ctx)
Restore on a model with no DeletedAt is ant.ErrNotSoftDeleted.
When a row was deleted
&N.DeletedAt.V names the column as a time. A row that was never deleted holds NULL, so Lt leaves it out.
cutoff := time.Now().AddDate(0, -3, 0)
stale, err := db.Notes.OnlyDeleted().Where(ant.Lt(&N.DeletedAt.V, cutoff)).Count(ctx)
Clearing out old rows
Prune removes rows for good, soft-deleted or not, in batches, so it never holds one long transaction open.
removed, err := db.Notes.Prune(ctx, ant.Lt(&N.DeletedAt.V, cutoff))
Unique values
A soft-deleted row still holds its values, so a plain unique constraint stops anyone reusing that email. Index only the live rows instead.
func (User) Configure(b *ant.ModelBuilder[User]) {
b.Index(&U.Email).Unique().Where("deleted_at IS NULL")
}
See it tested: tests/features/lifecycle/soft_delete_test.go