I am trying to hide the implementation details of accessing a Postgres database. To do this, I want to create "database" and "transaction" traits for use in a repository struct. This should allow:
- Using a fake implementation for unit tests
- Migrating to a new database implementation without requiring any changes to the interface
Specifically, I want to hide tokio-postgres behind such a trait. I came up with this:
use tokio_postgres::{row::Row, types::ToSql};
use async_trait::async_trait;
#[async_trait]
pub trait Database: Sync + Send {
type Transaction: Transaction;
type Connection: Connection<Transaction = Self::Transaction>;
async fn connect(&mut self) -> Result<Self::Connection, Error>;
}
#[async_trait]
pub trait Connection: Sync + Send {
type Transaction: Transaction;
async fn transaction(&mut self) -> Result<Self::Transaction, Error>;
}
#[async_trait]
pub trait Transaction: Sync + Send {
async fn query(
&self,
statement: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Vec<Row>, Error>;
async fn query_one(
&self,
statement: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Row, Error>;
async fn query_opt(
&self,
statement: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Option<Row>, Error>;
async fn commit(self) -> Result<(), Error>;
async fn rollback(self) -> Result<(), Error>;
}
And this works! I'm pretty proud of it. It took me a long time to figure the types out.
However, all these type parameters make it really difficult to use. For example, these type parameters infect the type signature of a simple repository:
struct Repository<D, T, C>
where
T: Transaction,
C: Connection<Transaction = T>,
D: Database<Transaction = T, Connection = C>,
{
db: D,
}
#[async_trait]
trait UserRepo {
async fn get_user(&mut self, txn: &dyn Transaction) -> Result<(), Error>;
}
#[async_trait]
impl<D, T, C> UserRepo for Repository<D, T, C>
where
T: Transaction,
C: Connection<Transaction = T>,
D: Database<Transaction = T, Connection = C>,
{
async fn get_user(&mut self, txn: &dyn Transaction) -> Result<(), Error> {
unimplemented!()
}
}
The definition of Repository
and UserRepo
are complex. I'm hoping there's a nicer way to do this. The type definitions of Database
are leaking into the definition of Repository
, which then leaks into UserRepo
.
It's not too bad to use, though. We can just box the UserRepo
trait:
struct LoginHandler {
repo: Box<dyn UserRepo>
}
However, although this code seems like it works, there is a very annoying constraint. It doesn't work when the Transaction
type takes a lifetime (this requires generic associated types), which makes it impossible to use with tokio-postgres without boxing.
I'm looking for any advice to simplify this code, and any other general Rust style recommendations.
Edit: Adding more detail
I'll expand on the requirement for using a lifetime in a transaction.
The Transaction struct in tokio-postgres takes a lifetime. It looks like this:
// Type from tokio-postgres
pub struct Transaction<'a> { /* private fields */ }
If I want to hide this behind the Transaction
trait, I'd do something like this:
// use tokio_postgres::Transaction as TokioTransaction
struct PostgresTransaction<'a>(TokioTransaction<'a>);
#[async_trait]
impl<'a> Transaction for PostgresTransaction<'a> {
// ...
}
But this fails when I try to declare it inside a Connection
:
#[async_trait]
impl Connection for PostgresConnection {
// This fails because it's different to the type signature
// in the Connection trait. This seems to require generic
// associated types.
type Transaction<'a> = PostgresTransaction<'a>;
// ...
}
I even tried to make this work using #![feature(generic_associated_types)]
, but also ran myself into tricky lifetime issues:
#[async_trait]
pub trait Database: Sync + Send {
type Transaction<'a>: Transaction;
type Connection<'a>: Connection<Transaction<'a> = Self::Transaction<'a>>;
// ...
}
This doesn't work because Database::Connection<'a>
may not live long enough. It's also pretty complicated to use:
struct Repository<D, T, C>
where
T: Transaction,
C: for <'a> Connection<Transaction<'a> = T>,
D: for <'a> Database<Transaction<'a> = T, Connection<'a> = C>,
{
db: D,
}
I'm wondering if there's a solution here using higher-rank trait bounds. I'm struggling to figure this out, though!
Edit 2: Aha! I have something working. But it requires nightly because it generic associated traits (GAT).
Hopefully I can find a way to simplify this without using GATs, because I don't want to depend on nightly. Maybe using HRTBs? But anywhere, this compiles:
#[async_trait]
pub trait Database: Sync + Send {
// Connection from the database connection pool.
type Connection<'a>: Connection<Transaction<'a> = Self::Transaction<'a>>
where
Self: 'a;
// A transaction whose lifetime is tied to a connection.
type Transaction<'a>: Transaction
where
Self: 'a;
async fn setup(&mut self, config: Config) -> Result<(), Error>;
async fn connect<'a>(&mut self) -> Result<Self::Connection<'a>, Error>;
}
#[async_trait]
pub trait Connection: Sync + Send {
type Transaction<'a>: Transaction
where
Self: 'a;
async fn transaction<'b>(&'b mut self) -> Result<Self::Transaction<'b>, Error>;
}
#[async_trait]
pub trait Transaction: Sync + Send {
async fn query(
&self,
statement: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Vec<Row>, Error>;
async fn query_one(
&self,
statement: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Row, Error>;
async fn query_opt(
&self,
statement: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Option<Row>, Error>;
async fn commit(self) -> Result<(), Error>;
async fn rollback(self) -> Result<(), Error>;
}
```