-
-
Notifications
You must be signed in to change notification settings - Fork 639
Description
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
- &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
};- &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
- 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(())
}- 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.