diff --git a/.claude/skills/sqlx-audit.md b/.claude/skills/sqlx-record/sqlx-audit.md similarity index 100% rename from .claude/skills/sqlx-audit.md rename to .claude/skills/sqlx-record/sqlx-audit.md diff --git a/.claude/skills/sqlx-batch-ops.md b/.claude/skills/sqlx-record/sqlx-batch-ops.md similarity index 100% rename from .claude/skills/sqlx-batch-ops.md rename to .claude/skills/sqlx-record/sqlx-batch-ops.md diff --git a/.claude/skills/sqlx-conn-provider.md b/.claude/skills/sqlx-record/sqlx-conn-provider.md similarity index 52% rename from .claude/skills/sqlx-conn-provider.md rename to .claude/skills/sqlx-record/sqlx-conn-provider.md index 52bef3c..47dca78 100644 --- a/.claude/skills/sqlx-conn-provider.md +++ b/.claude/skills/sqlx-record/sqlx-conn-provider.md @@ -6,12 +6,14 @@ Guide to flexible connection management. - "connection provider", "conn provider" - "borrow connection", "pool connection" - "lazy connection", "connection management" +- "transaction provider", "use transaction" ## Overview `ConnProvider` enables flexible connection handling: - **Borrowed**: Use an existing connection reference - **Owned**: Lazily acquire from pool on first use +- **Transaction**: Use a transaction reference (all operations participate in the transaction) ## Enum Variants @@ -26,6 +28,10 @@ pub enum ConnProvider<'a> { pool: Pool, conn: Option>, }, + /// Reference to a transaction + Transaction { + tx: &'a mut Transaction<'static, DB>, + }, } ``` @@ -45,15 +51,29 @@ let mut provider = ConnProvider::from_pool(pool.clone()); // Connection acquired on first get_conn() call ``` +### from_tx +Use a transaction (all operations participate in the transaction): +```rust +let mut tx = pool.begin().await?; +let mut provider = ConnProvider::from_tx(&mut tx); + +// All operations through provider use the transaction +do_work(&mut provider).await?; + +// You must commit/rollback the transaction yourself +tx.commit().await?; +``` + ## Getting the Connection ```rust let conn = provider.get_conn().await?; -// Returns &mut PoolConnection +// Returns &mut Connection (e.g., &mut MySqlConnection) ``` -- **Borrowed**: Returns reference immediately +- **Borrowed**: Returns underlying connection immediately - **Owned**: Acquires on first call, returns same connection on subsequent calls +- **Transaction**: Returns transaction's underlying connection ## Use Cases @@ -105,15 +125,37 @@ let mut conn = pool.acquire().await?; do_database_work(&mut ConnProvider::from_ref(&mut conn)).await?; // Call with pool -do_database_work(&mut ConnProvider::from_pool(pool)).await?; +do_database_work(&mut ConnProvider::from_pool(pool.clone())).await?; + +// Call with transaction +let mut tx = pool.begin().await?; +do_database_work(&mut ConnProvider::from_tx(&mut tx)).await?; +tx.commit().await?; ``` -### Transaction-like Patterns +### Using Transactions +```rust +async fn transactional_operation(pool: MySqlPool) -> Result<()> { + let mut tx = pool.begin().await?; + let mut provider = ConnProvider::from_tx(&mut tx); + + // All operations participate in the transaction + step_1(&mut provider).await?; + step_2(&mut provider).await?; + step_3(&mut provider).await?; + + // Commit (or rollback on error) + tx.commit().await?; + Ok(()) +} +``` + +### Same Connection Pattern ```rust async fn multi_step_operation(pool: MySqlPool) -> Result<()> { let mut provider = ConnProvider::from_pool(pool); - // All operations use same connection + // All operations use same connection (but no transaction) step_1(&mut provider).await?; step_2(&mut provider).await?; step_3(&mut provider).await?; @@ -127,11 +169,13 @@ async fn multi_step_operation(pool: MySqlPool) -> Result<()> { The concrete types depend on the enabled feature: -| Feature | Pool Type | Connection Type | -|---------|-----------|-----------------| -| `mysql` | `MySqlPool` | `PoolConnection` | -| `postgres` | `PgPool` | `PoolConnection` | -| `sqlite` | `SqlitePool` | `PoolConnection` | +| Feature | Pool Type | Connection Type | Transaction Type | +|---------|-----------|-----------------|------------------| +| `mysql` | `MySqlPool` | `MySqlConnection` | `Transaction<'static, MySql>` | +| `postgres` | `PgPool` | `PgConnection` | `Transaction<'static, Postgres>` | +| `sqlite` | `SqlitePool` | `SqliteConnection` | `Transaction<'static, Sqlite>` | + +Note: `get_conn()` returns `&mut Connection` (the underlying connection type). ## Example: Service Layer @@ -175,28 +219,28 @@ let user_id = UserService::create_with_profile(&mut provider, "Alice", "Hello!") ## Connection Lifecycle ``` -from_pool(pool) from_ref(&mut conn) - │ │ - ▼ ▼ - Owned { Borrowed { - pool, conn: &mut PoolConnection - conn: None } - } │ - │ │ - │ get_conn() │ get_conn() - ▼ ▼ - pool.acquire() return conn - │ │ - ▼ │ - Owned { │ - pool, │ - conn: Some(acquired) │ - } │ - │ │ - │ get_conn() (subsequent) │ - ▼ │ - return &mut acquired │ - │ │ - ▼ ▼ - Drop: conn returned Drop: nothing (borrowed) +from_pool(pool) from_ref(&mut conn) from_tx(&mut tx) + │ │ │ + ▼ ▼ ▼ + Owned { Borrowed { Transaction { + pool, conn: &mut tx: &mut + conn: None PoolConnection Transaction + } } } + │ │ │ + │ get_conn() │ get_conn() │ get_conn() + ▼ ▼ ▼ + pool.acquire() deref conn deref tx + │ │ │ + ▼ ▼ ▼ + Owned { return &mut return &mut + pool, Connection Connection + conn: Some(acquired) │ │ + } │ │ + │ │ │ + │ get_conn() (subsequent) │ │ + ▼ ▼ ▼ + return &mut conn Drop: nothing Drop: nothing + │ (borrowed) (tx managed + ▼ externally) + Drop: conn returned ``` diff --git a/.claude/skills/sqlx-entity.md b/.claude/skills/sqlx-record/sqlx-entity.md similarity index 91% rename from .claude/skills/sqlx-entity.md rename to .claude/skills/sqlx-record/sqlx-entity.md index 2f4d695..89d8d79 100644 --- a/.claude/skills/sqlx-entity.md +++ b/.claude/skills/sqlx-record/sqlx-entity.md @@ -59,11 +59,13 @@ large_count: i64, ### #[soft_delete] ```rust #[soft_delete] -is_deleted: bool, +is_active: bool, ``` - Enables soft delete functionality -- Generates `delete()`, `restore()`, `hard_delete()` methods +- Generates `soft_delete()`, `soft_delete_by_{pk}()`, `restore()`, `restore_by_{pk}()` methods - Field must be `bool` type +- Convention: `is_active` fields are auto-detected (FALSE = deleted) +- `#[soft_delete]` attribute means field is FALSE when entity is deleted ### #[created_at] ```rust @@ -194,17 +196,20 @@ pub async fn get_version(executor, pk: &PkType) -> Result, E pub async fn get_versions(executor, pks: &[PkType]) -> Result, Error> ``` -### Soft Delete Methods (if #[soft_delete] exists) +### Hard Delete (always generated) ```rust -// Soft delete - sets field to true -pub async fn delete(&self, executor) -> Result<(), Error> -pub async fn delete_by_id(executor, id: &Uuid) -> Result<(), Error> - -// Hard delete - permanently removes row +// Permanently removes row from database pub async fn hard_delete(&self, executor) -> Result<(), Error> pub async fn hard_delete_by_id(executor, id: &Uuid) -> Result<(), Error> +``` -// Restore - sets field to false +### Soft Delete Methods (if `is_active` field or `#[soft_delete]` exists) +```rust +// Soft delete - marks as deleted (is_active = FALSE) +pub async fn soft_delete(&self, executor) -> Result<(), Error> +pub async fn soft_delete_by_id(executor, id: &Uuid) -> Result<(), Error> + +// Restore - marks as active (is_active = TRUE) pub async fn restore(&self, executor) -> Result<(), Error> pub async fn restore_by_id(executor, id: &Uuid) -> Result<(), Error> diff --git a/.claude/skills/sqlx-filters.md b/.claude/skills/sqlx-record/sqlx-filters.md similarity index 100% rename from .claude/skills/sqlx-filters.md rename to .claude/skills/sqlx-record/sqlx-filters.md diff --git a/.claude/skills/sqlx-lookup.md b/.claude/skills/sqlx-record/sqlx-lookup.md similarity index 100% rename from .claude/skills/sqlx-lookup.md rename to .claude/skills/sqlx-record/sqlx-lookup.md diff --git a/.claude/skills/sqlx-pagination.md b/.claude/skills/sqlx-record/sqlx-pagination.md similarity index 100% rename from .claude/skills/sqlx-pagination.md rename to .claude/skills/sqlx-record/sqlx-pagination.md diff --git a/.claude/skills/sqlx-record.md b/.claude/skills/sqlx-record/sqlx-record.md similarity index 80% rename from .claude/skills/sqlx-record.md rename to .claude/skills/sqlx-record/sqlx-record.md index 3ae0cda..57a0428 100644 --- a/.claude/skills/sqlx-record.md +++ b/.claude/skills/sqlx-record/sqlx-record.md @@ -122,28 +122,30 @@ sqlx-record = { version = "0.3", features = ["mysql", "derive"] } # Optional: "derive", "static-validation" ``` -## Soft Delete, Timestamps, Batch Operations +## Delete, Soft Delete, Timestamps, Batch Operations ```rust #[derive(Entity, FromRow)] struct User { #[primary_key] id: Uuid, name: String, + is_active: bool, // Auto-detected for soft delete (is_active = FALSE when deleted) - #[soft_delete] // Enables delete/restore/hard_delete - is_deleted: bool, - - #[created_at] // Auto-set on insert + #[created_at] // Auto-set on insert created_at: i64, - #[updated_at] // Auto-set on update + #[updated_at] // Auto-set on update updated_at: i64, } -// Soft delete -user.delete(&pool).await?; // is_deleted = true -user.restore(&pool).await?; // is_deleted = false -user.hard_delete(&pool).await?; // DELETE FROM +// Hard delete (always available on all entities) +user.hard_delete(&pool).await?; // DELETE FROM +User::hard_delete_by_id(&pool, &id).await?; + +// Soft delete (when is_active or #[soft_delete] field exists) +user.soft_delete(&pool).await?; // is_active = false +User::soft_delete_by_id(&pool, &id).await?; +user.restore(&pool).await?; // is_active = true // Batch insert User::insert_many(&pool, &users).await?; @@ -206,11 +208,23 @@ User::update_by_id(&pool, &id, ## ConnProvider (Flexible Connections) ```rust -use sqlx_record::ConnProvider; +use sqlx_record::prelude::ConnProvider; -// Borrowed or owned pool connections -let conn = ConnProvider::Borrowed(&pool); -let users = User::find(&*conn, filters![], None).await?; +// From borrowed connection +let mut conn = pool.acquire().await?; +let mut provider = ConnProvider::from_ref(&mut conn); + +// From pool (lazy acquisition) +let mut provider = ConnProvider::from_pool(pool.clone()); + +// From transaction (operations participate in the transaction) +let mut tx = pool.begin().await?; +let mut provider = ConnProvider::from_tx(&mut tx); +// ... use provider ... +tx.commit().await?; + +// Get underlying connection +let conn = provider.get_conn().await?; ``` ## Database Differences diff --git a/.claude/skills/sqlx-soft-delete.md b/.claude/skills/sqlx-record/sqlx-soft-delete.md similarity index 58% rename from .claude/skills/sqlx-soft-delete.md rename to .claude/skills/sqlx-record/sqlx-soft-delete.md index d6642e3..47a908d 100644 --- a/.claude/skills/sqlx-soft-delete.md +++ b/.claude/skills/sqlx-record/sqlx-soft-delete.md @@ -1,61 +1,17 @@ -# sqlx-record Soft Delete Skill +# sqlx-record Delete & Soft Delete Skill -Guide to soft delete functionality with #[soft_delete] attribute. +Guide to hard delete and soft delete functionality. ## Triggers - "soft delete", "soft-delete" -- "is_deleted", "deleted" -- "restore", "undelete" - "hard delete", "permanent delete" +- "is_active", "is_deleted", "deleted" +- "restore", "undelete" +- "delete_by_id", "hard_delete_by_id" -## Overview +## Hard Delete (Always Generated) -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: +Every Entity gets `hard_delete()` and `hard_delete_by_{pk}()` methods. No configuration needed. ```rust // Instance method @@ -70,8 +26,73 @@ User::hard_delete_by_id(&pool, &user_id).await?; DELETE FROM users WHERE id = ? ``` +## Soft Delete + +Marks 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 + +**Preferred: `is_active` convention** (auto-detected, no attribute needed): + +```rust +use sqlx_record::prelude::*; + +#[derive(Entity, FromRow)] +#[table_name = "users"] +struct User { + #[primary_key] + id: Uuid, + name: String, + is_active: bool, // Auto-detected: FALSE = deleted, TRUE = active +} +``` + +**Alternative: `#[soft_delete]` attribute** on any bool field: + +```rust +#[derive(Entity, FromRow)] +#[table_name = "users"] +struct User { + #[primary_key] + id: Uuid, + name: String, + + #[soft_delete] // Field will be FALSE when deleted + is_active: bool, +} +``` + +**Legacy: `is_deleted`/`deleted` fields** are also auto-detected (TRUE = deleted). + +### Detection Priority + +1. Field with `#[soft_delete]` attribute (FALSE = deleted) +2. Field named `is_active` with bool type (FALSE = deleted) +3. Field named `is_deleted` or `deleted` with bool type (TRUE = deleted) + +## Generated Methods + +### soft_delete() / soft_delete_by_{pk}() +Marks the record as deleted: + +```rust +// Instance method +user.soft_delete(&pool).await?; + +// Static method by primary key +User::soft_delete_by_id(&pool, &user_id).await?; +``` + +**SQL generated (is_active convention):** +```sql +UPDATE users SET is_active = FALSE WHERE id = ? +``` + ### restore() / restore_by_{pk}() -Sets the soft delete field to `false`: +Restores a soft-deleted record: ```rust // Instance method @@ -81,16 +102,16 @@ user.restore(&pool).await?; User::restore_by_id(&pool, &user_id).await?; ``` -**SQL generated:** +**SQL generated (is_active convention):** ```sql -UPDATE users SET is_deleted = FALSE WHERE id = ? +UPDATE users SET is_active = TRUE WHERE id = ? ``` ### soft_delete_field() Returns the field name: ```rust -let field = User::soft_delete_field(); // "is_deleted" +let field = User::soft_delete_field(); // "is_active" ``` ## Filtering Deleted Records @@ -98,11 +119,11 @@ let field = User::soft_delete_field(); // "is_deleted" 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 active (non-deleted) +let users = User::find(&pool, filters![("is_active", true)], None).await?; // Include only deleted (trash view) -let deleted = User::find(&pool, filters![("is_deleted", true)], None).await?; +let deleted = User::find(&pool, filters![("is_active", false)], None).await?; // Include all records let all = User::find(&pool, filters![], None).await?; @@ -119,7 +140,7 @@ impl User { mut filters: Vec>, index: Option<&str> ) -> Result, sqlx::Error> { - filters.push(Filter::Equal("is_deleted", false.into())); + filters.push(Filter::Equal("is_active", true.into())); Self::find(pool, filters, index).await } } @@ -130,28 +151,28 @@ let users = User::find_active(&pool, filters![("role", "admin")], None).await?; ## Usage Examples -### Basic Soft Delete Flow +### Basic Flow ```rust // Create user let user = User { id: new_uuid(), name: "Alice".into(), - is_deleted: false, + is_active: true, }; user.insert(&pool).await?; // Soft delete -user.delete(&pool).await?; -// user still exists in DB with is_deleted = true +user.soft_delete(&pool).await?; +// user still exists in DB with is_active = false // Find won't return deleted users (with proper filter) -let users = User::find(&pool, filters![("is_deleted", false)], None).await?; +let users = User::find(&pool, filters![("is_active", true)], None).await?; // Alice not in results // Restore User::restore_by_id(&pool, &user.id).await?; -// user.is_deleted = false again +// user.is_active = true again // Hard delete (permanent) User::hard_delete_by_id(&pool, &user.id).await?; @@ -170,13 +191,13 @@ async fn soft_delete_with_audit( ) -> Result<(), sqlx::Error> { transaction!(&pool, |tx| { // Soft delete the user - User::delete_by_id(&mut *tx, user_id).await?; + User::soft_delete_by_id(&mut *tx, user_id).await?; // Record the deletion let change = EntityChange { id: new_uuid(), entity_id: *user_id, - action: "delete".into(), + action: "soft_delete".into(), changed_at: chrono::Utc::now().timestamp_millis(), actor_id: *actor_id, session_id: Uuid::nil(), @@ -198,11 +219,11 @@ async fn delete_user_cascade(pool: &Pool, user_id: &Uuid) -> Result<(), sqlx::Er // 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?; + order.soft_delete(&mut *tx).await?; } // Soft delete user - User::delete_by_id(&mut *tx, user_id).await?; + User::soft_delete_by_id(&mut *tx, user_id).await?; Ok::<_, sqlx::Error>(()) }).await @@ -215,27 +236,28 @@ Recommended column definition: ```sql -- MySQL -is_deleted BOOLEAN NOT NULL DEFAULT FALSE +is_active BOOLEAN NOT NULL DEFAULT TRUE -- PostgreSQL -is_deleted BOOLEAN NOT NULL DEFAULT FALSE +is_active BOOLEAN NOT NULL DEFAULT TRUE -- SQLite -is_deleted INTEGER NOT NULL DEFAULT 0 -- 0=false, 1=true +is_active INTEGER NOT NULL DEFAULT 1 -- 1=true, 0=false ``` Add an index for efficient filtering: ```sql -CREATE INDEX idx_users_is_deleted ON users (is_deleted); +CREATE INDEX idx_users_is_active ON users (is_active); -- Or composite index for common queries -CREATE INDEX idx_users_active_name ON users (is_deleted, name); +CREATE INDEX idx_users_active_name ON users (is_active, name); ``` ## Notes - Soft delete field must be `bool` type - The field is included in UpdateForm (can be manually toggled) +- `hard_delete()` / `hard_delete_by_{pk}()` are always available, even on entities with soft delete - Consider adding `deleted_at: Option` for deletion timestamps - For complex filtering, consider database views diff --git a/.claude/skills/sqlx-transactions.md b/.claude/skills/sqlx-record/sqlx-transactions.md similarity index 100% rename from .claude/skills/sqlx-transactions.md rename to .claude/skills/sqlx-record/sqlx-transactions.md diff --git a/.claude/skills/sqlx-update-expr.md b/.claude/skills/sqlx-record/sqlx-update-expr.md similarity index 100% rename from .claude/skills/sqlx-update-expr.md rename to .claude/skills/sqlx-record/sqlx-update-expr.md diff --git a/.claude/skills/sqlx-values.md b/.claude/skills/sqlx-record/sqlx-values.md similarity index 100% rename from .claude/skills/sqlx-values.md rename to .claude/skills/sqlx-record/sqlx-values.md diff --git a/CLAUDE.md b/CLAUDE.md index 1ee3146..f218dd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,13 +31,14 @@ sqlx-record/ │ └── src/main.rs ├── mcp/ # MCP server for documentation/code generation │ └── src/main.rs # sqlx-record-expert executable -├── .claude/skills/ # Claude Code skills documentation -│ ├── sqlx-record.md # Overview and quick reference -│ ├── sqlx-entity.md # #[derive(Entity)] detailed guide -│ ├── sqlx-filters.md # Filter system guide -│ ├── sqlx-audit.md # Audit trail guide -│ ├── sqlx-lookup.md # Lookup tables guide -│ └── sqlx-values.md # Value types guide +├── .claude/skills/sqlx-record/ # Claude Code skills documentation +│ ├── sqlx-record.md # Overview and quick reference +│ ├── sqlx-entity.md # #[derive(Entity)] detailed guide +│ ├── sqlx-filters.md # Filter system guide +│ ├── sqlx-audit.md # Audit trail guide +│ ├── sqlx-lookup.md # Lookup tables guide +│ ├── sqlx-values.md # Value types guide +│ └── sqlx-conn-provider.md # Connection provider guide └── Cargo.toml # Workspace root ``` @@ -122,7 +123,7 @@ let id = new_uuid(); // Timestamp prefix (8 bytes) + random (8 bytes) ## Connection Provider -Flexible connection management - borrow existing or lazily acquire from pool: +Flexible connection management - borrow existing connection, lazily acquire from pool, or use a transaction: ```rust use sqlx_record::prelude::ConnProvider; @@ -133,6 +134,12 @@ let mut provider = ConnProvider::from_ref(&mut conn); // From pool (lazy acquisition) let mut provider = ConnProvider::from_pool(pool.clone()); +// From transaction (operations participate in the transaction) +let mut tx = pool.begin().await?; +let mut provider = ConnProvider::from_tx(&mut tx); +// ... use provider ... +tx.commit().await?; + // Get connection (acquires on first call for Owned variant) let conn = provider.get_conn().await?; ``` diff --git a/mcp/src/main.rs b/mcp/src/main.rs index 96af3a9..3960504 100644 --- a/mcp/src/main.rs +++ b/mcp/src/main.rs @@ -51,7 +51,8 @@ A Rust library providing derive macros for automatic CRUD operations and compreh - **Audit Trails**: Track who changed what, when, and why - **Type-Safe Filters**: Composable query building with `Filter` enum - **UpdateExpr**: Advanced updates with arithmetic, CASE/WHEN, conditionals -- **Soft Deletes**: `#[soft_delete]` with delete/restore/hard_delete methods +- **Hard Delete**: `hard_delete_by_{pk}()` always generated for all entities +- **Soft Deletes**: `#[soft_delete]` or `is_active` convention with soft_delete/restore methods - **Auto Timestamps**: `#[created_at]`, `#[updated_at]` auto-populated - **Batch Operations**: `insert_many()`, `upsert()` for efficient bulk operations - **Pagination**: `Page` with `paginate()` method @@ -166,6 +167,21 @@ pub async fn update_by_ids(executor, ids: &[Uuid], form: UpdateForm) -> Result<( pub fn update_form() -> UpdateForm ``` +### Delete (always generated) +```rust +pub async fn hard_delete(&self, executor) -> Result<(), Error> +pub async fn hard_delete_by_id(executor, id: &Uuid) -> Result<(), Error> +``` + +### Soft Delete (if `is_active` field or `#[soft_delete]` exists) +```rust +pub async fn soft_delete(&self, executor) -> Result<(), Error> +pub async fn soft_delete_by_id(executor, id: &Uuid) -> Result<(), Error> +pub async fn restore(&self, executor) -> Result<(), Error> +pub async fn restore_by_id(executor, id: &Uuid) -> Result<(), Error> +pub const fn soft_delete_field() -> &'static str +``` + ### Diff (Change Detection) ```rust pub fn model_diff(form: &UpdateForm, model: &Self) -> serde_json::Value @@ -1100,11 +1116,43 @@ if page.has_next() { ``` "#; -const SOFT_DELETE: &str = r#"# Soft Delete +const SOFT_DELETE: &str = r#"# Delete Methods + +## Hard Delete (always generated) + +Every Entity gets `hard_delete` and `hard_delete_by_{pk}` methods: + +```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 = ? +``` + +## Soft Delete Mark records as deleted without removing from database. -## Enable +### Enable + +Convention: an `is_active` bool field is auto-detected (preferred): + +```rust +#[derive(Entity, FromRow)] +struct User { + #[primary_key] + id: Uuid, + is_active: bool, // Auto-detected: FALSE = deleted +} +``` + +Or use `#[soft_delete]` on any bool field: ```rust #[derive(Entity, FromRow)] @@ -1112,42 +1160,38 @@ struct User { #[primary_key] id: Uuid, - #[soft_delete] // Must be bool - is_deleted: bool, + #[soft_delete] // Field will be FALSE when deleted + is_active: bool, } ``` -Auto-detection: Fields named `is_deleted` or `deleted` with `bool` type work without attribute. +Auto-detection also works for `is_deleted` or `deleted` bool fields (TRUE = deleted). -## Generated Methods +### Generated Methods ```rust -// Soft delete (set to true) -user.delete(&pool).await?; -User::delete_by_id(&pool, &id).await?; +// Soft delete (set is_active = FALSE) +user.soft_delete(&pool).await?; +User::soft_delete_by_id(&pool, &id).await?; -// Hard delete (permanent) -user.hard_delete(&pool).await?; -User::hard_delete_by_id(&pool, &id).await?; - -// Restore (set to false) +// Restore (set is_active = TRUE) user.restore(&pool).await?; User::restore_by_id(&pool, &id).await?; // Field name -User::soft_delete_field() // "is_deleted" +User::soft_delete_field() // "is_active" ``` -## Filtering +### Filtering Soft delete does NOT auto-filter. Add filter manually: ```rust -// Only non-deleted -let users = User::find(&pool, filters![("is_deleted", false)], None).await?; +// Only active (non-deleted) +let users = User::find(&pool, filters![("is_active", true)], None).await?; -// Only deleted (trash) -let deleted = User::find(&pool, filters![("is_deleted", true)], None).await?; +// Only deleted +let deleted = User::find(&pool, filters![("is_active", false)], None).await?; // All records let all = User::find(&pool, filters![], None).await?; diff --git a/sqlx-record-derive/src/lib.rs b/sqlx-record-derive/src/lib.rs index bc7c4da..57a9a5f 100644 --- a/sqlx-record-derive/src/lib.rs +++ b/sqlx-record-derive/src/lib.rs @@ -62,7 +62,7 @@ fn db_type() -> TokenStream2 { } #[cfg(feature = "sqlite")] { - quote! { sqlx::Sqlite } + return quote! { sqlx::Sqlite }; } #[cfg(feature = "mysql")] { @@ -82,7 +82,7 @@ fn db_arguments() -> TokenStream2 { } #[cfg(feature = "sqlite")] { - quote! { sqlx::sqlite::SqliteArguments<'static> } + return quote! { sqlx::sqlite::SqliteArguments<'q> }; } #[cfg(feature = "mysql")] { @@ -99,7 +99,7 @@ fn table_quote() -> &'static str { #[cfg(feature = "postgres")] { "\"" } #[cfg(feature = "sqlite")] - { "\"" } + { return "\""; } #[cfg(feature = "mysql")] { "`" } #[cfg(not(any(feature = "mysql", feature = "postgres", feature = "sqlite")))] @@ -139,10 +139,11 @@ fn derive_entity_internal(input: TokenStream) -> TokenStream { .or_else(|| fields.iter().find(|&f| is_version_field(f))); // Find soft delete field (by attribute or by name convention) + // Convention: `is_active` (FALSE = deleted), `is_deleted`/`deleted` (TRUE = deleted) let soft_delete_field = fields.iter() .find(|f| f.is_soft_delete) .or_else(|| fields.iter().find(|f| { - (f.ident == "is_deleted" || f.ident == "deleted") && + (f.ident == "is_active" || f.ident == "is_deleted" || f.ident == "deleted") && matches!(&f.ty, Type::Path(p) if p.path.is_ident("bool")) })); @@ -151,6 +152,7 @@ fn derive_entity_internal(input: TokenStream) -> TokenStream { let get_impl = generate_get_impl(&name, &table_name, primary_key, version_field, soft_delete_field, &fields, &impl_generics, &ty_generics, &where_clause); let update_impl = generate_update_impl(&name, &update_form_name, &table_name, &fields, primary_key, version_field, has_updated_at, &impl_generics, &ty_generics, &where_clause); let diff_impl = generate_diff_impl(&name, &update_form_name, &fields, primary_key, version_field, &impl_generics, &ty_generics, &where_clause); + let delete_impl = generate_delete_impl(&name, &table_name, primary_key, &impl_generics, &ty_generics, &where_clause); let soft_delete_impl = generate_soft_delete_impl(&name, &table_name, primary_key, soft_delete_field, &impl_generics, &ty_generics, &where_clause); let pk_type = &primary_key.ty; @@ -161,6 +163,7 @@ fn derive_entity_internal(input: TokenStream) -> TokenStream { #get_impl #update_impl #diff_impl + #delete_impl #soft_delete_impl impl #impl_generics #name #ty_generics #where_clause { @@ -1446,6 +1449,51 @@ fn generate_diff_impl( } } +// Generate delete implementation - always generated for ALL entities +fn generate_delete_impl( + name: &Ident, + table_name: &str, + primary_key: &EntityField, + impl_generics: &ImplGenerics, + ty_generics: &TypeGenerics, + where_clause: &Option<&WhereClause>, +) -> TokenStream2 { + let pk_field = &primary_key.ident; + let pk_type = &primary_key.ty; + let pk_db_name = &primary_key.db_name; + let db = db_type(); + let tq = table_quote(); + + let pk_field_name = primary_key.ident.to_string(); + let hard_delete_by_func = format_ident!("hard_delete_by_{}", pk_field_name); + + quote! { + impl #impl_generics #name #ty_generics #where_clause { + /// Hard delete - permanently removes the row from database + pub async fn hard_delete<'a, E>(&self, executor: E) -> Result<(), sqlx::Error> + where + E: sqlx::Executor<'a, Database = #db>, + { + Self::#hard_delete_by_func(executor, &self.#pk_field).await + } + + /// Hard delete by primary key - permanently removes the row from database + pub async fn #hard_delete_by_func<'a, E>(executor: E, #pk_field: &#pk_type) -> Result<(), sqlx::Error> + where + E: sqlx::Executor<'a, Database = #db>, + { + let query = format!( + "DELETE FROM {}{}{} WHERE {} = {}", + #tq, #table_name, #tq, + #pk_db_name, ::sqlx_record::prelude::placeholder(1) + ); + sqlx::query(&query).bind(#pk_field).execute(executor).await?; + Ok(()) + } + } + } +} + // Generate soft delete implementation fn generate_soft_delete_impl( name: &Ident, @@ -1468,51 +1516,41 @@ fn generate_soft_delete_impl( let tq = table_quote(); let pk_field_name = primary_key.ident.to_string(); - let delete_by_func = format_ident!("delete_by_{}", pk_field_name); - let hard_delete_by_func = format_ident!("hard_delete_by_{}", pk_field_name); + let soft_delete_by_func = format_ident!("soft_delete_by_{}", pk_field_name); let restore_by_func = format_ident!("restore_by_{}", pk_field_name); + // Determine semantics based on field name and attribute: + // - #[soft_delete] attribute: field should be FALSE when deleted (user convention) + // - `is_active` by name: FALSE when deleted, TRUE when active + // - `is_deleted`/`deleted` by name: TRUE when deleted, FALSE when active + let sd_field_name = sd_field.ident.to_string(); + let is_inverted = sd_field.is_soft_delete || sd_field_name == "is_active"; + + let (delete_value, restore_value) = if is_inverted { + ("FALSE", "TRUE") + } else { + ("TRUE", "FALSE") + }; + quote! { impl #impl_generics #name #ty_generics #where_clause { - /// Soft delete - sets the soft_delete field to true - pub async fn delete<'a, E>(&self, executor: E) -> Result<(), sqlx::Error> + /// Soft delete - marks record as deleted without removing from database + pub async fn soft_delete<'a, E>(&self, executor: E) -> Result<(), sqlx::Error> where E: sqlx::Executor<'a, Database = #db>, { - Self::#delete_by_func(executor, &self.#pk_field).await + Self::#soft_delete_by_func(executor, &self.#pk_field).await } /// Soft delete by primary key - pub async fn #delete_by_func<'a, E>(executor: E, #pk_field: &#pk_type) -> Result<(), sqlx::Error> + pub async fn #soft_delete_by_func<'a, E>(executor: E, #pk_field: &#pk_type) -> Result<(), sqlx::Error> where E: sqlx::Executor<'a, Database = #db>, { let query = format!( - "UPDATE {}{}{} SET {} = TRUE WHERE {} = {}", - #tq, #table_name, #tq, - #sd_db_name, - #pk_db_name, ::sqlx_record::prelude::placeholder(1) - ); - sqlx::query(&query).bind(#pk_field).execute(executor).await?; - Ok(()) - } - - /// Hard delete - permanently removes the row from database - pub async fn hard_delete<'a, E>(&self, executor: E) -> Result<(), sqlx::Error> - where - E: sqlx::Executor<'a, Database = #db>, - { - Self::#hard_delete_by_func(executor, &self.#pk_field).await - } - - /// Hard delete by primary key - pub async fn #hard_delete_by_func<'a, E>(executor: E, #pk_field: &#pk_type) -> Result<(), sqlx::Error> - where - E: sqlx::Executor<'a, Database = #db>, - { - let query = format!( - "DELETE FROM {}{}{} WHERE {} = {}", + "UPDATE {}{}{} SET {} = {} WHERE {} = {}", #tq, #table_name, #tq, + #sd_db_name, #delete_value, #pk_db_name, ::sqlx_record::prelude::placeholder(1) ); sqlx::query(&query).bind(#pk_field).execute(executor).await?; @@ -1533,9 +1571,9 @@ fn generate_soft_delete_impl( E: sqlx::Executor<'a, Database = #db>, { let query = format!( - "UPDATE {}{}{} SET {} = FALSE WHERE {} = {}", + "UPDATE {}{}{} SET {} = {} WHERE {} = {}", #tq, #table_name, #tq, - #sd_db_name, + #sd_db_name, #restore_value, #pk_db_name, ::sqlx_record::prelude::placeholder(1) ); sqlx::query(&query).bind(#pk_field).execute(executor).await?; diff --git a/src/conn_provider.rs b/src/conn_provider.rs index 5080bcb..d2271f0 100644 --- a/src/conn_provider.rs +++ b/src/conn_provider.rs @@ -1,13 +1,13 @@ use sqlx::pool::PoolConnection; #[cfg(feature = "mysql")] -use sqlx::{MySql, MySqlPool}; +use sqlx::{MySql, MySqlConnection, MySqlPool, Transaction}; #[cfg(feature = "postgres")] -use sqlx::{Postgres, PgPool}; +use sqlx::{Postgres, PgConnection, PgPool, Transaction}; #[cfg(feature = "sqlite")] -use sqlx::{Sqlite, SqlitePool}; +use sqlx::{Sqlite, SqliteConnection, SqlitePool, Transaction}; // ============================================================================ // MySQL Implementation @@ -24,6 +24,10 @@ pub enum ConnProvider<'a> { pool: MySqlPool, conn: Option>, }, + /// Stores a reference to a transaction + Transaction { + tx: &'a mut Transaction<'static, MySql>, + }, } #[cfg(feature = "mysql")] @@ -38,18 +42,25 @@ impl<'a> ConnProvider<'a> { ConnProvider::Owned { pool, conn: None } } + /// Create a ConnProvider from a borrowed transaction reference + pub fn from_tx(tx: &'a mut Transaction<'static, MySql>) -> Self { + ConnProvider::Transaction { tx } + } + /// Get a mutable reference to the underlying connection. /// For borrowed connections, returns the reference directly. /// For owned connections, lazily acquires from pool on first call. - pub async fn get_conn(&mut self) -> Result<&mut PoolConnection, sqlx::Error> { + /// For transactions, returns the transaction's underlying connection. + pub async fn get_conn(&mut self) -> Result<&mut MySqlConnection, sqlx::Error> { match self { - ConnProvider::Borrowed { conn } => Ok(conn), + ConnProvider::Borrowed { conn } => Ok(&mut **conn), ConnProvider::Owned { pool, conn } => { if conn.is_none() { *conn = Some(pool.acquire().await?); } - Ok(conn.as_mut().unwrap()) + Ok(&mut **conn.as_mut().unwrap()) } + ConnProvider::Transaction { tx } => Ok(&mut **tx), } } } @@ -69,6 +80,10 @@ pub enum ConnProvider<'a> { pool: PgPool, conn: Option>, }, + /// Stores a reference to a transaction + Transaction { + tx: &'a mut Transaction<'static, Postgres>, + }, } #[cfg(feature = "postgres")] @@ -83,18 +98,25 @@ impl<'a> ConnProvider<'a> { ConnProvider::Owned { pool, conn: None } } + /// Create a ConnProvider from a borrowed transaction reference + pub fn from_tx(tx: &'a mut Transaction<'static, Postgres>) -> Self { + ConnProvider::Transaction { tx } + } + /// Get a mutable reference to the underlying connection. /// For borrowed connections, returns the reference directly. /// For owned connections, lazily acquires from pool on first call. - pub async fn get_conn(&mut self) -> Result<&mut PoolConnection, sqlx::Error> { + /// For transactions, returns the transaction's underlying connection. + pub async fn get_conn(&mut self) -> Result<&mut PgConnection, sqlx::Error> { match self { - ConnProvider::Borrowed { conn } => Ok(conn), + ConnProvider::Borrowed { conn } => Ok(&mut **conn), ConnProvider::Owned { pool, conn } => { if conn.is_none() { *conn = Some(pool.acquire().await?); } - Ok(conn.as_mut().unwrap()) + Ok(&mut **conn.as_mut().unwrap()) } + ConnProvider::Transaction { tx } => Ok(&mut **tx), } } } @@ -114,6 +136,10 @@ pub enum ConnProvider<'a> { pool: SqlitePool, conn: Option>, }, + /// Stores a reference to a transaction + Transaction { + tx: &'a mut Transaction<'static, Sqlite>, + }, } #[cfg(feature = "sqlite")] @@ -128,18 +154,25 @@ impl<'a> ConnProvider<'a> { ConnProvider::Owned { pool, conn: None } } + /// Create a ConnProvider from a borrowed transaction reference + pub fn from_tx(tx: &'a mut Transaction<'static, Sqlite>) -> Self { + ConnProvider::Transaction { tx } + } + /// Get a mutable reference to the underlying connection. /// For borrowed connections, returns the reference directly. /// For owned connections, lazily acquires from pool on first call. - pub async fn get_conn(&mut self) -> Result<&mut PoolConnection, sqlx::Error> { + /// For transactions, returns the transaction's underlying connection. + pub async fn get_conn(&mut self) -> Result<&mut SqliteConnection, sqlx::Error> { match self { - ConnProvider::Borrowed { conn } => Ok(conn), + ConnProvider::Borrowed { conn } => Ok(&mut **conn), ConnProvider::Owned { pool, conn } => { if conn.is_none() { *conn = Some(pool.acquire().await?); } - Ok(conn.as_mut().unwrap()) + Ok(&mut **conn.as_mut().unwrap()) } + ConnProvider::Transaction { tx } => Ok(&mut **tx), } } } diff --git a/src/lib.rs b/src/lib.rs index a777fba..bbc78ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -181,6 +181,7 @@ pub mod prelude { pub use crate::values; pub use crate::{new_uuid, lookup_table, lookup_options, transaction}; pub use crate::pagination::{Page, PageRequest}; + pub use crate::conn_provider::*; #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] pub use crate::conn_provider::ConnProvider;