2
\$\begingroup\$

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:

  1. Using a fake implementation for unit tests
  2. 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>;
}
```
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

For your intricate types I have found another approach:

struct Repository<D: Database> {
    db: D, // Note: the D type will carry its own associated types, unless you really need them you do not have to specify them
           // Note: This makes the Repository struct kind of pointless, it is a direct wrapper for an object that implements Database.
           // So you could just as easily work with Database directly, unless you need the level of indirection.
}

#[async_trait]
trait UserRepo {
    async fn get_user(&mut self, txn: &impl Transaction) -> Result<(), Error>;
}

// Note: You can implement a trait for another trait, but this leaves the question why there is a separation in the first place.
// Unless you introduce extra restraints, or the trait you want to 'extend' is not in your control. In any other case it is
// likely more clear to just stuff the behaviour in the original trait.
#[async_trait]
impl<D: Database> UserRepo for D {
    async fn get_user(&mut self, txn: &impl Transaction) -> Result<(), Error> {
        unimplemented!()
    }
}

Edit

For the Transaction with lifetime I think you will need a lifetime in the database to tie all other lifetimes to. But this means that the interface might get a little bit harder to use because you have to specify the lifetime more often. This example (combined with some code of the previous code sample) compiles on my computer in stable rust. I hope this helps!

#[async_trait]
pub trait Database<'a>: Sync + Send {
    type Transaction: Transaction;
    type Connection: Connection<Transaction = Self::Transaction>;

    async fn connect(&mut self) -> Result<Self::Connection, Error>;
}

// Note: To use this in another struct you may need to add a marker for the compiler that it uses the lifetime.
use std::marker::PhantomData;
struct Repository<'a, D: Database<'a>> {
    db: D, 
    phantom: PhantomData<&'a D>,
}

struct Trans<'a> {
    text: &'a str,
}

#[async_trait]
impl<'a> Transaction for Trans<'a> {
    ...
}

struct Con<T> {
    t: Vec<T>,
}

#[async_trait]
impl<T: Transaction> Connection for Con<T> {
    type Transaction = T;
    async fn transaction(&mut self) -> Result<Self::Transaction, Error> {
        unimplemented!()
    }
}

struct Db {}

// Note: this leaves this as the final database implementation.
#[async_trait]
impl<'a> Database<'a> for Db {
    type Transaction = Trans<'a>;
    type Connection = Con<Trans<'a>>;

    async fn connect(&mut self) -> Result<Self::Connection, Error> {
        unimplemented!()
    }
}
\$\endgroup\$
2
  • \$\begingroup\$ Thanks for your answer, and very good point on the repository being useless! I think I'll keep it in the question as a minimal example of using the Database trait, though. I've added some more detail on trying to add a lifetime to the Transaction struct. I quickly ran into issues with HRTB and GATs :( \$\endgroup\$
    – haz
    Commented May 31, 2022 at 23:28
  • \$\begingroup\$ Okay thanks for the extra details, this makes it clear where you are running into problems. I hope my edit sends you in the right direction. \$\endgroup\$
    – Nonno
    Commented Jun 2, 2022 at 23:12

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.