Tutoriel FuelPHP #6 : L'ORM, gestion des relations
Cet article, sans être une suite directe, s'inscrit dans la continuité des tutoriels réalisés par Julian et Sébastien. L'objectif de ce tutoriel est d'en apprendre plus sur l'ORM de FuelPHP, afin d'en comprendre les mécanismes principaux. Il s'inscrit dans le cadre de la réalisation d'une application de gestion d'un catalogue de produits, qui sera motorisée sous Novius OS (CMS open source basé sur FuelPHP).
Pour ce tutoriel, on prendra l'exemple plus simple d'un carnet d'adresses.
Mise en place du projet
Sommaire du tutoriel
Développer sous FuelPHP
- Choix du framework
- Mise en place
- Création d'un projet
- Application type
- Cheat sheet
- L'ORM, gestion des relations
Préambule
Bien qu'il soit recommandé d'avoir suivi les précédents tutoriels sur FuelPHP, celui-ci est indépendant. Pour ceux qui auraient aimé reprendre leur précédent projet, vous verrez ci-dessous pourquoi il est préférable de commencer un nouveau projet.
Comme pour les tutoriels précédents, on part du principe que vous utilisez MySQL et le logiciel de gestion de bases de données phpMyAdmin.
Création du projet et des premières classes
Pour aller plus vite et pour vous épargner des lignes de codes, on va utiliser la commande oil. On commence donc par créer le projet :
oil create addressbook
N'oubliez pas de créer la base données address_book qui sera associée à votre projet et de configurer votre projet (cf. tutoriels #2 et #3). Ne pas oublier de décommenter la ligne //'Orm', de la liste des packages dans le fichier fuel/app/config/config.php.
On va ensuite ajouter au projet l'élément principal du carnet d'adresse : le contact. On se place donc dans le dossier du projet, puis on exécute les commandes suivantes :
php oil g model contact name:string surname:string tel:string email:string address:text
php oil refine migrate
Vous l'aurez peut-être remarqué, contrairement à la commande utilisée lors du tutoriel #3, celle-ci n'utilise pas le CRUD. Comme expliqué dans ce même tutoriel, la conséquence principale vient du fait que le modèle généré n'étendra pas le Model_Crud, mais le modèle de l'ORM. Étendre le model de l'ORM de FuelPHP est la condition qui va ensuite permettre de gérer des relations. C'est d'ailleurs la raison principale qui fait que le projet précédent ne permettait pas de travailler sur ces questions !
À ce stade je vous conseille d'ouvrir votre éditeur, et vous pouvez commencer par constater que votre modèle étend bien le Model de l'ORM :
<?php
use Orm\\Model;
class Model_Contact extends Model
{
protected static $_properties = array(
'id',
'name',
'surname',
'tel',
'email',
'address',
'created_at',
'updated_at',
);
protected static $_observers = array(
'Orm\\Observer_CreatedAt' => array(
'events' => array('before_insert'),
'mysql_timestamp' => false,
),
'Orm\\Observer_UpdatedAt' => array(
'events' => array('before_save'),
'mysql_timestamp' => false,
),
);
}
Il ne vous reste qu'à générer les deux autres modèles qui définiront votre application :
php oil g model profile contact_id:int title:string description:text hobbies:string
php oil g model category parent_id:int title:string description:text
php oil refine migrate
Du fait d'avoir utiliser la commande oil generate model plutôt que oil generate scaffold, vous aurez remarqué que les contrôleurs et vues associés n'ont pas été générés. Pour manipuler nos modèles, on utilisera la console de FuelPHP ! Cet outil a le mérite de nous permettre d'effectuer des opérations sur les modèles sans avoir à configurer les vues et les contrôleurs associés. Cela va donc nous permettre de rester concentré sur l'objet de ce tutoriel : l'ORM et les relations.
Création des relations
Afin d'essayer d'être le plus exhaustif possible, on va mettre en place les 3 types principaux de relations:
- "many to many"
- "has many" (et son complément "belongs to")
- "has one" (idem)
Relation "Has One"
Commençons par la relation la plus simple.
Un contact pourra ainsi être associé à un profil. Imaginons que vous souhaitiez étoffer un peu les informations dont vous disposez sur certains contacts. Plutôt que d'avoir un modèle de contacts avec des champs supplémentaires et entraîner la création de contacts incomplets, on ajoute une relation vers un profil qui complètera le contact. On va préciser cette relation au niveau du modèle.
Pour le contact, il suffit d'ajouter l'attribut $_has_one comme ceci :
protected static $_has_one = array(
'profile' => array(
'key_from' => 'id',
'model_to' => 'Model_Profile',
'key_to' => 'contact_id',
'cascade_save' => true,
'cascade_delete' => true,
)
);
On remarque que, puisque le profil n'a pas d'existence légitime sans un contact, la sauvegarde du contact entraîne nécessairement la sauvegarde du profil. Il en va de même pour la suppression.
En complément, on ajoute sur le modèle du profil l'attribut suivant :
protected static $_belongs_to = array(
'contact' => array(
'key_from' => 'contact_id',
'model_to' => 'Model_Contact',
'key_to' => 'id',
'cascade_save' => false,
'cascade_delete' => false,
)
);
On remarque que, pour le profil, sa suppression ne doit pas entraîner la suppression du contact et ce pour deux raisons :
- On peut envisager que le contact n'ait pas de profil,
- Mais surtout le seul moyen de mettre à jour une relation est de supprimer cette relation avant de la réaffecter.
On va maintenant pouvoir commencer à utiliser la console.
Pour bien comprendre les mécanismes de l'ORM, il peut être intéressant d'observer les requêtes réalisées par l'ORM. La démarche pour MySQL sous Ubuntu est expliquée ici.
Dans votre terminal, à la racine de votre projet, exécutez la commande suivante (attention : la console n'est pas complètement stable. En cas d'échec lors de l'exécution de votre commande, il vous faudra la relancer et vous n'aurez plus accès à votre historique, ni à vos variables...) :
oil console
On commence par créer un contact, on lui affecte un profil, avant de valider la création et donc de l'enregistrer en base :
$model = new Model_Contact(array('name'=>'ALBERT', 'surname'=> 'Victor', 'tel'=> '0123456789', 'email'=> 'victor@plop.fr', 'address'=> '0 rue du tutoriel 00000 TUTO'));
$model->profile = new Model_Profile(array('title'=> 'ecommerce', 'description'=> 'works on a project for a few months', 'hobbies'=> 'climbing'));
$model->save();
On peut alors voir les requêtes associées :
INSERT INTO `contacts` (`name`, `surname`, `tel`, `email`, `address`, `created_at`, `updated_at`) VALUES ('ALBERT', 'Victor', '0123456789', 'victor@plop.fr', '0 rue du tutoriel 00000 TUTO', 1333034478, 1333034478)
INSERT INTO `profiles` (`contact_id`, `title`, `description`, `hobbies`, `created_at`, `updated_at`) VALUES (1, 'ecommerce', 'works on a project for a few months', 'climbing', 1333034478, 1333034478)
L'ajout du profil se fait en renseignant la relation. En réalisant l'appel à la méthode save() sur le contact, le cascade_save => true défini avec la relation suffit à sauvegarder le profil dans la base.
La mise à jour de vos données peut ainsi se faire uniquement via le contact :
$model->profile->hobbies = 'cinema';
$model->save()
$model->email = 'albert@plop.fr';
$model->save();
UPDATE `profiles` SET `contact_id` = 1, `title` = 'ecommerce', `description` = 'works on a project for a few months', `hobbies` = 'cinema', `created_at` = 1333034478, `updated_at` = 1333034923 WHERE `profile_id` = 1 LIMIT 1
UPDATE `contacts` SET `name` = 'ALBERT', `surname` = 'Victor', `tel` = '0123456789', `email` = 'albert@plop.fr', `address` = '0 rue du tutoriel 00000 TUTO', `created_at` = 1333034478, `updated_at` = 1333034979 WHERE `contact_id` = 1 LIMIT 1
La suppression est un peu plus compliquée : appeler la méthode delete() sur le profil supprime les données en base, mais ne détruit pas l'objet PHP. Si on appelle la méthode save() par la suite, l'ORM considère que la relation existe toujours et tente de sauvegarder les données. Puisque la relation est théoriquement détruite, le champ contact_id est null et si la base ne le permet pas (comme c'est le cas par défaut) cela génère une erreur. Il faut donc en plus affecter null à la relation ; c'est par ce mécanisme que FuelPHP détruit effectivement l'objet, avant d'appliquer la suppression en base via la méthode save() de votre contact.
$model->profile->delete();
$model->profile = null;
$model->save()
SELECT `t0`.`contact_id` AS `t0_c0`, `t0`.`name` AS `t0_c1`, `t0`.`surname` AS `t0_c2`, `t0`.`tel` AS `t0_c3`, `t0`.`email` AS `t0_c4`, `t0`.`address` AS `t0_c5`, `t0`.`created_at` AS `t0_c6`, `t0`.`updated_at` AS `t0_c7` FROM `contacts` AS `t0` WHERE `t0`.`contact_id` = 1 LIMIT 1
DELETE FROM `profiles` WHERE `profile_id` = 1 LIMIT 1
UPDATE `contacts` SET `name` = 'ALBERT', `surname` = 'Victor', `tel` = '0123456789', `email` = 'albert@plop.fr', `address` = '0 rue du tutoriel 00000 TUTO', `created_at` = 1333035504, `updated_at` = 1333036579 WHERE `contact_id` = 1 LIMIT 1
SELECT `t0`.`profile_id` AS `t0_c0`, `t0`.`contact_id` AS `t0_c1`, `t0`.`title` AS `t0_c2`, `t0`.`description` AS `t0_c3`, `t0`.`hobbies` AS `t0_c4`, `t0`.`created_at` AS `t0_c5`, `t0`.`updated_at` AS `t0_c6` FROM `profiles` AS `t0` WHERE `t0`.`profile_id` = '1' LIMIT 1
Relation "Has Many"
On va maintenant travailler avec les catégories. Envisageons l'utilisation suivante : chaque contact pourra par la suite être associé à une ou plusieurs catégories. Ces catégories sont hiérarchisées : on peut envisager une catégorie "Travail" et des sous-catégories "R&D", "Marketing". On pourrait classer ses contacts de manière suffisamment précise pour pouvoir :
- les trouver rapidement
- les filtrer rapidement
- retrouver le contexte dans lequel on les a connus
Ce type de relation peut donc être mis en place avec la relation "Has Many". On ajoute donc les deux propriétés suivantes au modèle de la catégorie :
protected static $_has_many = array(
'variants' => array(
'key_from' => 'id',
'model_to' => 'Model_Category',
'key_to' => 'parent_id',
'cascade_save' => true,
'cascade_delete' => true,
)
);
protected static $_belongs_to = array(
'parent' => array(
'key_from' => 'parent_id',
'model_to' => 'Model_Category',
'key_to' => 'id',
'cascade_save' => true,
'cascade_delete' => false,
)
);
On remarque que le cascade_delete est true dans le sens "parent -> enfant", et false dans le "sens enfant -> parent". Cela se comprend facilement : "Travail" reste légitime si on décide de supprimer "Marketing" alors que si on décide de supprimer "Travail", c'est qu'on ne souhaite plus associer de contacts au "Travail" et à toutes ses déclinaisons.
On va commencer par préciser que le champ parent_id peut être null. Plusieurs solutions sont possibles :
- l'indiquer directement dans la base via votre gestionnaire de base de données ;
- ou modifier la fonction up() de la migration correspondant à la création des catégories :
public function up()
{
\\DBUtil::create_table('categories', array(
'id' => array('constraint' => 11, 'type' => 'int', 'auto_increment' => true),
'parent_id' => array('constraint' => 11, 'type' => 'int', 'null' => true),
'title' => array('constraint' => 255, 'type' => 'varchar'),
'description' => array('type' => 'text'),
'created_at' => array('constraint' => 11, 'type' => 'int'),
'updated_at' => array('constraint' => 11, 'type' => 'int'),
), array('id'));
}
oil refine migrate:down
oil refine migrate:up
On va ensuite vérifier le bon comportement des catégories et des déclinaisons :
$parent = Model_Category::forge(array('title' => 'Friends', 'description' => 'People I like to see at anytime'));
$parent->save();
$parent->variants[] = Model_Category::forge(array('title' => 'School', 'description' => 'People I met when I was a student'));
$parent->variants[] = Model_Category::forge(array('title' => 'Childhood', 'description' => 'People I know since I was a kid'));
$parent->save();
INSERT INTO `categories` (`parent_id`, `title`, `description`, `created_at`, `updated_at`) VALUES (null, 'Friends', 'People I like to see at anytime', 1333354737, 1333354737)
UPDATE `categories` SET `parent_id` = null, `title` = 'Friends', `description` = 'People I like to see at anytime', `created_at` = 1333354737, `updated_at` = 1333354791 WHERE `id` = 1 LIMIT 1
INSERT INTO `categories` (`parent_id`, `title`, `description`, `created_at`, `updated_at`) VALUES (1, 'School', 'People I met when I was a student', 1333354791, 1333354791)
INSERT INTO `categories` (`parent_id`, `title`, `description`, `created_at`, `updated_at`) VALUES (1, 'Childhood', 'People I know since I was a kid', 1333354791, 1333354791)
La clé du tableau qui gère les relations correspond à l'identifiant de la catégorie associée :
>>> $parent->variants
array (
2 =>
Model_Category::__set_state(array(
'_is_new' => false,
'_frozen' => false,
'_data' =>
array (
'title' => 'School',
'description' => 'People I met when I was a student',
'parent_id' => 1,
'updated_at' => 1333358505,
'created_at' => 1333358505,
'id' => 2,
),
'_original' =>
array (
'title' => 'School',
'description' => 'People I met when I was a student',
'parent_id' => 1,
'updated_at' => 1333358505,
'created_at' => 1333358505,
'id' => 2,
),
'_data_relations' =>
array (
),
'_original_relations' =>
array (
),
'_view' => NULL,
'_iterable' =>
array (
),
)),
3 =>
Model_Category::__set_state(array(
'_is_new' => false,
'_frozen' => false,
'_data' =>
array (
'title' => 'Childhood',
'description' => 'People I know since I was a kid',
'parent_id' => 1,
'updated_at' => 1333358505,
'created_at' => 1333358505,
'id' => 3,
),
'_original' =>
array (
'title' => 'Childhood',
'description' => 'People I know since I was a kid',
'parent_id' => 1,
'updated_at' => 1333358505,
'created_at' => 1333358505,
'id' => 3,
),
'_data_relations' =>
array (
),
'_original_relations' =>
array (
),
'_view' => NULL,
'_iterable' =>
array (
),
)),
)
On peut ensuite modifier indifféremment toutes les catégories, directement ou via la catégorie parente :
$parent->variants[2]->description = 'Those I met when I was a student';
$parent->save();
$child = Model_Category::find('first', array('where' => array(array('title', 'Childhood'))));
$child->description = 'Those I know since I was a child';
$child->save();
$parent->variants
UPDATE `categories` SET `parent_id` = null, `title` = 'Friends', `description` = 'People I like to see at anytime', `created_at` = 1333358474, `updated_at` = 1333358676 WHERE `id` = 1 LIMIT 1
UPDATE `categories` SET `parent_id` = 1, `title` = 'School', `description` = 'Those I met when I was a student', `created_at` = 1333358505, `updated_at` = 1333358676 WHERE `id` = 2 LIMIT 1
UPDATE `categories` SET `parent_id` = '1', `title` = 'Childhood', `description` = 'Those I know since I was a child', `created_at` = '1333358505', `updated_at` = 1333358954 WHERE `id` = '3' LIMIT 1
Pour limiter le nombre de requêtes, il est donc préférable d'appeler la méthode save() directement sur l'objet modifié, mais il peut être bien pratique d'utiliser la sauvegarde récursive.
Note : on peut aussi appeler la méthode save() sur la relation en passant par la catégorie parente :
$parent->variants[3]->description = 'Those I have known since my childhood';
$parent->variants[3]->save();
La suppression des catégories se fait selon le comportement prévu, et comme pour la relation "has one", il faut supprimer l'objet dans la base si on ne souhaite pas la réaffecter ensuite :
Remarque : La fonction unset est actuellement mal supportée par la console, pour rectifier le problème, on ajoute false; avant l'appel.
$parent->variants[3]->delete();
false; unset($parent->variants[3]);
$parent->save();
Vous pouvez constater que votre table ne contient plus que la catégorie principale "Friends" et la catégorie secondaire "School". En supprimant la catégorie principale, la catégorie secondaire sera également supprimée :
$parent->delete();
Model_Category::find('all')
SELECT `t0`.`id` AS `t0_c0`, `t0`.`parent_id` AS `t0_c1`, `t0`.`title` AS `t0_c2`, `t0`.`description` AS `t0_c3`, `t0`.`created_at` AS `t0_c4`, `t0`.`updated_at` AS `t0_c5` FROM `categories` AS `t0` WHERE `t0`.`parent_id` = '1'
DELETE FROM `categories` WHERE `id` = '1' LIMIT 1
SELECT `t0`.`id` AS `t0_c0`, `t0`.`parent_id` AS `t0_c1`, `t0`.`title` AS `t0_c2`, `t0`.`description` AS `t0_c3`, `t0`.`created_at` AS `t0_c4`, `t0`.`updated_at` AS `t0_c5` FROM `categories` AS `t0` WHERE `t0`.`id` IS null LIMIT 1
SELECT `t0`.`id` AS `t0_c0`, `t0`.`parent_id` AS `t0_c1`, `t0`.`title` AS `t0_c2`, `t0`.`description` AS `t0_c3`, `t0`.`created_at` AS `t0_c4`, `t0`.`updated_at` AS `t0_c5` FROM `categories` AS `t0` WHERE `t0`.`parent_id` = '2'
DELETE FROM `categories` WHERE `id` = '2' LIMIT 1
Relation "Many to Many"
Dernière relation à mettre en place : affecter une à plusieurs catégories à un ou plusieurs contacts.
Pour enregistrer la relation, il faut une table dédiée :
- Vous pouvez l'ajouter via votre gestionnaire de base de données ;
- Ou générer la migration suivante et effectuer la commande oil refine migrate. Le contenu de la migration est donné ci-dessous:
php oil g migration create_contacts_categories
<?php
namespace Fuel\\Migrations;
class Create_contacts_categories
{
public function up()
{
\\DBUtil::create_table('contacts_categories', array(
'contact_id' => array('constraint' => 11, 'type' => 'int'),
'category_id' => array('constraint' => 11, 'type' => 'int'),
), array('contact_id', 'category_id'));
}
public function down()
{
\\DBUtil::drop_table('contacts_categories');
}
}
Il vous faut ensuite écrire la relation sur vos modèles. Pour le contact :
protected static $_many_many = array(
'categories' => array(
'key_from' => 'id',
'key_through_from' => 'contact_id',
'table_through' => 'contacts_categories',
'key_through_to' => 'category_id',
'model_to' => 'Model_Category',
'key_to' => 'id',
'cascade_save' => false,
'cascade_delete' => false,
)
);
Pour la catégorie :
protected static $_many_many = array(
'contacts' => array(
'key_from' => 'id',
'key_through_from' => 'category_id',
'table_through' => 'contacts_categories',
'key_through_to' => 'contact_id',
'model_to' => 'Model_Contact',
'key_to' => 'id',
'cascade_save' => true,
'cascade_delete' => false,
)
);
Vous pouvez maintenant créer vos contacts et catégories et tester les relations :
$colleague = Model_Contact::forge(array('name' => 'LEFEUVRE', 'surname' => 'Antoine', 'tel' => '0123456789', 'email' => 'lefeuvre@plop.fr', 'address'=> '0 rue du tutoriel 00000 TUTO'));
$friend = Model_Contact::forge(array('name' => 'DUPONT', 'surname' => 'Jean', 'tel' => '0987654321', 'email' => 'jean@plop.fr', 'address' => '0 rue de la paix 00000 TUTO'));
$colleague->save();
$friend->save();
$category = Model_Category::forge(array('title' => 'Friends', 'description' => 'People I like to see at anytime'));
$category->save();
$work = Model_Category::forge(array('title' => 'Work', 'description' => 'People I met at work'));
$work->save();
$work->variants[] = Model_Category::forge(array('title' => 'Novius', 'description' => 'People I met at Novius'));
$work->variants[] = Model_Category::forge(array('title' => 'Former', 'description' => 'People I met when working in a former enterprise'));
$work->save();
$novius = Model_Category::find('first', array('where' => array(array('title', 'Novius'))));
$colleague->categories[] = $novius;
$colleague->save();
$friend->categories[] = Model_Category::find('first', array('where' => array(array('title', 'Friends'))));
$friend->categories[] = Model_Category::find('first', array('where' => array(array('title', 'Former'))));;
$friend->save();
Model_Contact::find('all')
Exemple : supprimer la catégorie principale et vérifier qu'elle n'est plus affectée aux contacts (on la retrouve sur les _original_relations mais a disparu des _data_relations) :
$work->delete();
Model_Contact::find('all')
Bilan
Vous êtes maintenant capable de mettre en place les trois types de relations principaux. Il ne vous reste qu'à les configurer selon vos besoins, et vous pourrez également tester les nombreuses possibilités associées à la fonction find (expliquées succintement sur cette page).
Pour info : le schéma illustrant ce tutoriel provient provient de cette page.