sqlx-record/.claude/skills/sqlx-soft-delete.md

5.3 KiB

sqlx-record Soft Delete Skill

Guide to soft delete functionality with #[soft_delete] attribute.

Triggers

  • "soft delete", "soft-delete"
  • "is_deleted", "deleted"
  • "restore", "undelete"
  • "hard delete", "permanent delete"

Overview

Soft delete allows marking records as deleted without removing them from the database. This enables:

  • Recovery of accidentally deleted data
  • Audit trails of deletions
  • Referential integrity preservation

Enabling Soft Delete

Add #[soft_delete] to a boolean field:

use sqlx_record::prelude::*;

#[derive(Entity, FromRow)]
#[table_name = "users"]
struct User {
    #[primary_key]
    id: Uuid,
    name: String,

    #[soft_delete]
    is_deleted: bool,  // Must be bool type
}

Auto-detection: Fields named is_deleted or deleted with bool type are automatically treated as soft delete fields even without the attribute.

Generated Methods

delete() / delete_by_{pk}()

Sets the soft delete field to true:

// Instance method
user.delete(&pool).await?;

// Static method by primary key
User::delete_by_id(&pool, &user_id).await?;

SQL generated:

UPDATE users SET is_deleted = TRUE WHERE id = ?

hard_delete() / hard_delete_by_{pk}()

Permanently removes the row:

// Instance method
user.hard_delete(&pool).await?;

// Static method by primary key
User::hard_delete_by_id(&pool, &user_id).await?;

SQL generated:

DELETE FROM users WHERE id = ?

restore() / restore_by_{pk}()

Sets the soft delete field to false:

// Instance method
user.restore(&pool).await?;

// Static method by primary key
User::restore_by_id(&pool, &user_id).await?;

SQL generated:

UPDATE users SET is_deleted = FALSE WHERE id = ?

soft_delete_field()

Returns the field name:

let field = User::soft_delete_field();  // "is_deleted"

Filtering Deleted Records

Soft delete does NOT automatically filter find() queries. You must add the filter manually:

// Include only non-deleted
let users = User::find(&pool, filters![("is_deleted", false)], None).await?;

// Include only deleted (trash view)
let deleted = User::find(&pool, filters![("is_deleted", true)], None).await?;

// Include all records
let all = User::find(&pool, filters![], None).await?;

Helper Pattern

Create a helper function for consistent filtering:

impl User {
    pub async fn find_active(
        pool: &Pool,
        mut filters: Vec<Filter<'_>>,
        index: Option<&str>
    ) -> Result<Vec<Self>, sqlx::Error> {
        filters.push(Filter::Equal("is_deleted", false.into()));
        Self::find(pool, filters, index).await
    }
}

// Usage
let users = User::find_active(&pool, filters![("role", "admin")], None).await?;

Usage Examples

Basic Soft Delete Flow

// Create user
let user = User {
    id: new_uuid(),
    name: "Alice".into(),
    is_deleted: false,
};
user.insert(&pool).await?;

// Soft delete
user.delete(&pool).await?;
// user still exists in DB with is_deleted = true

// Find won't return deleted users (with proper filter)
let users = User::find(&pool, filters![("is_deleted", false)], None).await?;
// Alice not in results

// Restore
User::restore_by_id(&pool, &user.id).await?;
// user.is_deleted = false again

// Hard delete (permanent)
User::hard_delete_by_id(&pool, &user.id).await?;
// Row completely removed from database

With Audit Trail

use sqlx_record::{transaction, prelude::*};

async fn soft_delete_with_audit(
    pool: &Pool,
    user_id: &Uuid,
    actor_id: &Uuid
) -> Result<(), sqlx::Error> {
    transaction!(&pool, |tx| {
        // Soft delete the user
        User::delete_by_id(&mut *tx, user_id).await?;

        // Record the deletion
        let change = EntityChange {
            id: new_uuid(),
            entity_id: *user_id,
            action: "delete".into(),
            changed_at: chrono::Utc::now().timestamp_millis(),
            actor_id: *actor_id,
            session_id: Uuid::nil(),
            change_set_id: Uuid::nil(),
            new_value: None,
        };
        create_entity_change(&mut *tx, "entity_changes_users", &change).await?;

        Ok::<_, sqlx::Error>(())
    }).await
}

Cascade Soft Delete

async fn delete_user_cascade(pool: &Pool, user_id: &Uuid) -> Result<(), sqlx::Error> {
    transaction!(&pool, |tx| {
        // Soft delete user's orders
        let orders = Order::find(&mut *tx, filters![("user_id", user_id)], None).await?;
        for order in orders {
            order.delete(&mut *tx).await?;
        }

        // Soft delete user
        User::delete_by_id(&mut *tx, user_id).await?;

        Ok::<_, sqlx::Error>(())
    }).await
}

Database Schema

Recommended column definition:

-- MySQL
is_deleted BOOLEAN NOT NULL DEFAULT FALSE

-- PostgreSQL
is_deleted BOOLEAN NOT NULL DEFAULT FALSE

-- SQLite
is_deleted INTEGER NOT NULL DEFAULT 0  -- 0=false, 1=true

Add an index for efficient filtering:

CREATE INDEX idx_users_is_deleted ON users (is_deleted);

-- Or composite index for common queries
CREATE INDEX idx_users_active_name ON users (is_deleted, name);

Notes

  • Soft delete field must be bool type
  • The field is included in UpdateForm (can be manually toggled)
  • Consider adding deleted_at: Option<i64> for deletion timestamps
  • For complex filtering, consider database views