As a learning exercise, I am creating a simple authentication web service with Rust. I thought it would be nice to create a DB abstraction layer, so that it could work with different DB engines (Postgres, MySQL, MSSQL, Oracle etc.) - currently based on the connection string in an env variable (i.e. at runtime).
I tried a couple of different approaches, but have ended up with something like this (I am using sqlx for Postgres, MySQL and sqlite):
pub enum DatabaseType {
Postgres,
MySQL,
SQLite,
MSSQL,
}
//
// shared traits
//
#[async_trait]
pub trait Pool<'pool> {
fn db_type(&self) -> DatabaseType;
async fn get_connection<'conn>(&self) -> Result<Box<dyn Connection + 'conn>, DatabaseError>
where
'pool: 'conn;
}
#[async_trait]
pub trait Connection<'conn> {
fn db_type(&self) -> DatabaseType;
async fn begin<'tx_instance>(
&'tx_instance mut self,
) -> Result<Box<dyn Transaction + 'tx_instance>, DatabaseError>
where
'conn: 'tx_instance;
}
#[async_trait]
pub trait Transaction {
fn db_type(&self) -> DatabaseType;
async fn commit(self) -> Result<(), DatabaseError>;
async fn rollback(self) -> Result<(), DatabaseError>;
async fn create_savepoint(&mut self, name: &str) -> Result<(), DatabaseError>;
async fn commit_savepoint(&mut self, name: &str) -> Result<(), DatabaseError>;
}
//
// postgres implementation
//
pub struct PostgresPool {
pool: SqlxPool<SqlxPostgres>,
}
#[async_trait]
impl<'pool> Pool<'pool> for PostgresPool {
// implementation here
}
pub struct PostgresConnection {
conn: sqlx::pool::PoolConnection<SqlxPostgres>,
}
#[async_trait]
impl<'conn> Connection<'conn> for PostgresConnection {
// implementation here
}
pub struct PostgresTransaction<'a> {
transaction: SqlxTransaction<'a, SqlxPostgres>,
}
#[async_trait]
impl Transaction for PostgresTransaction<'_> {
// implementation here
}
// the querying is done through macros - mainly to avoid re-implementing the object mapping SQLX's macros already provide (and take advantage of its compile time query checking)
#[macro_export]
macro_rules! fetch_one {
($tx:expr, $return_type:ty, $query:literal, $($args:tt)*) => {{
async {
use $crate::lib::db::db_wrapper::{DatabaseType, DatabaseError};
let db_type = $tx.db_type();
match db_type {
DatabaseType::Postgres => {
// Forward the arguments directly to sqlx's query_as!
let query_result = sqlx::query_as!($return_type, $query, $($args)*)
.fetch_optional(&mut *$tx.transaction) // <-- compilation error here
.await?;
Ok(query_result)
},
// Other database types...
}
}
}};
}
My intention was to create a transaction per request in middleware, and pass the resulting dyn Transaction
to an AppState
which axum would then pass into the request handlers:
pub struct AppState {
pub tx: dyn Transaction,
}
// this method is called by the request handler:
pub async fn logon(
username: String,
password: String,
state: &AppState,
) -> Result<User, AppError> {
let user = fetch_one!(
state.tx, // <-- transaction created in middleware - causes the compilation error
LogonUserDetails,
"
SELECT id, uuid, email, password
FROM auth.app_user
WHERE tenant_id = $1
AND email = $2",
1,
username
)
.await?;
}
Compilation currently fails with no field 'transaction' on type '(dyn lib::db::db_wrapper::Transaction + 'static)'
within the fetch_one!
macro
My question is:
- is there a solution to the problem above (i.e. getting the implementation-specific
transaction
from theTransaction
/PostgresTransaction
argument passed to the macro, in order to pass it into sqlx'squery_as!
macro - and similarly for other databases) - more generally, have I taken the best approach to this problem? (a wrapper around different database drivers in which none of the implementation-specific concepts or types leak through to the calling code - ideally reusable in other projects)