sugatasei / bredala-data
PHP modeling and data manipulation class
Requires
- php: >=8.5
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^9.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Outils de modélisation et de manipulation de données pour PHP.
Pas d'ORM, pas de constructeur de requètes, pas de persistance, pas de validation, pas de relations : uniquement un modèle abstrait avec conversion tableau/JSON, un contrat d'adaptateur vers un stockage, un micro-cache de durée de vie du script, et un lecteur de métadonnées de classe.
Installation
composer require sugatasei/bredala-data
Claude Code
Ce package fournit un skill Claude Code dans skills/bredala-data/ qui documente les patterns d'usage et les pièges de la librairie (import() qui ignore silencieusement les clés inconnues, export() qui passe par un aller-retour JSON, mémoïsation non invalidée de Reflection, etc.).
Dans un projet qui dépend de sugatasei/bredala-data, 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-data/skills/bredala-data .claude/skills/bredala-data
ModelAbstract
Bredala\Data\ModelAbstract Classe de base pour créer des entités, des modèles, des DTO.
Elle implémente Bredala\Data\ModelInterface, qui étend lui-même JsonSerializable. La surface sérialisée d'un modèle est exactement l'ensemble de ses propriétés publiques, non statiques et déclarées. Les propriétés protected, private et static en sont exclues : c'est le mécanisme pour garder des données internes hors du payload.
use Bredala\Data\ModelAbstract; class User extends ModelAbstract { public ?int $id = null; public string $email = ''; public string $name = ''; }
Chaque propriété publique doit avoir une valeur par défaut : la sérialisation les lit toutes, et une propriété typée non initialisée lève une Error.
Construction
__construct(array $data = [])Appelleinit()puisimport($data).create(array $data = [])Équivalent statique denew static($data).
Initialisation
init(): staticPoint d'extension pour les valeurs par défaut qu'une valeur littérale ne peut pas exprimer. Ne fait rien dans la classe de base.
init() s'exécute avant import() : une donnée entrante écrase donc toujours ce qu'init() a posé, et ce qu'init() a posé survit quand la clé est absente.
class Post extends ModelAbstract { public string $slug = ''; public string $createdAt = ''; public function init(): static { $this->createdAt = date('Y-m-d H:i:s'); return $this; } }
Import
import(array $data = []): staticAffecte chaque clé du tableau à la propriété de même nom.
import() filtre les clés reçues contre properties(), c'est-à-dire les propriétés publiques non statiques déclarées. Tout le reste — clé inconnue, propriété protected, private ou static — est silencieusement ignoré : aucune propriété dynamique, aucune dépréciation, aucune exception. Une faute de frappe dans une clé passe donc inaperçue ; le champ est simplement absent en aval.
Pour alimenter une propriété non publique, surcharger import() et faire le mappage à la main.
La liste blanche porte sur la visibilité, pas sur la sensibilité : id, role et consorts restent écrivables depuis les données brutes dès lors qu'ils sont publics. Un type incorrect lève une TypeError.
Pour des données non maîtrisées (corps de requète, API externe), restreindre en amont aux champs réellement modifiables — properties() ne suffit pas ici, puisque c'est déjà le filtre appliqué par import() :
$editable = ['email', 'name']; // ni id, ni role $user->import(array_intersect_key($input, array_flip($editable)));
Export
export(): arrayRetourne un tableau de scalaires. Implémenté par un aller-retourjson_encode/json_decode.jsonSerialize(): mixedRetourne les propriétés telles quelles.properties(): arrayRetourne les noms des propriétés sérialisées, dans l'ordre de déclaration (propriétés propres avant propriétés héritées).
Les deux exports ne donnent pas la même forme : export() convertit récursivement les modèles imbriqués en tableaux, alors que jsonSerialize() conserve les objets.
$user = User::create(['email' => 'a@b.c', 'name' => 'Tom']); $user->export(); // ['id' => null, 'email' => 'a@b.c', 'name' => 'Tom'] json_encode($user); // {"id":null,"email":"a@b.c","name":"Tom"}
export() hérite des limites de json_encode : une ressource, une closure, NAN/INF ou une référence récursive font échouer l'encodage, et le type de retour : array transforme le résultat en TypeError portant sur la signature d'export() — pas en JsonException nommant la propriété fautive.
AdapterAbstract
Bredala\Data\AdapterAbstract Conversion d'un modèle vers un stockage et inversement. Implémente Bredala\Data\AdapterInterface.
encode(mixed $input): mixedEncode un modèle pour le stockage. Identité par défaut.decode(mixed $input): mixedDécode une représentation de stockage. Identité par défaut.unique(array $rows, string $property): arrayRetourne les valeurs distinctes d'une colonne, réindexées.
Les deux conversions sont l'identité par défaut, pas abstraites : une sous-classe qui oublie d'en redéfinir une laisse passer les données telles quelles sans erreur.
use Bredala\Data\AdapterAbstract; class UserAdapter extends AdapterAbstract { public function encode(mixed $input): mixed { return ['id' => $input->id, 'email' => $input->email, 'full_name' => $input->name]; } public function decode(mixed $input): mixed { return User::create([ 'id' => $input['id'] ?? null, 'email' => $input['email'] ?? '', 'name' => $input['full_name'] ?? '', ]); } }
unique() sert à collecter les clés étrangères distinctes d'un jeu de résultats pour alimenter une seule requète IN (…) :
$authorIds = UserAdapter::unique($postRows, 'author_id');
Elle fonctionne sur des tableaux de tableaux comme sur des tableaux d'objets (propriétés publiques). Le second argument est optionnel : omis, unique() déduplique directement le tableau reçu. Les lignes dépourvues de la colonne sont ignorées silencieusement. null, '' et [] sont écartés. La comparaison passe par array_unique() en SORT_REGULAR, donc en == : 1 et '1' fusionnent toujours, comme 0 et false. C'est le comportement voulu pour une colonne d'identifiants ; pour une déduplication stricte, comparez vous-même en ===.
Reflection
Bredala\Data\Reflection Lecture mémoïsée des métadonnées d'une classe. C'est ce qu'utilise ModelAbstract::properties().
getInstance(string $classname): staticRetourne le lecteur mémoïsé pour cette classe. Le constructeur estprotected: c'est le seul point d'entrée.properties(): arrayRetourne les noms des propriétés publiques non statiques.hasProperty(string $key): boolmethods(): arrayRetourne les noms des méthodes publiques non statiques.hasMethod(string $key): bool
Les instances sont mises en cache par nom de classe, sans invalidation. La classe est inspectée, jamais une instance : une propriété ajoutée dynamiquement n'est jamais listée. Un nom de classe inconnu lève une ReflectionException dès getInstance(), et n'est pas mis en cache. Les interfaces sont acceptées.
methods() inclut les méthodes publiques héritées : sur une sous-classe de ModelAbstract, import, export, init, properties, jsonSerialize et __construct apparaissent à côté des méthodes propres.
Utilisation
use Bredala\Data\AdapterAbstract; use Bredala\Data\ModelAbstract; class User extends ModelAbstract { public ?int $id = null; public string $email = ''; public string $name = ''; public function init(): static { $this->name = 'anonyme'; return $this; } } class UserAdapter extends AdapterAbstract { public function decode(mixed $input): mixed { return User::create([ 'id' => (int) $input['id'], 'email' => $input['email'], 'name' => $input['full_name'], ]); } } $adapter = new UserAdapter(); $rows = [ ['id' => '1', 'email' => 'tom@example.test', 'full_name' => 'Tom'], ['id' => '2', 'email' => 'tim@example.test', 'full_name' => 'Tim'], ]; $users = array_map(fn(array $row) => $adapter->decode($row), $rows); foreach ($users as $user) { print_r($user->export()); }
Tests
composer install vendor/bin/phpunit