bdvvn / query-builder
Query builder SQL fluide et immuable, multi-dialecte, sans dépendance d'exécution
Requires
- php: ^8.5
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-06-05 16:54:47 UTC
README
Query builder SQL fluide, immuable et sécurisé, multi-dialecte, sans dépendance d'exécution.
La bibliothèque a un seul rôle : produire des requêtes SQL paramétrées (sql + bindings) de façon
expressive, sans ORM ni magie, et sans jamais exposer de surface d'injection SQL — ni sur les valeurs
(toujours bindées), ni sur les identifiants (toujours validés et quotés).
Elle n'exécute rien : tu récupères un CompiledQuery et tu l'exécutes avec ta propre couche d'accès aux
données (PDO brut, Doctrine DBAL, un mock en test, etc.).
use Bdvvn\QueryBuilder\Adapter\MySqlAdapter; use Bdvvn\QueryBuilder\QueryFactory; $qf = new QueryFactory(new MySqlAdapter()); $query = $qf->table('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) ->compileSelect(); // $query->sql → "SELECT `a`.`id`, ... FROM `articles` AS `a` ... LIMIT 10" // $query->bindings → [true] $stmt = $pdo->prepare($query->sql); // ta couche d'exécution $stmt->execute($query->bindings); $articles = $stmt->fetchAll();
Installation
composer require bdvvn/query-builder
Aucune extension requise (PHP ^8.5). En particulier, aucune dépendance à PDO.
Concepts
QueryFactory — choisir le dialecte une fois
Le dialecte (quoting des identifiants, syntaxe LIMIT/OFFSET) est porté par un SqlAdapter. On le
configure une fois dans la fabrique :
use Bdvvn\QueryBuilder\Adapter\{MySqlAdapter, PostgreSqlAdapter, SqliteAdapter}; use Bdvvn\QueryBuilder\QueryFactory; $qf = new QueryFactory(new PostgreSqlAdapter()); $qb = $qf->table('articles'); // un QueryBuilder
QueryBuilder — construire (immuable)
Toutes les clauses sont disponibles en API fluide : select/addSelect/selectRaw/distinct,
join/leftJoin/rightJoin/crossJoin/joinRaw, where/orWhere et leurs variantes
(whereNull, whereIn, whereBetween, whereColumn, whereRaw…), groupBy,
having avec la même symétrie que where (havingNull, havingIn, havingBetween, havingRaw…),
orderBy, limit, offset, ainsi que union/unionAll.
// DISTINCT + comparaison de colonnes $qf->table('stock')->distinct()->select('ref')->whereColumn('quantite', '<', 'seuil'); // UNION : ORDER BY / LIMIT englobants s'appliquent au résultat combiné $publies = $qf->table('articles')->select('id')->where('publie', '=', true); $qf->table('brouillons')->select('id')->where('auteur_id', '=', 5) ->union($publies)->orderByRaw('id ASC')->limit(10);
L'objet est immuable : chaque méthode renvoie une nouvelle instance, ce qui rend les requêtes de base réutilisables sans effet de bord.
$base = $qf->table('articles')->where('publie', '=', true); $recents = $base->orderBy('publie_le', 'DESC')->limit(5); // $base n'a pas été modifié
CompiledQuery — le résultat à exécuter
Les terminales renvoient un CompiledQuery immuable (->sql, ->bindings) :
| Méthode | Produit |
|---|---|
compileSelect(?int $forceLimit = null) |
SELECT … |
compileCount() |
SELECT COUNT(*) … (sous-requête si GROUP BY/HAVING/DISTINCT) |
compileExists() |
SELECT 1 … LIMIT 1 |
compileInsert(array $data) |
INSERT INTO … (une ligne) |
compileInsertMany(array $rows) |
INSERT INTO … VALUES (…), (…) (multi-lignes, colonnes identiques) |
compileUpdate(array $data, bool $allowEmptyWhere = false) |
UPDATE … |
compileDelete(bool $allowEmptyWhere = false) |
DELETE … |
Introspection sans exécution : toSql(?int $forceLimit = null) et getBindings().
$q = $qf->table('articles')->where('id', '=', 7)->compileUpdate(['titre' => 'X']); // $q->sql → 'UPDATE "articles" SET "titre" = ? WHERE "id" = ?' // $q->bindings → ['X', 7]
Garde-fous repris du builder d'origine : compileUpdate()/compileDelete() exigent un WHERE
(sauf allowEmptyWhere: true) et refusent les clauses qu'un DML standard ne supporte pas universellement
(JOIN, GROUP BY, alias…).
Sécurité
- Valeurs : jamais interpolées, toujours rendues sous forme de
?+ binding. - Identifiants (tables, colonnes, alias) : validés par
IdentifierValidator(regex stricte, longueur bornée) puis quotés par le dialecte. Unselect($_GET['col'])non conforme lève une exception. - Opérateurs : restreints à une whitelist (
SqlOperator). - Fragments
*Raw()(selectRaw,whereRaw,joinRaw,groupByRaw,orderByRaw,havingRaw) : insérés tels quels dans le SQL — c'est la seule porte d'injection. N'y mets jamais d'entrée utilisateur concaténée ; passe les valeurs dynamiques en?via l'argument$bindings. LIKE: les méta-caractères%et_présents dans la valeur ne sont pas échappés (comportement standard). Si la valeur vient de l'utilisateur, échappe-la côté appelant.
C'est l'exécutant (toi) qui reste responsable d'utiliser une requête préparée avec les bindings
fournis — ne jamais réinjecter ->sql en concaténant des valeurs.
Exécution : à toi de jouer
La bibliothèque s'arrête à CompiledQuery. Côté exécution, deux options :
- Ta propre couche (PDO, DBAL, …) comme dans les exemples ci-dessus.
- bdvvn/database : surcouche PDO multi-driver qui ajoute une
Connection, les transactions et unExecutingQueryBuilderexposant directementfind(),findAll(),insert(),update(),delete()— pour qui veut le confort sans assembler l'exécution soi-même.
Dialectes fournis
MySqlAdapter (backticks), PostgreSqlAdapter / SqliteAdapter (guillemets doubles). Pour un autre SGBD,
étendre AbstractSqlAdapter (définir le caractère de quoting et, au besoin, la syntaxe OFFSET sans LIMIT)
et le passer à QueryFactory.
Qualité du code
composer qa # phpstan (niveau 10 + strict-rules), phpmd, php-cs-fixer, phpunit
Documentation de référence par classe dans doc/.