242 lines
5.3 KiB
Markdown
242 lines
5.3 KiB
Markdown
# 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:
|
|
|
|
```rust
|
|
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`:
|
|
|
|
```rust
|
|
// Instance method
|
|
user.delete(&pool).await?;
|
|
|
|
// Static method by primary key
|
|
User::delete_by_id(&pool, &user_id).await?;
|
|
```
|
|
|
|
**SQL generated:**
|
|
```sql
|
|
UPDATE users SET is_deleted = TRUE WHERE id = ?
|
|
```
|
|
|
|
### hard_delete() / hard_delete_by_{pk}()
|
|
Permanently removes the row:
|
|
|
|
```rust
|
|
// Instance method
|
|
user.hard_delete(&pool).await?;
|
|
|
|
// Static method by primary key
|
|
User::hard_delete_by_id(&pool, &user_id).await?;
|
|
```
|
|
|
|
**SQL generated:**
|
|
```sql
|
|
DELETE FROM users WHERE id = ?
|
|
```
|
|
|
|
### restore() / restore_by_{pk}()
|
|
Sets the soft delete field to `false`:
|
|
|
|
```rust
|
|
// Instance method
|
|
user.restore(&pool).await?;
|
|
|
|
// Static method by primary key
|
|
User::restore_by_id(&pool, &user_id).await?;
|
|
```
|
|
|
|
**SQL generated:**
|
|
```sql
|
|
UPDATE users SET is_deleted = FALSE WHERE id = ?
|
|
```
|
|
|
|
### soft_delete_field()
|
|
Returns the field name:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
// 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:
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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:
|
|
|
|
```sql
|
|
-- 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:
|
|
|
|
```sql
|
|
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
|