sqlx-record/.claude/skills/sqlx-transactions.md

5.4 KiB

sqlx-record Transaction Skill

Guide to the transaction! macro for ergonomic transactions.

Triggers

  • "transaction", "transactions"
  • "commit", "rollback"
  • "atomic", "transactional"

Overview

The transaction! macro provides ergonomic transaction handling with automatic commit on success and rollback on error.

Basic Syntax

use sqlx_record::transaction;

let result = transaction!(&pool, |tx| {
    // Operations using &mut *tx as executor
    user.insert(&mut *tx).await?;
    order.insert(&mut *tx).await?;
    Ok::<_, sqlx::Error>(order.id)  // Return value type annotation
}).await?;

Key Points

  1. Automatic commit: Transaction commits if closure returns Ok
  2. Automatic rollback: Transaction rolls back if closure returns Err or panics
  3. Return values: The closure can return any value wrapped in Result<T, sqlx::Error>
  4. Executor access: Use &mut *tx to pass the transaction as an executor

Usage Examples

Basic Transaction

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

async fn create_user_with_profile(pool: &Pool, user: User, profile: Profile) -> Result<Uuid, sqlx::Error> {
    transaction!(&pool, |tx| {
        let user_id = user.insert(&mut *tx).await?;

        let mut profile = profile;
        profile.user_id = user_id;
        profile.insert(&mut *tx).await?;

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

Multiple Operations

async fn transfer_funds(
    pool: &Pool,
    from_id: &Uuid,
    to_id: &Uuid,
    amount: i64
) -> Result<(), sqlx::Error> {
    transaction!(&pool, |tx| {
        // Debit from source
        Account::update_by_id(&mut *tx, from_id,
            Account::update_form().eval_balance(UpdateExpr::Sub(amount.into()))
        ).await?;

        // Credit to destination
        Account::update_by_id(&mut *tx, to_id,
            Account::update_form().eval_balance(UpdateExpr::Add(amount.into()))
        ).await?;

        // Create transfer record
        let transfer = Transfer {
            id: new_uuid(),
            from_account: *from_id,
            to_account: *to_id,
            amount,
            created_at: chrono::Utc::now().timestamp_millis(),
        };
        transfer.insert(&mut *tx).await?;

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

With Error Handling

async fn create_order(pool: &Pool, cart: Cart) -> Result<Order, AppError> {
    transaction!(&pool, |tx| {
        // Verify stock
        for item in &cart.items {
            let product = Product::get_by_id(&mut *tx, &item.product_id).await?
                .ok_or(sqlx::Error::RowNotFound)?;

            if product.stock < item.quantity {
                return Err(sqlx::Error::Protocol("Insufficient stock".into()));
            }
        }

        // Create order
        let order = Order {
            id: new_uuid(),
            user_id: cart.user_id,
            status: OrderStatus::PENDING.into(),
            total: cart.total(),
            created_at: chrono::Utc::now().timestamp_millis(),
        };
        order.insert(&mut *tx).await?;

        // Create order items and decrement stock
        for item in cart.items {
            let order_item = OrderItem {
                id: new_uuid(),
                order_id: order.id,
                product_id: item.product_id,
                quantity: item.quantity,
                price: item.price,
            };
            order_item.insert(&mut *tx).await?;

            Product::update_by_id(&mut *tx, &item.product_id,
                Product::update_form().eval_stock(UpdateExpr::Sub(item.quantity.into()))
            ).await?;
        }

        Ok::<_, sqlx::Error>(order)
    }).await.map_err(AppError::from)
}

Nested Operations (Not Nested Transactions)

// Helper function that accepts any executor
async fn create_audit_log<'a, E>(executor: E, action: &str, entity_id: Uuid) -> Result<(), sqlx::Error>
where
    E: sqlx::Executor<'a, Database = sqlx::MySql>,
{
    let log = AuditLog {
        id: new_uuid(),
        action: action.into(),
        entity_id,
        created_at: chrono::Utc::now().timestamp_millis(),
    };
    log.insert(executor).await?;
    Ok(())
}

// Use in transaction
transaction!(&pool, |tx| {
    user.insert(&mut *tx).await?;
    create_audit_log(&mut *tx, "user_created", user.id).await?;
    Ok::<_, sqlx::Error>(())
}).await?;

Type Annotation

The closure must have an explicit return type annotation:

// Correct - with type annotation
Ok::<_, sqlx::Error>(value)

// Also correct
Ok::<i32, sqlx::Error>(42)

// Incorrect - missing annotation (won't compile)
// Ok(value)

Comparison with Manual Transactions

// Manual approach
let mut tx = pool.begin().await?;
match async {
    user.insert(&mut *tx).await?;
    order.insert(&mut *tx).await?;
    Ok::<_, sqlx::Error>(order.id)
}.await {
    Ok(result) => {
        tx.commit().await?;
        Ok(result)
    }
    Err(e) => {
        tx.rollback().await?;
        Err(e)
    }
}

// With transaction! macro - cleaner
transaction!(&pool, |tx| {
    user.insert(&mut *tx).await?;
    order.insert(&mut *tx).await?;
    Ok::<_, sqlx::Error>(order.id)
}).await

Notes

  • The macro works with all supported databases (MySQL, PostgreSQL, SQLite)
  • Transactions use the pool's default isolation level
  • For custom isolation levels, use sqlx's native transaction API
  • The closure is async - use .await for all database operations