Programmation PHP/Symfony/Doctrine


InstallationModifier

Doctrine est l'ORM par défaut de Symfony. Il utilise PDO. Son langage PHP traduit en SQL est appelé DQL, et utilise le principe de la chaîne de responsabilité.

Installation en SF4[1] :

composer require symfony/orm-pack
composer require symfony/maker-bundle --dev

Renseigner l'accès au SGBD dans le .env :

DATABASE_URL="mysql://mon_login:mon_mot_de_passe@127.0.0.1:3306/ma_base"

Ensuite la base de données doit être créée avec :

php bin/console doctrine:database:create
À faire... 

Différences avec :

  • composer require doctrine/orm
  • composer require doctrine/doctrine-bundle


Commandes DoctrineModifier

Exemples de commandes :

php bin/console doctrine:query:sql "SELECT * FROM ma_table"
php bin/console doctrine:query:sql "$(< mon_fichier.sql)"
php bin/console doctrine:cache:clear-metadata
php bin/console doctrine:cache:clear-query 
php bin/console doctrine:cache:clear-result

EntityModifier

Une entité est une classe PHP associée à une table de la base de données. Elle est composée d'un attribut par colonne, et de leurs getters et setters respectifs. Pour en générer une :

php bin/console generate:doctrine:entity

Cette association est définie par des annotations Doctrine. Pour vérifier les annotations :

php bin/console doctrine:schema:validate

ExempleModifier

Voici par exemple plusieurs types d'attributs :

/**
* @ORM\Entity()
* @ORM\Table(name="word")
*/
class Word
{
    /**
    * @ORM\Id
    * @ORM\Column(name="id", type="integer", nullable=false)
    * @ORM\GeneratedValue(strategy="IDENTITY")
    */
    private $id;

    /**
    * @ORM\Column(name="pronunciation", type="string", length=100, nullable=true)
    */
    private $pronunciation;

    /**
     * @var Language
     *
     * @ORM\ManyToOne(targetEntity="Language", inversedBy="words")
     * @ORM\JoinColumn(name="language_id", referencedColumnName="id")
     */
    protected $language;

    /**
     * @var ArrayCollection
     *
     * @ORM\OneToMany(targetEntity="Homophon", mappedBy="word", cascade={"persist", "remove"})
     */
    private $homophons;


    public function __construct()
    {
        $this->homophons = new ArrayCollection();
    }

    public function setPronunciation($p)
    {
        $this->pronunciation = $p;

        return $this;
    }

    public function getPronunciation()
    {
        return $this->pronunciation;
    }

    public function setLanguage($l)
    {
        $this->language = $l;

        return $this;
    }

    public function getLanguage()
    {
        return $this->language;
    }

    public function addHomophons($homophon)
    {
        if (!$this->homophons->contains($homophon)) {
            $this->homophons->add($homophon);
            $homophon->setWord($this);
        }
        return $this;
    }
}

On voit ici que la table "word" possède trois champs : "id" (clé primaire), "pronunciation" (chaine de caractère) et "language_id" (clé étrangère vers la table "language"). Doctrine stockera automatiquement l'id de la table "language" dans la troisième colonne quand on associera une entité "Language" à une "Word" avec $word->setLanguage($language).

Le quatrième attribut permet juste de récupérer les enregistrements de la table "homophon" ayant une clé étrangère pointant vers "word".

Par ailleurs, en relation "OneToMany", c'est toujours l'entité ciblée par le "Many" qui définit la relation car elle contient la clé étrangère. Elle contient donc l'attribut "inversedBy=", alors que celle ciblée par "One" contient "mappedBy=". Elle contient aussi une deuxième annotation @ORM\JoinColumn mentionnant la clé étrangère en base de données (et pas en PHP).

  Dans les relations *toMany :
  • il faut initialiser l'attribut dans le constructeur en ArrayCollection().
  • on peut avoir une méthode ->set(ArrayCollection) mais le plus souvent on utilise ->add(un seul élément)
  • cette méthode add() doit idéalement contenir le set() de l'entité cible vers la courante (pour ne pas avoir à l'ajouter après chaque appel).


NB : par défaut la longueur des types "string" est 255, on peut l'écraser ou la retirer avec length=0[2]. Le type "text" par contre n'a pas de limite.

TriggersModifier

Les opérations en cascade sont définies sous deux formes d'annotations :

  • cascade={"persist", "remove"} : au niveau ORM.
  • onDelete="CASCADE" : au niveau base de données.

Concepts avancésModifier

Pour utiliser une entité depuis une autre, alors qu'elles n'ont pas de liaison SQL, il existe l'interface ObjectManagerAware[3].

Pour ajouter des triggers sur la mise à jour d'une table, ajouter l'annotation suivante dans son entité : @ORM\HasLifecycleCallbacks().

  Les types des attributs peuvent être quelque peu différents du SGBD[4].


  Dans le cas de jointure vers une entité d'un autre espace de nom (par exemple une table d'une autre base), il faut indiquer son namespace complet dans l'annotation Doctrine (car elle ne tient pas compte des "use").


EntityManagerModifier

L'EntityManager (em) est l'objet qui synchronise les entités avec la base de données. Une application doit en avoir un par base de données, définis dans doctrine.yaml.

Il possède trois méthodes pour cela :

  • persist() : prépare un INSERT ou un UPDATE SQL.
  • remove() : prépare un DELETE SQL.
  • flush() : exécute le code SQL préparé.

Il existe aussi les méthodes suivantes :

  • merge() : fusionne une entité absent de l'em dedans.
  • refresh() : rafraichit l'entité PHP à partir de la base de données. C'est utile par exemple pour tenir compte des résultats d'un trigger after insert. Exemple si le trigger ajoute une date de création à écraser par $createdDate :
        $entity = new MyEntity();
        $em->persist($entity);
        $em->flush($entity);
        $em->refresh($entity);
        $entity->setCreatedDate($createdDate);
        $em->flush($entity);

RepositoryModifier

On appelle "repository" les classes PHP qui contiennent les requêtes pour la base de données. Elles héritent de Doctrine\ORM\EntityRepository. Chacune permet de récupérer une entité associée en base de données. Les repo doivent donc être nommés NomDeLEntitéRepository.

 d'un point de vue architectural, avant d'instancier une nouvelle entité, on utilise généralement le repository pour savoir si son enregistrement existe en base ou si on doit le créer. Dans ce deuxième cas, la bonne pratique en DDD est d'utiliser une Factory pour faire le new de l'entité, mais aussi pour les new de son agrégat si elle est le nœud racine. Par exemple une CarFactory fera un new Car() mais aussi créera et lui associera ses composants : new Motor()...

SQLModifier

Depuis DoctrineModifier

$rsm = new ResultSetMapping();
$this->_em->createNativeQuery('call my_stored_procedure', $rsm)->getResult();

Sans DoctrineModifier

Pour exécuter du SQL natif dans Symfony sans Doctrine, il faut créer un service de connexion, par exemple qui appelle PDO en utilisant les identifiants du .env, puis l'injecter dans les repos (dans chaque constructeur ou par une classe mère commune) :

return $this->connection->fetchAll($sql);

Depuis un repository Doctrine, tout ceci est déjà fait et les deux techniques sont disponibles :

1. Par l'attribut entity manager (em, ou _em pour les anciennes versions) hérité de la classe mère (le "use" permettra ici d'appeler des constantes pour paramétrer le résultat) :

use Doctrine\DBAL\Connection;
...
$statement = $this->_em->getConnection()->executeQuery($sql);
$statement->fetchAll(\PDO::FETCH_KEY_PAIR);
$statement->closeCursor();
$this->_em->getConnection()->close();

return $statement;

2. En injectant le service de connexion dans le constructeur ('@database_connection') :

use Doctrine\DBAL\Connection;
...
return $this->dbalConnection->fetchAll($sql);

DQLModifier

Méthodes magiquesModifier

Doctrine peut ensuite générer des requêtes SQL à partir du nom d'une méthode PHP appelée mais non écrite dans les repository (car ils en héritent). Ex :

  • $repo->find($id) : cherche par la clé primaire définie dans l'entité.
  • $repo->findAll() : récupère tous les enregistrements (sans clause WHERE).
  • $repo->findById($id) : engendre automatiquement un SELECT * WHERE id = $id dans la table associée au repo.
  • $repo->findBy(['lastname' => $lastname, 'firstname' => $firstname]) engendre automatiquement un SELECT * WHERE lastname = $lastname AND firstname = $firstname.
  • $repo->findOneById($id) : engendre automatiquement un SELECT * WHERE id = $id LIMIT 1.
  • $repo->findOneBy(['lastname' => $lastname, 'firstname' => $firstname]) : engendre automatiquement un SELECT * WHERE lastname = $lastname AND firstname = $firstname LIMIT 1.
  Lors des tests unitaires PHPUnit, il est probable qu'une erreur survienne sur l'inexistence de méthode "findById" pour le mock du repository (du fait qu'elle est magique). Il vaut donc mieux utiliser findBy().


Par ailleurs, on peut compléter les requêtes avec des paramètres supplémentaires. Ex :

$repo->findBy(
    ['lastname' => $lastname], // where
    ['lastname' => 'ASC'],     // order by
    10,                        // limit
    0,                         // offset
);
QueryBuilderModifier

Les méthodes des repos font appel createQueryBuilder() :

public function findAllWithCalculus()
{
    return $this->createQueryBuilder('mon_entité')
        ->where('id < 3')
        ->getQuery()
        ->getResult()
    ;
}

Pour éviter le SELECT * dans cet exemple, on peut y ajouter la méthode ->select().

JointuresModifier

Quand deux entités ne sont pas reliées entre elles, on peut tout de même lancer une jointure en DQL :

use Doctrine\ORM\Query\Expr\Join;
...
    ->join('AcmeCategoryBundle:Category', 'c', Expr\Join::WITH, 'v.id = c.id')

RésultatsModifier

Doctrine renvoie des objets avec leurs méthodes (get pas set) avec getResult, ou un tableau avec getArrayResult, ou 2D avec getScalar.

  • getResult() renvoie un objet ArrayCollection, pour rechercher dedans : ->contains().

Patrons à copier-collerModifier

À faire... 


  • Connexion à chaque SGBD Doctrine : MSSQL + GUI Linux, MariaDB, Webdis, MySQL
  • Fonctions injectées avec $qb->expr()
  • transactional()


ÉvènementsModifier

prePersistModifier

Se produit avant la persistance d'une entité.

postPersistModifier

Se produit après la persistance d'une entité.

postFlushModifier

Se produit après la sauvegarde d'une entité.

preFlushModifier

Se produit avant la sauvegarde d'une entité.

 
  • Dans cet évènement, les attributs en lazy loading de l'entité flushée s'ils sont appelés, sont issus de la base de données et donc correspondent aux données écrasées (et pas aux nouvelles flushées).
  • Si on flush l'entité qui déclenche cet évènement il faut penser à un dispositif anti-boucle infinie (ex : variable d'instance).
  • Dans le cas d'un new sur une entité, le persist ne suffit pas pour préparer sa sauvegarde. Il faut alors appeler $unitOfWork->computeChangeSet($classMetadata, $entity)[5].


MigrationsModifier

Pour modifier la base de données avec une commande, par exemple pour ajouter une colonne à une table ou modifier une procédure stockée, il existe une bibliothèque qui s'installe comme suit :

composer require doctrine/doctrine-migrations-bundle

CréationModifier

Ensuite, on peut créer un squelette de "migration" :

php bin/console doctrine:migrations:generate

Cette classe comporte une méthode "up()" qui réalise la modification en SQL ou DQL, et une "down()" censée faire l'inverse à des fins de rollback. De plus, on ne peut pas lancer deux fois de suite le "up()" sans un "down()" entre les deux (une table nommée migration_versions enregistre leur succession).

Exemple :

    public function up(Schema $schema) : void
    {
        $this->connection->fetchAll('SHOW DATABASES;');
        $this->addSql('CREATE TABLE...');
    }

ExécutionModifier

La commande suivante exécute toutes les migrations qui n'ont pas encore été lancées dans une base :

php bin/console doctrine:migrations:migrate

Sinon, on peut les exécuter une par une selon le paramètre, avec la partie variable du nom du fichier de la classe (timestamp) :

php bin/console doctrine:migrations:execute --up 20170321095644'

Pour le rollback :

php bin/console doctrine:migrations:execute --down 20170321095644'

Pour éviter que Doctrine pose des questions durant les migrations, ajouter --no-interaction.

Sur plusieurs bases de donnéesModifier

Pour exécuter sur plusieurs bases :

 php bin/console doctrine:migrations:migrate --em=em1 --configuration=src/DoctrineMigrations/Base1/migrations.yaml
 php bin/console doctrine:migrations:migrate --em=em2 --configuration=src/DoctrineMigrations/Base2/migrations.yaml

Avec des migrations.yaml de type :

name: 'Doctrine Migrations base 1'
migrations_namespace: 'App\DoctrineMigrations\Base1'
migrations_directory: 'src/DoctrineMigrations/Base1'
table_name: 'migration_versions'
# custom_template: 'src/DoctrineMigrations/migration.tpl'

CritiqueModifier

  1. Il faut revenir en SQL si les performances sont limites (ex : un million de lignes avec jointures).
  2. Si les valeurs d'une table jointe n'apparaissent pas tout le temps, vérifier que le lazy loading est contourné par au choix :
    1. Avant l'appel null, un ObjetJoint->get().
    2. Dans l'entité, un @ManyToOne(…, fetch="EAGER").
    3. Dans le repository, un $this->queryBuilder->addSelect().
  3. Pas de HAVING MAX car il n'est pas connu lors de la construction dans la chaine de responsabilité
  4. Pas de FULL OUTER JOIN ou RIGHT JOIN (que "leftJoin" et "innerJoin")
  5. Attention aux $this->queryBuilder->setMaxResults() et $this->queryBuilder->setFirstResult() en cas de jointure, car elles ne conservent que le nombre d'enregistrements de la première table (à l'instar du LIMIT SQL). La solution consiste à ajouter un paginateur[6].
  6. L'annotation @ORM/JOIN TABLE crée une table vide et ne permet pas d'y placer des fixtures lors de sa construction.
  7. Pas de hints.
  8. Bug des UNION ALL quand on joint deux entités non liées dans le repo.

RéférencesModifier