On s’est rendu compte ici qu’il y avait pas mal de questions en rapport avec le schema.yml qui ressortaient régulièrement.
Le schema.yml est généralement trop vite oublié. C’est facile à faire, ça génère la base sans trop se poser de questions, et puis on l’oublie… Alors que le schema.yml est certainement le fichier le plus important d’une application Symfony.
Je vais donc essayer de regrouper dans ce post les questions auxquelles on répond généralement par “Regardes dans ton schema.yml” où “Comment est ce que tu l’as défini dans ton schema.yml ?”.
Notamment au niveau des définitions des relations et les méthodes magiques qui en découlent.
Tout d’abord un petit bookmark des pages à lire et à relire de la documentation doctrine.
(Personnellement je trouve que la documentation Doctrine est très bien faite. Les exemples omniprésent et la rendent très explicite, même pour un anglophobe.)
Pour ce post j’ai fait le schema.yml d’une application lambda. On a des utilisateurs, des groupes, la relation n:n entre les deux. Et pour rajouter une petite relation 1:n les utilisateurs ont des photos. (original non?)
J’ai utilisé des behaviors Timestampable et Sluggable. Dans les types j’en ai profité pour tester le Enum que je n’utilisais pas jusqu’à présent, mais je pense que dans un avenir proche je vais m’en servir beaucoup plus 😉
Pareil dans les data validation le “email: true” sur le champ email et le “past: true” sur la date de naissance sont assez énormes et permettent d’avoir déjà les validators adéquats dans les classes formulaires.
Utilisateur: tableName: test_utilisateur actAs: Timestampable: ~ Sluggable: unique: true fields: [nom,prenom] canUpdate: true columns: id: { type: integer(4), unsigned: true, primary: true, autoincrement: true } sexe: { type: enum, values: ['','Homme','Femme'], default: 'Homme' } nom: { type: string(255) } prenom: { type: string(255) } date_naissance: { type: date, past: true } email: { type: string(255), notnull: true, unique: true, email: true } relations: Groupes: class: Groupe refClass: Membre local: utilisateur_id foreign: groupe_id Photo: tableName: test_photo actAs: Timestampable: ~ columns: id: { type: integer(4), unsigned: true, primary: true, autoincrement: true } fichier: { type: string(255), notnull: true } utilisateur_id: { type: integer(4), unsigned: true, primary: true } relations: Utilisateur: local: utilisateur_id foreign: id foreignAlias: Photos onDelete: CASCADE Membre: tableName: test_membre columns: utilisateur_id: { type: integer(4), unsigned: true, primary: true } groupe_id: { type: integer(4), unsigned: true, primary: true } relations: Utilisateur: local: utilisateur_id foreign: id foreignAlias: Membres onDelete: CASCADE Groupe: local: groupe_id foreign: id foreignAlias: Membres onDelete: CASCADE Groupe: tableName: test_groupe columns: id: { type: integer(4), unsigned: true, primary: true, autoincrement: true } nom: { type: string(255), notnull: true } relations: Utilisateurs: class: Utilisateur refClass: Membre local: groupe_id foreign: utilisateur_id
La convention de nommage
Le nom des objets : Le plus court et le plus explicite possible. En CamelCase avec la première lettre en majuscule et au singulier. Pourquoi la 1er lettre en majuscule. Si la table a le même nom, ça permet de les dissocier facilement pour éviter les ambiguïté, notamment dans la construction des requête en DQL.
Le nom des relations : Si possible, je donne le même nom que l’objet cible. Au singulier si on est sur une relation N:1 ou 1:1 et au pluriel quand on est sur une relation 1:N ou N:N (généralement les foreignAlias). La aussi la 1er lettre en majuscule est importante, car elle permet de conserver une syntaxe camelCase sur les accessers magiques. Par exemple sur l’objet Photo :
Photo: ... relations: Utilisateur: local: utilisateur_id foreign: id
L’accesseur magique sera :
$ma_photo->getUtilisateur();
Alors que si on appelle la relation en minuscule :
Photo: ... relations: utilisateur: local: utilisateur_id foreign: id
L’accesseur magique sera
$ma_photo->getutilisateur()
Et du coup on perd la syntaxe camelCase qui est généralisée dans tout le framework. C’est du chipotage mais ca peut être source d’erreur bête qui fait perdre du temps.
Les TOC (Troubles Obsessionnels de Codage :p) ou Bonnes pratiques
Sur les relations 1:N et 1:1, je définie toute la relation dans l’objet qui possède la clé étrangère. Pour essayer de ne pas alourdir trop mon schema.yml et aussi par faignantise.
Photo: ... relations: Utilisateur: local: utilisateur_id foreign: id foreignAlias: Photos onDelete: CASCADE
Cette relation défini 2 méthodes magiques Photo::getUtilisateur() qui va retourner l’objet Utilisateur lié à la photo et Utilisateur::getPhotos() qui retourne une collection d’objets Photo liés à utilisateur. Cette même relation peut se décrire aussi en la décomposant en 2 parties.
Utilisateur: ... relations: Photos: local: id foreign: utilisateur_id onDelete: CASCADE Photo: ... relations: Utilisateur: local: utilisateur_id foreign: id onDelete: CASCADE
Les 2 méthodes marchent parfaitement et ont leurs avantages et leurs inconvénients. Sur les relations N:N, je définie toute la relation dans la Classe de liaison, et sur les objets externes seulement la partie qui les concernent. Pour éviter les multiples redéfinitions.
Membre: ... relations: Utilisateur: local: utilisateur_id foreign: id foreignAlias: Membres onDelete: CASCADE Groupe: local: groupe_id foreign: id foreignAlias: Membres onDelete: CASCADE Utilisateur: ... relations: Groupes: class: Groupe refClass: Membre local: utilisateur_id foreign: groupe_id Groupe: ... relations: Utilisateurs: class: Utilisateur refClass: Membre local: groupe_id foreign: utilisateur_id
Sur les objets, j’aime bien avoir des noms simples et explicites mais paradoxalement, j’aime bien préfixer mes tables dans la base de donnée. C’est pourquoi je redéfinie systématiquement le tableName des objets dans le schema.yml
Utilisateur: tableName: test_utilisateur ...
Sur les objets l’id est généré automatiquement mais il est de type bigint(20) signé. Et là aussi, comme je suis un gros maniaque et que je n’aime pas avoir des id signé qui plus est en bigint. Je redéfini l’id en int signé.
Utilisateur: ... columns: id: { type: integer(4), unsigned: true, primary: true, autoincrement: true }
Attention, il est possible de définir les relations à plusieurs endroits et de plusieurs manières différentes. Il faut essayer de se tenir à une méthodologie la plus stricte possible pour éviter des redéfinitions de relations. Si il y a des redéfinitions, Symfony ne va pas forcement les détecter lors de la re-génération des classes et ça risque de ne pas fonctionner correctement.
Créer des relations avec les objets d’un plugin
On va prendre le cas de sfGuard qui est un des plugins les plus utilisés. Petite chose à savoir. Quand on utilise sfGuard, l’id de l’objet sfGuardUser est un int signé. Si vous avez besoin de faire des jointures pensez bien à utiliser un integer(4). (Le type des clé étrangère est très important et source de pas mal de problèmes.) Si on voulait lier l’objet Photo à sfGuardUser il faudrait écrire quelque-chose comme :
Photo: ... columns: ... user_id: { type: integer(4) } relations: User: class: sfGuardUser local: user_id foreign: id foreignAlias: Photos onDelete: CASCADE
Toujours dans l’hypothèse de l’intégration de sfGuard à la place de mon objet Utilisateur. Pour les relations N:N, étant donné que l’on a pas accès au schema.yml de l’objet sfGuarduser avec lequel on va se lier. Il faut définir toutes les relations de manière externe. Pour la relation avec les groupes ça donnerais :
Membre: ... relations: Utilisateur: class: sfGuardUser local: utilisateur_id foreign: id foreignAlias: Membres onDelete: CASCADE Groupe: local: groupe_id foreign: id foreignAlias: Membres onDelete: CASCADE Groupe: ... relations: Utilisateurs: class: sfGuardUser refClass: Membre local: groupe_id foreign: utilisateur_id foreignAlias: Groupes
Méthodes magiques dans les objets
On a vu que les nom des relations et les foreignAlias engendraient des méthodes magiques qui permettent de remonter facilement les objets de la relation. Si on reprends le schema.yml du début, la liste des méthodes magiques est la suivante :
//methodes magiques su l'objet Utilisateur $utilisateur = new Utilisateur(); $utilisateur->getPhotos(); $utilisateur->getGroupes(); $utilisateur->getMembres(); //methodes magiques su l'objet Photo $photo = new Photo(); $photo->getUtilisateur(); //methodes magiques su l'objet Membre $membre = new Membre(); $membre->getUtilisateur(); $membre->getGroupe(); //methodes magiques su l'objet Groupe $groupe = new Groupe(); $groupe->getMembres(); $groupe->getUtilisateurs();
Les getters et les setters sont aussi des méthodes magiques. Il y a un petit piège à éviter si on veut surcharger un getter ou un setter. Par exemple si on vaut surcharger le getter Nom de l’objet Utilisateur, on a tendance a vouloir écrire :
class UtilisateurTable extends Doctrine_Table { public function getNom() { return parent::getNom(); } }
Et bien il ne faut pas ! Ça fait en fait une boucle infinie. Il faut écrire :
class UtilisateurTable extends Doctrine_Table { public function getNom() { return parent::_get('nom'); } }
Finders magiques dans les classes Table
Comme pour les getters, il y a deux finders magiques par champ dans la classe Table. Un findByLechamp et un findOneByLechamp. Le premier qui retourne une collection d’objets (même s’il n’y en a qu’un) et le second qui renvoie un objet seul. Par exemple:
$utilisateur = Doctrine::getTable('Utilisateur')->findOneByNom('Toto');
Le nombre de requêtes
Le nombre de requêtes effectuer en base peut rapidement devenir impressionnant. Mais si on y regarde de plus prés ce sont souvent de petites requêtes. On va voir comment jouer sur le nombre de requêtes générées avec un petit exemple. Une action toute simple ou je récupère un objet Utilisateur par son slug.
test_utilisateur = Doctrine::getTable('Utilisateur')->findOneBySlug('teta-toto'); } }
Et la vue qui correspond
//indexSuccess.php Utilisateur : getPrenom() ?>
Photos : getGroupes()->count() ?>
- getPhotos() as $photo): ?>
- getFichier() ?>
Groupes : getGroupes()->count() ?>
- getGroupes() as $groupe): ?>
- getNom() ?>
On vois que 3 requetes sont effectuées.
1. SELECT t.id AS t__id, t.sexe AS t__sexe, t.nom AS t__nom, t.prenom AS t__prenom, t.date_naissance AS t__date_naissance, t.email AS t__email, t.created_at AS t__created_at, t.updated_at AS t__updated_at, t.slug AS t__slug FROM test_utilisateur t WHERE t.slug = ? LIMIT 1 - (teta-toto ) 2. SELECT t.id AS t__id, t.nom AS t__nom, t2.utilisateur_id AS t2__utilisateur_id, t2.groupe_id AS t2__groupe_id FROM test_groupe t LEFT JOIN test_membre t2 ON t.id = t2.groupe_id WHERE t2.utilisateur_id IN (?) - (1 ) 3. SELECT t.id AS t__id, t.utilisateur_id AS t__utilisateur_id, t.fichier AS t__fichier, t.created_at AS t__created_at, t.updated_at AS t__updated_at FROM test_photo t WHERE t.utilisateur_id IN (?) - (1 )
La 1er requête est effectuée lors du findOneBySlug() dans l’action. La seconde lors du getPhotos() dans la vue. Et la derniere lors du getGroupes() dans la vue également. On peut obtimiser le nombre de requete en forceant les jointures. Pour le même code si on surcharge le finder findOneBySlug() en y rajoutant toutes les jointures
from('Utilisateur u') ->leftJoin('u.Photos') ->leftJoin('u.Groupes') ->where('u.slug = ?', $slug); return $q->fetchOne(); } }
On obtiens le même résultat avec une seule requète
SELECT t.id AS t__id, t.sexe AS t__sexe, t.nom AS t__nom, t.prenom AS t__prenom, t.date_naissance AS t__date_naissance, t.email AS t__email, t.created_at AS t__created_at, t.updated_at AS t__updated_at, t.slug AS t__slug, t2.id AS t2__id, t2.utilisateur_id AS t2__utilisateur_id, t2.fichier AS t2__fichier, t2.created_at AS t2__created_at, t2.updated_at AS t2__updated_at, t3.id AS t3__id, t3.nom AS t3__nom FROM test_utilisateur t LEFT JOIN test_photo t2 ON t.id = t2.utilisateur_id LEFT JOIN test_membre t4 ON t.id = t4.utilisateur_id LEFT JOIN test_groupe t3 ON t3.id = t4.groupe_id WHERE t.slug = ? - (teta-toto )
Il ne faut pas nonplus s’amuser a remonter la moitié de la base de donnée dans chaque requête, mais ça permet de mieu gérer la charge générée par les scripts.
Supprimer ou renommer un objet
Si on supprime ou qu’on renomme une classe, les classes générés ne sont pas nettoyées et il faut le faire “a la main” sinon, les objets sont toujours regénéré et les tables toujours présentes en base. Par exemple si ou voulais supprimer l’objet Utilisateur parcequ’on a décidé de se servir de sfGuard pour gérer les utilisateurs. Il faut supprimer :
- /lib/filter/doctrine/UtilisateurFormFilter.class
- /lib/filter/doctrine/base/BaseUtilisateurFormFilter.class.php
- /lib/form/doctrine/UtilisateurForm.class.php
- /lib/form/doctrine/base/BaseUtilisateurForm.class.php
- /lib/model/doctrine/Utilisateur.class.php
- /lib/model/doctrine/UtilisateurTable.class.php
- /lib/model/doctrine/base/Utilisateur.class.php
Merci a ceux qui ont eu le courage d’aller jusqu’au bout. J’avoue que je ne pensais pas faire un post aussi long à la base. J’ai un peu tapé au kilomètre et je pense que je le remanierai d’ici quelques temps avec plus d’explications et beaucoup moins de fautes 😀