bdvvn/query-builder

Query builder SQL fluide et immuable, multi-dialecte, sans dépendance d'exécution

Maintainers

Package info

github.com/bdvvn/query-builder

pkg:composer/bdvvn/query-builder

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-06-05 15:48 UTC

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. Un select($_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 :

  1. Ta propre couche (PDO, DBAL, …) comme dans les exemples ci-dessus.
  2. bdvvn/database : surcouche PDO multi-driver qui ajoute une Connection, les transactions et un ExecutingQueryBuilder exposant directement find(), 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/.