Compare commits

..

No commits in common. "main" and "v0.3.4" have entirely different histories.
main ... v0.3.4

21 changed files with 320 additions and 619 deletions

View File

@ -6,14 +6,12 @@ Guide to flexible connection management.
- "connection provider", "conn provider" - "connection provider", "conn provider"
- "borrow connection", "pool connection" - "borrow connection", "pool connection"
- "lazy connection", "connection management" - "lazy connection", "connection management"
- "transaction provider", "use transaction"
## Overview ## Overview
`ConnProvider` enables flexible connection handling: `ConnProvider` enables flexible connection handling:
- **Borrowed**: Use an existing connection reference - **Borrowed**: Use an existing connection reference
- **Owned**: Lazily acquire from pool on first use - **Owned**: Lazily acquire from pool on first use
- **Transaction**: Use a transaction reference (all operations participate in the transaction)
## Enum Variants ## Enum Variants
@ -28,10 +26,6 @@ pub enum ConnProvider<'a> {
pool: Pool, pool: Pool,
conn: Option<PoolConnection<DB>>, conn: Option<PoolConnection<DB>>,
}, },
/// Reference to a transaction
Transaction {
tx: &'a mut Transaction<'static, DB>,
},
} }
``` ```
@ -51,29 +45,15 @@ let mut provider = ConnProvider::from_pool(pool.clone());
// Connection acquired on first get_conn() call // 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 ## Getting the Connection
```rust ```rust
let conn = provider.get_conn().await?; let conn = provider.get_conn().await?;
// Returns &mut <DB>Connection (e.g., &mut MySqlConnection) // Returns &mut PoolConnection<DB>
``` ```
- **Borrowed**: Returns underlying connection immediately - **Borrowed**: Returns reference immediately
- **Owned**: Acquires on first call, returns same connection on subsequent calls - **Owned**: Acquires on first call, returns same connection on subsequent calls
- **Transaction**: Returns transaction's underlying connection
## Use Cases ## Use Cases
@ -125,37 +105,15 @@ let mut conn = pool.acquire().await?;
do_database_work(&mut ConnProvider::from_ref(&mut conn)).await?; do_database_work(&mut ConnProvider::from_ref(&mut conn)).await?;
// Call with pool // Call with pool
do_database_work(&mut ConnProvider::from_pool(pool.clone())).await?; do_database_work(&mut ConnProvider::from_pool(pool)).await?;
// Call with transaction
let mut tx = pool.begin().await?;
do_database_work(&mut ConnProvider::from_tx(&mut tx)).await?;
tx.commit().await?;
``` ```
### Using Transactions ### Transaction-like Patterns
```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 ```rust
async fn multi_step_operation(pool: MySqlPool) -> Result<()> { async fn multi_step_operation(pool: MySqlPool) -> Result<()> {
let mut provider = ConnProvider::from_pool(pool); let mut provider = ConnProvider::from_pool(pool);
// All operations use same connection (but no transaction) // All operations use same connection
step_1(&mut provider).await?; step_1(&mut provider).await?;
step_2(&mut provider).await?; step_2(&mut provider).await?;
step_3(&mut provider).await?; step_3(&mut provider).await?;
@ -169,13 +127,11 @@ async fn multi_step_operation(pool: MySqlPool) -> Result<()> {
The concrete types depend on the enabled feature: The concrete types depend on the enabled feature:
| Feature | Pool Type | Connection Type | Transaction Type | | Feature | Pool Type | Connection Type |
|---------|-----------|-----------------|------------------| |---------|-----------|-----------------|
| `mysql` | `MySqlPool` | `MySqlConnection` | `Transaction<'static, MySql>` | | `mysql` | `MySqlPool` | `PoolConnection<MySql>` |
| `postgres` | `PgPool` | `PgConnection` | `Transaction<'static, Postgres>` | | `postgres` | `PgPool` | `PoolConnection<Postgres>` |
| `sqlite` | `SqlitePool` | `SqliteConnection` | `Transaction<'static, Sqlite>` | | `sqlite` | `SqlitePool` | `PoolConnection<Sqlite>` |
Note: `get_conn()` returns `&mut <DB>Connection` (the underlying connection type).
## Example: Service Layer ## Example: Service Layer
@ -219,28 +175,28 @@ let user_id = UserService::create_with_profile(&mut provider, "Alice", "Hello!")
## Connection Lifecycle ## Connection Lifecycle
``` ```
from_pool(pool) from_ref(&mut conn) from_tx(&mut tx) from_pool(pool) from_ref(&mut conn)
│ │ │ │
▼ ▼ ▼ ▼
Owned { Borrowed { Transaction { Owned { Borrowed {
pool, conn: &mut tx: &mut pool, conn: &mut PoolConnection
conn: None PoolConnection Transaction conn: None }
} } } }
│ │ │ │
│ get_conn() │ get_conn() │ get_conn() │ get_conn() │ get_conn()
▼ ▼ ▼ ▼
pool.acquire() deref conn deref tx pool.acquire() return conn
│ │ │ │
▼ ▼
Owned { return &mut return &mut Owned {
pool, Connection Connection pool,
conn: Some(acquired) │ conn: Some(acquired) │
} │ } │
│ │ │ │
│ get_conn() (subsequent) │ │ get_conn() (subsequent) │
▼ ▼
return &mut conn Drop: nothing Drop: nothing return &mut acquired │
(borrowed) (tx managed
externally)
Drop: conn returned Drop: conn returned Drop: nothing (borrowed)
``` ```

View File

@ -59,13 +59,11 @@ large_count: i64,
### #[soft_delete] ### #[soft_delete]
```rust ```rust
#[soft_delete] #[soft_delete]
is_active: bool, is_deleted: bool,
``` ```
- Enables soft delete functionality - Enables soft delete functionality
- Generates `soft_delete()`, `soft_delete_by_{pk}()`, `restore()`, `restore_by_{pk}()` methods - Generates `delete()`, `restore()`, `hard_delete()` methods
- Field must be `bool` type - 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] ### #[created_at]
```rust ```rust
@ -196,20 +194,17 @@ pub async fn get_version(executor, pk: &PkType) -> Result<Option<VersionType>, E
pub async fn get_versions(executor, pks: &[PkType]) -> Result<HashMap<PkType, VersionType>, Error> pub async fn get_versions(executor, pks: &[PkType]) -> Result<HashMap<PkType, VersionType>, Error>
``` ```
### Hard Delete (always generated) ### Soft Delete Methods (if #[soft_delete] exists)
```rust ```rust
// Permanently removes row from database // 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
pub async fn hard_delete(&self, executor) -> Result<(), Error> pub async fn hard_delete(&self, executor) -> Result<(), Error>
pub async fn hard_delete_by_id(executor, id: &Uuid) -> Result<(), Error> pub async fn hard_delete_by_id(executor, id: &Uuid) -> Result<(), Error>
```
### Soft Delete Methods (if `is_active` field or `#[soft_delete]` exists) // Restore - sets field to false
```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(&self, executor) -> Result<(), Error>
pub async fn restore_by_id(executor, id: &Uuid) -> Result<(), Error> pub async fn restore_by_id(executor, id: &Uuid) -> Result<(), Error>

View File

@ -122,14 +122,16 @@ sqlx-record = { version = "0.3", features = ["mysql", "derive"] }
# Optional: "derive", "static-validation" # Optional: "derive", "static-validation"
``` ```
## Delete, Soft Delete, Timestamps, Batch Operations ## Soft Delete, Timestamps, Batch Operations
```rust ```rust
#[derive(Entity, FromRow)] #[derive(Entity, FromRow)]
struct User { struct User {
#[primary_key] id: Uuid, #[primary_key] id: Uuid,
name: String, 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, created_at: i64,
@ -138,14 +140,10 @@ struct User {
updated_at: i64, updated_at: i64,
} }
// Hard delete (always available on all entities) // Soft delete
user.delete(&pool).await?; // is_deleted = true
user.restore(&pool).await?; // is_deleted = false
user.hard_delete(&pool).await?; // DELETE FROM 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 // Batch insert
User::insert_many(&pool, &users).await?; User::insert_many(&pool, &users).await?;
@ -208,23 +206,11 @@ User::update_by_id(&pool, &id,
## ConnProvider (Flexible Connections) ## ConnProvider (Flexible Connections)
```rust ```rust
use sqlx_record::prelude::ConnProvider; use sqlx_record::ConnProvider;
// From borrowed connection // Borrowed or owned pool connections
let mut conn = pool.acquire().await?; let conn = ConnProvider::Borrowed(&pool);
let mut provider = ConnProvider::from_ref(&mut conn); let users = User::find(&*conn, filters![], None).await?;
// 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 ## Database Differences

View File

@ -1,17 +1,61 @@
# sqlx-record Delete & Soft Delete Skill # sqlx-record Soft Delete Skill
Guide to hard delete and soft delete functionality. Guide to soft delete functionality with #[soft_delete] attribute.
## Triggers ## Triggers
- "soft delete", "soft-delete" - "soft delete", "soft-delete"
- "hard delete", "permanent delete" - "is_deleted", "deleted"
- "is_active", "is_deleted", "deleted"
- "restore", "undelete" - "restore", "undelete"
- "delete_by_id", "hard_delete_by_id" - "hard delete", "permanent delete"
## Hard Delete (Always Generated) ## Overview
Every Entity gets `hard_delete()` and `hard_delete_by_{pk}()` methods. No configuration needed. 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 ```rust
// Instance method // Instance method
@ -26,73 +70,8 @@ User::hard_delete_by_id(&pool, &user_id).await?;
DELETE FROM users WHERE id = ? 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}() ### restore() / restore_by_{pk}()
Restores a soft-deleted record: Sets the soft delete field to `false`:
```rust ```rust
// Instance method // Instance method
@ -102,16 +81,16 @@ user.restore(&pool).await?;
User::restore_by_id(&pool, &user_id).await?; User::restore_by_id(&pool, &user_id).await?;
``` ```
**SQL generated (is_active convention):** **SQL generated:**
```sql ```sql
UPDATE users SET is_active = TRUE WHERE id = ? UPDATE users SET is_deleted = FALSE WHERE id = ?
``` ```
### soft_delete_field() ### soft_delete_field()
Returns the field name: Returns the field name:
```rust ```rust
let field = User::soft_delete_field(); // "is_active" let field = User::soft_delete_field(); // "is_deleted"
``` ```
## Filtering Deleted Records ## Filtering Deleted Records
@ -119,11 +98,11 @@ let field = User::soft_delete_field(); // "is_active"
Soft delete does **NOT** automatically filter `find()` queries. You must add the filter manually: Soft delete does **NOT** automatically filter `find()` queries. You must add the filter manually:
```rust ```rust
// Include only active (non-deleted) // Include only non-deleted
let users = User::find(&pool, filters![("is_active", true)], None).await?; let users = User::find(&pool, filters![("is_deleted", false)], None).await?;
// Include only deleted (trash view) // Include only deleted (trash view)
let deleted = User::find(&pool, filters![("is_active", false)], None).await?; let deleted = User::find(&pool, filters![("is_deleted", true)], None).await?;
// Include all records // Include all records
let all = User::find(&pool, filters![], None).await?; let all = User::find(&pool, filters![], None).await?;
@ -140,7 +119,7 @@ impl User {
mut filters: Vec<Filter<'_>>, mut filters: Vec<Filter<'_>>,
index: Option<&str> index: Option<&str>
) -> Result<Vec<Self>, sqlx::Error> { ) -> Result<Vec<Self>, sqlx::Error> {
filters.push(Filter::Equal("is_active", true.into())); filters.push(Filter::Equal("is_deleted", false.into()));
Self::find(pool, filters, index).await Self::find(pool, filters, index).await
} }
} }
@ -151,28 +130,28 @@ let users = User::find_active(&pool, filters![("role", "admin")], None).await?;
## Usage Examples ## Usage Examples
### Basic Flow ### Basic Soft Delete Flow
```rust ```rust
// Create user // Create user
let user = User { let user = User {
id: new_uuid(), id: new_uuid(),
name: "Alice".into(), name: "Alice".into(),
is_active: true, is_deleted: false,
}; };
user.insert(&pool).await?; user.insert(&pool).await?;
// Soft delete // Soft delete
user.soft_delete(&pool).await?; user.delete(&pool).await?;
// user still exists in DB with is_active = false // user still exists in DB with is_deleted = true
// Find won't return deleted users (with proper filter) // Find won't return deleted users (with proper filter)
let users = User::find(&pool, filters![("is_active", true)], None).await?; let users = User::find(&pool, filters![("is_deleted", false)], None).await?;
// Alice not in results // Alice not in results
// Restore // Restore
User::restore_by_id(&pool, &user.id).await?; User::restore_by_id(&pool, &user.id).await?;
// user.is_active = true again // user.is_deleted = false again
// Hard delete (permanent) // Hard delete (permanent)
User::hard_delete_by_id(&pool, &user.id).await?; User::hard_delete_by_id(&pool, &user.id).await?;
@ -191,13 +170,13 @@ async fn soft_delete_with_audit(
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
transaction!(&pool, |tx| { transaction!(&pool, |tx| {
// Soft delete the user // Soft delete the user
User::soft_delete_by_id(&mut *tx, user_id).await?; User::delete_by_id(&mut *tx, user_id).await?;
// Record the deletion // Record the deletion
let change = EntityChange { let change = EntityChange {
id: new_uuid(), id: new_uuid(),
entity_id: *user_id, entity_id: *user_id,
action: "soft_delete".into(), action: "delete".into(),
changed_at: chrono::Utc::now().timestamp_millis(), changed_at: chrono::Utc::now().timestamp_millis(),
actor_id: *actor_id, actor_id: *actor_id,
session_id: Uuid::nil(), session_id: Uuid::nil(),
@ -219,11 +198,11 @@ async fn delete_user_cascade(pool: &Pool, user_id: &Uuid) -> Result<(), sqlx::Er
// Soft delete user's orders // Soft delete user's orders
let orders = Order::find(&mut *tx, filters![("user_id", user_id)], None).await?; let orders = Order::find(&mut *tx, filters![("user_id", user_id)], None).await?;
for order in orders { for order in orders {
order.soft_delete(&mut *tx).await?; order.delete(&mut *tx).await?;
} }
// Soft delete user // Soft delete user
User::soft_delete_by_id(&mut *tx, user_id).await?; User::delete_by_id(&mut *tx, user_id).await?;
Ok::<_, sqlx::Error>(()) Ok::<_, sqlx::Error>(())
}).await }).await
@ -236,28 +215,27 @@ Recommended column definition:
```sql ```sql
-- MySQL -- MySQL
is_active BOOLEAN NOT NULL DEFAULT TRUE is_deleted BOOLEAN NOT NULL DEFAULT FALSE
-- PostgreSQL -- PostgreSQL
is_active BOOLEAN NOT NULL DEFAULT TRUE is_deleted BOOLEAN NOT NULL DEFAULT FALSE
-- SQLite -- SQLite
is_active INTEGER NOT NULL DEFAULT 1 -- 1=true, 0=false is_deleted INTEGER NOT NULL DEFAULT 0 -- 0=false, 1=true
``` ```
Add an index for efficient filtering: Add an index for efficient filtering:
```sql ```sql
CREATE INDEX idx_users_is_active ON users (is_active); CREATE INDEX idx_users_is_deleted ON users (is_deleted);
-- Or composite index for common queries -- Or composite index for common queries
CREATE INDEX idx_users_active_name ON users (is_active, name); CREATE INDEX idx_users_active_name ON users (is_deleted, name);
``` ```
## Notes ## Notes
- Soft delete field must be `bool` type - Soft delete field must be `bool` type
- The field is included in UpdateForm (can be manually toggled) - 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<i64>` for deletion timestamps - Consider adding `deleted_at: Option<i64>` for deletion timestamps
- For complex filtering, consider database views - For complex filtering, consider database views

View File

@ -31,14 +31,13 @@ sqlx-record/
│ └── src/main.rs │ └── src/main.rs
├── mcp/ # MCP server for documentation/code generation ├── mcp/ # MCP server for documentation/code generation
│ └── src/main.rs # sqlx-record-expert executable │ └── src/main.rs # sqlx-record-expert executable
├── .claude/skills/sqlx-record/ # Claude Code skills documentation ├── .claude/skills/ # Claude Code skills documentation
│ ├── sqlx-record.md # Overview and quick reference │ ├── sqlx-record.md # Overview and quick reference
│ ├── sqlx-entity.md # #[derive(Entity)] detailed guide │ ├── sqlx-entity.md # #[derive(Entity)] detailed guide
│ ├── sqlx-filters.md # Filter system guide │ ├── sqlx-filters.md # Filter system guide
│ ├── sqlx-audit.md # Audit trail guide │ ├── sqlx-audit.md # Audit trail guide
│ ├── sqlx-lookup.md # Lookup tables guide │ ├── sqlx-lookup.md # Lookup tables guide
│ ├── sqlx-values.md # Value types guide │ └── sqlx-values.md # Value types guide
│ └── sqlx-conn-provider.md # Connection provider guide
└── Cargo.toml # Workspace root └── Cargo.toml # Workspace root
``` ```
@ -123,7 +122,7 @@ let id = new_uuid(); // Timestamp prefix (8 bytes) + random (8 bytes)
## Connection Provider ## Connection Provider
Flexible connection management - borrow existing connection, lazily acquire from pool, or use a transaction: Flexible connection management - borrow existing or lazily acquire from pool:
```rust ```rust
use sqlx_record::prelude::ConnProvider; use sqlx_record::prelude::ConnProvider;
@ -134,12 +133,6 @@ let mut provider = ConnProvider::from_ref(&mut conn);
// From pool (lazy acquisition) // From pool (lazy acquisition)
let mut provider = ConnProvider::from_pool(pool.clone()); 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) // Get connection (acquires on first call for Owned variant)
let conn = provider.get_conn().await?; let conn = provider.get_conn().await?;
``` ```

View File

@ -5,7 +5,7 @@ edition.workspace = true
description = "Entity CRUD and change tracking for SQL databases with SQLx" description = "Entity CRUD and change tracking for SQL databases with SQLx"
[workspace.package] [workspace.package]
version = "0.3.7" version = "0.3.4"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@ -28,7 +28,7 @@ members = [
[features] [features]
default = [] default = []
derive = ["dep:sqlx-record-derive"] derive = ["dep:sqlx-record-derive"]
static-check = ["sqlx-record-derive?/static-check"] static-validation = ["sqlx-record-derive?/static-validation"]
decimal = ["dep:rust_decimal", "sqlx/rust_decimal"] decimal = ["dep:rust_decimal", "sqlx/rust_decimal"]
# Database backends - user must enable at least one # Database backends - user must enable at least one

View File

@ -51,8 +51,7 @@ A Rust library providing derive macros for automatic CRUD operations and compreh
- **Audit Trails**: Track who changed what, when, and why - **Audit Trails**: Track who changed what, when, and why
- **Type-Safe Filters**: Composable query building with `Filter` enum - **Type-Safe Filters**: Composable query building with `Filter` enum
- **UpdateExpr**: Advanced updates with arithmetic, CASE/WHEN, conditionals - **UpdateExpr**: Advanced updates with arithmetic, CASE/WHEN, conditionals
- **Hard Delete**: `hard_delete_by_{pk}()` always generated for all entities - **Soft Deletes**: `#[soft_delete]` with delete/restore/hard_delete methods
- **Soft Deletes**: `#[soft_delete]` or `is_active` convention with soft_delete/restore methods
- **Auto Timestamps**: `#[created_at]`, `#[updated_at]` auto-populated - **Auto Timestamps**: `#[created_at]`, `#[updated_at]` auto-populated
- **Batch Operations**: `insert_many()`, `upsert()` for efficient bulk operations - **Batch Operations**: `insert_many()`, `upsert()` for efficient bulk operations
- **Pagination**: `Page<T>` with `paginate()` method - **Pagination**: `Page<T>` with `paginate()` method
@ -167,21 +166,6 @@ pub async fn update_by_ids(executor, ids: &[Uuid], form: UpdateForm) -> Result<(
pub fn update_form() -> UpdateForm 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) ### Diff (Change Detection)
```rust ```rust
pub fn model_diff(form: &UpdateForm, model: &Self) -> serde_json::Value pub fn model_diff(form: &UpdateForm, model: &Self) -> serde_json::Value
@ -1116,43 +1100,11 @@ if page.has_next() {
``` ```
"#; "#;
const SOFT_DELETE: &str = r#"# Delete Methods const SOFT_DELETE: &str = r#"# Soft Delete
## 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. 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 ```rust
#[derive(Entity, FromRow)] #[derive(Entity, FromRow)]
@ -1160,38 +1112,42 @@ struct User {
#[primary_key] #[primary_key]
id: Uuid, id: Uuid,
#[soft_delete] // Field will be FALSE when deleted #[soft_delete] // Must be bool
is_active: bool, is_deleted: bool,
} }
``` ```
Auto-detection also works for `is_deleted` or `deleted` bool fields (TRUE = deleted). Auto-detection: Fields named `is_deleted` or `deleted` with `bool` type work without attribute.
### Generated Methods ## Generated Methods
```rust ```rust
// Soft delete (set is_active = FALSE) // Soft delete (set to true)
user.soft_delete(&pool).await?; user.delete(&pool).await?;
User::soft_delete_by_id(&pool, &id).await?; User::delete_by_id(&pool, &id).await?;
// Restore (set is_active = TRUE) // Hard delete (permanent)
user.hard_delete(&pool).await?;
User::hard_delete_by_id(&pool, &id).await?;
// Restore (set to false)
user.restore(&pool).await?; user.restore(&pool).await?;
User::restore_by_id(&pool, &id).await?; User::restore_by_id(&pool, &id).await?;
// Field name // Field name
User::soft_delete_field() // "is_active" User::soft_delete_field() // "is_deleted"
``` ```
### Filtering ## Filtering
Soft delete does NOT auto-filter. Add filter manually: Soft delete does NOT auto-filter. Add filter manually:
```rust ```rust
// Only active (non-deleted) // Only non-deleted
let users = User::find(&pool, filters![("is_active", true)], None).await?; let users = User::find(&pool, filters![("is_deleted", false)], None).await?;
// Only deleted // Only deleted (trash)
let deleted = User::find(&pool, filters![("is_active", false)], None).await?; let deleted = User::find(&pool, filters![("is_deleted", true)], None).await?;
// All records // All records
let all = User::find(&pool, filters![], None).await?; let all = User::find(&pool, filters![], None).await?;

View File

@ -13,7 +13,7 @@ futures = "0.3"
[features] [features]
default = [] default = []
static-check = [] static-validation = []
mysql = [] mysql = []
postgres = [] postgres = []
sqlite = [] sqlite = []

View File

@ -62,7 +62,7 @@ fn db_type() -> TokenStream2 {
} }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
{ {
return quote! { sqlx::Sqlite }; quote! { sqlx::Sqlite }
} }
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
{ {
@ -82,7 +82,7 @@ fn db_arguments() -> TokenStream2 {
} }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
{ {
return quote! { sqlx::sqlite::SqliteArguments<'q> }; quote! { sqlx::sqlite::SqliteArguments<'static> }
} }
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
{ {
@ -99,14 +99,14 @@ fn table_quote() -> &'static str {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
{ "\"" } { "\"" }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
{ return "\""; } { "\"" }
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
{ "`" } { "`" }
#[cfg(not(any(feature = "mysql", feature = "postgres", feature = "sqlite")))] #[cfg(not(any(feature = "mysql", feature = "postgres", feature = "sqlite")))]
{ "`" } { "`" }
} }
/// Get compile-time placeholder for static-check SQL /// Get compile-time placeholder for static-validation SQL
fn static_placeholder(index: usize) -> String { fn static_placeholder(index: usize) -> String {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
{ format!("${}", index) } { format!("${}", index) }
@ -139,11 +139,10 @@ fn derive_entity_internal(input: TokenStream) -> TokenStream {
.or_else(|| fields.iter().find(|&f| is_version_field(f))); .or_else(|| fields.iter().find(|&f| is_version_field(f)));
// Find soft delete field (by attribute or by name convention) // 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() let soft_delete_field = fields.iter()
.find(|f| f.is_soft_delete) .find(|f| f.is_soft_delete)
.or_else(|| fields.iter().find(|f| { .or_else(|| fields.iter().find(|f| {
(f.ident == "is_active" || f.ident == "is_deleted" || f.ident == "deleted") && (f.ident == "is_deleted" || f.ident == "deleted") &&
matches!(&f.ty, Type::Path(p) if p.path.is_ident("bool")) matches!(&f.ty, Type::Path(p) if p.path.is_ident("bool"))
})); }));
@ -152,7 +151,6 @@ 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 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 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 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 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; let pk_type = &primary_key.ty;
@ -163,7 +161,6 @@ fn derive_entity_internal(input: TokenStream) -> TokenStream {
#get_impl #get_impl
#update_impl #update_impl
#diff_impl #diff_impl
#delete_impl
#soft_delete_impl #soft_delete_impl
impl #impl_generics #name #ty_generics #where_clause { impl #impl_generics #name #ty_generics #where_clause {
@ -370,13 +367,52 @@ fn generate_insert_impl(
.filter(|f| *f != #pk_db_name) .filter(|f| *f != #pk_db_name)
.collect(); .collect();
let upsert_stmt = ::sqlx_record::prelude::build_upsert_stmt( #[cfg(feature = "mysql")]
#table_name, let upsert_stmt = {
&[#(#db_names),*], let update_clause = non_pk_fields.iter()
.map(|f| format!("{} = VALUES({})", f, f))
.collect::<Vec<_>>()
.join(", ");
format!(
"INSERT INTO {}{}{} ({}) VALUES ({}) ON DUPLICATE KEY UPDATE {}",
#tq, #table_name, #tq,
vec![#(#db_names),*].join(", "),
placeholders,
update_clause
)
};
#[cfg(feature = "postgres")]
let upsert_stmt = {
let update_clause = non_pk_fields.iter()
.map(|f| format!("{} = EXCLUDED.{}", f, f))
.collect::<Vec<_>>()
.join(", ");
format!(
"INSERT INTO {}{}{} ({}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {}",
#tq, #table_name, #tq,
vec![#(#db_names),*].join(", "),
placeholders,
#pk_db_name, #pk_db_name,
&non_pk_fields, update_clause
&placeholders, )
); };
#[cfg(feature = "sqlite")]
let upsert_stmt = {
let update_clause = non_pk_fields.iter()
.map(|f| format!("{} = excluded.{}", f, f))
.collect::<Vec<_>>()
.join(", ");
format!(
"INSERT INTO {}{}{} ({}) VALUES ({}) ON CONFLICT({}) DO UPDATE SET {}",
#tq, #table_name, #tq,
vec![#(#db_names),*].join(", "),
placeholders,
#pk_db_name,
update_clause
)
};
sqlx::query(&upsert_stmt) sqlx::query(&upsert_stmt)
#(.bind(#bindings))* #(.bind(#bindings))*
@ -537,8 +573,8 @@ fn generate_get_impl(
let field_list = fields.iter().map(|f| f.db_name.clone()).collect::<Vec<_>>(); let field_list = fields.iter().map(|f| f.db_name.clone()).collect::<Vec<_>>();
// Check if static-check feature is enabled at macro expansion time // Check if static-validation feature is enabled at macro expansion time
let use_static_validation = cfg!(feature = "static-check"); let use_static_validation = cfg!(feature = "static-validation");
let get_by_impl = if use_static_validation { let get_by_impl = if use_static_validation {
// Static validation: use sqlx::query_as! with compile-time checked SQL // Static validation: use sqlx::query_as! with compile-time checked SQL
@ -726,7 +762,13 @@ fn generate_get_impl(
String::new() String::new()
}; };
let index_clause = ::sqlx_record::prelude::build_index_clause(index); // Index hints are MySQL-specific
#[cfg(feature = "mysql")]
let index_clause = index
.map(|idx| format!("USE INDEX ({})", idx))
.unwrap_or_default();
#[cfg(not(feature = "mysql"))]
let index_clause = { let _ = index; String::new() };
//Filter order_by fields to only those managed //Filter order_by fields to only those managed
let fields = Self::select_fields().into_iter().collect::<::std::collections::HashSet<_>>(); let fields = Self::select_fields().into_iter().collect::<::std::collections::HashSet<_>>();
@ -790,8 +832,23 @@ fn generate_get_impl(
String::new() String::new()
}; };
let index_clause = ::sqlx_record::prelude::build_index_clause(index); // Index hints are MySQL-specific
let count_expr = ::sqlx_record::prelude::build_count_expr(#pk_db_field_name); #[cfg(feature = "mysql")]
let index_clause = index
.map(|idx| format!("USE INDEX ({})", idx))
.unwrap_or_default();
#[cfg(not(feature = "mysql"))]
let index_clause = { let _ = index; String::new() };
// Use database-appropriate COUNT syntax
#[cfg(feature = "postgres")]
let count_expr = format!("COUNT({})::BIGINT", #pk_db_field_name);
#[cfg(feature = "sqlite")]
let count_expr = format!("COUNT({})", #pk_db_field_name);
#[cfg(feature = "mysql")]
let count_expr = format!("CAST(COUNT({}) AS SIGNED)", #pk_db_field_name);
#[cfg(not(any(feature = "mysql", feature = "postgres", feature = "sqlite")))]
let count_expr = format!("COUNT({})", #pk_db_field_name);
let query = format!( let query = format!(
r#"SELECT {} FROM {}{}{} {} {}"#, r#"SELECT {} FROM {}{}{} {} {}"#,
@ -882,7 +939,13 @@ fn generate_get_impl(
String::new() String::new()
}; };
let index_clause = ::sqlx_record::prelude::build_index_clause(index); // Index hints are MySQL-specific
#[cfg(feature = "mysql")]
let index_clause = index
.map(|idx| format!("USE INDEX ({})", idx))
.unwrap_or_default();
#[cfg(not(feature = "mysql"))]
let index_clause = { let _ = index; String::new() };
let query = format!( let query = format!(
"SELECT DISTINCT {} FROM {}{}{} {} {}", "SELECT DISTINCT {} FROM {}{}{} {} {}",
@ -1006,16 +1069,13 @@ fn generate_update_impl(
quote! {} quote! {}
}; };
// Auto-update updated_at timestamp (only if not manually set) // Auto-update updated_at timestamp
let updated_at_increment = if has_updated_at { let updated_at_increment = if has_updated_at {
quote! { quote! {
// Only auto-set updated_at if not already set in form or via expression
if self.updated_at.is_none() && !self._exprs.contains_key("updated_at") {
parts.push(format!("updated_at = {}", ::sqlx_record::prelude::placeholder(idx))); parts.push(format!("updated_at = {}", ::sqlx_record::prelude::placeholder(idx)));
values.push(::sqlx_record::prelude::Value::Int64(chrono::Utc::now().timestamp_millis())); values.push(::sqlx_record::prelude::Value::Int64(chrono::Utc::now().timestamp_millis()));
idx += 1; idx += 1;
} }
}
} else { } else {
quote! {} quote! {}
}; };
@ -1109,31 +1169,40 @@ fn generate_update_impl(
/// Bind all form values to query in correct order. /// Bind all form values to query in correct order.
/// Handles both simple values and expression values, respecting expression precedence. /// Handles both simple values and expression values, respecting expression precedence.
/// Uses Value enum for proper type handling of Option<T> fields. pub fn bind_all_values(&self, mut query: sqlx::query::Query<'_, #db, #db_args>)
pub fn bind_all_values<'q>(&'q self, mut query: sqlx::query::Query<'q, #db, #db_args>) -> sqlx::query::Query<'_, #db, #db_args>
-> sqlx::query::Query<'q, #db, #db_args>
{ {
// Use update_stmt_with_values to get properly converted values #(
// This handles nested Options (Option<Option<T>>) correctly // Expression takes precedence over simple value
let (_, values) = self.update_stmt_with_values(); if let Some(expr) = self._exprs.get(#db_names) {
for value in values { let (_, expr_values) = expr.build_sql(#db_names, 1);
for value in expr_values {
query = ::sqlx_record::prelude::bind_value_owned(query, value); query = ::sqlx_record::prelude::bind_value_owned(query, value);
} }
} else if let Some(ref value) = self.#field_idents {
query = query.bind(value);
}
)*
query query
} }
/// Legacy binding method - binds values through the Value enum for proper type handling. /// Legacy binding method - only binds simple Option values (ignores expressions).
/// For backward compatibility. New code should use bind_all_values(). /// For backward compatibility. New code should use bind_all_values().
pub fn bind_form_values<'q>(&'q self, mut query: sqlx::query::Query<'q, #db, #db_args>) pub fn bind_form_values<'q>(&'q self, mut query: sqlx::query::Query<'q, #db, #db_args>)
-> sqlx::query::Query<'q, #db, #db_args> -> sqlx::query::Query<'q, #db, #db_args>
{ {
// Always use Value-based binding to properly handle Option<T> fields if self._exprs.is_empty() {
// This ensures nested Options (Option<Option<T>>) are unwrapped correctly // No expressions, use simple binding
let (_, values) = self.update_stmt_with_values(); #(
for value in values { if let Some(ref value) = self.#field_idents {
query = ::sqlx_record::prelude::bind_value_owned(query, value); query = query.bind(value);
} }
)*
query query
} else {
// Has expressions, use full binding
self.bind_all_values(query)
}
} }
/// Check if this form uses any expressions /// Check if this form uses any expressions
@ -1264,7 +1333,6 @@ fn generate_diff_impl(
pub fn to_update_form(&self) -> #update_form_name #ty_generics { pub fn to_update_form(&self) -> #update_form_name #ty_generics {
#update_form_name { #update_form_name {
#(#field_idents: Some(self.#field_idents.clone()),)* #(#field_idents: Some(self.#field_idents.clone()),)*
_exprs: std::collections::HashMap::new(),
} }
} }
@ -1399,97 +1467,6 @@ fn generate_diff_impl(
Ok(()) Ok(())
} }
/// Update all records matching the filter conditions
/// Returns the number of affected rows
pub async fn update_by_filter<'a, E>(
executor: E,
filters: Vec<::sqlx_record::prelude::Filter<'a>>,
form: #update_form_name,
) -> Result<u64, sqlx::Error>
where
E: sqlx::Executor<'a, Database=#db>,
{
use ::sqlx_record::prelude::{Filter, bind_values};
if filters.is_empty() {
// Require at least one filter to prevent accidental table-wide updates
return Err(sqlx::Error::Protocol(
"update_by_filter requires at least one filter to prevent accidental table-wide updates".to_string()
));
}
let (update_stmt, form_values) = form.update_stmt_with_values();
if update_stmt.is_empty() {
return Ok(0);
}
let form_param_count = form_values.len();
let (where_conditions, filter_values) = Filter::build_where_clause_with_offset(&filters, form_param_count + 1);
let query_str = format!(
r#"UPDATE {}{}{} SET {} WHERE {}"#,
#tq, Self::table_name(), #tq,
update_stmt,
where_conditions,
);
// Combine form values and filter values
let mut all_values = form_values;
all_values.extend(filter_values);
let query = sqlx::query(&query_str);
let result = bind_values(query, &all_values)
.execute(executor)
.await?;
Ok(result.rows_affected())
}
}
}
}
// 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(())
}
} }
} }
} }
@ -1516,41 +1493,51 @@ fn generate_soft_delete_impl(
let tq = table_quote(); let tq = table_quote();
let pk_field_name = primary_key.ident.to_string(); let pk_field_name = primary_key.ident.to_string();
let soft_delete_by_func = format_ident!("soft_delete_by_{}", pk_field_name); 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 restore_by_func = format_ident!("restore_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! { quote! {
impl #impl_generics #name #ty_generics #where_clause { impl #impl_generics #name #ty_generics #where_clause {
/// Soft delete - marks record as deleted without removing from database /// Soft delete - sets the soft_delete field to true
pub async fn soft_delete<'a, E>(&self, executor: E) -> Result<(), sqlx::Error> pub async fn delete<'a, E>(&self, executor: E) -> Result<(), sqlx::Error>
where where
E: sqlx::Executor<'a, Database = #db>, E: sqlx::Executor<'a, Database = #db>,
{ {
Self::#soft_delete_by_func(executor, &self.#pk_field).await Self::#delete_by_func(executor, &self.#pk_field).await
} }
/// Soft delete by primary key /// Soft delete by primary key
pub async fn #soft_delete_by_func<'a, E>(executor: E, #pk_field: &#pk_type) -> Result<(), sqlx::Error> pub async fn #delete_by_func<'a, E>(executor: E, #pk_field: &#pk_type) -> Result<(), sqlx::Error>
where where
E: sqlx::Executor<'a, Database = #db>, E: sqlx::Executor<'a, Database = #db>,
{ {
let query = format!( let query = format!(
"UPDATE {}{}{} SET {} = {} WHERE {} = {}", "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 {} = {}",
#tq, #table_name, #tq, #tq, #table_name, #tq,
#sd_db_name, #delete_value,
#pk_db_name, ::sqlx_record::prelude::placeholder(1) #pk_db_name, ::sqlx_record::prelude::placeholder(1)
); );
sqlx::query(&query).bind(#pk_field).execute(executor).await?; sqlx::query(&query).bind(#pk_field).execute(executor).await?;
@ -1571,9 +1558,9 @@ fn generate_soft_delete_impl(
E: sqlx::Executor<'a, Database = #db>, E: sqlx::Executor<'a, Database = #db>,
{ {
let query = format!( let query = format!(
"UPDATE {}{}{} SET {} = {} WHERE {} = {}", "UPDATE {}{}{} SET {} = FALSE WHERE {} = {}",
#tq, #table_name, #tq, #tq, #table_name, #tq,
#sd_db_name, #restore_value, #sd_db_name,
#pk_db_name, ::sqlx_record::prelude::placeholder(1) #pk_db_name, ::sqlx_record::prelude::placeholder(1)
); );
sqlx::query(&query).bind(#pk_field).execute(executor).await?; sqlx::query(&query).bind(#pk_field).execute(executor).await?;

View File

@ -1,13 +1,13 @@
use sqlx::pool::PoolConnection; use sqlx::pool::PoolConnection;
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
use sqlx::{MySql, MySqlConnection, MySqlPool, Transaction}; use sqlx::{MySql, MySqlPool};
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
use sqlx::{Postgres, PgConnection, PgPool, Transaction}; use sqlx::{Postgres, PgPool};
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
use sqlx::{Sqlite, SqliteConnection, SqlitePool, Transaction}; use sqlx::{Sqlite, SqlitePool};
// ============================================================================ // ============================================================================
// MySQL Implementation // MySQL Implementation
@ -24,10 +24,6 @@ pub enum ConnProvider<'a> {
pool: MySqlPool, pool: MySqlPool,
conn: Option<PoolConnection<MySql>>, conn: Option<PoolConnection<MySql>>,
}, },
/// Stores a reference to a transaction
Transaction {
tx: &'a mut Transaction<'static, MySql>,
},
} }
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
@ -42,25 +38,18 @@ impl<'a> ConnProvider<'a> {
ConnProvider::Owned { pool, conn: None } 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. /// Get a mutable reference to the underlying connection.
/// For borrowed connections, returns the reference directly. /// For borrowed connections, returns the reference directly.
/// For owned connections, lazily acquires from pool on first call. /// For owned connections, lazily acquires from pool on first call.
/// For transactions, returns the transaction's underlying connection. pub async fn get_conn(&mut self) -> Result<&mut PoolConnection<MySql>, sqlx::Error> {
pub async fn get_conn(&mut self) -> Result<&mut MySqlConnection, sqlx::Error> {
match self { match self {
ConnProvider::Borrowed { conn } => Ok(&mut **conn), ConnProvider::Borrowed { conn } => Ok(conn),
ConnProvider::Owned { pool, conn } => { ConnProvider::Owned { pool, conn } => {
if conn.is_none() { if conn.is_none() {
*conn = Some(pool.acquire().await?); *conn = Some(pool.acquire().await?);
} }
Ok(&mut **conn.as_mut().unwrap()) Ok(conn.as_mut().unwrap())
} }
ConnProvider::Transaction { tx } => Ok(&mut **tx),
} }
} }
} }
@ -80,10 +69,6 @@ pub enum ConnProvider<'a> {
pool: PgPool, pool: PgPool,
conn: Option<PoolConnection<Postgres>>, conn: Option<PoolConnection<Postgres>>,
}, },
/// Stores a reference to a transaction
Transaction {
tx: &'a mut Transaction<'static, Postgres>,
},
} }
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
@ -98,25 +83,18 @@ impl<'a> ConnProvider<'a> {
ConnProvider::Owned { pool, conn: None } 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. /// Get a mutable reference to the underlying connection.
/// For borrowed connections, returns the reference directly. /// For borrowed connections, returns the reference directly.
/// For owned connections, lazily acquires from pool on first call. /// For owned connections, lazily acquires from pool on first call.
/// For transactions, returns the transaction's underlying connection. pub async fn get_conn(&mut self) -> Result<&mut PoolConnection<Postgres>, sqlx::Error> {
pub async fn get_conn(&mut self) -> Result<&mut PgConnection, sqlx::Error> {
match self { match self {
ConnProvider::Borrowed { conn } => Ok(&mut **conn), ConnProvider::Borrowed { conn } => Ok(conn),
ConnProvider::Owned { pool, conn } => { ConnProvider::Owned { pool, conn } => {
if conn.is_none() { if conn.is_none() {
*conn = Some(pool.acquire().await?); *conn = Some(pool.acquire().await?);
} }
Ok(&mut **conn.as_mut().unwrap()) Ok(conn.as_mut().unwrap())
} }
ConnProvider::Transaction { tx } => Ok(&mut **tx),
} }
} }
} }
@ -136,10 +114,6 @@ pub enum ConnProvider<'a> {
pool: SqlitePool, pool: SqlitePool,
conn: Option<PoolConnection<Sqlite>>, conn: Option<PoolConnection<Sqlite>>,
}, },
/// Stores a reference to a transaction
Transaction {
tx: &'a mut Transaction<'static, Sqlite>,
},
} }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@ -154,25 +128,18 @@ impl<'a> ConnProvider<'a> {
ConnProvider::Owned { pool, conn: None } 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. /// Get a mutable reference to the underlying connection.
/// For borrowed connections, returns the reference directly. /// For borrowed connections, returns the reference directly.
/// For owned connections, lazily acquires from pool on first call. /// For owned connections, lazily acquires from pool on first call.
/// For transactions, returns the transaction's underlying connection. pub async fn get_conn(&mut self) -> Result<&mut PoolConnection<Sqlite>, sqlx::Error> {
pub async fn get_conn(&mut self) -> Result<&mut SqliteConnection, sqlx::Error> {
match self { match self {
ConnProvider::Borrowed { conn } => Ok(&mut **conn), ConnProvider::Borrowed { conn } => Ok(conn),
ConnProvider::Owned { pool, conn } => { ConnProvider::Owned { pool, conn } => {
if conn.is_none() { if conn.is_none() {
*conn = Some(pool.acquire().await?); *conn = Some(pool.acquire().await?);
} }
Ok(&mut **conn.as_mut().unwrap()) Ok(conn.as_mut().unwrap())
} }
ConnProvider::Transaction { tx } => Ok(&mut **tx),
} }
} }
} }

View File

@ -111,121 +111,6 @@ pub fn placeholder(index: usize) -> String {
} }
} }
/// Returns the table quote character for the current database
#[inline]
pub fn table_quote() -> &'static str {
#[cfg(feature = "mysql")]
{ "`" }
#[cfg(feature = "postgres")]
{ "\"" }
#[cfg(feature = "sqlite")]
{ "\"" }
#[cfg(not(any(feature = "mysql", feature = "postgres", feature = "sqlite")))]
{ "`" }
}
/// Builds an index hint clause (MySQL-specific, empty for other databases)
#[inline]
pub fn build_index_clause(index: Option<&str>) -> String {
#[cfg(feature = "mysql")]
{
index.map(|idx| format!("USE INDEX ({})", idx)).unwrap_or_default()
}
#[cfg(not(feature = "mysql"))]
{
let _ = index;
String::new()
}
}
/// Builds a COUNT expression appropriate for the database backend
#[inline]
pub fn build_count_expr(field: &str) -> String {
#[cfg(feature = "postgres")]
{
format!("COUNT({})::BIGINT", field)
}
#[cfg(feature = "sqlite")]
{
format!("COUNT({})", field)
}
#[cfg(feature = "mysql")]
{
format!("CAST(COUNT({}) AS SIGNED)", field)
}
#[cfg(not(any(feature = "mysql", feature = "postgres", feature = "sqlite")))]
{
format!("COUNT({})", field)
}
}
/// Builds an upsert statement for the current database backend
pub fn build_upsert_stmt(
table_name: &str,
all_fields: &[&str],
pk_field: &str,
non_pk_fields: &[&str],
placeholders: &str,
) -> String {
let tq = table_quote();
let fields_str = all_fields.join(", ");
#[cfg(feature = "mysql")]
{
let _ = pk_field; // Not used in MySQL ON DUPLICATE KEY syntax
let update_clause = non_pk_fields
.iter()
.map(|f| format!("{} = VALUES({})", f, f))
.collect::<Vec<_>>()
.join(", ");
format!(
"INSERT INTO {}{}{} ({}) VALUES ({}) ON DUPLICATE KEY UPDATE {}",
tq, table_name, tq, fields_str, placeholders, update_clause
)
}
#[cfg(feature = "postgres")]
{
let update_clause = non_pk_fields
.iter()
.map(|f| format!("{} = EXCLUDED.{}", f, f))
.collect::<Vec<_>>()
.join(", ");
format!(
"INSERT INTO {}{}{} ({}) VALUES ({}) ON CONFLICT ({}) DO UPDATE SET {}",
tq, table_name, tq, fields_str, placeholders, pk_field, update_clause
)
}
#[cfg(feature = "sqlite")]
{
let update_clause = non_pk_fields
.iter()
.map(|f| format!("{} = excluded.{}", f, f))
.collect::<Vec<_>>()
.join(", ");
format!(
"INSERT INTO {}{}{} ({}) VALUES ({}) ON CONFLICT({}) DO UPDATE SET {}",
tq, table_name, tq, fields_str, placeholders, pk_field, update_clause
)
}
#[cfg(not(any(feature = "mysql", feature = "postgres", feature = "sqlite")))]
{
let _ = pk_field; // Not used in MySQL ON DUPLICATE KEY syntax
// Fallback to MySQL syntax
let update_clause = non_pk_fields
.iter()
.map(|f| format!("{} = VALUES({})", f, f))
.collect::<Vec<_>>()
.join(", ");
format!(
"INSERT INTO {}{}{} ({}) VALUES ({}) ON DUPLICATE KEY UPDATE {}",
tq, table_name, tq, fields_str, placeholders, update_clause
)
}
}
impl Filter<'_> { impl Filter<'_> {
/// Returns the number of bind parameters this filter will use /// Returns the number of bind parameters this filter will use
pub fn param_count(&self) -> usize { pub fn param_count(&self) -> usize {

View File

@ -181,7 +181,6 @@ pub mod prelude {
pub use crate::values; pub use crate::values;
pub use crate::{new_uuid, lookup_table, lookup_options, transaction}; pub use crate::{new_uuid, lookup_table, lookup_options, transaction};
pub use crate::pagination::{Page, PageRequest}; pub use crate::pagination::{Page, PageRequest};
pub use crate::conn_provider::*;
#[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))] #[cfg(any(feature = "mysql", feature = "postgres", feature = "sqlite"))]
pub use crate::conn_provider::ConnProvider; pub use crate::conn_provider::ConnProvider;

View File

@ -256,7 +256,6 @@ impl UpdateExpr {
pub type SqlValue = Value; pub type SqlValue = Value;
// MySQL supports unsigned integers natively // MySQL supports unsigned integers natively
// Note: UUID is bound as bytes for BINARY(16) column compatibility
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
macro_rules! bind_value { macro_rules! bind_value {
($query:expr, $value: expr) => {{ ($query:expr, $value: expr) => {{