QoreDB LogoQoreDB
Retour au blog
ArchitectureJournal technique

Le trait DataEngine : une seule interface Rust pour seize moteurs

QoreDB parle à seize moteurs de bases de données : PostgreSQL, MySQL, MariaDB, SQL Server, MongoDB, Redis, SQLite, DuckDB, ClickHouse, Elasticsearch et quelques autres. Ces moteurs n’ont pas le même vocabulaire : certains connaissent les transactions, d’autres non ; certains ont…

Raphaël – Creator of QoreDBRaphaël – Creator of QoreDB
4 min de lecture
Le trait DataEngine : une seule interface Rust pour seize moteurs

QoreDB parle à seize moteurs de bases de données : PostgreSQL, MySQL, MariaDB, SQL Server, MongoDB, Redis, SQLite, DuckDB, ClickHouse, Elasticsearch et quelques autres. Ces moteurs n’ont pas le même vocabulaire : certains connaissent les transactions, d’autres non ; certains ont des triggers, des séquences ou des procédures stockées, d’autres ignorent jusqu’au concept de table. La question d’architecture est donc : comment offrir une expérience cohérente sans réduire tout le monde au plus petit dénominateur commun, ni forker la logique métier moteur par moteur ? La réponse de QoreDB tient dans un seul trait Rust, DataEngine.

Un seul contrat pour tous les moteurs

Le cœur du système est défini dans src-tauri/crates/qore-core/src/traits.rs. Chaque driver — PostgresDriver, MongoDriver, RedisDriver… — implémente le même trait asynchrone. La signature est délibérément simple :

traits.rs
#[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>;
    // …
}

La contrainte Send + Sync n’est pas cosmétique : les drivers sont partagés entre tâches Tokio et manipulés derrière un Arc<dyn DataEngine>. Le trait doit donc être utilisable de façon concurrente, sans exception.

Un noyau obligatoire, le reste optionnel

Seule une poignée de méthodes n’a pas d’implémentation par défaut : tout driver doit fournir driver_id, driver_name, test_connection, connect, disconnect, ping, list_namespaces, list_collections, create_database, drop_database, execute, describe_table et preview_table. C’est le socle commun : se connecter, lister, exécuter, décrire.

Tout le reste — transactions, mutations ligne à ligne, triggers, séquences, procédures, événements planifiés, opérations de maintenance, annulation de requête, streaming — possède une implémentation par défaut. Et cette valeur par défaut est presque toujours la même : refuser proprement.

La négociation de capacités : refuser plutôt qu’émuler

Voici le parti pris central. Plutôt que de forcer chaque driver à simuler des fonctionnalités absentes de son moteur, le trait fournit un comportement par défaut qui renvoie une erreur explicite NotSupported :

traits.rs
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
}

Un driver qui ne connaît pas les transactions — Redis, par exemple — n’a rien à écrire : il hérite du défaut. Le driver SQLite, lui, redéfinit supports_transactions() pour renvoyer true et fournit une vraie implémentation. Le même mécanisme joue pour les listes : list_triggers, list_sequences ou list_routines renvoient par défaut une liste vide plutôt qu’une erreur, ce qui laisse l’interface afficher « aucun trigger » sans cas particulier.

C’est la traduction concrète d’un principe produit de QoreDB : ne pas émuler ce qu’un moteur ne sait pas faire. Une capacité annoncée correspond toujours à une capacité réelle du moteur sous-jacent — jamais à une couche de compatibilité qui masque la réalité.

capabilities() : exposer l’état réel à l’interface

Ces drapeaux ne servent pas qu’au backend. Le trait les agrège dans une structure sérialisable, via une méthode capabilities() qui interroge chaque supports_* :

types.rs
#[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,
}

Le frontend reçoit un DriverInfoid, name et ce bloc capabilities — et adapte l’interface en conséquence : pas de bouton « annuler la requête » si le moteur ne sait pas annuler, pas d’onglet transactions si le driver ne les gère pas. L’interface reflète les capacités déclarées, sans les deviner.

Quand une capacité dépend de la session : le cas MongoDB

Un booléen statique ne suffit pas toujours. Les transactions MongoDB exigent un déploiement en replica set : un serveur MongoDB isolé ne les accepte pas. Le driver déclare donc supports_transactions() à true au niveau du type, mais tranche réellement au niveau de la session ouverte :

mongodb.rs
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)
}

La capacité n’est donc pas une propriété figée du driver, mais une propriété de la connexion réelle, vérifiée à l’exécution. Le trait prévoit les deux niveaux : un défaut statique supports_transactions() et une version asynchrone par session que les drivers concernés redéfinissent.

Le DriverRegistry : instancier et retrouver un driver

Reste à peupler ce catalogue. Le DriverRegistry est une simple table indexée par driver_id() :

registry.rs
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()
    }
}

L’enregistrement se fait en un seul endroit, à la construction du contexte de service dans context.rs, où les seize drivers sont ajoutés les uns après les autres :

context.rs
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, OpenSearch

À partir de là, le reste de l’application n’a plus jamais besoin de connaître un type concret : elle demande registry.get("mongodb") et manipule un Arc<dyn DataEngine>. La méthode list_infos() renvoie même la liste complète des drivers avec leurs capacités, ce qui alimente directement l’écran de choix de connexion.

Une erreur unifiée pour seize moteurs

Pour que tout cela tienne, les erreurs doivent aussi être unifiées. Chaque driver traduit les erreurs natives de son moteur vers un unique enum EngineError, qui couvre ConnectionFailed, AuthenticationFailed, SyntaxError, Timeout, SslError, NotSupported et une douzaine d’autres variantes. La couche supérieure ne voit jamais une erreur Postgres ou une erreur MongoDB : elle voit un EngineResult<T>, c’est-à-dire un Result<T, EngineError>.

Ce que ce trait dit du produit

Le trait DataEngine n’est pas qu’une commodité technique. C’est la matérialisation d’un choix : l’abstraction s’arrête là où commence l’honnêteté. QoreDB unifie ce qui peut l’être — se connecter, lister, exécuter, décrire — et refuse explicitement le reste quand le moteur ne le sait pas faire, au lieu de le simuler. Un seul point d’extension, une seule surface d’erreur, et des capacités qui disent la vérité sur ce que chaque base sait réellement faire. C’est ce qui permet d’ajouter un dix-septième moteur sans toucher au reste de l’application : il suffit d’implémenter le socle, de redéfinir ce que le moteur sait faire, et de l’enregistrer.

Newsletter

Restez informé des nouveautés

Rejoignez notre newsletter pour recevoir les mises à jour majeures, les nouveaux drivers et nos coulisses techniques.

🎁 Bonus : Recevez notre fiche mémo d'optimisation SQL — 9 pages, PostgreSQL / MySQL / SQLite (PDF) !
Partager
Le trait DataEngine : une seule interface Rust pour seize moteurs - Blog - QoreDB