Dans un projet d'application Symfony, j'ai dû implémenter une fonctionnalité où un utilisateur devait pouvoir créer et éditer des graphiques interactifs de type ChartJS, et donc de pouvoir sauvegarder en BDD les données du graphique ainsi que la configuration du graphique (libellé, échelle, couleurs des traits, source...) sous forme de JSON.

Au début, pour aller "vite" 😄, j'ai stocké la configuration du graphique dans un simple tableau PHP en tant que propriété $configuration de l'objet Chart, puis j'ai stocké la configuration du graphique en BDD directement dans un champ de type JSON.

Je n'avais alors pas besoin de manipuler les valeurs à l'intérieur de ce JSON.

Le problème qui s'est vite posé c'est que je n'avais alors aucun contrôle sur ce qu'il y avait dans ce JSON, à part savoir que c'était un JSON que je convertissais en array PHP lors d'une récupération depuis la BDD.

Mais progressivement, le besoin métier a évolué et j'ai dû manipuler ce tableau de configuration pour devoir vérifier certaines valeurs, pouvoir en calculer d'autres, et l'idée de manipuler un simple array PHP pour vérifier que telle clé existe, faire des isset() ou des array_key_exists(), et récupérer des valeurs du tableau sans savoir ce qu'il y a dedans m'a posé problème.

Il y avait ces problématiques qui se posaient:

  • qu'est-ce qui se passe si je rajoute un ensemble clé/valeur au tableau, qui n'existait pas avant ?
  • si je reviens dans 6 mois sur le code, comment je saurai quelles types de clés et de valeurs ce tableau peut contenir ?
  • je n'avais pas vraiment de visibilité globale avec un tableau PHP car je n'avais pas de vision globale des propriétés qu'il pouvait contenir.

Pour me faciliter la vie sur le long terme et pour pouvoir gérer cette configuration de graphique de manière plus structurée j'ai décidé donc de transformer ce tableau en un type Doctrine personnalisé (i.e. Custom Doctrine Mapping Type).

Dans cet article je vais vous montrer comment l'utilisation de types custom Doctrine m'a permis d'être plus rigoureux, plus serein et plus productif sur le long terme dans la manipulation de ces données.

Qu'est-ce qu'un type Doctrine ?

Commençons par une petite définition du sujet.

Doctrine est une librairie PHP qui représente une couche d'abstraction entre votre application PHP et la base de données. Elles gère plein de choses comme la connexion à la BDD, le mapping ORM des données, la validation, la conversion SQL, la création des tables, des index, etc.

Les types Doctrine sont la couche d'abstraction qui permettent de traduire les données du format de la base de données vers le format de données typées en PHP, et vice versa.

Le but est de rendre votre application écrite en PHP indépendante du type de base de données que vous utilisez: que ce soit du MySQL Server, MariaDB, PostgreSQL, ou autre.

Ainsi Doctrine 2 a un système intégré qui supporte la conversion des valeurs en PHP vers des valeurs en SQL et inversement, et ce pour n'importe quelle plateforme de base de données.

Par défaut Doctrine intègre des types de données standard qui nous sont familiers:

  • des types numériques (smallint, integer, bigint, decimal, float)
  • des types chaînes de caractères (string, text, guid)
  • des types binaires (binary, blob)
  • le type booléen (boolean)
  • des types date et temps (datetime, time, date, datetime_immutable...)
  • des types array (array, simple_array, json, json_array)
  • le type object (permettant de convertir un objet PHP en format BDD TEXT à l'aide de la serialization/déserialization)

Vous pouvez retrouver toutes les classes de définition des types Doctrine standard dans ce dossier du vendor Doctrine de votre projet: vendor/doctrine/dbal/lib/Doctrine/DBAL/Types .

Mais là où ça devient intéressant c'est qu'on peut créer nos propres types Doctrine personnalisés pour qu'on puisse convertir automatiquement un value object PHP en valeur SQL à insérer en BDD et inversement.

On peut créer nos propres Types Doctrine simplement en étendant la classe Doctrine\DBAL\Types\Type.

Exemple:

<?php
namespace App\Doctrine\Types;

use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use App\Entity\ValueObject\Money;

/**
 * My custom datatype.
 */
class MoneyType extends Type
{
    const NAME = 'money'; // modify to match your type name

    public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform)
    {
        return 'Money';
    }

    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        return new Money($value);
    }

    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        return $value->toDecimal();
    }

    public function getName()
    {
        return self::NAME;
    }
}

Voyons les méthodes auxquelles on a accès:

  • getSQLDeclaration(): permet de déclarer un type SQL vers lequel notre data va être convertie.
  • convertToPHPValue(): c'est la méthode qui permet justement de convertir la donnée SQL en une donnée typée pour PHP, par exemple ici un value object PHP nommé "Money".
  • convertToDatabaseValue(): c'est la méthode inverse de la précédente, comme son nom l'indique elle permet de transformer notre représentation PHP en valeur SQL, qui sera insérée en base.
  • enfin getName(): renvoie le nom que vous voulez donner à votre type lorsque vous voulez l'utiliser dans les annotations de vos entités PHP: @ORM\Column(type="money").

Dans quels cas peut-on utiliser les types Doctrine custom ?

Il y a beaucoup de cas où les types Doctrine personnalisés sont très utiles. On peut par exemple en créer si on a besoin de:

  • stocker de la configuration au format JSON mais qu'on veut convertir ensuite en objet PHP
  • stocker des coordonnées GPS, faire du geofencing...
  • stocker les données et les options d'un graphique
  • stocker des données pour la 3D dans un format précis
  • stocker une liste de données sous forme de tableau json array
  • manipuler des unités de valeur comme des id, des quantités, des montants... en forçant un typage particulier avec un ValueObject PHP
  • stocker un format particulier de données: numéro de téléphone, un code...
  • ou simplement pour typer toute donnée simple string ou int qu'on veut contrôler à l'aide d'un objet PHP, par exemple un id unique (uuid), qui ne doit prendre que certaines valeurs précises et donc qui a besoin d'une validation en PHP.

L'objectif est bien entendu de mieux structurer ses données et les rendre facilement manipulables en PHP.

En utilisant un objet PHP, on peut typer la donnée plus facilement, définir des propriétés, valider l'objet, le manipuler plus aisément, puis le stocker facilement en base en le convertissant au format SQL.

Et quand on voudra récupérer la donnée depuis la BDD, elle va être automatiquement reconvertie par Doctrine en objet PHP avec le typage qu'on aura défini, donc on pourra l'utiliser facilement dans notre code.

Bref, cela rend le code beaucoup plus propre et maintenable.

On va prendre mon exemple de graphique ChartJS.

On a une entité Doctrine que l'on a nommé Chart comme le montre le code ci-dessous:

<?php

namespace App\Entity;

use App\Repository\ChartRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=ChartRepository::class)
 */
class Chart
{
    /**
     * @var int
     *
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=255)
     */
    private $title;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=255)
     */
    private $type;

    /**
     * @var array
     * 
     * @ORM\Column(type="json")
     */
    private $data = [];

    /**
     * @var array
     * 
     * @ORM\Column(type="json")
     */
    private $configuration;

    public function getId(): ?int
    {
        return $this->id;
    }

    ...

Le graphique a un id, un titre, un type (courbe, camembert, histogramme, etc.), un champ data (qui va contenir les données chiffrées du graphique) et un champ $configuration qui va nous servir à stocker tout ce qui touche aux options d'affichage du graphique (par exemple le libellé des axes, la couleur, le min et le max de chaque axe, l'option d'affichage ou non de la grille...).

Ce champ configuration peut être représenté sous la forme d'un JSON de cette manière:

{
    "xAxisLabel": "Années",
    "xAxisMin": 2000,
    "xAxisMax": 2020,
    "yAxisLabel": "Chiffre d'Affaires",
    "yAxisMin": 0,
    "yAxisMax": 1000000,
    "color": "blue",
    "displayGrid": true, 
}

On pourrait stocker ce champ tel quel en SQL au format JSON et utiliser un simple array pour gérer tout ça en PHP, mais cette méthode n'est pas très qualitative, pour plusieurs raisons:

  • déjà on doit manipuler un tableau array et pas un objet PHP, donc il faudra faire des $array['key'] à chaque fois pour récupérer les valeurs du tableau (pas très pratique, imaginez l'enfer si on utilise un tableau multi-dimensionnel 🤯). De plus on n'a pas de vision globale de toutes les propriétés que doit contenir notre tableau. Personnellement je préfère manipuler des objets en PHP avec des getters et setters plutôt que des tableaux, c'est plus simple et on peut forcer le typage des valeurs.
  • avec un tableau il faudra faire des isset() et des array_key_exists() pour voir si telle ou telle clé existe, ça rend le code plus lourd quand on rajoute de nouvelles clés au tableau.
  • le code est plus difficilement testable avec un array qu'avec un objet PHP.
  • le code n'est pas fortement typé, donc on a plus de risque d'erreurs.

On peut bien entendu continuer à utiliser un array et faire ça de façon "quick and dirty", mais sur le long terme ce n'est pas très maintenable, pas testable, pas facilement manipulable, pas fortement typé. Bref, ce n'est pas qualitatif.

L'avantage d'utiliser un objet PHP est que:

  • on sait quelles propriétés contient un objet.
  • on peut plus facilement manipuler, valider, tester cet objet PHP versus un tableau de valeurs.
  • on protège les données en ajoutant du typage et on peut utiliser les getters et les setters de l'objet PHP.

Je vous ai convaincu ?

Alors passons à l'étape de création de notre custom type Doctrine.

Comment Définir un type Doctrine personnalisé dans son application Symfony

Pour notre cas particulier de la configuration du graphique ChartJS on va procéder comme suit:

Etape 1: définition du Value Object en PHP

Dans le répertoire src/Entity/ de notre application on va créer un sous-répertoire appelé ValueObject (src/Entity/ValueObject) où l'on va stocker toutes les classes PHP qui représentent des objets de valeur utiles à nos entités.


Dans ce répertoire on va créer notre classe PHP ChartConfiguration qui va être une représentation PHP orientée objet de notre champ configuration qui était au format JSON.


<?php

namespace App\Entity\ValueObject;

class ChartConfiguration implements \JsonSerializable
{
    /** @var string|null */
    private $xAxisLabel;

    /** @var int */
    private $xAxisMin;

    /** @var int */
    private $xAxisMax;

    /** @var string|null */
    private $yAxisLabel;

    /** @var int */
    private $yAxisMin;

    /** @var int */
    private $yAxisMax;

    /** @var string|null */
    private $color;

    /** @var bool */
    private $displayGrid;

    /**
     * @return string|null
     */
    public function getXAxisLabel(): ?string
    {
        return $this->xAxisLabel;
    }

    /**
     * @param string|null $xAxisLabel
     */
    public function setXAxisLabel(?string $xAxisLabel): void
    {
        $this->xAxisLabel = $xAxisLabel;
    }

    /**
     * @return int
     */
    public function getXAxisMin(): int
    {
        return $this->xAxisMin;
    }

    /**
     * @param int $xAxisMin
     */
    public function setXAxisMin(int $xAxisMin): void
    {
        $this->xAxisMin = $xAxisMin;
    }

    /**
     * @return int
     */
    public function getXAxisMax(): int
    {
        return $this->xAxisMax;
    }

    /**
     * @param int $xAxisMax
     */
    public function setXAxisMax(int $xAxisMax): void
    {
        $this->xAxisMax = $xAxisMax;
    }

    /**
     * @return string|null
     */
    public function getYAxisLabel(): ?string
    {
        return $this->yAxisLabel;
    }

    /**
     * @param string|null $yAxisLabel
     */
    public function setYAxisLabel(?string $yAxisLabel): void
    {
        $this->yAxisLabel = $yAxisLabel;
    }

    /**
     * @return int
     */
    public function getYAxisMin(): int
    {
        return $this->yAxisMin;
    }

    /**
     * @param int $yAxisMin
     */
    public function setYAxisMin(int $yAxisMin): void
    {
        $this->yAxisMin = $yAxisMin;
    }

    /**
     * @return int
     */
    public function getYAxisMax(): int
    {
        return $this->yAxisMax;
    }

    /**
     * @param int $yAxisMax
     */
    public function setYAxisMax(int $yAxisMax): void
    {
        $this->yAxisMax = $yAxisMax;
    }

    /**
     * @return string|null
     */
    public function getColor(): ?string
    {
        return $this->color;
    }

    /**
     * @param string|null $color
     */
    public function setColor(?string $color): void
    {
        $this->color = $color;
    }

    /**
     * @return bool
     */
    public function isDisplayGrid(): bool
    {
        return $this->displayGrid;
    }

    /**
     * @param bool $displayGrid
     */
    public function setDisplayGrid(bool $displayGrid): void
    {
        $this->displayGrid = $displayGrid;
    }

    public static function createFromArray(array $data): self
    {
        $chartConfiguration = new self();
        $chartConfiguration->setXAxisLabel($data['xAxisLabel'] ?? null);
        $chartConfiguration->setXAxisMin($data['xAxisMin'] ?? 0);
        $chartConfiguration->setXAxisMax($data['xAxisMax'] ?? 0);
        $chartConfiguration->setYAxisLabel($data['yAxisLabel'] ?? null);
        $chartConfiguration->setYAxisMin($data['yAxisMin'] ?? 0);
        $chartConfiguration->setYAxisMax($data['yAxisMax'] ?? 0);
        $chartConfiguration->setColor($data['color'] ?? null);
        $chartConfiguration->setDisplayGrid($data['displayGrid'] ?? false);

        return $chartConfiguration; 
    }

    public function jsonSerialize(): array
    {
        return [
            'xAxisLabel' => $this->xAxisLabel,
            'xAxisMin' => $this->xAxisMin,
            'xAxisMax' => $this->xAxisMax,
            'yAxisLabel' => $this->yAxisLabel,
            'yAxisMin' => $this->yAxisMin,
            'yAxisMax' => $this->yAxisMax,
            'color' => $this->color,
            'displayGrid' => $this->displayGrid,
        ];
    }
}

Ici j'ai implémenté l'interface \JsonSerialisable pour pouvoir facilement sérialiser cet objet en tableau JSON par la suite. Mais libre à vous de définir cette classe comme bon vous semble.

Ensuite, la méthode statique createFromArray() va nous servir à créer une instance de ChartConfiguration à partir du JSON stocké dans la base de données.

Etape 2: création du Custom Type Doctrine associé à ce Value Object

Maintenant on va définir la classe représentant le custom type Doctrine dans un répertoire src/Doctrine/Type:

<?php

namespace App\Doctrine\Type;

use App\Entity\ValueObject\ChartConfiguration;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

class ChartConfigurationType extends Type
{
    public const TYPE = 'chart_configuration';

    public function getSQLDeclaration(array $column, AbstractPlatform $platform)
    {
        return $platform->getJsonTypeDeclarationSQL($column);
    }

    public function convertToDatabaseValue($value, AbstractPlatform $platform)
    {
        if ($value === null) {
            return null;
        }

        if (!$value instanceof ChartConfiguration) {
            throw new \Exception('Only App\\Entity\\ValueObject\\ChartConfiguration object is supported.');
        }

        return json_encode($value->jsonSerialize());
    }

    public function convertToPHPValue($value, AbstractPlatform $platform)
    {
        if ($value === null) {
            return $value;
        }

        $data = json_decode($value, true);

        return ChartConfiguration::createFromArray($data);
    }

    public function getName()
    {
        return self::TYPE;
    }

    public function requiresSQLCommentHint(AbstractPlatform $platform)
    {
        return true;
    }

}

C'est là que toute la magie opère: là on vient d'indiquer à Doctrine comment convertir notre objet PHP ChartConfiguration en JSON lors de l'insertion en BDD et de le reconvertir de nouveau en objet PHP lors de la récupération du JSON depuis la BDD.

Remarque: la méthode requiresSQLCommentHint() indique à Doctrine de rajouter le typehint chart_configuration au champ qui va être défini en BDD. Ainsi Doctrine va savoir à quel type correspond notre JSON en base et va pouvoir faire la transformée inverse (du SQL JSON vers l'objet PHP ChartConfiguration) lors de la récupération des données depuis la BDD.

Etape 3: ajout du type Doctrine à la configuration Doctrine du projet.

Pour que Doctrine prenne en compte le nouveau type custom que l'on vient de créer il faut l'ajouter à la config de Doctrine dans Symfony, dans le fichier config.yml du framework, dans la partie doctrinetypes:

doctrine:
    dbal:
        driver:   pdo_mysql
        host:     "%database_host%"
        port:     "%database_port%"
        dbname:   "%database_name%"
        user:     "%database_user%"
        password: "%database_password%"
        charset:  UTF8
        profiling: "%kernel.debug%"
        server_version: "%database_version%"
    ...

    types:
        chart_configuration: App\Doctrine\Type\ChartConfigurationType


Voilà, là on vient de finir la définition du tout nouveau custom type ChartConfigurationType .

On pourra maintenant l'utiliser dans notre entité.


Remarque: pour ceux qui n'utilisent pas le framework Symfony, il faut enregistrer le type dans Doctrine comme ci-dessous:

<?php
Type::addType('chart_configuration', 'App\Doctrine\Type\ChartConfigurationType');
$connection->getDatabasePlatform()->registerDoctrineTypeMapping('ChartConfiguration', 'chart_configuration');

Utiliser le custom type dans son entité

Revenons à notre entité src/Entity/Chart.php et modifions l'annotation ORM de Doctrine au dessus de la propriété $configuration pour la typer en tant que type Doctrine chart_configuration (j'ai volontairement omis les lignes qui ne nous concernent pas):

<?php

namespace App\Entity;

use App\Repository\ChartRepository;
use Doctrine\ORM\Mapping as ORM;
use App\Entity\ValueObject\ChartConfiguration;

/**
 * @ORM\Entity(repositoryClass=ChartRepository::class)
 */
class Chart
{
    ...

    /**
     * @var Chartconfiguration|null
     *  
     * @ORM\Column(type="chart_configuration", nullable=true)
     */
    private $configuration;

    ...

    public function getConfiguration(): ?ChartConfiguration
    {
        return $this->configuration;
    }

    public function setConfiguration(ChartConfiguration $configuration): self
    {
        $this->configuration = $configuration;

        return $this;
    }

Voilà, c'est tout, maintenant on peut utiliser la propriété configuration en tant que ValueObject ChartConfiguration .

Générer la migration Doctrine

Il reste une dernière étape avant de rendre notre code fonctionnel, c'est de générer la migration Doctrine correspondante au changement que l'on vient de faire.

Sur Symfony, lancez la commande php bin/console doctrine:migrations:diff .

Ceci va générer le différentiel de schéma SQL à exécuter afin de synchroniser le code avec la base de données.

On devrait maintenant avoir une nouvelle migration Doctrine:

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
final class Version20211103175238 extends AbstractMigration
{
    public function getDescription() : string
    {
        return '';
    }

    public function up(Schema $schema) : void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->addSql('CREATE TABLE chart (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, data LONGTEXT NOT NULL COMMENT \'(DC2Type:json)\', configuration LONGTEXT DEFAULT NULL COMMENT \'(DC2Type:chart_configuration)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
    }

    public function down(Schema $schema) : void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('DROP TABLE chart');
    }
}


Notez que cette ligne contient la mention configuration LONGTEXT NOT NULL COMMENT \'(DC2Type:chart_configuration)\' . Doctrine va en effet stocker nos données de configuration dans un champ LONGTEXT en BDD, sous forme de texte JSON, mais avec le typehint chart_configuration (cf la méthode requiresSQLCommentHint() définie dans notre ChartConfigurationType). Ainsi Doctrine saura vers quel format PHP convertir le json.

Conclusion:

J'espère que ce premier article vous a aidé à mieux comprendre l'intérêt des types Doctrine custom et que vous aller désormais les implémenter dans vos applications pour vous simplifier la vie et rendre votre code plus maintenable.

Si vous avez des commentaires ou des remarques n'hésites pas à m'en faire part. 🙂