Skip to main content

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")
}