Mikael Paris
Fondateur de Sismic et contributeur du projet Pomm.
Aujourd'hui, lorsque l'on parle des bases de données, nous pensons tout de suite ORM.
Le plus célèbre d'entre eux est sans doute Doctrine.
Mais à quoi sert l'ORM ?
Le but premier de celui-ci est l'abstraction de la base de données. En effet, nul besoin de connaissances en SQL pour persister ou récupérer des données.
Celui-ci permet également de changer facilement de SGBDR. Cependant, qui, dans la vie d'un projet, a besoin de ça
sans refonte ?
Le constat est qu'aujourd'hui, si bon nombre de développeurs font du Symfony avant de faire du PHP, ils sont encore plus nombreux à faire du Doctrine avant de faire du SQL.
Ce qui était, avant, le point de départ d'un projet, est devenu complètement ignoré aujourd'hui.
De plus nous assistons, de nos jours à des courants qui mettent le métier au centre du développement (DDD, Clean Architecture etc..). La base de données exprime "le métier",
et ne pas maîtriser celle-ci va donc à l'encontre de ces courants.
Dans cette introduction, il me reste un faux combat à résoudre : MySql Vs PostgreSQL. De nombreux articles traitent déjà le sujet depuis bien longtemps.
Si vous êtes malgré tout persuadés que MySQL peut rivaliser avec PostgreSQL, nous allons découvrir dans cet article quelques concepts qui devraient vous convaincre du contraire.
Pomm est une alternative aux ORM. Il se définit comme un gestionnaire de modèle objet. La principale ligne de conduite du projet est d'être la moins intrusive possible et la plus simple. Ainsi, vous l'oublierez rapidement pour vous concentrer sur PostgreSQL. Pomm vous permettra de reprendre le contrôle de votre SGBDR et de comprendre ce qu'il s'y passe afin de gagner en performance et bien plus.
C'est la brique principale qui s'interface avec PostgreSQL. Elle est composée de sessions qui, elles-mêmes, embarquent des clients :
Cette brique est une extension de la première, c'est un client de la session au même titre que les précédents. Ce client apporte la modélisation objet de la base.
Il nous facilite la vie en générant les différents objets pour le ModelManager
par exemple. Des commandes d'inspection sont également disponibles pour éviter d'ouvrir une console psql.
Pomm propose un bundle pour faciliter son intégration dans un projet Symfony : https://github.com/pomm-project/pomm-bundle
Celui-ci respecte également le principe de Pomm, être le moins intrusif, le plus transparent et simple.
Il intègre tout ce dont on a besoin pour développer avec Symfony :
Le but de ce tutoriel sera de créer un mini système d'événements et de réservations. Cela nous permettra de passer en revue des concepts PostgreSQL et Pomm.
Pour la suite de l'article, j'essaierai de ne pas complexifier l'application pour me concentrer sur l'essentiel du sujet.
Dépôt Github du tutoriel
J'utilise la version 3.4 de symfony pour ce tutoriel.
composer require pomm-project/pomm-bundle
Avec Flex, le reste se fait tout seul. Pour ceux qui n'utilisent pas Flex nous restons sur les classiques d'ajout au Kernel etc. Tout est expliqué ici : Setup Pomm-Bundle
pomm:
configuration:
db:
dsn: "%env(DATABASE_URL)%"
session_builder: App\SessionBuilder\DbSessionBuilder
class DbSessionBuilder extends SessionBuilder
{
protected function initializeConverterHolder(ConverterHolder $converter_holder)
{
parent::initializeConverterHolder($converter_holder);
$converter_holder
->registerConverter('Hstore', new PgHstore(), ['public.hstore']);
}
}
Contrairement aux ORM, Pomm ne construit pas la base de données depuis des fichiers XML ou grâce à des annotations. La principale raison ?
PostgreSQL est le meilleur outil pour faire cela et il prend en compte des éléments ignorés par Pomm ( ACL, tablespaces etc.)
Cela vous permettra également de gros gains, en pouvant créer, par exemple, des index.
Voici le fichier SQL utilisé pour la création de la base qui nous servira pour le tutoriel :
database.sql
Utilisation des schémas
CREATE SCHEMA application;
Pour ceux qui ne connaissent pas les schémas, cela permet d'organiser vos tables en groupes logiques
Utilisation des types : HSTORE, JSONB, TSRANGE, POINT
Ce sont des types disponibles dans PostgreSQL qui viennent enrichir ceux que vous connaissez déjà.
bin/console pomm:generate:schema-all db application -d ./src -a 'App' --psr4
Cette commande crée les fichiers nécessaires à Pomm depuis la base de données.FlexibleEntity
: ce sont nos objets métiers qui seront le reflet de la projection de la requête.Model
: ils permettent d'intéragir avec la base.Structure
: c'est la définition de la table qui permettra à Pomm de mapper les types.
La liste des commandes disponibles dans Pomm :
Voila nous sommes prêts, nous pouvons y aller !
$events = $this->get('pomm')
->getDefaultSession()
->getModel(EventModel::class)
->findAll();
/* src/Db/ApplicationSchema/EventModel.php */
public function findWithCategoryAndNbRegister()
{
$sql = <<<SQL
SELECT
:projection
FROM :event event
INNER JOIN :category category USING (category_id)
LEFT JOIN :register register USING (event_id)
GROUP BY event_id, category.*
SQL;
$projection = $this->createProjection()
->setField('category', 'category', '\App\Db\ApplicationSchema\Category')
->setField('nb_register', 'count(register)', 'int4')
->setField('nb_day', 'extract(DAY FROM upper(event.timespan) - current_date)', 'int4');
$sql = strtr($sql,
[
':projection' => $projection->formatFieldsWithFieldAlias('event'),
':event' => $this->getStructure()->getRelation(),
':category' => $this->getSession()
->getModel(CategoryModel::class)
->getStructure()
->getRelation(),
':register' => $this->getSession()
->getModel(RegisterModel::class)
->getStructure()
->getRelation()
]
);
return $this->query($sql, [], $projection);
}
$events = $this->get('pomm')
->getDefaultSession()
->getModel(EventModel::class)
->findWithCategoryAndNbRegister();
Ici nous reprenons le contrôle en écrivant nous-même la requête bien que celle-ci est simple. Nous avons enrichi
notre projection en lui ajoutant des champs. Cette fonction renvoie un itérateur de FlexibleEntity.
En ne maîtrisant pas correctement l'ORM que nous utilisons, il n'est pas rare que pour récupérer deux éléments liés, nous fassions deux requêtes sans même le savoir.
Alors que sur des boucles qui présentent des listes, les requêtes sont multipliées par le nombre de résultats.
findAll() | findWithCategoryAndNbRegister() | |
=> |
C'est là que le nom de Flexible prend tout son sens. En effet, l'objet retourné est maitenant la représentation de cette nouvelle projection.
Nous remarquons, au passage, les typages (DateTime, TsRange, Point, Array ...).
C'est donc notre SGBDR qui fait le travail, ou plutôt qui fait son travail en facilitant le nôtre côté PHP.
/* src/Controller/RegistrationController.php */
public function process(Request $request)
{
$register = new Register([
'event' => null,
'lastname' => null,
'firstname' => null,
'email' => null
]);
$form = $this->createForm(RegistrationType::class, $register);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->get('pomm')
->getDefaultSession()
->getModel(RegisterModel::class)
->insertOne($register);
return $this->redirectToRoute('event_list');
}
return $this->render('registration/create.html.twig', array(
'form' => $form->createView(),
));
}
Grâce au Model, je crée un objet FlexibleEntity d'inscription (Register). Celui-ci étant vide, puisque sans projection, je lui
fournis la liste des propriétés qui le composeront.
Le module Form a donc tout ce qu'il faut pour associer le formulaire avec l'objet.
Pour mon rattachement à l'événement j'utilise un Listener.
N'ayant pas de Type spécifique pour Pomm, j'utilise le ChoiceList qui me permet d'indiquer les éléments à prendre en compte.
/* src/Form/EventListener/AddEventFieldSubscriber.php */
public function preSetData(FormEvent $event)
{
$form = $event->getForm();
$form->add('event', ChoiceType::class, [
'label' => 'Evenement',
'choices' => $this->getEvents(),
'choice_label' => function($elt) {
return $elt->getName()['fr'];
},
'placeholder' => 'Choisir un événement',
'choice_value' => function($elt) {
if ($elt !== null) {
return $elt->getEventId();
}
},
'required' => true,
'attr' => ['class'=>'form-control']
]);
}
public function postSubmit(FormEvent $event)
{
$data = $event->getData();
if ($data->getEvent() != null) {
$data->set('event_id', $data->getEvent()->getEventId());
}
return;
}
private function getEvents()
{
return $this->pomm
->getDefaultSession()
->getModel(EventModel::class)
->findAll();
}
/* src/Controller/EditRegistrationController.php */
$this->get('pomm')
->getDefaultSession()
->getModel(RegisterModel::class)
->updateOne(
$register,
['lastname', 'firstname', 'email', 'event_id']
);
Pour l'édition, pas de changements notables si ce n'est que nous spécifions les colonnes à mettre à jour. Dans la v2.1 du ModelManager, ce tableau
sera optionnel, et seules les colonnes qui auront changé d'état seront mises à jour.
Il est également possible d'utiliser les 'Param Converter' de Symfony pour récupérer l'élément (Voir la doc).
/* src/Controller/DeleteRegistrationController.php */
public function process(Request $request, $registerId)
{
$register = $this->get('pomm')
->getDefaultSession()
->getModel(RegisterModel::class)
->deleteByPK(['register_id' => $registerId]);
return $this->redirectToRoute('event_registration_list');
}
Pour la suppression, c'est du basique !
Une des questions qui revient souvent sur Pomm : comment récupérer plusieurs éléments liés ?
La difficulté, pour répondre à cette question, est d'accepter de ne pas penser ORM.
Nous allons récupérer la liste des évènements avec leurs inscrits.
/* src/Db/ApplicationSchema/EventModel */
public function findWithRegister()
{
$sql = <<<SQL
SELECT
:projection
FROM :event event
LEFT JOIN :register register USING (event_id)
GROUP BY event_id
SQL;
$projection = $this->createProjection()
->setField('registers', 'array_agg(register)', '\App\Db\ApplicationSchema\Register[]');
$sql = strtr($sql,
[
':projection' => $projection->formatFieldsWithFieldAlias('event'),
':event' => $this->getStructure()->getRelation(),
':register' => $this->getSession()
->getModel(RegisterModel::class)
->getStructure()
->getRelation()
]
);
return $this->query($sql, [], $projection);
}
Résultat :
De nos jours, beaucoup d'applications nécessitent le multilingue. Avec un ORM, généralement, cela passe par une table de liaison de traductions.
Et là, c'est le drame ! En effet, votre base double de volume et des liaisons se forment dans tous les sens. Un enfer pour le développeur !
Pourquoi ? Avec PostgreSQL vous avez le choix depuis bien longtemps de stocker en Hstore ou en JSONB. C'est ce premier que je vais exposer ici.
Nous pourrions stocker en JSONB mais je pense que cela équivaut à sortir la machine de guerre pour stocker un tableau à une seule dimension.
C'est ce que gère parfaitement HSTORE.
Nous reprenons la dernière requête pour ne récupérer que la locale FR.
/* src/Db/ApplicationSchema/EventModel */
public function findWithRegister()
{
$sql = <<<SQL
SELECT
:projection
FROM :event event
LEFT JOIN :register register USING (event_id)
GROUP BY event_id
SQL;
$projection = $this->createProjection()
->setField('name', "name -> 'fr'", 'varchar')
->setField('registers', 'array_agg(register)', '\App\Db\ApplicationSchema\Register[]');
$sql = strtr($sql,
[
':projection' => $projection->formatFieldsWithFieldAlias('event'),
':event' => $this->getStructure()->getRelation(),
':register' => $this->getSession()
->getModel(RegisterModel::class)
->getStructure()
->getRelation()
]
);
return $this->query($sql, [], $projection);
}
Avec HSTORE | En spécifiant la locale | |
=> |
Dans nos applications, nous avons régulièrement besoin d'afficher des statistiques.
Je crois que c'est à ce moment-là que j'ai détesté les ORM.
Avec Pomm, utiliser le ModelManager serait la même chose. En effet, un tableau de statistiques est avant tout un tableau, alors pourquoi vouloir modéliser celui-ci en objet ?
Ressortons le nombre d'inscrits par mois par événement :
/* src/Controller/StatisticsController.php */
public function show()
{
$sql = <<<SQL
SELECT
to_char(register.created_at, 'month') as "month",
count(register.register_id) as nb_register
FROM application.register register
GROUP BY ROLLUP (to_char(register.created_at, 'month'))
SQL;
$result = $this->get('pomm')
->getDefaultSession()
->getQueryManager()
->query($sql, []);
return $this->render('register/statistics.html.twig', array(
'result' => $result
));
}
Grâce au client query_manager
, je peux envoyer une requête directement à PostgreSQL et en recevoir l'itérateur.
Pour les Rollup, je vous laisse les découvrir sur la doc PostgreSQL (voir la doc).
Attention vous pourriez avoir envie de faire du SQL 😛.
(Il va de soi que mettre la requête directement dans le Contrôleur est à proscrire.)
Oui c'est moche, mais le résultat est bluffant non ? 😛
Je ne suis pas là pour dire que c'était mieux avant, au contraire, toutefois je pense que nous avons boudé trop longtemps
un language pas à la mode et que nous essayons de nous convaincre qu'un outil pourrait le faire à notre place.
L'abstraction des bases de données par les ORM a engendré un certain nombre de problèmes dont le plus notable est le manque de performances des requêtes.
Pour répondre à cette problématique nous avons redimensionné les serveurs ou créé des paliatifs. Par exemple, bon nombre d'applications mettent en place
des systèmes de cache évolués pour résoudre des problèmes de performances avec leurs SGBDR.
Des moteurs de recherche évolués ont vu le jour alors que c'est un travail que gère très bien PostgreSQL, même avec beaucoup de données.
J'ai commencé l'apprentissage de Pomm il y a maintenant 2 ans. Celà a été assez rapide à prendre en main, environ 2 semaines pour commencer à ne plus souffrir.
La plus grande difficulté reste dans le fait de penser différement qu'avec un ORM et de prendre le temps de lire la documentation de PostgreSQL.
PommBundle sera bientôt compatible avec symfony 4, nous travaillons activement dessus !