Skip to content

Transactions

A transaction is a sequence of one or more database operations that are treated as a single unit of work. Transactions in a database are used to ensure data integrity, consistency, and reliability when multiple operations need to be executed as a single, atomic unit.

In rorm, a transaction can be retrieved from a Database instance:

use std::error::Error;

use rorm::prelude::*;

async fn transaction(db: &Database) -> Result<Transaction, Box<dyn Error>> {
    Ok(db.start_transaction().await?)
}

The resulting Transaction can be used in place of the Database instance, with the difference that the instance must be provided as mutable reference.

There are two possible options to end a transaction:

  • commit() This will end the transaction and apply all modifications to the database
  • rollback() This will end the transaction and rollback all modifications

Tip

Rollback is default that gets executed if a Transaction is dropped.

Example usage

use std::fs::File;
use std::io::Write;

use rorm::prelude::*;

#[derive(Model)]
pub struct User {
    #[rorm(id)]
    pub id: i64,

    #[rorm(unique, max_length = 255)]
    pub username: String,
}

#[derive(Patch)]
#[rorm(model = "User")]
pub struct UserInsert {
    pub username: String,
}

pub async fn create_user(
    db: &Database,
    username: String,
    profile_picture: &[u8]
) -> Result<(), Box<dyn std::error::Error>> {
    // Start a transaction
    let mut tx = db.start_transaction().await?;

    // Check if the username is already taken
    let is_taken = query!(&mut tx, (User::F.id,))
        .condition(User::F.username.equals(&username))
        .optional()
        .await?
        .is_some();

    if is_taken {
        return Err("Username is already taken".to_string());
    }

    // Insert the new user
    let id = insert!(&mut tx, UserInsert)
        .return_primary_key()
        .single(&UserInsert { username })
        .await
        .unwrap();

    // If any of the io tasks throw an error, the transaction is dropped when the 
    // function exits, and a rollback is performed.
    // So the user wouldn't be created in the database in this case
    let mut file = File::create(format!("profile_pictures/{id}.png"))?;
    file.write_all(profile_picture)?;

    // Commit the transaction
    tx.commit().await?;

    Ok(())
}

Using a Guard

When either a transaction or the database itself may be passed to a function, a TransactionGuard can be used. It can be retrieved by an Executor, which is implemented by both the Transaction and Database. The guard is responsible for committing the transaction if necessary; since mid-transaction commits are not supported, this is a no-op when the guard was created from a transaction and will perform the commit if the guard was created from a database.

async fn create_user(username: String, exe: impl Executor<'_>) -> Result<(), rorm::Error> {
    // The guard will either be a new transaction or an
    // existing one, depending on the Executor
    let mut guard = exe.ensure_transaction().await?;

    // It can be used very similar to a normal transaction
    // through `get_transaction`
    insert!(guard.get_transaction(), UserInsert)
        .return_primary_key()
        .single(&UserInsert { username })
        .await?;

    guard.commit()?;
    Ok(())
}

async fn test_double_creation(db: &Database) -> Result<(), rorm:Error> {
    // Start a transaction
    let mut tx = db.start_transaction().await?;

    // The first call will work but not commit the transaction
    create_user("username".to_string(), &mut tx).await?;

    // The second call will also work but not commit the transaction
    create_user("another username".to_string(), &mut tx).await?;

    // Commit the transaction, since the guard's commit
    // in `create_user` is a no-op there
    tx.commit().await?;

    Ok(())
}