QoreDB talks to sixteen database engines: PostgreSQL, MySQL, MariaDB, SQL Server, MongoDB, Redis, SQLite, DuckDB, ClickHouse, Elasticsearch, and a few more. These engines do not share a vocabulary: some know transactions, others do not; some have triggers, sequences, or stored procedures, others do not even have the concept of a table. The architectural question is therefore: how do you offer a coherent experience without reducing everyone to the lowest common denominator, and without forking the business logic engine by engine? QoreDB’s answer fits in a single Rust trait, DataEngine.
One contract for every engine
The heart of the system is defined in src-tauri/crates/qore-core/src/traits.rs. Every driver — PostgresDriver, MongoDriver, RedisDriver… — implements the same asynchronous trait. The signature is deliberately small:
#[async_trait]
pub trait DataEngine: Send + Sync {
fn driver_id(&self) -> &'static str;
fn driver_name(&self) -> &'static str;
async fn test_connection(&self, config: &ConnectionConfig) -> EngineResult<()>;
async fn connect(&self, config: &ConnectionConfig) -> EngineResult<SessionId>;
async fn disconnect(&self, session: SessionId) -> EngineResult<()>;
async fn ping(&self, session: SessionId) -> EngineResult<()>;
async fn execute(&self, session: SessionId, query: &str, query_id: QueryId)
-> EngineResult<QueryResult>;
// …
}The Send + Sync bound is not cosmetic: drivers are shared across Tokio tasks and handled behind an Arc<dyn DataEngine>. The trait must therefore be usable concurrently, without exception.
A required core, everything else optional
Only a handful of methods have no default implementation: every driver must provide driver_id, driver_name, test_connection, connect, disconnect, ping, list_namespaces, list_collections, create_database, drop_database, execute, describe_table, and preview_table. That is the common floor: connect, list, execute, describe.
Everything else — transactions, row-level mutations, triggers, sequences, routines, scheduled events, maintenance operations, query cancellation, streaming — ships with a default implementation. And that default is almost always the same: refuse cleanly.
Capability negotiation: refuse rather than emulate
Here is the central decision. Rather than forcing every driver to fake features its engine lacks, the trait provides a default behavior that returns an explicit NotSupported error:
async fn begin_transaction(&self, session: SessionId) -> EngineResult<()> {
let _ = session;
Err(EngineError::not_supported(
"Transactions are not supported by this driver",
))
}
fn supports_transactions(&self) -> bool {
false
}A driver that does not know transactions — Redis, for instance — writes nothing: it inherits the default. The SQLite driver, on the other hand, overrides supports_transactions() to return true and provides a real implementation. The same mechanism applies to lists: list_triggers, list_sequences, and list_routines return an empty list by default rather than an error, which lets the UI show "no triggers" with no special case.
capabilities(): exposing the real state to the UI
These flags are not only for the backend. The trait aggregates them into a serializable struct, through a capabilities() method that queries each supports_*:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriverCapabilities {
pub transactions: bool,
pub mutations: bool,
pub cancel: CancelSupport,
pub supports_ssh: bool,
pub schema: bool,
pub streaming: bool,
pub explain: bool,
pub maintenance: bool,
}The frontend receives a DriverInfo — id, name, and this capabilities block — and adapts the interface accordingly: no "cancel query" button if the engine cannot cancel, no transactions tab if the driver does not handle them. The UI reflects declared capabilities rather than guessing them.
When a capability depends on the session: the MongoDB case
A static boolean is not always enough. MongoDB transactions require a replica set deployment: a standalone MongoDB server rejects them. So the driver declares supports_transactions() as true at the type level, but decides for real at the level of the open session:
async fn supports_transactions_for_session(&self, session: SessionId) -> bool {
let sessions = self.sessions.read().await;
sessions
.get(&session)
.map(|mongo_session| mongo_session.supports_transactions)
.unwrap_or(false)
}The capability is therefore not a frozen property of the driver, but a property of the actual connection, checked at runtime. The trait anticipates both levels: a static default supports_transactions() and an async per-session version that the relevant drivers override.
The DriverRegistry: instantiating and looking up a driver
That catalogue still has to be populated. The DriverRegistry is a plain map indexed by driver_id():
pub struct DriverRegistry {
drivers: HashMap<String, Arc<dyn DataEngine>>,
}
impl DriverRegistry {
pub fn register(&mut self, driver: Arc<dyn DataEngine>) {
let id = driver.driver_id().to_string();
self.drivers.insert(id, driver);
}
pub fn get(&self, driver_id: &str) -> Option<Arc<dyn DataEngine>> {
self.drivers.get(driver_id).cloned()
}
}Registration happens in a single place, when the service context is built in context.rs, where the sixteen drivers are added one after another:
registry.register(Arc::new(PostgresDriver::new()));
registry.register(Arc::new(MySqlDriver::new()));
registry.register(Arc::new(MongoDriver::new()));
registry.register(Arc::new(RedisDriver::new()));
registry.register(Arc::new(SqliteDriver::new()));
// … DuckDB, MotherDuck, CockroachDB, SQL Server, MariaDB,
// Supabase, Neon, TimescaleDB, ClickHouse, Elasticsearch, OpenSearchFrom there, the rest of the application never needs to know a concrete type again: it asks registry.get("mongodb") and works with an Arc<dyn DataEngine>. The list_infos() method even returns the full list of drivers with their capabilities, which feeds the connection-picker screen directly.
One unified error for sixteen engines
For all of this to hold, errors must be unified too. Each driver maps its engine’s native errors onto a single EngineError enum, which covers ConnectionFailed, AuthenticationFailed, SyntaxError, Timeout, SslError, NotSupported, and a dozen other variants. The upper layer never sees a Postgres error or a MongoDB error: it sees an EngineResult<T>, that is, a Result<T, EngineError>.
What this trait says about the product
The DataEngine trait is not just a technical convenience. It is the materialization of a choice: abstraction stops where honesty begins. QoreDB unifies what can be unified — connect, list, execute, describe — and explicitly refuses the rest when the engine cannot do it, instead of simulating it. A single extension point, a single error surface, and capabilities that tell the truth about what each database can really do. That is what makes it possible to add a seventeenth engine without touching the rest of the application: implement the floor, override what the engine can do, register it.
Stay updated on new releases
Subscribe to get product releases, new drivers notifications, and technical tutorials.

