bdvvn/database

Abstraction PDO multi-driver avec query builder

Maintainers

Package info

github.com/bdvvn/database

pkg:composer/bdvvn/database

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-05-31 14:42 UTC

This package is auto-updated.

Last update: 2026-05-31 14:42:44 UTC


README

Abstraction PDO multi-driver dotée d'un query builder fluide, immuable et sécurisé.

La bibliothèque vise un objectif simple : écrire des requêtes SQL paramétrées de façon expressive, sans ORM, sans magie, et sans jamais exposer une surface d'injection SQL — ni sur les valeurs (toujours bindées), ni sur les identifiants (toujours validés et quotés).

use Bdvvn\Database\Connection;

$db = Connection::fromEnv();

$articles = $db->from('articles', 'a')
    ->select('a.id', 'a.titre', 'auteurs.nom')
    ->join('auteurs', 'auteurs.id', '=', 'a.auteur_id')
    ->where('a.publie', '=', true)
    ->whereNotNull('a.publie_le')
    ->orderBy('a.publie_le', 'DESC')
    ->limit(10)
    ->findAll();

Sommaire

Prérequis

  • PHP ≥ 8.5
  • Extension pdo activée (plus le driver PDO correspondant : pdo_mysql, pdo_pgsql ou pdo_sqlite)
  • bdvvn/env — utilisé par Connection::fromEnv() pour lire la configuration

Dialectes supportés : MySQL / MariaDB, PostgreSQL, SQLite.

Installation

composer require bdvvn/database

Autoload PSR-4 : namespace Bdvvn\Database\ mappé sur src/.

ℹ️ Note de distribution. En l'état, composer.json dépend de bdvvn/env en dev-main via un repository local de type path (../env), avec minimum-stability: dev. Cette configuration fonctionne en monorepo mais n'est pas installable telle quelle par un tiers depuis Packagist. Avant toute publication : taguer une version stable de bdvvn/env, remplacer la contrainte par une version sémantique (^1.0) et retirer le bloc repositories path. De même, phpmd/phpmd est épinglé en 3.x-dev (branche de développement) : à figer sur une version stable une fois disponible.

Démarrage rapide

use Bdvvn\Database\Connection;

// 1. Une connexion (voir « Établir une connexion » pour toutes les fabriques)
$db = Connection::sqlite(__DIR__ . '/blog.sqlite');

// 2. Un builder par table, obtenu via from()
$query = $db->from('articles');

// 3. On compose, on exécute
$article = $query->where('slug', '=', 'mon-premier-article')->find();

Le builder est immuable : chaque méthode renvoie une nouvelle instance. On peut donc dériver des requêtes sans effet de bord :

$base = $db->from('articles')->where('publie', '=', true);

$recents   = $base->orderBy('publie_le', 'DESC')->limit(5)->findAll();
$decompte  = $base->count(); // $base n'a pas été modifié par la ligne précédente

Établir une connexion

La classe Connection est le point d'entrée. Elle encapsule le PDO, le dialecte, l'exécuteur et le validateur, puis fabrique des QueryBuilder via from().

$db->from('articles');           // FROM `articles`
$db->from('articles', 'a');      // FROM `articles` AS `a` — voir « Alias de table »

Fabriques statiques

use Bdvvn\Database\Connection;

Connection::mysql(host: '127.0.0.1', port: 3306, dbname: 'blog', user: 'root', password: 'secret');
Connection::mariadb(...);   // alias de mysql()
Connection::postgresql(host: '127.0.0.1', port: 5432, dbname: 'blog', user: 'postgres', password: 'secret');
Connection::pgsql(...);     // alias de postgresql()
Connection::sqlite('/chemin/vers/base.sqlite');  // ou ':memory:'

mysql() force charset=utf8mb4 et désactive l'émulation des requêtes préparées (ATTR_EMULATE_PREPARES = false). sqlite() active PRAGMA foreign_keys = ON et journal_mode = WAL. Toutes les connexions utilisent ERRMODE_EXCEPTION et FETCH_ASSOC par défaut.

Depuis l'environnement

Connection::fromEnv() lit la configuration exclusivement via la bibliothèque bdvvn/env (plus aucun accès direct à $_ENV / getenv() dans cette lib) :

Variable Rôle Défaut
DB_DRIVER / DB_CONNECTION mysql | mariadb | pgsql | postgres | postgresql | sqlite mysql
DB_HOST hôte 127.0.0.1
DB_PORT port (1–65535) 3306 (MySQL) / 5432 (PostgreSQL)
DB_NAME / DB_DATABASE nom de base — (obligatoire hors SQLite)
DB_USER utilisateur ''
DB_PASSWORD mot de passe ''
DB_PATH / DB_DATABASE / DB_NAME chemin du fichier SQLite :memory:

Sans argument, une instance Env lisant l'environnement courant est créée :

$db = Connection::fromEnv();

Pour charger un fichier .env au préalable, on injecte une instance Env configurée — ce qui laisse la lib database totalement découplée de la source des variables :

use Bdvvn\Env\Env;

$env = (new Env())->load(__DIR__ . '/.env');

$db = Connection::fromEnv($env);

DB_PORT est optionnel, mais strict : absent ou vide, il retombe sur le port par défaut du driver ; s'il est présent, il doit être entier puis compris dans la plage 1–65535.

Constructeur

Pour brancher un PDO préconfiguré, surcharger le dialecte ou injecter un exécuteur décoré :

$db = new Connection(
    pdo: $pdoPreconfigure,
    adapter: new MySqlAdapter(),       // optionnel — déduit du driver PDO sinon
    executor: new LoggingExecutor(...),// optionnel — voir « Étendre la bibliothèque »
);

Connection::pdo() et Connection::adapter() exposent les objets sous-jacents si besoin.

Construire des requêtes

Toutes les méthodes de construction renvoient un nouveau QueryBuilder (chaînage fluide).

Alias de table

Passer un second argument à from() déclare un alias (FROM articles AS a) et permet de préfixer les colonnes avec la forme courte plutôt que le nom complet de la table :

$db->from('articles', 'a')
    ->select('a.titre', 'auteurs.nom')
    ->join('auteurs', 'auteurs.id', '=', 'a.auteur_id')
    ->where('a.publie', '=', true)
    ->findAll();

Contraintes (volontaires) :

  • L'alias doit être un identifiant simple (a, art…), validé comme tel.
  • L'alias est réservé à la lecture. insert() l'ignore ; update() / delete() lèvent une LogicException si un alias est défini — car UPDATE/DELETE ... AS alias n'est pas portable entre MySQL, PostgreSQL et SQLite. Pour une écriture, utilisez from('articles') sans alias.

SELECT

->select('id', 'titre')          // colonnes validées + quotées
->addSelect('auteurs.nom')       // ajoute aux colonnes déjà sélectionnées
->selectRaw('COUNT(*) AS total')          // expression brute (non quotée — voir Sécurité)
->selectRaw('vues * ? AS pondere', [2])   // valeurs paramétrables via bindings

Sans select(), la requête sélectionne *.

JOIN

->join('auteurs', 'auteurs.id', '=', 'articles.auteur_id')      // INNER JOIN
->leftJoin('commentaires', 'commentaires.article_id', '=', 'articles.id')
->rightJoin(...)
->crossJoin('tags')
->joinRaw('JOIN ... ON ... AND x = ?', [1])  // jointure brute (valeurs paramétrables)

Les opérateurs de jointure sont restreints aux comparaisons (=, !=, <, >, <=, >=) ; LIKE est refusé dans un ON.

WHERE

->where('statut', '=', 'publie')
->orWhere('statut', '=', 'archive')

->whereNull('supprime_le')        ->whereNotNull('publie_le')
->orWhereNull(...)                ->orWhereNotNull(...)

->whereIn('categorie_id', [1, 2, 3])      ->whereNotIn(...)
->orWhereIn(...)                          ->orWhereNotIn(...)

->whereBetween('vues', 100, 1000)         ->whereNotBetween(...)
->orWhereBetween(...)                      ->orWhereNotBetween(...)

->whereRaw('LENGTH(titre) > ?', [10])     ->orWhereRaw(...)

Détails utiles :

  • Les opérateurs autorisés pour where/having : =, !=, <>, <, >, <=, >=, LIKE, NOT LIKE (insensibles à la casse). Tout autre opérateur lève une InvalidArgumentException.
  • where('col', '=', null) lève une exception : un = NULL ne matche jamais en SQL. Utilisez whereNull() / whereNotNull().
  • whereIn('col', []) se compile en 1 = 0 (jamais vrai) et whereNotIn('col', []) en 1 = 1 (toujours vrai) — sémantique correcte d'un ensemble vide.

GROUP BY / HAVING

->groupBy('auteur_id')
->groupByRaw('DATE(publie_le, ?)', ['start of month'])
->having('total', '>', 5)
->orHaving(...)
->havingRaw('COUNT(*) > ?', [5])

ORDER / LIMIT / OFFSET

->orderBy('publie_le', 'DESC')   // direction ∈ {ASC, DESC}, sinon exception
->orderByRaw('ABS(vues - ?) ASC', [200])   // valeurs paramétrables via bindings
->limit(10)                      // ≥ 0, sinon exception
->offset(20)                     // ≥ 0, sinon exception

Lire des données

->find(): ?array        // 1re ligne (force LIMIT 1) ou null
->findAll(): array      // liste de lignes associatives
->count(): int          // COUNT(*) — gère GROUP BY / HAVING via sous-requête
->exists(): bool        // SELECT 1 ... LIMIT 1

Optimisations intégrées : find() et exists() court-circuitent et renvoient null / false sans toucher la base si limit(0) a été posé.

$db->from('utilisateurs')->where('email', '=', $email)->exists();   // bool
$db->from('articles')->where('publie', '=', true)->count();         // int

Écrire des données

->insert(['titre' => 'Hello', 'corps' => '...']): bool
->update(['titre' => 'Edité']): int       // nombre de lignes affectées
->delete(): int                            // nombre de lignes affectées
->lastInsertId(?string $sequence = null): ?string

Garde-fous volontaires contre les erreurs destructrices :

  • update() / delete() sans clause WHERE lèvent une LogicException. Pour vider sciemment une table, passez allowEmptyWhere: true.
  • update() / delete() refusent toute clause non supportée universellement par ces DML (JOIN, GROUP BY, HAVING, ORDER BY, LIMIT, OFFSET).
  • insert() / update() exigent au moins une colonne et valident chaque clé comme identifiant simple (non qualifié).
$db->from('articles')->insert(['titre' => 'Hello', 'slug' => 'hello']);
$id = $db->from('articles')->lastInsertId();

$db->from('articles')->where('id', '=', $id)->update(['vues' => 1]);
$db->from('articles')->where('id', '=', $id)->delete();

Transactions

Connection::transaction() exécute un callback de façon atomique : COMMIT si tout réussit, ROLLBACK automatique si une exception est levée (elle est ensuite relancée). La méthode est réentrante (les appels imbriqués s'exécutent dans la transaction la plus externe, PDO ne gérant pas l'imbrication native).

$db->transaction(function (Connection $db): void {
    $db->from('comptes')->where('id', '=', 1)->update(['solde' => 90]);
    $db->from('comptes')->where('id', '=', 2)->update(['solde' => 110]);
    // Une exception ici annule les deux UPDATE.
});

Le contrôle manuel reste disponible si besoin :

$db->beginTransaction();
$db->commit();    // ou
$db->rollBack();
$db->inTransaction(); // bool

Introspection

Inspecter le SQL généré sans l'exécuter — pratique pour le debug et les tests :

$q = $db->from('articles')->where('publie', '=', true)->orderBy('id');

$q->toSql();        // "SELECT * FROM `articles` WHERE `publie` = ? ORDER BY `id` ASC"
$q->getBindings();  // [true]

Sécurité

La bibliothèque applique une défense en profondeur :

  1. Valeurs → toujours transmises en requêtes préparées (placeholders ?). Aucune valeur n'est jamais interpolée dans le SQL.
  2. Identifiants (tables, colonnes) → ils ne peuvent pas être bindés en PDO ; ils sont donc validés par IdentifierValidator (regex stricte ^[a-zA-Z_][a-zA-Z0-9_]* éventuellement qualifiée par .) puis quotés selon le dialecte. Un select($_GET['col']) malveillant est rejeté.
  3. Opérateurs → liste blanche fermée via l'enum SqlOperator. Impossible d'injecter un opérateur arbitraire.
  4. Types de bindingPdoExecutor choisit PARAM_NULL / PARAM_BOOL / PARAM_INT / PARAM_STR selon la valeur.

⚠️ Les méthodes *Raw court-circuitent la validation des identifiants. selectRaw(), whereRaw(), joinRaw(), groupByRaw(), havingRaw(), orderByRaw() insèrent le fragment SQL tel quel. Elles refusent les fragments vides et les octets NUL, mais ne tentent pas d'analyser le SQL. Toutes acceptent un second argument bindings : faites toujours transiter les valeurs par des placeholders (orderByRaw('ABS(x - ?)', [$n])) plutôt que de les interpoler. N'injectez jamais d'identifiant (table/colonne) issu d'une entrée utilisateur dans le fragment lui-même.

  1. Mode d'erreur PDO → le constructeur de Connection force PDO::ERRMODE_EXCEPTION, y compris sur un PDO injecté manuellement. Une requête fautive lève donc toujours une exception plutôt que d'échouer silencieusement.

⚠️ N'exposez jamais une PDOException brute à l'utilisateur final. Son message — et sa stack trace — peuvent contenir la requête SQL, des noms de tables/colonnes, voire des bribes de configuration. En production, rattrapez ces exceptions à la frontière applicative, journalisez-les côté serveur, et ne renvoyez qu'un message générique. La librairie laisse remonter l'exception telle quelle : c'est à l'application appelante de décider de la politique de journalisation/affichage.

Architecture

Le QueryBuilder est une façade fluide qui n'assume volontairement aucune responsabilité lourde : il valide les entrées, accumule un état immuable, puis orchestre la compilation et l'exécution. Chaque préoccupation vit dans sa propre classe.

Composant Fichier Responsabilité
Connection Connection.php Point d'entrée ; câble PDO + dialecte + exécuteur ; fabrique les builders
QueryBuilder QueryBuilder.php API fluide ; validation ; orchestration
QueryState Query/QueryState.php Snapshot immuable de la requête en construction (clone-on-modify)
Compiler Query/Compiler.php Assemble un QueryState en SQL paramétré — pur, sans effet de bord
CompiledQuery Query/CompiledQuery.php Couple immuable {sql, bindings}
IdentifierValidator Query/IdentifierValidator.php Barrière anti-injection sur les identifiants
SqlOperator Query/SqlOperator.php Liste blanche d'opérateurs (enum)
Executor Execution/Executor.php Interface d'exécution SQL
PdoExecutor Execution/PdoExecutor.php Implémentation PDO de Executor
SqlAdapter Adapter/SqlAdapter.php Interface de dialecte (quoting, LIMIT/OFFSET)
AbstractSqlAdapter Adapter/AbstractSqlAdapter.php Logique commune de dialecte
MySqlAdapter · PostgreSqlAdapter · SqliteAdapter Adapter/ Dialectes concrets
PdoAdapterFactory Adapter/PdoAdapterFactory.php Déduit le bon adapter depuis le driver PDO

Flux d'une requête

Connection::from('articles')
        │  fabrique
        ▼
  QueryBuilder ──valide──▶ IdentifierValidator / SqlOperator
        │
        │  clone-on-modify (accumule)
        ▼
   QueryState (immuable)
        │
        │  ->findAll() / ->insert() ...
        ▼
   Compiler ──dialecte──▶ SqlAdapter ──▶ CompiledQuery {sql, bindings}
        │
        ▼
   Executor (PdoExecutor) ──▶ PDO ──▶ résultats

Les rôles sont nettement séparés :

  • Quoi (la requête voulue) → QueryState
  • Comment l'écrire (le SQL) → Compiler + SqlAdapter
  • Comment l'exécuterExecutor
  • Ce qui est permisIdentifierValidator + SqlOperator

C'est ce qui rend chaque pièce testable isolément : le Compiler se teste sans base de données, l'Executor se mocke trivialement, et le QueryBuilder accepte des doubles via son constructeur public.

Spécificités par dialecte

Le quoting et la syntaxe LIMIT/OFFSET varient ; AbstractSqlAdapter centralise la logique commune, les sous-classes ne portent que les différences :

Dialecte Quote OFFSET sans LIMIT
MySQL / MariaDB ` (backtick) LIMIT 18446744073709551615 OFFSET n
PostgreSQL " (guillemet double) OFFSET n (standard SQL)
SQLite ` (backtick) LIMIT -1 OFFSET n

L'adapter est déduit automatiquement du driver PDO par PdoAdapterFactory::fromPdo(), ou injectable explicitement via le constructeur de Connection.

Étendre la bibliothèque

L'interface Executor est le point d'extension naturel. On peut la décorer sans toucher au reste — par exemple pour journaliser ou profiler :

use Bdvvn\Database\Execution\Executor;

final readonly class LoggingExecutor implements Executor
{
    public function __construct(
        private Executor $inner,
        private \Psr\Log\LoggerInterface $logger,
    ) {}

    public function fetchAll(string $sql, array $bindings): array
    {
        $this->logger->debug($sql, $bindings);

        return $this->inner->fetchAll($sql, $bindings);
    }

    // … déléguer fetchOne / fetchValue / execute / lastInsertId à $this->inner
}

$db = new Connection(
    pdo: $pdo,
    executor: new LoggingExecutor(new PdoExecutor($pdo), $logger),
);

De même, un dialecte exotique se branche en implémentant SqlAdapter (ou en étendant AbstractSqlAdapter) et en le passant au constructeur de Connection.

Qualité du code

L'outillage QA est défini dans composer.json :

composer stan      # PHPStan (niveau strict)
composer md        # PHPMD
composer cs:check  # PHP-CS-Fixer (dry-run)
composer cs:fix    # PHP-CS-Fixer (applique)
composer test      # PHPUnit
composer qa        # stan + md + cs:check + test

L'ensemble du code est en declare(strict_types=1), les value objects sont final readonly, et les invariants sont vérifiés par PHPStan en mode strict.

Tests

La suite PHPUnit vit dans tests/ et se divise en deux types :

  • tests/Unit/ — composants purs testés isolément : Compiler, adapters de dialecte, SqlOperator, IdentifierValidator.
  • tests/Integration/ — le QueryBuilder de bout en bout, exécuté sur un vrai moteur SQLite en mémoire (:memory:). Le schéma tests/fixtures/schema.sql et un jeu de données sont rechargés avant chaque test (cf. DatabaseTestCase), ce qui garantit une base fraîche et isolée — pas de fichier .sqlite partagé ni d'état rémanent.
composer test                          # toute la suite
vendor/bin/phpunit --testsuite unit    # unitaires seulement
vendor/bin/phpunit --testsuite integration

Licence

MIT.