sugatasei / bredala-database
PHP Abstract Database Class
Requires
- php: >=8.5
- ext-pdo: *
Requires (Dev)
- phpunit/phpunit: ^9.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Boîte à outils SQL bas niveau pour PHP : deux constructeurs de chaînes (QB pour les requètes, FB pour le DDL), une fine enveloppe PDO, un gestionnaire de sessions et un utilitaire d'introspection de schéma.
Ce n'est pas un ORM : pas de modèles, pas de relations, pas d'exécuteur de migrations, pas de chargement paresseux. On obtient une chaîne SQL et ses paramètres liés, et on l'exécute soi-même.
Installation
composer require sugatasei/bredala-database
Claude Code
Ce package fournit un skill Claude Code dans skills/bredala-database/ qui documente les patterns d'usage et les pièges de la librairie (mélange OR/AND refusé hors groupe, tableau vide qui ne correspond à rien, aucun constructeur qui se réinitialise entre terminaux, FB::checkIdentifier() jamais appelé en interne, etc.).
Dans un projet qui dépend de sugatasei/bredala-database, copie-le une fois dans .claude/skills/ après composer install pour que Claude Code le charge automatiquement (le nom du dossier doit correspondre au name déclaré dans SKILL.md) :
cp -r vendor/sugatasei/bredala-database/skills/bredala-database .claude/skills/bredala-database
QB
Bredala\Database\QB Constructeur de requètes. Chaque méthode de clause retourne QB, chaque terminal retourne un Query.
Construction
__construct(string $table = '')create(string $table = ''): QBÉquivalent statique.
Sélection
select(string ...$cols): QBCumule les appels au lieu de remplacer.distinct(): QB
Jointures
join(string $table, string $cond): QBleft(string $table, string $cond): QBright(string $table, string $cond): QB
Conditions
where(string $statement, ...$values): QBCondition brute avec ses paramètres liés.orWhere(string $statement, ...$values): QBwhereEq(string $field, $value): QBwhereNot(string $field, $value): QBorWhereEq(string $field, $value): QBorWhereNot(string $field, $value): QBwhereIn(string $field, array|Query $values): QB/whereNotIn(...)/orWhereIn(...)/orWhereNotIn(...)groupStart(): QB/orGroupStart(): QB/groupEnd(): QB
whereEq() et whereNot() s'adaptent au type de la valeur :
| Valeur | SQL produit |
|---|---|
| scalaire | field = ? / field <> ? |
null |
field IS NULL / field IS NOT NULL |
| tableau non vide | field IN (?,?,…) / field NOT IN (…) |
[] |
1 = 0 / 1 = 1 — ne correspond à rien / à tout |
un Query |
field IN (<sous-requète>), paramètres fusionnés |
whereIn() est l'écriture explicite de whereEq() avec un ensemble, et produit exactement le même SQL. whereEq() porte quatre sémantiques sous un nom qui en annonce une ; quand la valeur est un ensemble, le dire au call-site enlève l'ambiguïté. La famille n'accepte **qu'**un tableau ou un Query — un scalaire ou null lève une TypeError.
Le tableau vide ne correspond à rien, et son miroir whereNot() correspond à tout : appartenir à l'ensemble vide est faux pour toute ligne. Un filtre construit depuis une liste d'identifiants vide rend donc zéro ligne, sans qu'il faille traiter le cas en amont.
La condition émise est 1 = 0 / 1 = 1 et non IN (), qui n'est pas portable — SQLite l'accepte, MySQL le rejette.
Le cas vide n'est pas la limite continue du cas non vide. whereNot('role_id', []) retourne toutes les lignes, y compris celles où role_id IS NULL ; whereNot('role_id', [1]) exclut ces lignes, NULL NOT IN (1) valant NULL et non true. C'est la logique ternaire de SQL, pas une particularité du constructeur.
Un OR exige toujours un groupe. Un groupe de conditions est lié par un seul opérateur : la deuxième condition le fixe, les suivantes doivent l'employer. Mélanger AND et OR dans un même groupe lève une Exception de code Exception::BUILD plutôt que d'émettre du SQL à plat — a AND b OR c serait valide mais signifierait (a AND b) OR c, AND liant plus fort que OR en SQL. Pour exprimer un mélange, il faut grouper explicitement :
QB::create('users') ->whereEq('active', 1) ->groupStart() ->whereEq('role', 'admin') ->orWhereEq('role', 'owner') ->groupEnd() ->read(); // WHERE active = ? AND (role = ? OR role = ?)
Le groupe porte OR en interne et se rattache au parent par AND : deux opérateurs différents, mais jamais dans le même groupe. Sans lui, la chaîne lève.
Détails du comportement des groupes :
- Un groupe vide est supprimé :
groupStart()->groupEnd()ne produit rien et ne fixe pas l'opérateur du parent. Un groupe dont le contenu est conditionnel est donc sûr. - Les groupes s'imbriquent sans limite, chaque niveau ayant son propre opérateur.
- Un
groupEnd()oublié est fermé par le terminal (read(),count(), …). - Un
groupEnd()sansgroupStart()correspondant est sans effet. - Le contrôle porte aussi sur
HAVING, avec ses propreshavingGroupStart()/orHavingGroupStart()/havingGroupEnd().
Regroupement et tri
groupBy(string ...$cols): QBhaving(string $statement, ...$values): QB/orHaving(string $statement, ...$values): QBhavingGroupStart(): QB/orHavingGroupStart(): QB/havingGroupEnd(): QBMêmes règles que les groupes duWHERE.orderAsc(string ...$cols): QB/orderDesc(string ...$cols): QB/orderBy(string ...$cols): QBCumulent dans l'ordre d'appel.limit(int $limit, int $offset = 0): QBUne limite de0est omise, ce qui signifie « pas de limite ».
Données à écrire
add(string $col, $value): QBValeur liée par un paramètre.addRaw(string $col, $value): QBValeur insérée telle quelle dans le SQL.addList(array $data): QB/addListRaw(array $data): QBincrement(string $col, $val = 1): QB/decrement(string $col, $val = 1): QBPassent paraddRaw().
addRaw(), addListRaw(), increment() et decrement() insèrent leur valeur dans le SQL sans paramètre lié ni échappement. Elles existent pour des expressions comme NOW() ou col + 1 : leur passer une donnée utilisateur est une injection SQL directe.
Les arguments de limit() sont également insérés, mais leur typage int rend cela sûr.
Aucun identifiant n'est validé ni échappé : noms de tables, de colonnes, alias, conditions de jointure et instructions brutes passées à where()/having() sont recopiés verbatim. Seules les valeurs sont liées. Ne jamais construire un identifiant depuis un paramètre de requète.
Terminaux
Tous retournent un Query et ne prennent aucun nom de table : la table vient de QB::create().
read(): Querycount(): QueryAlias le résultat ensum, pascount.insert(bool $ignore = false): Queryreplace(): Queryupdate(bool $ignore = false): Querydelete(): QueryinsertAll(array $data, bool $ignore = false): QueryreplaceAll(array $data): Query
Le constructeur n'est pas réinitialisé par un terminal : read() puis count() sur la même instance fonctionnent et partagent les conditions accumulées. C'est pratique pour une requète paginée, mais cela signifie qu'un constructeur réutilisé accumule les clauses — en créer un par requète.
insertAll() et replaceAll() utilisent une mise en forme compacte, différente de celle d'insert() et replace().
Query
Bredala\Database\Query Une chaîne SQL et ses valeurs liées. Implémente Bredala\Database\QueryInterface, qui déclare getStatement(): string et getData(): array.
__construct(string $statement = '', array $data = [])setStatement(string $statement = ''): QueryRogne et normalise à exactement un;final.setData(array $data = []): QuerygetStatement(): stringgetData(): array__toString()Rendu de débogage.
__toString() substitue chaque valeur au ? suivant, en entourant les chaînes de guillemets simples sans échappement : "O'Brien" devient 'O'Brien', du SQL invalide. À n'utiliser que pour déboguer, jamais pour exécuter.
FB
Bredala\Database\FB Constructeur DDL MySQL. Les méthodes de préparation retournent $this, les terminaux retournent une chaîne SQL.
Le constructeur n'est pas réinitialisé par un terminal, même politique que QB : un terminal lit l'état préparé, il ne le consomme pas. Un FB réutilisé accumule donc ses clauses — en créer un par instruction.
Construction
__construct()/create()
Terminaux
createSchema(string $name, string $charset = 'utf8mb4', string $collate = 'utf8mb4_general_ci'): stringdropSchema(string $name): stringÉmetDROP DATABASE.createTable(string $name, string $comment = ""): stringToujoursIF NOT EXISTS.dropTable(string $name): stringrenameTable(string $from, string $to): stringalterTable(string $name): stringApplique les changements préparés.
$name accepte la forme schema.table, les deux parties étant entourées d'accents graves.
Un terminal ne réinitialise pas l'état préparé : il le lit sans le consommer, donc l'appeler deux fois d'affilée rend deux fois la même instruction. Un FB réutilisé accumule ses clauses — en créer un par instruction, comme pour QB.
Colonnes
addColumn(string $name, ?callable $callback = null)Le callback reçoit unColumn.dropColumn(string $name)changeColumn(string $name, ?string $new_name = null, ?callable $callback = null)
Dans un ALTER TABLE, l'ordre d'émission est fixé par le constructeur (les suppressions avant les ajouts), pas par l'ordre d'appel.
Clés
addPrimary(...$names)/dropPrimary()addIndex(string $name, array $cols = [])addUnique(string $name, array $cols = [])addFulltext(string $name, array $cols = [])dropIndex(string $name)/dropUnique(string $name)/dropFulltext(string $name)addFk(string $field, string $target, $delete = 'CASCADE', $update = 'CASCADE')$targetest de la formetable.colonne.dropFk(string $field)addFkIndex(string $field)/dropFkIndex(string $field)
$cols est une map colonne => estCroissant, pas une liste. addIndex('name') est le cas courant et porte sur la colonne du même nom ; addIndex('combo', ['a' => true, 'b' => false]) donne (`a` ASC,`b` DESC). Passer une liste comme ['a'] fait des clés du tableau les noms de colonnes : on indexe silencieusement une colonne nommée 0.
Les noms de clés générés sont `{table}_{name}_{idx|unq|txt|fk}`. Passer le même $name court à l'ajout et à la suppression, pas le nom généré.
La cible d'addFk() doit être de la forme table.colonne : toute autre valeur ('roles', '', 'db.roles.id') lève une Exception de code Exception::PREPARE, et le constructeur reste intact — aucune contrainte n'est enregistrée.
Statiques
quote($value): stringEntoure de guillemets simples.checkIdentifier(string $value): string
quote() guillemette toute valeur, falsy comprise — quote(0) et quote('0') donnent '0', quote('') donne '', quote(false) donne '0' et quote(true) '1' — en doublant les apostrophes internes. Seul null rend une chaîne vide, au sens « aucun littéral à émettre » : c'est ce qui permet aux appelants de décider s'ils écrivent la clause.
checkIdentifier() accepte les identifiants strictement alphanumériques et les retourne inchangés ; tout le reste (_, ., backtick, espace, point-virgule) lève une Exception de code Exception::PREPARE. Deux réserves : la chaîne vide passe la garde de truthiness et ressort telle quelle, et la méthode n'est jamais appelée en interne — FB interpole toujours tables, colonnes et index verbatim. C'est un outil à appeler soi-même en amont, pas une protection automatique.
Column
Bredala\Database\Column Définition de colonne, construite dans un callback d'FB::addColumn() et rendue par __toString(). Par défaut : `nom` VARCHAR(255) NULL DEFAULT NULL. Toutes les méthodes sont fluides.
Types
type(string $type, ...$constraints)int($prefix = '')Le préfixe donneBIGINT,TINYINT, etc.float()decimal(int $precision = 10, int $scale = 2)char(int $len)/varchar(int $len = 255)text($prefix = '')/blob($prefix = '')timestamp()/datetime()/date()/time()bool(bool $default = null)
bool() n'est pas une simple déclaration de type : elle émet TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, donc non signé, non nul et une valeur par défaut d'un seul coup.
Modificateurs
unsigned(bool $value = true)notNull()defaultValue($value, bool $quote = true)ImpliquenotNull().defaultTimestamp($on_update = false)autoIncrement(bool $value = true)Impliqueunsigned()->notNull().comment(string $value)first()/after(string $name)
defaultValue() stocke le littéral SQL à émettre. Un bool est donc converti en 1/0 en amont, comme le fait bool() : defaultValue(false) donne DEFAULT 0. Une chaîne est guillemetée via FB::quote() sauf si $quote vaut false, ce qui permet les expressions (defaultValue('CURRENT_TIMESTAMP', false)).
En revanche defaultValue(null) n'émet aucun DEFAULT tout en appliquant notNull() — la garde de rendu est $default !== null. Pour une colonne nullable, ne pas appeler defaultValue() du tout.
defaultValue() impliquant notNull() et autoIncrement() impliquant unsigned()->notNull(), on ne peut pas obtenir par cette API une colonne nullable avec une valeur par défaut.
L'ordre de sortie est fixe, indépendant de l'ordre d'appel : nom, type, UNSIGNED, NOT NULL/NULL DEFAULT NULL, AUTO_INCREMENT ou DEFAULT x, COMMENT, FIRST/AFTER. first() l'emporte toujours sur after(). Redéclarer le type le remplace silencieusement.
SQLiteBuilder
Bredala\Database\SQLiteBuilder DDL SQLite minimal, indépendant de FB.
__construct(string $name)/create(string $name): staticcol(string $name, string $type, string $opt = ''): staticpk(string ...$columns)fk(string $column, string $dest_table, string $dest_column): staticadd(): string/rename(string $name): string/del(): string/index(string $column): string/unique(string $column): string
Les contraintes issues de pk() et fk() sont séparées par des virgules, comme les colonnes : une table avec une clé primaire et une clé étrangère, ou deux clés étrangères, produit du SQL accepté par SQLite. Rien n'empêche par ailleurs de déclarer les contraintes dans l'argument $opt de col() :
SQLiteBuilder::create('t')->col('id', 'INTEGER', 'PRIMARY KEY')->col('n', 'TEXT')->add();
del() n'a pas de ; final, contrairement aux autres terminaux. Les noms d'index ne portent pas de préfixe de table (idx_{colonne}, unq_{colonne}) alors que les noms d'index sont globaux à la base dans SQLite. L'état n'est pas réinitialisé par un terminal, contrairement à FB.
DBInterface et PDO\DB
Bredala\Database\PDO\DB implémente Bredala\Database\DBInterface. Constantes HOOK_BEFORE_QUERY et HOOK_AFTER_QUERY.
__construct(\PDO $pdo)/create(\PDO $pdo)exec(QueryInterface $query)Exécute unQueryconstruit.query(string $sql)/prepare(string $statement)/bind(...$params)/execute(array $data = [])one()/all()/next()/count()Récupération. Aucune ne prend d'argument.getId(): intDernier identifiant inséré.transaction()/commit()/rollback()use(string $database)escape(string $str): stringdisableFkCheck()/enableFkCheck()Syntaxe MySQL (SET FOREIGN_KEY_CHECKS), rejetée par SQLite.addHook(string $hook, callable $callback)/execHook(string $hook, array $params = [])
exec() retourne le pilote, pas les lignes : il faut chaîner une récupération. Appeler exec() seul exécute l'instruction et jette le jeu de résultats.
one() rend la ligne s'il y en a exactement une, null sinon — zéro ligne comme deux lignes et plus. Pour « la première ligne d'un ensemble », ajouter un limit(1) explicite. La vérification ne peut pas passer par rowCount(), que PDO ne garantit que pour les instructions qui modifient des lignes ; elle sonde une seconde ligne.
count() est PDOStatement::rowCount() : il n'a de sens qu'après un INSERT, UPDATE, DELETE ou REPLACE. Sur un SELECT, sa valeur dépend du pilote — SQLite rend 0 là où MySQL rend le nombre de lignes. Pour compter des lignes, utiliser QB::count() et lire la colonne sum.
Les paramètres sont liés en PDO::PARAM_STR, execute($data) passant le tableau entier à PDOStatement::execute(). Sous MySQL c'est sans conséquence. Sous SQLite, la comparaison fonctionne face à une colonne (son affinité convertit le texte) mais pas face à une expression sans affinité : having('COUNT(*) > ?', 1) compare un entier à '1', or un entier précède toujours le texte dans l'ordre de tri SQLite — la clause ne retient rien. Écrire la valeur en clair (having('COUNT(*) > 1')) lorsqu'elle ne vient pas de l'utilisateur.
Tous les mutateurs retournent DBInterface, donc les appels se chaînent.
$row = $db->exec($query)->one(); $rows = $db->exec($query)->all(); $n = $db->exec($query)->count();
PDO\Factory
Bredala\Database\PDO\Factory
create(string $dsn, $username = null, $password = null, array $options = []): \PDO
Force ERRMODE_EXCEPTION, EMULATE_PREPARES = false et STRINGIFY_FETCHES = false sauf surcharge — passer ses propres $options peut donc réactiver silencieusement les requètes préparées émulées. Une PDOException est enveloppée dans une Bredala\Database\Exception de code CONNECT, donc un catch (PDOException) autour de la connexion ne se déclenche jamais.
SessionHandler
Bredala\Database\SessionHandler Implémente \SessionHandlerInterface.
__construct(DBInterface $driver, array $options = [])Options :table(défautsessions),id(id),time(ts),data(data).
session_set_save_handler(new SessionHandler($db), true); session_start();
La table est à créer soi-même : une colonne d'identifiant (char(40)), un entier non signé pour l'horodatage, et un text/blob pour la charge. Aucune migration n'est fournie.
read() retourne '' pour une session inconnue, ce que PHP interprète comme une session vide et non comme une erreur.
Doc
Bredala\Database\Doc\Database Introspection de schéma.
__construct(DBInterface $db, string $dbname)/create(DBInterface $db, string $dbname)run(): DocTable[]
Doc\DocTable expose $name, $comment et $fields (des DocField). Doc\DocField expose $name, $idx, $type, $ref, $value et $comment.
Interroge l'information_schema de MySQL : non portable vers SQLite ou PostgreSQL.
Exception
Bredala\Database\Exception Étend \Exception. Codes CONNECT (1), PREPARE (2), BIND (3), EXECUTE (4), TRANSACTION (5).
connect(string $msg, ?\Throwable $prev = null)prepare(string $msg, ?\Throwable $prev = null)bind(string $msg, ?\Throwable $prev = null)execute(string $msg, ?\Throwable $prev = null)transaction(string $msg, ?\Throwable $prev = null)
Ces méthodes statiques sont des fabriques, pas des lanceuses : elles construisent et retournent une exception. Les appeler sans throw compile, s'exécute et ne fait rien — c'était précisément le défaut de la validation d'FB::addFk(). Toujours écrire throw Exception::prepare($msg).
Le type d'échec vit dans le code de l'exception, pas dans des sous-classes : tester getCode().
Utilisation
use Bredala\Database\Column; use Bredala\Database\FB; use Bredala\Database\PDO\DB; use Bredala\Database\PDO\Factory; use Bredala\Database\QB; $db = DB::create(Factory::create( 'mysql:host=localhost;dbname=app;charset=utf8mb4', 'user', 'password' )); // Migration -- un FB par instruction $db->query(FB::create() ->addColumn('id', fn(Column $c) => $c->int()->autoIncrement()) ->addColumn('email', fn(Column $c) => $c->varchar(190)->notNull()) ->addColumn('active', fn(Column $c) => $c->bool()) ->addColumn('created_at', fn(Column $c) => $c->timestamp()->defaultTimestamp()) ->addPrimary('id') ->createTable('users', 'Utilisateurs')); $db->query(FB::create()->addUnique('email')->alterTable('users')); // Écriture $db->exec(QB::create('users') ->add('email', 'tom@example.test') ->add('active', 1) ->addRaw('created_at', 'NOW()') ->insert()); $id = $db->getId(); // Lecture $roles = ['admin', 'owner']; $rows = $db->exec(QB::create('users u') ->select('u.id', 'u.email') ->left('roles r', 'r.id = u.role_id') ->whereEq('u.active', 1) ->whereEq('u.deleted_at', null) ->groupStart() ->whereEq('r.name', $roles) ->orWhereEq('u.id', $id) ->groupEnd() ->orderAsc('u.email') ->limit(50) ->read())->all(); // Transaction $db->transaction(); try { $db->exec(QB::create('users')->increment('login_count')->whereEq('id', $id)->update()); $db->commit(); } catch (\Throwable $e) { $db->rollback(); throw $e; }
Tests
composer install vendor/bin/phpunit
Les tests couvrent les constructeurs de chaînes (QB, FB, Column, Query, SQLiteBuilder), Exception et SessionHandler (avec un pilote simulé). PDO\DB, PDO\Factory et Doc\* exigent une connexion réelle et ne sont pas couverts.