Skip to content

Add ConnectionOrTransaction as a Unified Executor Type #2827

@uuushiro

Description

@uuushiro

First of all, thank you for developing such a fantastic ORM. It has been a great experience using SeaORM.

I propose promoting the SchemaManagerConnection pattern (currently found in sea-orm-migration) to sea-orm core as a generalized ConnectionOrTransaction<'c> type.

This type serves as a "Unified Executor" that wraps either a reference to a DatabaseConnection or a DatabaseTransaction. It solves fundamental limitations regarding runtime dispatch and object safety, enabling flexible transaction management patterns that are currently impossible with impl ConnectionTrait.

Motivation

In real-world applications (Services, Repositories, Test Helpers), we frequently encounter scenarios where a function needs to accept either a connection or a transaction dynamically.

Key Use Cases

  • Runtime Branching: Deciding at runtime (e.g., based on a config flag) whether to run an operation atomically (in a transaction) or directly (for performance/batching).
  • Test Isolation: Reusing production repository logic in tests where a transaction is started in the test setup and automatically rolled back, without changing the repository code.
  • Safe Boundaries: Allowing the caller to manage the transaction lifecycle while passing only "execution rights" to downstream functions.

The Problem: Why existing solutions fail

Currently, there is no way to represent "Either Connection or Transaction" as a single type due to Rust's type system constraints

  1. &impl ConnectionTrait (Generics) is Static
    Generics are monomorphized at compile time. We cannot switch types based on runtime conditions.
// ❌ Compile Error: `if` and `else` have incompatible types
let db = if use_transaction {
    &txn  // &DatabaseTransaction
} else {
    &conn // &DatabaseConnection
};
  1. &dyn TransactionTrait is NOT Object Safe
    We cannot use trait objects because TransactionTrait contains generic methods (transaction<F, T, E>) and associated types. It is impossible to create a &dyn (ConnectionTrait + TransactionTrait).

Proposed Solutions

Introduce the enum pattern into sea-orm core. This pattern has been proven stable in sea-orm-migration for years.

// sea-orm/src/database/connection_or_transaction.rs

pub enum ConnectionOrTransaction<'c> {
    Connection(&'c DatabaseConnection),
    Transaction(&'c DatabaseTransaction),
}

// Ergonomics
impl<'c> From<&'c DatabaseConnection> for ConnectionOrTransaction<'c> { ... }
impl<'c> From<&'c DatabaseTransaction> for ConnectionOrTransaction<'c> { ... }

// Proxy Implementations
impl ConnectionTrait for ConnectionOrTransaction<'_> { /* delegates to inner */ }
impl TransactionTrait for ConnectionOrTransaction<'_> { /* delegates to inner */ }

Executor vs. Manager

One might ask: "Why doesn't this type support commit()?"

This is a deliberate design choice to enforce Separation of Concerns. This type acts as a Query Executor, not a Transaction Manager.
Manager (The Caller): Owns the DatabaseTransaction. Responsible for lifecycle management (begin, commit, rollback).
Executor (This Type): Holds only a reference (&'c). Responsible for executing queries and starting nested transactions (savepoints).
Since ConnectionOrTransaction only holds a borrow, it cannot call .commit(self), which prevents repositories from accidentally closing transactions owned by the caller. This aligns perfectly with Rust's ownership model and ensures safety.

Usage Examples

  1. Runtime Branching (Configurable Atomicity)
pub async fn execute_batch(
    conn: &DatabaseConnection, 
    use_atomic: bool
) -> Result<(), DbErr> {
    // 1. Decide context at runtime
    let db: ConnectionOrTransaction = if use_atomic {
        let txn = conn.begin().await?;
        ConnectionOrTransaction::from(&txn)
    } else {
        ConnectionOrTransaction::from(conn)
    };

    // 2. Execute logic (reused code)
    // The repository function signature: fn save(db: &ConnectionOrTransaction, ...)
    my_repository::save_all(&db, data).await?;

    // 3. Commit only if we own the transaction
    if let ConnectionOrTransaction::Transaction(txn) = db {
        txn.commit().await?;
    }
    
    Ok(())
}
  1. Repository Pattern (The Safe Executor)
struct UserRepository;

impl UserRepository {
    // Accepts both Connection and Transaction via the wrapper.
    // Cannot accidentally commit the transaction because it only holds a reference.
    pub async fn create(
        db: &ConnectionOrTransaction<'_>, 
        name: String
    ) -> Result<Model, DbErr> {
        ActiveModel {
            name: Set(name),
            ..Default::default()
        }
        .insert(db) // Works as ConnectionTrait
        .await
    }
}

Benefits

  • Solves the Object Safety Gap: Provides a concrete type where dyn Trait is impossible.
  • Safety by Design: Prevents "rogue commits" in downstream functions.
  • Code Deduplication: Could eventually replace SchemaManagerConnection in the migration crate.
  • Non-Breaking: Additive change only.

I would love to hear your thoughts on this. If accepted, I am happy to provide a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions