bdvvn / database
Abstraction PDO multi-driver avec query builder
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpmd/phpmd: 3.x-dev
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.0
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
- Installation
- Démarrage rapide
- Établir une connexion
- Construire des requêtes
- Lire des données
- Écrire des données
- Introspection
- Sécurité
- Architecture
- Spécificités par dialecte
- Étendre la bibliothèque
- Qualité du code
Prérequis
- PHP ≥ 8.5
- Extension
pdoactivée (plus le driver PDO correspondant :pdo_mysql,pdo_pgsqloupdo_sqlite) bdvvn/env— utilisé parConnection::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.jsondépend debdvvn/envendev-mainvia un repository local de typepath(../env), avecminimum-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 debdvvn/env, remplacer la contrainte par une version sémantique (^1.0) et retirer le blocrepositoriespath. De même,phpmd/phpmdest épinglé en3.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_PORTest 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 plage1–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 uneLogicExceptionsi un alias est défini — carUPDATE/DELETE ... AS aliasn'est pas portable entre MySQL, PostgreSQL et SQLite. Pour une écriture, utilisezfrom('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 uneInvalidArgumentException. where('col', '=', null)lève une exception : un= NULLne matche jamais en SQL. UtilisezwhereNull()/whereNotNull().whereIn('col', [])se compile en1 = 0(jamais vrai) etwhereNotIn('col', [])en1 = 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 clauseWHERElèvent uneLogicException. Pour vider sciemment une table, passezallowEmptyWhere: 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 :
- Valeurs → toujours transmises en requêtes préparées (placeholders
?). Aucune valeur n'est jamais interpolée dans le SQL. - 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. Unselect($_GET['col'])malveillant est rejeté. - Opérateurs → liste blanche fermée via l'enum
SqlOperator. Impossible d'injecter un opérateur arbitraire. - Types de binding →
PdoExecutorchoisitPARAM_NULL/PARAM_BOOL/PARAM_INT/PARAM_STRselon la valeur.
⚠️ Les méthodes
*Rawcourt-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 argumentbindings: 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.
- Mode d'erreur PDO → le constructeur de
ConnectionforcePDO::ERRMODE_EXCEPTION, y compris sur unPDOinjecté manuellement. Une requête fautive lève donc toujours une exception plutôt que d'échouer silencieusement.
⚠️ N'exposez jamais une
PDOExceptionbrute à 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écuter →
Executor - Ce qui est permis →
IdentifierValidator+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/— leQueryBuilderde bout en bout, exécuté sur un vrai moteur SQLite en mémoire (:memory:). Le schématests/fixtures/schema.sqlet 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.sqlitepartagé ni d'état rémanent.
composer test # toute la suite vendor/bin/phpunit --testsuite unit # unitaires seulement vendor/bin/phpunit --testsuite integration
Licence
MIT.