Chapitre 9. Implémenter le Modèle de Données : les Entrées et les Auteurs

Table des matières

9.1. Introduction
9.2. Le Modèle de Données et les schémas d'accès à la Base de Données
9.3. Exploration des Objets Métier
9.4. Exploration du Data Mapper Entry
9.5. Faire le Tour des Outils d'Implémentation
9.5.1. Objets Métier
9.5.2. Validation et Règles de Filtrage
9.5.3. Accès à la Base de Données
9.6. Implémentation
9.6.1. Ajouter des Tests Unitaires
9.6.2. Les Objets Métier
9.6.3. Les Data Mappers
9.6.4. Lazy Loading des Objets Métier
9.6.5. Eviter les duplications d'entités avec une Identity Map
9.7. Conclusion

9.1. Introduction

Au Chapitre 3 : Le Modèle, je parlais de quelques uns des concepts qui orbitent autour du Modèle, une représentation de l'état et des règles métier d'une entité, telles celles gouvernant les entrées d'un blog. Nous sommes chanceux dans ce chapître, puisque nos entités sont extrêmement simples et que, gràce à cette simplicité, nous pouvons ignorer un grand nombre d'idées abstraites à propos du Modèle, qui sont plus importantes pour des applications Web gérant des systèmes bien plus complexes. Cela dit, même dans nos conditions de simplicité, nous allons rencontrer plusieurs challenges au niveau de la conception.

Tout d'abord, notons que ce chapitre ne vous apprendra nullement à utiliser Zend_Db, Zend_Db_Table ou Zend_Db_Table_Row. L'utilisation de ces classes sera expliquée au fur et à mesure que nous les rencontrerons au cours de ce livre, alors que l'objectif de ce chapitre est d'utiliser Zend_Db_Table comme base pour la conception et l'implémentation de notre Modèle pour ce blog. En particulier, nous nous pencherons sur un point précis du Modèle : les entrées de blog.

9.2. Le Modèle de Données et les schémas d'accès à la Base de Données

Lors que nous parlons d'un Modèle, il existe quelques termes standards que nous pouvons appliquer. Tout Modèle doit tout d'abord appartenir à un domaine, qui est, pour faire simple, le système global au sein duquel le Modèle opère. Dans notre application actuelle, il s'agit simplement de "blogger". Au sein de ce domaine, le Modèle est composé de un ou plus objets de domaine. Un objet de domaine est une représentation d'une entité, de ses propriétés, et des règles métiers (aussi connues sous le terme de logique de domaine) qui lui sont appliquées. Donc, pour notre domaine de blogging, nous pouvons avoir des objets de domaine représentant des entitées Entrée. Pour faire plus complet, une entité peut être définie comme un membre identifiable de manière unique du domaine, avec une série de comportements. Toutes les Entrées, par exemple, devraient avoir un titre unique et du contenu, mais la principale propriété unique sera son id. Toutes les Entrées auront aussi un ensemble de comportements procéduraux : elles sont écrites, validées, et publiées.

Si vous regardez cette explication, vous remarquerez qu'il n'est dit nulle part qu'un Modèle est un seul objet. Dans un Modèle de climat planétaire, nous aurions en interaction des milliers d'entités, facteurs, comportements, contraintes, etc. Donc, lorsque nous parlons d'un Modèle, nous faisons en fait référence à toutes les entitées contenues par ce Modèle, dans son domaine, et comment elles se comportent et inter-agissent les unes avec les autres. Vous verrez qu'une bonne partie de la terminologie de Zend Framework fait référence aux Entités sous le terme de Modèles, du genre Modèle Entrée, Modèle Auteur, etc. Dans quasiment tous les cas, ce sont des objets de domaine au sein d'un seul Modèle de données.

Nous avons mentionné la logique de domaine, qui est plutôt un terme général pour faire référence aux règles métier et comportements : toutes les applications ne sont pas liées à du business. Souvent, notre logique de domaine va décrire des contraintes sur les propriétés de nos objets ; par exemple, des règles de validation et de filtrage. Je mentionne ceci pour insister sur le fait que la validation est du ressort du Modèle, et non quelque chose qui revient au Contrôleur ou à la Vue. Vous verrez où ceci entre en jeux dans un futur chapître, lorsque nous parlerons des formulaires.

Le principal problème auquel les développeur peuvent faire face lorsqu'ils conçoivent la couche Modèle de leur application est de déterminer à quelle distance de la surface sera la couche de persistence du Modèle.

Dans des applications très simples, nous pouvons directement lancer des requêtes SQL en utilisant Zend_Db ou en créant des objets métier qui, pour faciliter les choses, étendent Zend_Db_Table (qui implémente le pattern Table Data Gateway décrit par Martin Fowler dans son livre "Patterns Of Enterprise Application Architecture" (ou POEAA)). Ceci apporte le mécanisme de stockage, une base de données relationnelle, à la surface, comme quelque chose que notre application peut utiliser directement. La même chose peut être dite à propos de Zend_Db_Table_Row, qui implémente le patter Row Data Gateway, ou l'ActiveRecord de Ruby On Rails, qui implémente (avec quelques améliorations qui le rendent un peu plus proche du Data Mapper) ce que Fowler appelle pattern Active Record. Ces trois patterns ont quelque chose en commun : ils encapsulent directement l'accès à la base de données, souvent en héritant d'une classe de base liée à une seule table ou ligne de la base de données.

Cela dit, dans beaucoup d'applications non-simples, le Modèle de Données peut devenir plus complexe. Il se pourrait même que nous soyons face à un Modèle de Données, qui est exprimé en des termes d'objets et de propriétés d'objets, qui ne puisse pas être facilement mis en correspondance avec des tables de base de données. Un exemple simple, à ce niveau, est une entrée de blog. Bien que l'entrée semble être une entité simple qui puisse être assignée à une table Entrées dans notre schéma de base de données, elle contient aussi une référence (une clef étrangère) vers un auteur. Du point de vue de nos objets métier, cela signifierait que l'objet Entrée contient un objet Auteur. Pourquoi ? Parce que le Modèle est conçu selon une perspective orientée objet, et que c'est la façon de faire la plus évidente qui soit naturellement.

D'un point de vue Base de Données, les Auteurs seraient enregistrés dans une table différente. Ce que cela signifie est que notre objet Entrée ne fait pas référence à exactement une seule table, mais devrait correspondre à deux tables. Nous pouvons peut-être résoudre ce problème en utilisant une jointure SQL lorsque nous chargeons les données. En utilisant une jointure, il nous faudrait filtrer les données de l'auteur vers un objet Author, ou, alternativement, retomber sur deux requêtes SQL distinctes pour chacun des deux objets. D'une façon comme de l'autre, nous avons un problème, puisque notre objet métier hérite d'une classe liée à une seule table de la base de données, tout en ayant en fait besoin d'effectuer des requêtes sur deux.

Cela permet de démontrer un fait inévitable dans le développement d'applications : les objets ne correspondent aux tables en base de données un à un que pour des Modèles de données extrêmement simples. Au final, tout Modèle plus complet aura besoin d'une logique plus complexe pour réaliser la mise en correspondance entre les deux, et cela mène à l'un des premiers patterns utilisés en dehors des scénarios simples, le Data Mapper. Devinez qui l'a défini, d'ailleurs ? Martin Fowler - et, oui, vous devriez réellement lire son livre si vous parvenez à en obtenir une copie...

Finalement, où est-ce que je vais avec cela ? Et bien, si nous laissons l'accès à la base de données en surface (par exemple, en étendant Zend_Db_Table qui ne représente qu'une seule table), nous nous retrouverons rapidement bloqués avec une conception peu flexible, qui n'est pas capable de traiter la moindre non-correspondance entre les objets métiers et les tables en base de données. Par exemple, si notre objet métier étend Zend_Db_Table, il sera directement lié à la table Entries. Et là, d'où est-ce que l'Auteur viendra ? Les Auteurs ne peuvent pas être obtenus en requêtant une table d'Entrées, donc, d'une façon ou d'une autre, il nous faudra introduire du code additionnel, ou lancer directement des requêtes SQL (on en revient aux jointures ou aux deux requêtes séparées), etc. Tout cela met bien en évidence le fait que hériter d'une classe liée à une seule table n'est pas viable.

La solution alternative est de rendre nos objets métier totalement indépendants de toute méthode en rapport avec la Base de Données, et, à la place, de masquer tous les accès à celle-ci derrière un Data Mapper qui s'occupera de la mise en correspondance entre les objets et les tables, et qui peut gérer n'importe quel nombre de tables (vous aurez juste à ajouter de nouveaux Mappers). Maintenant, nos objets métier sont indépendants du schéma de la base de données.

Jetons juste un coup d'oeil à la définition donnée par Fowler au Data Mapper.

Une couche de correspondances qui lie des données entre des objets et une base de données tout en les gardant indépendant les uns des autres, et du Mapper lui-même.

La définition confirme ce que nous supposions, donc : les objets métier ne seront même pas au courant de l'existence d'une base données, une fois qu'un Data Mapper aura été implémenté.

S'il y a quelque chose à retenir de cette section, c'est ce qui suit : étendre les classes d'accès à la Base de données, comme Zend_Db_Table, est acceptable, pour des Modèles de Données très simples. C'est facile à faire, facile à comprendre, et ne requiert que peu de code pour être mis en place. Cela dit, dans des situations plus complexes, où les objets métier peuvent en contenir d'autres, la solution de l'héritage ne fonctionnera pas bien. A ce niveau, le besoin d'une meilleure solution, comme un Data Mapper, deviendra évident et justifiera le besoin d'une conception de classes légérement plus complexe.

Je me suis volontairement limité aux patterns de Fowler avec une raison. Plus cet héritage simple se manifeste en tant que source de problème, plus il est probable que les développeurs commencent à changer leurs habitudes de conception pour supprimer cette in-flexibilité. Au sein de la communauté Zend Framework, cela s'est souvent fini en débat entre ceux qui pensent que les Modèles (ou plutôt, les objets métiers) devraient avoir une relation est-un (via un mécanisme d'héritage) ou a-un (via un principe de composition) avec les classes Zend_Db. A d'autres moments, il y a des départs vers le royaume des Data Containers et Dateways. Tous ceux-là parlent ou codent autour d'un concept évident : ce sont des étapes intermédiaires ou complètes vers la solution du Data Mapper. C'est une solution tellement évidente que de nombreux développeurs vont créer un Data Mapper à partir de zéro sans jamais réaliser qu'il a un nom officiel, et qu'il existe beaucoup de contenu à son propos dans la littérature !

9.3. Exploration des Objets Métier

Avec toutes ces idées, nous pouvons commencer à examiner les points nécessaires pour les objets métiers qui représenteront chacune de nos entrées de blog. Nous savons, gràce à la section précédente, qu'il nous faudra au minimum un objet métier Entry. Arrivés ici, nous ne nous soucions toujours pas du schéma de la base de données, et nous n'avons de toute façon qu'une idée basique des propriétés dont notre entrée a besoin, donc nous nous concentrerons seulement sur les besoins principaux.

A ce niveau, il est aussi important de garder à l'esprit la définition d'un objet métier. Il représente une entité identifiable de manière unique, avec un ensemble de comportements. Le mot-clef est "de manière unique", puisque chaque objet métier représente une seule entité. Si nous avions besoin de représenter plusieurs entités similaires, nous aurions à créer une classe chargée de contenir une collection d'entrées.

Au minimum, notre objet métier va exposer les propriétés suivantes :

  • id

  • title

  • content

  • published_date

  • author

Il ne s'agit pas de toutes les propriétés dont nous aurons besoin, mais elles suffiront pour que nous puissions commencer. En appliquant un principe de développement incrémental, nous nous soucierons des fonctionnalités supplémentaires lorsqu'elles deviendront requises.

Nous pouvons décrire ces propriétés en considérant les contraintes qui devraient leur être appliquées pour que notre objet représente une entrée validée. Bien que nous ne gérerons pas la validation dans ce chapitre, nous verrons cette fonctionnalité être intégrée peu après.

  • L'id est un entier positif, supérieur à zéro, qui identifie l'entrée courante de manière unique.

  • Le title est une chaîne de caractères non vide, qui contient du texte brut ou du XHTML, et qui représente le titre unique d'une entrée.

  • Le content est une chaîne de caractères non vide contenant soit du XHTML 1.0 Strict, soit du texte brut formaté.

  • Le title et le content peuvent tous deux contenir un sous-ensemble des balises et attributs de XHTML, spécifiés par une liste-blanche.

  • L'author est un objet Author valide, qui représente l'auteur de l'entrée.

  • La propriété published_date est une date qui peut être interprétée en utilisant le standard ISO 8601.

Ces contraintes sont ce qui permet à un objet métier de déterminer si les propriétés qui lui sont assignées font de lui une entrée valide ou non. Comme nous pouvons le remarquer, notre entrée fera référence à un auteur. Puisque toutes ces entités sont représentées par des objets métier, nous pouvons crééer une description similaire de l'objet Author. Les propriétés de cet objet peuvent inclure :

  • id

  • username

  • fullname

  • email

  • url

9.4. Exploration du Data Mapper Entry

Là où l'objet Entry gère seule entrée, ses propriétés et sa validité en tant qu'entrée, le Data Mapper est plus concerné par la persistence de ces objets entre les requêtes. Son rôle est de créer, lire, mettre à jour, et supprimer (on fait fréquemment référence à ces quatre opérations sous le terme de CRUD : Create, Read, Update, Delete) les données des objets dans la base de données via une couche d'accès à celle-ci, et, bien entendu, de faire correspondre les propriétés de ces objets avec les tables et colonnes qui leur correspondent. Le Mapper doit aussi faire ceci sans exposer aux objet métier le schéma de la base de donnée, la méthode d'accès, ni même la logique de correspondance.

Puisque le Mapper est responsable de la récupération des entités depuis la base de données, de manière à pouvoir retourner un objet métier créé à partir de ces données, il semble logique qu'il contienne aussi les méthodes utiles en rapport avec les opérations CRUD et leurs critères de sélection, qui pourraient correspondre à la portion WHERE d'une requête SQL où au nom de la colonne lorsque seuls certains détails sont requis pour une entité. Avec tout cela, il devient plus clair que les objets métier ne feront probablement pas appel au Data Mapper. A la place, nous utiliserons le Data Mapper depuis notre application, et lui passerons les objets métier sur lesquels il devra travailler. Ce n'est pas forcément la solution la plus facile à utiliser : pour des raisons évidentes, elle signifie que nous aurons besoin de plus de code dans la couche Contrôleur de notre application, puisque nous aurons deux fois plus d'objets, en introduisant les Data Mappers.

Une méthode souvent utilisée pour éviter ce problème de nombre d'objets croissant est de permettre aux objets métier de connaître leur Data Mappers, tout en s'assurant que cette connaissance n'aille pas plus loin que l'API du Data Mapper. Rappelez-vous que lorsque nous mettons en application la notion de programmation orientée objet, nous devrions toujours coder en respectant une interface, et sans jamais tenir compte de l'implémentation. Nous ne prendrons pas de raccourci ici, cela dit : notre Modèle de Données pour notre application de blogging est relativement simple ; passer plus de temps à élaborer une solution plus complexe serait aller au-delà de la limite de l'acceptable par rapport au temps que nous devrions passer pour mettre en place ce Data Mapper.

9.5. Faire le Tour des Outils d'Implémentation

Maintenant que notre Modèle a été étudié un peu plus en détails, nous pouvons commencer à identifier quelques unes de ses fonctionnalités que nous pourrions passer à des composants existant de Zend Framework.

9.5.1. Objets Métier

Implémenter des objets métier simples ne requiert qu'une seule chose : PHP 5. Tous les objets métier sont juste de bon vieux objets PHP, sans quoi que ce soit de particulier à noter. C'est décevant, je sais ! Le Modèle est supposé être complexe, impossible à comprendre, et nécessiter un Doctorat... A la place, nous l'avons réduit à un ensemble d'objets qui ne sont pas particulièrement compliqués, une fois pris isolément.

9.5.2. Validation et Règles de Filtrage

Avant que notre Modèle ne puisse être stocké où que ce soit, il doit s'assurer que les données qu'il contient respectent les contraintes et règles que nous définissons ; autrement dit, qu'il soit valide et que toutes les valeurs aient été filtrées comme souhaité. Nous pourrions commencer à ajouter des validateurs en utilisant Zend_Validate et des filtres à l'aide de Zend_Filter, mais nous savons déjà que tous nos formulaires générés pour le Modèle dupliqueraient ces règles. Dupliquer est mal, donc la logique veut que nous utilisions des instances de Zend_Form comme base, pour implémenter ceci.

L'utilisation de Zend_Form ne vient pas sans un point d'interrogation. Puisque ce composant représente un formulaire, c'est de toute évidence quelque chose que nous utiliserons beaucoup dans nos Vues. Est-ce que méler le Modèle et la Vue n'est pas inapproprié ? C'est une façon de voir les choses. L'autre est de considerer que toute instance de Zend_Form peut remplir un double rôle de présentation et de conteneur pour les règles de validation/filtrage dérivées du Modèle. Il se peut que ça ne soit pas toujours le cas, et, effectivement, pour des Modèles complexes, ça peut ne fonctionner que pour une petite partie du Modèle où les formulaires correspondent bien aux données contenues par le Modèle. Cela dit, notre blog est plutôt simple, donc nous n'avons trop de soucis à nous faire. Techniquement, une solution théoriquement idéale serait une solution basée sur un formulaire qui maintienne deux parties indépendantes : un conteneur de données avec des validateurs et des filtres (plus proche de l'objet métier), et un ensemble de renderers capables de transformer ces conteneurs en un formulaire inclu dans la Vue. Réveries mises part (même les solutions théoriquement parfaites, dans ce domaine, tendent à être aussi complexes, si pas plus, que Zend_Form), nous allons devoir faire avec ce que nous avons en main, et l'adapter à nos besoins.

Nous étudierons Zend_Form et verrons comment ceci est implémenté dans un prochain chapitre.

9.5.3. Accès à la Base de Données

Puisque ceci est un livre à propos de Zend Framework, nous utiliserons bien évidemment Zend_Db. Pour être exact, nous travaillerons avec Zend_Db_Table_Abstract, qui implémente le pattern Table Data Gateway décrit par le POEAA de Martin Fowler. Ce pattern Gateway peut être défini comme une classe qui fournit un accès à une table de la base de données, nous permettant d'effectuer des inserts, selects, updates, et deletes pour toute ligne ou groupe de lignes de cette table. Fowler definit ce pattern de la manière suivante :

Un objet qui agit comme Gateway vers une table de la base de données. Une instance gère toutes les lignes de la table.

Zend_Db offre aussi une implémentation du pattern Row Data Gateway via Zend_Db_Table_Row, qui est similaire à Table Data Gateway, si ce n'est qu'il travaille avec des lignes individuelles d'une table de base de données. Dans les deux cas, Zend_Db fournit des accès rendus abstraits via son API publique, qui vous permet de construire des requêtes SQL en enchaînant des méthodes d'objets, et est utilisé pour les implémentations des deux patterns.

Pour les besoins de notre Modèle, nous créerons des Data Mappers qui accèdent à la base de données via Zend_Db_Table (c'est-à-dire l'option Table Data Gateway). Cela correspond à l'objectif d'un Data Mapper qui peut être utilisé par plusieurs objets métiers mais reste totalement indépendant ; qui n'est pas concerné par les spécificités d'un objet métier, mais peut offrir des sous-classes spécifiques concrêtes pour gérer la logique de mise en correspondance spécifique de n'importe quel type d'objet métier.

Il est important de noter que nos objets métier ou nos Mappers n'étendront jamais Zend_Db_Table, contrairement à ce qui est suggéré par le Guide de Référence. Cela forcerait nos Modèles en une implémentation basée sur l'accès à la Base de Données. Cela force aussi les Modèles à connaitre leur moyen de stockage, et encourage les développeurs à librement mélanger un peu partout le code lié à la base de données à celui qui n'en dépend pas. Globalement, c'est un mauvais design orienté objet à moins que votre objectif ne soit réellement de n'utiliser qu'une abstraction de la base de données. Le résultat est que nous insistons sur l'utilisation de composition plutôt que d'héritage, une bonne pratique fondamentale en programmation orientée objet. Tous nos objets métier auront des relations "a-un" ou "a-plusieurs" avec les autres classes, sauf peut-être pour des classes parents abstraites ou des interfaces qui permettront de s'assurer que tous les objets métiers partagent au moins une approche similaire dans leur API.

9.6. Implémentation

Je vous avais prévenu au début de ce livre : en dehors des courts articles que je poste sur mon blog, je développe toujours en utilisant une conception dirigée par les tests (Test Driven Design, TDD). En conséquence, tout le code ci-dessous est présenté avec des tests unitaires à chaque étape. Voyez les choses du bon côté : au moins, vous aurez quelque chose à placer dans le répertoire /tests ! Pour mettre en place le Framework initial de tests, veuillez vous reporter à l'Annexe C: Tests Unitaires et Test Driven Design (qui sera ajoutée bientôt).

9.6.1. Ajouter des Tests Unitaires

Les tests pour notre Modèle seront enregistrés dans /tests/ZFExt/Model ; par exemple, un test pour notre objet métier Entry existera dans /tests/ZFExt/Model/EntryTest.php. Cela requiert l'addition d'un fichier AllTests.php dans le même répertoire, contenant :

  • <?php
  • if (!defined('PHPUnit_MAIN_METHOD')) {
  • define('PHPUnit_MAIN_METHOD', 'ZFExt_Model_AllTests::main');
  • }
  • require_once 'TestHelper.php';
  • require_once 'ZFExt/Model/EntryTest.php';
  • class ZFExt_Model_AllTests
  • {
  • public static function main()
  • {
  • PHPUnit_TextUI_TestRunner::run(self::suite());
  • }
  • public static function suite()
  • {
  • $suite = new PHPUnit_Framework_TestSuite('ZFSTDE Blog Suite: Models');
  • $suite->addTestSuite('ZFExt_Model_EntryTest');
  • return $suite;
  • }
  • }
  • if (PHPUnit_MAIN_METHOD == 'ZFExt_Model_AllTests::main') {
  • ZFExt_Model_AllTests::main();
  • }

Au fur et à mesure que nous implémentons notre Modèle, nous aurons besoin d'ajouter d'autres tests dans ce fichier, afin qu'ils soient exécutés. Ils pourront être ajoutés en suivant le modèle utilisé pour la suite ZFExt_Model_EntryTest. Puisque ce fichier n'est pas le AllTests.php de plus haut niveau, vous devriez l'ajouter au fichier racine /tests/AllTests.php, en utilisant :

  • <?php
  • if (!defined('PHPUnit_MAIN_METHOD')) {
  • define('PHPUnit_MAIN_METHOD', 'AllTests::main');
  • }
  • require_once 'TestHelper.php';
  • require_once 'ZFExt/Model/AllTests.php';
  • class AllTests
  • {
  • public static function main()
  • {
  • PHPUnit_TextUI_TestRunner::run(self::suite());
  • }
  • public static function suite()
  • {
  • $suite = new PHPUnit_Framework_TestSuite('ZFSTDE Blog Suite');
  • $suite->addTest(ZFExt_Model_AllTests::suite());
  • return $suite;
  • }
  • }
  • if (PHPUnit_MAIN_METHOD == 'AllTests::main') {
  • AllTests::main();
  • }

Comme décrit en Annexe, les tests sont lancés en navigant jusqu'à /tests/ZFExt/Model (ou /tests pour lancer l'ensemble des tests de l'application) dans une console, et en lançant :

phpunit AllTests.php

9.6.2. Les Objets Métier

Puisque notre objet métier Entry est juste un simple objet ordinaire, nous pouvons le créer comme simple conteneur de données. Nous devrions toujours namespacer nos classes (en utilisant l'ancienne, pré-5.3, notion d'espace de noms), donc nous utiliserons un espace de noms ZFExt_Model pour toutes les classes en rapport avec le Modèle (ça s'applique aussi aux fichiers de tests). Pour l'instant, tout sera enregistré dans le répertoire /library. Commençons avec un test initial, qui vérifie que nous pouvons définir des propriétés sur nos objets métier, et instancie un objet Entry avec une liste de données. Voici le contenu initial de /tests/ZFExt/Model/EntryTest.php :

  • <?php
  • require_once 'ZFExt/Model/Entry.php';
  • class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
  • {
  • public function testSetsAllowedDomainObjectProperty()
  • {
  • $entry = new ZFExt_Model_Entry;
  • $entry->title = 'My Title';
  • $this->assertEquals('My Title', $entry->title);
  • }
  • public function testConstructorInjectionOfProperties()
  • {
  • $data = array(
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author' => new ZFExt_Model_Author
  • );
  • $entry = new ZFExt_Model_Entry($data);
  • $expected = $data;
  • $expected['id'] = null;
  • $this->assertEquals($expected, $entry->toArray());
  • }
  • }

Nous pouvons maintenant implémenter ceci (de toute manière, les tests vont échouer si la classe n'est pas écrite) dans /library/ZFExt/Model/Entry.php :

  • <?php
  • class ZFExt_Model_Entry
  • {
  • protected $_data = array(
  • 'id' => null,
  • 'title' => '',
  • 'content' => '',
  • 'published_date' => '',
  • 'author' => null
  • );
  • public function __construct(array $data = null)
  • {
  • if (!is_null($data)) {
  • foreach ($data as $name => $value) {
  • $this->{$name} = $value;
  • }
  • }
  • }
  • public function toArray()
  • {
  • return $this->_data;
  • }
  • public function __set($name, $value)
  • {
  • $this->_data[$name] = $value;
  • }
  • public function __get($name)
  • {
  • if (array_key_exists($name, $this->_data)) {
  • return $this->_data[$name];
  • }
  • }
  • }

Vous pourriez vous demander pourquoi nous ne rendons pas toutes les propriétés publiques. L'avantage d'utiliser un tableau protected, et d'utiliser les méthodes magiques de PHP (comme __set()) pour y accéder, est que cela crée une passerrelle à travers laquelle tous les accès passent. Ce qui signifie que, maintenant, nous pouvons utiliser les méthodes magiques pour effectuer des tests sur les propriétés et lever des Exceptions en cas d'erreurs.

Notre nouvel objet est plutôt basique. Ajoutons le reste des méthodes magiques pour que nous puissons vérifier si les propriétés dans le tableau protégé sont définies, et les supprimer si besoin est. Seuls les nouveaux tests sont reproduits ci-dessous :

  • <?php
  • require_once 'ZFExt/Model/Entry.php';
  • class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testReturnsIssetStatusOfProperties()
  • {
  • $entry = new ZFExt_Model_Entry;
  • $entry->title = 'My Title';
  • $this->assertTrue(isset($entry->title));
  • }
  • public function testCanUnsetAnyProperties()
  • {
  • $entry = new ZFExt_Model_Entry;
  • $entry->title = 'My Title';
  • unset($entry->title);
  • $this->assertFalse(isset($entry->title));
  • }
  • }
  • <?php
  • class ZFExt_Model_Entry
  • {
  • protected $_data = array(
  • 'id' => null,
  • 'title' => '',
  • 'content' => '',
  • 'published_date' => '',
  • 'author' => null
  • );
  • public function __construct(array $data = null)
  • {
  • if (!is_null($data)) {
  • foreach ($data as $name => $value) {
  • $this->{$name} = $value;
  • }
  • }
  • }
  • public function toArray()
  • {
  • return $this->_data;
  • }
  • public function __set($name, $value)
  • {
  • $this->_data[$name] = $value;
  • }
  • public function __get($name)
  • {
  • if (array_key_exists($name, $this->_data)) {
  • return $this->_data[$name];
  • }
  • }
  • public function __isset($name)
  • {
  • return isset($this->_data[$name]);
  • }
  • public function __unset($name)
  • {
  • if (isset($this->_data[$name])) {
  • unset($this->_data[$name]);
  • }
  • }
  • }

Notre objet métier est maintenant mieux défini. Pour l'instant, il offre un accès illimité lorsqu'il s'agit de définir des propriétés, mais nos objets n'ont besoin que de celles définies comme clefs de la liste initiale de données. Nous pouvons supprimer la possibilité de définir des propriétés non présentes dans cette liste, et lever une Exception si cela arrive, en ajoutant une vérification supplémentaire à la méthode __set(), comme indiqué ci-dessous :

  • <?php
  • require_once 'ZFExt/Model/Entry.php';
  • class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testCannotSetNewPropertiesUnlessDefinedForDomainObject()
  • {
  • $entry = new ZFExt_Model_Entry;
  • try {
  • $entry->notdefined = 1;
  • $this->fail('Setting new property not defined in class should'
  • . ' have raised an Exception');
  • } catch (ZFExt_Model_Exception $e) {
  • }
  • }
  • }
  • <?php
  • class ZFExt_Model_Entry
  • {
  • protected $_data = array(
  • 'id' => null,
  • 'title' => '',
  • 'content' => '',
  • 'published_date' => '',
  • 'author' => null
  • );
  • public function __construct(array $data = null)
  • {
  • if (!is_null($data)) {
  • foreach ($data as $name => $value) {
  • $this->{$name} = $value;
  • }
  • }
  • }
  • public function toArray()
  • {
  • return $this->_data;
  • }
  • public function __set($name, $value)
  • {
  • if (!array_key_exists($name, $this->_data)) {
  • throw new ZFExt_Model_Exception('You cannot set new properties'
  • . 'on this object');
  • }
  • $this->_data[$name] = $value;
  • }
  • public function __get($name)
  • {
  • if (array_key_exists($name, $this->_data)) {
  • return $this->_data[$name];
  • }
  • }
  • public function __isset($name)
  • {
  • return isset($this->_data[$name]);
  • }
  • public function __unset($name)
  • {
  • if (isset($this->_data[$name])) {
  • unset($this->_data[$name]);
  • }
  • }
  • }

Ensuite, notre objet Entry va contenir un objet Author. Puisque la plupart de ces objets métier vont probablement dupliquer du code que nous venons d'écrire dans ZFExt_Model_Entry, nous devrions retravailler notre classe pour qu'elle hérite d'un parent contenant toutes les méthodes potentiellement réutilisables. Ici nous ajoutons une nouvelle classe mère, nommée ZFExt_Model_Entity, pour remplir ce rôle :

  • <?php
  • class ZFExt_Model_Entity
  • {
  • public function __construct(array $data = null)
  • {
  • if (!is_null($data)) {
  • foreach ($data as $name => $value) {
  • $this->{$name} = $value;
  • }
  • }
  • }
  • public function toArray()
  • {
  • return $this->_data;
  • }
  • public function __set($name, $value)
  • {
  • if (!array_key_exists($name, $this->_data)) {
  • throw new ZFExt_Model_Exception('You cannot set new properties'
  • . ' on this object');
  • }
  • $this->_data[$name] = $value;
  • }
  • public function __get($name)
  • {
  • if (array_key_exists($name, $this->_data)) {
  • return $this->_data[$name];
  • }
  • }
  • public function __isset($name)
  • {
  • return isset($this->_data[$name]);
  • }
  • public function __unset($name)
  • {
  • if (isset($this->_data[$name])) {
  • unset($this->_data[$name]);
  • }
  • }
  • }
  • <?php
  • class ZFExt_Model_Entry extends ZFExt_Model_Entity
  • {
  • protected $_data = array(
  • 'id' => null,
  • 'title' => '',
  • 'content' => '',
  • 'published_date' => '',
  • 'author' => null
  • );
  • }

Lancer nos tests une fois de plus confirmera que ce refactoring a été effectué avec succès.

A présent, ajoutons une classe similaire pour les auteurs. En premier lieu, pour préparer les nouveaux tests, éditez votre fichier /tests/ZFExt/Model/AllTests.php pour inclure un nouveau test, enregistré dans /tests/ZFExt/Model/AuthorTest.php. Les tests et la classe qui les contient vont être très proches de ceux de l'objet Entry. Voici les tests initiaux, qui reflètent ceux écrits pour l'objet Entry, mais avec les propriétés correspondant à un objet Author :

  • <?php
  • class ZFExt_Model_AuthorTest extends PHPUnit_Framework_TestCase
  • {
  • public function testSetsAllowedDomainObjectProperty()
  • {
  • $author = new ZFExt_Model_Author;
  • $author->fullname = 'Joe';
  • $this->assertEquals('Joe', $author->fullname);
  • }
  • public function testConstructorInjectionOfProperties()
  • {
  • $data = array(
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • );
  • $author = new ZFExt_Model_Author($data);
  • $expected = $data;
  • $expected['id'] = null;
  • $this->assertEquals($expected, $author->toArray());
  • }
  • public function testReturnsIssetStatusOfProperties()
  • {
  • $author = new ZFExt_Model_Author;
  • $author->fullname = 'Joe Bloggs';
  • $this->assertTrue(isset($author->fullname));
  • }
  • public function testCanUnsetAnyProperties()
  • {
  • $author = new ZFExt_Model_Author;
  • $author->fullname = 'Joe Bloggs';
  • unset($author->fullname);
  • $this->assertFalse(isset($author->fullname));
  • }
  • public function testCannotSetNewPropertiesUnlessDefinedInClass()
  • {
  • $author = new ZFExt_Model_Author;
  • try {
  • $author->notdefinedinclass = 1;
  • $this->fail('Setting new property not defined in class should'
  • . ' have raised an Exception');
  • } catch (ZFExt_Model_Exception $e) {
  • }
  • }
  • }

Et voici l'implémentation qui passe l'ensemble de ces nouveaux tests :

  • <?php
  • class ZFExt_Model_Author extends ZFExt_Model_Entity
  • {
  • protected $_data = array(
  • 'id' => null,
  • 'username' => '',
  • 'fullname' => '',
  • 'email' => '',
  • 'url' => ''
  • );
  • }

Comme mesure finale, pour nous assurer que notre interface est liée à ces objets métier, faisons en sorte que ZFExt_Model_Entry n'accepte que des objets ZFExt_Model_Author lorsque nous définissons une propriété author. Comme d'habitude, le test en premier lieu, et, ensuite seulement, le code qui permet à ce test de réussir.

  • <?php
  • class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testThrowsExceptionIfAuthorNotAnAuthorEntityObject()
  • {
  • $entry = new ZFExt_Model_Entry;
  • try {
  • $entry->author = 1;
  • $this->fail('Setting author should have raised an Exception'
  • . ' since value was not an instance of ZFExt_Model_Author');
  • } catch (ZFExt_Model_Exception $e) {
  • }
  • }
  • }
  • <?php
  • class ZFExt_Model_Entry extends ZFExt_Model_Entity
  • {
  • protected $_data = array(
  • 'id' => null,
  • 'title' => '',
  • 'content' => '',
  • 'published_date' => '',
  • 'author' => null
  • );
  • public function __set($name, $value)
  • {
  • if ($name == 'author' && !$value instanceof ZFExt_Model_Author) {
  • throw new ZFExt_Model_Exception('Author can only be set using'
  • . ' an instance of ZFExt_Model_Author');
  • }
  • parent::__set($name, $value);
  • }
  • }

Tout ce que nous avons fait ici est surcharger la méthode __set() de la classe-mère pour ajouter une nouvelle vérification assurant que notre objet n'accepte pour author qu'une valeur qui soit un objet de type ZFExt_Model_Author. Dans les autres cas, nous pouvons re-donner le contrôle à la méthode de classe parente, pour définir la propriété.

Et voila pour l'instant ! Passons maintenant à l'implémentation du Data Mapper, afin que nous puissions enregistrer ces objets en base de données et les récupérer.

9.6.3. Les Data Mappers

Notre Data Mapper utilisera Zend_Db_Table à l'arrière plan, donc sa fonction dans notre design est de gérer les opérations CRUD typiques. Plus tard, nous verrons aussi qu'il peut contenir des méthodes aux usages plus spécifiques, comme le chargement conditionnel de données. Pour l'instant, concentrons-nous sur sa création. Dans le premier test que nous allons ajouter, notre Data Mapper sera instancié, et, à son tour, créera une instance configurée de Zend_Db_Table_Abstract, avec laquelle il travaillera. Vous noterez que je n'utilise pas une base de données réelle. Bien que cela signifie un peu plus de code pour commencer, j'utilise un Mock Object (une sorte de doublure de tests) à la place d'un objet Zend_Db_Table_Abstract réel. Cela me permettra de contrôler tout ce que cet objet fera, y compris ses valeurs de retour, et de définir des attentes sur les méthodes qui devraient être appelées, avec quels arguments, etc. La raison principale pour laquelle j'effectue ceci est parce que, de toute façon, une base de données réelle n'apporterait rien de plus : nos tests n'en n'ont pas besoin. Si nous utilisions une base de données, cela fonctionnerait aussi, mais, dans ce cas, nous testerions aussi Zend_Db_Table_Abstract en plus de tout le reste, puisqu'il serait effectivement appelé. Zend Framework a déjà des tests pour ce composant.

Comme précédemment, pour ajouter ce test à notre suitre, ajoutez le fichier et la classe à /tests/ZFExt/Model/AllTests.php, les tests en eux-même étant écrits dans /tests/ZFExt/Model/EntryMapperTest.php :

  • <?php
  • class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
  • {
  • protected $_tableGateway = null;
  • protected $_adapter = null;
  • protected $_rowset = null;
  • protected $_mapper = null;
  • public function setup()
  • {
  • $this->_tableGateway = $this->_getCleanMock(
  • 'Zend_Db_Table_Abstract'
  • );
  • $this->_adapter = $this->_getCleanMock(
  • 'Zend_Db_Adapter_Abstract'
  • );
  • $this->_rowset = $this->_getCleanMock(
  • 'Zend_Db_Table_Rowset_Abstract'
  • );
  • $this->_tableGateway->expects($this->any())->method('getAdapter')
  • ->will($this->returnValue($this->_adapter));
  • $this->_mapper = new ZFExt_Model_EntryMapper($this->_tableGateway);
  • }
  • protected function _getCleanMock($className) {
  • $class = new ReflectionClass($className);
  • $methods = $class->getMethods();
  • $stubMethods = array();
  • foreach ($methods as $method) {
  • if ($method->isPublic() || ($method->isProtected()
  • && $method->isAbstract())) {
  • $stubMethods[] = $method->getName();
  • }
  • }
  • $mocked = $this->getMock(
  • $className,
  • $stubMethods,
  • array(),
  • $className . '_EntryMapperTestMock_' . uniqid(),
  • false
  • );
  • return $mocked;
  • }
  • }

Tous les tests de Data Mappers se baseront sur le mocking de Zend_Db_Table_Abstract : après tout, ce composant est déjà testé par l'équipe de Zend Framework, donc il est inutile d'utiliser un véritable objet connecté à une base de données. Typiquement, nous ne passerons pas une réelle instance au constructeur lorsque nous utiliserons ceci dans l'application ; nous pouvons, à la place, faire confiance au constructeur pour la création d'une instance adaptée. Ce squelette de tests est conçu pour créer une version complètement mockée de Zend_Db_Table_Abstract.

Bien que ce soit difficile à déterminer à partir du manuel de PHPUnit sans aller fouiller dans le code, la méthode protégée _getCleanMock() que j'utilise crée un mock object complètement propre, avec toutes ses méthodes définies et mockées. Elle crée un nom unique pour le mock à chaque appel, assurant que les noms de classes mockées n'entreront pas en conflit. La seule étape nécessaire pour l'instant est de s'assurer que tous les mocks objects de Zend_Db_Table_Abstract retournent aussi un Adapter mocké. Concrètement, la seule raison de mocker l'Adapter est parce qu'il a une méthode fréquemment utilisée, quoteInto(), pour échapper les valeur dans les expressions ou conditions SQL.

Voici notre implémentation initale (et pour l'instant non testée) montrant pourquoi tester une véritable instanciation n'en vaut pas la peine : elle est extrêmement simple, et, encore une fois, la tester reviendrait uniquement à tester Zend_Db_Table_Abstract.

  • <?php
  • class ZFExt_Model_EntryMapper
  • {
  • protected $_tableGateway = null;
  • protected $_tableName = 'entries';
  • public function __construct(Zend_Db_Table_Abstract $tableGateway)
  • {
  • if (is_null($tableGateway)) {
  • $this->_tableGateway = new Zend_Db_Table($this->_tableName);
  • } else {
  • $this->_tableGateway = $tableGateway;
  • }
  • }
  • protected function _getGateway()
  • {
  • return $this->_tableGateway;
  • }
  • }

Dans le code ci-dessus, nous créons une instance de Zend_Db_Table, avec laquelle nous accéderons à la base de données. Bien que cette classe soit appelée Abstract, elle contient en fait aucune méthode abstraite. La seule configuration dont nous ayons besoin pour l'instant est d'indiquer à cette instance quelle table en base de données sera à utiliser. Nous n'avons pas besoin de passer d'option de configuration de connexion à quelque base que ce soit, parce que nous pouvons définir un Adapter de base de données par défaut dans notre bootstrap ; ce que nous ferons un peu plus loin.

Maintenant, commençons à ajouter quelques méthodes utiles. Nous commencerons avec une méthode pour enregistrer un nouvel objet métier. Puisque nous avons mocké Zend_Db_Table_Abstract, nous ne faisons pas la moindre assertion directe pour l'instant. Nos assertions correspondent plutôt à de la définition d'attentes sur les mock objects, de manière à vérifier que notre Mapper appelle, comme attendu, la méthode insert() de la classe Zend_Db_Table_Abstract avec la bonne liste de données.

  • <?php
  • class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testSavesNewEntryAndSetsEntryIdOnSave() {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 2,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • $entry = new ZFExt_Model_Entry(array(
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author' => $author
  • ));
  • // set mock expectation on calling Zend_Db_Table::insert()
  • $insertionData = array(
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author_id' => 2
  • );
  • $this->_tableGateway->expects($this->once())
  • ->method('insert')
  • ->with($this->equalTo($insertionData))
  • ->will($this->returnValue(123));
  • $this->_mapper->save($entry);
  • $this->assertEquals(123, $entry->id);
  • }
  • // ...
  • }

Et voici l'implémentation qui passe ce test :

  • <?php
  • class ZFExt_Model_EntryMapper
  • {
  • protected $_tableGateway = null;
  • protected $_tableName = 'entries';
  • public function __construct(Zend_Db_Table_Abstract $tableGateway)
  • {
  • if (is_null($tableGateway)) {
  • $this->_tableGateway = new Zend_Db_Table($this->_tableName);
  • } else {
  • $this->_tableGateway = $tableGateway;
  • }
  • }
  • protected function _getGateway()
  • {
  • return $this->_tableGateway;
  • }
  • public function save(ZFExt_Model_Entry $entry)
  • {
  • $data = array(
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $entry->id = $this->_getGateway()->insert($data);
  • }
  • }

Enregistrer une nouvelle ligne vers la base de données signifie appeler Zend_Db_Table_Abstract::insert() avec la liste des noms de colonnes et valeurs à insérer dans la table "entries" de la base de données. Le nom de la table est défini au sein du constructeur du Data Mapper. Le champ id est omis, puisqu'il sera obtenu à partir de la valeur de retour de Zend_Db_Table_Abstract::insert().

Comme vous pouvez le voir, notre Mapper connait vraiment le schéma de la base de données : il fait correspondre la propriété id de l'objet Author avec un champ nommé author_id dans la table. L'objet métier ne sait pas que cette colonne en base de données existe. Le reste des données de l'auteur est ignoré, puisqu'elles sont enregistrées dans une autre table, et ne sont pas nouvelles. En fait, ce n'est pas un point si subtil : vous ne pouvez pas enregistrer un auteur de cette manière, puisque les objets Author ne peuvent être enregistrés que via un futur Data Mapper Author.

Nous pourrions aussi vouloir mettre à jour des entrées, et cela devrait être assez simple, puisqu'elles auront déjà une valeur d'id existante. Ajoutons un test, et l'implémentation correspondante, pour le comportement de mise à jour. Encore une fois, nous utiliserons des attentes sur un Mock Object, plutôt que des assertions. Vous devriez noter que les attentes sont vérifiées, bien entendu, ce qui signifie que si la moindre contrainte sur les nombres d'appels de méthodes ou autre ne sont pas respectées par votre implémentation, le test échouera.

  • <?php
  • class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testUpdatesExistingEntry() {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 2,
  • 'name' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • $entry = new ZFExt_Model_Entry(array(
  • 'id' => 1,
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author' => $author
  • ));
  • // set mock expectation on calling Zend_Db_Table::update()
  • $updateData = array(
  • 'id' => 1,
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author_id' => 2
  • );
  • // quoteInto() is called to escape parameters from the adapter
  • $this->_adapter->expects($this->once())
  • ->method('quoteInto')
  • ->will($this->returnValue('id = 1'));
  • $this->_tableGateway->expects($this->once())
  • ->method('update')
  • ->with($this->equalTo($updateData), $this->equalTo('id = 1'));
  • $this->_mapper->save($entry);
  • }
  • // ...
  • }
  • <?php
  • class ZFExt_Model_EntryMapper
  • {
  • protected $_tableGateway = null;
  • protected $_tableName = 'entries';
  • public function __construct(Zend_Db_Table_Abstract $tableGateway)
  • {
  • if (is_null($tableGateway)) {
  • $this->_tableGateway = new Zend_Db_Table($this->_tableName);
  • } else {
  • $this->_tableGateway = $tableGateway;
  • }
  • }
  • protected function _getGateway()
  • {
  • return $this->_tableGateway;
  • }
  • public function save(ZFExt_Model_Entry $entry)
  • {
  • if (!$entry->id) {
  • $data = array(
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $entry->id = $this->getGateway()->insert($data);
  • } else {
  • $data = array(
  • 'id' => $entry->id,
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $where = $this->getGateway()->getAdapter()
  • ->quoteInto('entry_id = ?', $entry->id);
  • $this->getGateway()->update($data, $where);
  • }
  • }
  • }

Ajoutons encore une méthode de plus avant de passer à la suite ; nous ajouterons les autres au fur et à mesure de la mise en place de notre application de blogging. En plus de l'enregistrement et de la mise à jour, au strict minimum, nous aurons aussi besoin de supprimer et de récupérer des entrées.

Cela pose au moins un problème, du fait que les entrées peuvent contenir des auteurs. Pour permettre à l'EntryMapper de récupérer un objet Author, nous devons commencer par ajouter un Data Mapper pour les auteurs. Voici l'ensemble complet de tests et une implémentation pour la classe ZFExt_Model_AuthorMapper (la plus grande partie étant très proche des tests que nous avons écrit jusqu'à présent ; et en inclut quelques autres que nous verrons très bientôt pour le Data Mapper Entry).

  • <?php
  • class ZFExt_Model_AuthorMapperTest extends PHPUnit_Framework_TestCase
  • {
  • protected $_tableGateway = null;
  • protected $_adapter = null;
  • protected $_rowset = null;
  • protected $_mapper = null;
  • public function setup()
  • {
  • $this->_tableGateway = $this->_getCleanMock(
  • 'Zend_Db_Table_Abstract'
  • );
  • $this->_adapter = $this->_getCleanMock(
  • 'Zend_Db_Adapter_Abstract'
  • );
  • $this->_rowset = $this->_getCleanMock(
  • 'Zend_Db_Table_Rowset_Abstract'
  • );
  • $this->_tableGateway->expects($this->any())->method('getAdapter')
  • ->will($this->returnValue($this->_adapter));
  • $this->_mapper = new ZFExt_Model_AuthorMapper($this->_tableGateway);
  • }
  • public function testCreatesSuitableTableDataGatewayObjectWhenInstantiated()
  • {
  • $mapper = new ZFExt_Model_EntryMapper($this->_tableGateway);
  • $this->assertTrue($mapper->getGateway()
  • instanceof Zend_Db_Table_Abstract);
  • }
  • public function testSavesNewAuthorAndSetsAuthorIdOnSave() {
  • $author = new ZFExt_Model_Author(array(
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • // set mock expectation on calling Zend_Db_Table::insert()
  • $insertionData = array(
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • );
  • $this->_tableGateway->expects($this->once())
  • ->method('insert')
  • ->with($this->equalTo($insertionData))
  • ->will($this->returnValue(123));
  • $this->_mapper->save($author);
  • $this->assertEquals(123, $author->id);
  • }
  • public function testUpdatesExistingAuthor() {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 2,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • // set mock expectation on calling Zend_Db_Table::update()
  • $updateData = array(
  • 'id' => 2,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • );
  • $this->_adapter->expects($this->once())
  • ->method('quoteInto')
  • ->will($this->returnValue('id = 2'));
  • $this->_tableGateway->expects($this->once())
  • ->method('update')
  • ->with($this->equalTo($updateData), $this->equalTo('id = 2'));
  • $this->_mapper->save($author);
  • }
  • public function testFindsRecordByIdAndReturnsDomainObject()
  • {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 1,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • // expected rowset result for found entry
  • $dbData = new stdClass;
  • $dbData->id = 1;
  • $dbData->fullname = 'Joe Bloggs';
  • $dbData->username = 'joe_bloggs';
  • $dbData->email = 'joe@example.com';
  • $dbData->url = 'http://www.example.com';
  • // set mock expectation on calling Zend_Db_Table::find()
  • $this->_rowset->expects($this->once())
  • ->method('current')
  • ->will($this->returnValue($dbData));
  • $this->_tableGateway->expects($this->once())
  • ->method('find')
  • ->with($this->equalTo(1))
  • ->will($this->returnValue($this->_rowset));
  • $entryResult = $this->_mapper->find(1);
  • $this->assertEquals($author, $entryResult);
  • }
  • public function testDeletesAuthorUsingEntryId()
  • {
  • $this->_adapter->expects($this->once())
  • ->method('quoteInto')
  • ->with($this->equalTo('id = ?'), $this->equalTo(1))
  • ->will($this->returnValue('author_id = 1'));
  • $this->_tableGateway->expects($this->once())
  • ->method('delete')
  • ->with($this->equalTo('id = 1'));
  • $this->_mapper->delete(1);
  • }
  • public function testDeletesAuthorUsingEntryObject()
  • {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 1,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • $this->_adapter->expects($this->once())
  • ->method('quoteInto')
  • ->with($this->equalTo('id = ?'), $this->equalTo(1))
  • ->will($this->returnValue('author_id = 1'));
  • $this->_tableGateway->expects($this->once())
  • ->method('delete')
  • ->with($this->equalTo('id = 1'));
  • $this->_mapper->delete($author);
  • }
  • protected function _getCleanMock($className) {
  • $class = new ReflectionClass($className);
  • $methods = $class->getMethods();
  • $stubMethods = array();
  • foreach ($methods as $method) {
  • if ($method->isPublic() || ($method->isProtected()
  • && $method->isAbstract())) {
  • $stubMethods[] = $method->getName();
  • }
  • }
  • $mocked = $this->getMock(
  • $className,
  • $stubMethods,
  • array(),
  • $className . '_AuthorMapperTestMock_' . uniqid(),
  • false
  • );
  • return $mocked;
  • }
  • }

L'implémentation de cette classe est à l'image de cette du Mapper Entry, et ajoute les méthodes supplémentaires find() et delete() :

  • <?php
  • class ZFExt_Model_AuthorMapper
  • {
  • protected $_tableGateway = null;
  • protected $_tableName = 'authors';
  • protected $_entityClass = 'ZFExt_Model_Author';
  • public function __construct(Zend_Db_Table_Abstract $tableGateway)
  • {
  • if (is_null($tableGateway)) {
  • $this->_tableGateway = new Zend_Db_Table($this->_tableName);
  • } else {
  • $this->_tableGateway = $tableGateway;
  • }
  • }
  • protected function _getGateway()
  • {
  • return $this->_tableGateway;
  • }
  • public function save(ZFExt_Model_Author $author)
  • {
  • if (!$author->id) {
  • $data = array(
  • 'fullname' => $author->fullname,
  • 'username' => $author->username,
  • 'email' => $author->email,
  • 'url' => $author->url
  • );
  • $author->id = $this->_getGateway()->insert($data);
  • } else {
  • $data = array(
  • 'id' => $author->id,
  • 'fullname' => $author->fullname,
  • 'username' => $author->username,
  • 'email' => $author->email,
  • 'url' => $author->url
  • );
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $author->id);
  • $this->_getGateway()->update($data, $where);
  • }
  • }
  • public function find($id)
  • {
  • $result = $this->_getGateway()->find($id)->current();
  • $author = new $this->_entityClass(array(
  • 'id' => $result->id,
  • 'fullname' => $result->fullname,
  • 'username' => $result->username,
  • 'email' => $result->email,
  • 'url' => $result->url
  • ));
  • return $author;
  • }
  • public function delete($author)
  • {
  • if ($author instanceof ZFExt_Model_Author) {
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $author->id);
  • } else {
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $author);
  • }
  • $this->_getGateway()->delete($where);
  • }
  • }

Maintenant que nous avons ce nouveau Data Mapper Author à disposition, nous pouvons l'utiliser dans notre Data Mapper Entry pour récupérer tout objet author qui doit être inclu dans l'objet Entry retourné depuis une nouvelle méthode find(). Nous pouvons aussi ajouter une méthode delete() similaire.

Voici les tests pour la recherche d'une entrée en utilisant sa propriété id, et la suppression d'une entrée par son id :

  • <?php
  • class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testFindsRecordByIdAndReturnsDomainObject()
  • {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 1,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • $entry = new ZFExt_Model_Entry(array(
  • 'id' => 1,
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author' => $author
  • ));
  • // expected rowset result for found entry
  • $dbData = new stdClass;
  • $dbData->id = 1;
  • $dbData->title = 'My Title';
  • $dbData->content = 'My Content';
  • $dbData->published_date = '2009-08-17T17:30:00Z';
  • $dbData->author_id = 1;
  • // set mock expectation on calling Zend_Db_Table::find()
  • $this->_rowset->expects($this->once())
  • ->method('current')
  • ->will($this->returnValue($dbData));
  • $this->_tableGateway->expects($this->once())
  • ->method('find')
  • ->with($this->equalTo(1))
  • ->will($this->returnValue($this->_rowset));
  • // mock the AuthorMapper - it has separate tests
  • $authorMapper = $this->_getCleanMock('ZFExt_Model_AuthorMapper');
  • $authorMapper->expects($this->once())
  • ->method('find')->with($this->equalTo(1))
  • ->will($this->returnValue($author));
  • $this->_mapper->setAuthorMapper($authorMapper);
  • $entryResult = $this->_mapper->find(1);
  • $this->assertEquals($entry, $entryResult);
  • }
  • public function testDeletesEntryUsingEntryId()
  • {
  • $this->_adapter->expects($this->once())
  • ->method('quoteInto')
  • ->with($this->equalTo('id = ?'), $this->equalTo(1))
  • ->will($this->returnValue('entry_id = 1'));
  • $this->_tableGateway->expects($this->once())
  • ->method('delete')
  • ->with($this->equalTo('id = 1'));
  • $this->_mapper->delete(1);
  • }
  • public function testDeletesEntryUsingEntryObject()
  • {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 2,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • $entry = new ZFExt_Model_Entry(array(
  • 'id' => 1,
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author' => $author
  • ));
  • $this->_adapter->expects($this->once())
  • ->method('quoteInto')
  • ->with($this->equalTo('id = ?'), $this->equalTo(1))
  • ->will($this->returnValue('entry_id = 1'));
  • $this->_tableGateway->expects($this->once())
  • ->method('delete')
  • ->with($this->equalTo('id = 1'));
  • $this->_mapper->delete($entry);
  • }
  • // ...
  • }

Et voici notre implémentation avec ces deux nouvelles méthodes. Comme les tests le suggéraient, nous pouvons supprimer des entrées soit en passant une valeur entière d'id, soit en passant l'objet lui-même.

  • <?php
  • class ZFExt_Model_EntryMapper
  • {
  • protected $_tableGateway = null;
  • protected $_tableName = 'entries';
  • protected $_entityClass = 'ZFExt_Model_Entry';
  • protected $_authorMapperClass = 'ZFExt_Model_AuthorMapper';
  • protected $_authorMapper = null;
  • public function __construct(Zend_Db_Table_Abstract $tableGateway)
  • {
  • if (is_null($tableGateway)) {
  • $this->_tableGateway = new Zend_Db_Table($this->_tableName);
  • } else {
  • $this->_tableGateway = $tableGateway;
  • }
  • }
  • protected function _getGateway()
  • {
  • return $this->_tableGateway;
  • }
  • public function save(ZFExt_Model_Entry $entry)
  • {
  • if (!$entry->id) {
  • $data = array(
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $entry->id = $this->_getGateway()->insert($data);
  • } else {
  • $data = array(
  • 'id' => $entry->id,
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $entry->id);
  • $this->_getGateway()->update($data, $where);
  • }
  • }
  • public function find($id)
  • {
  • $result = $this->_getGateway()->find($id)->current();
  • if (!$this->_authorMapper) {
  • $this->_authorMapper = new $this->_authorMapperClass;
  • }
  • $author = $this->_authorMapper->find($result->author_id);
  • $entry = new $this->_entityClass(array(
  • 'id' => $result->id,
  • 'title' => $result->title,
  • 'content' => $result->content,
  • 'published_date' => $result->published_date,
  • 'author' => $author
  • ));
  • return $entry;
  • }
  • public function delete($entry)
  • {
  • if ($entry instanceof ZFExt_Model_Entry) {
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $entry->id);
  • } else {
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $entry);
  • }
  • $this->_getGateway()->delete($where);
  • }
  • public function setAuthorMapper(ZFExt_Model_AuthorMapper $mapper)
  • {
  • $this->_authorMapper = $mapper;
  • }
  • }

Et, finallement... Nous avons quelque chose qui ressemble à une implémentation de Data Mapper qui fonctionne ! Voici ce que l'exécution finale des tests devrait donner en sortie de PHPUnit :

PHPUnit 3.3.17 by Sebastian Bergmann.

.......................

Time: 0 seconds

OK (23 tests, 51 assertions)

9.6.4. Lazy Loading des Objets Métier

Notre implémentation du Data Mapper dans ZFExt_Model_EntryMapper prend la route qui nécessite deux requêtes SQL pour créer un objet métier complet : une pour l'entrée elle-même, et une autre pour l'auteur référencé par celle-ci. Il pourrait y avoir des cas où nous n'ayons tout simplement pas besoin des détails de l'auteur, et, dans ces cas, la requête supplémentaire est inutile. Il serait intéressant de modifier le Data Mapper pour qu'il charge paresseusement les données de l'auteur à la demande, ce qui économiserait potentiellement des appels à la base de données.

Nous avons déjà vu comment nous pouvions surcharger la méthode __set() pour valider une propriété en train d'être définie, et nous pouvons utiliser la méthode __get() pour arriver au même type de fonctionnalité, en interceptant toute tentative d'accès à l'objet author dans notre objet métier entry, et, seulement à ce moment là, lancer une requête via ZFExt_Model_AuthorMapper pour charger cet objet.

Puisque, de toute évidence, cela modifie des comportements déjà existant et testés, nous devons modifier au moins un des tests pour le Data Mapper Entry. Nous avons aussi besoin de trouver une solution pour stocker la valeur de l'id de l'auteur dans l'objet métier, de manière à avoir quelque chose à partir de quoi charger paresseusement ; et, finalement, nous devons nous assurer que le chargement paresseux fonctionne réellement. Voici les tests nouveaux/revus, à la fois pour le Mapper Entry, et pour l'objet Entry lui-même :

  • <?php
  • class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testFindsRecordByIdAndReturnsDomainObject()
  • {
  • $entry = new ZFExt_Model_Entry(array(
  • 'id' => 1,
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z'
  • ));
  • // expected rowset result for found entry
  • $dbData = new stdClass;
  • $dbData->id = 1;
  • $dbData->title = 'My Title';
  • $dbData->content = 'My Content';
  • $dbData->published_date = '2009-08-17T17:30:00Z';
  • $dbData->author_id = 1;
  • // set mock expectation on calling Zend_Db_Table::find()
  • $this->_rowset->expects($this->once())
  • ->method('current')
  • ->will($this->returnValue($dbData));
  • $this->_tableGateway->expects($this->once())
  • ->method('find')
  • ->with($this->equalTo(1))
  • ->will($this->returnValue($this->_rowset));
  • $entryResult = $this->_mapper->find(1);
  • $this->assertEquals('My Title', $entryResult->title);
  • }
  • public function testFoundRecordCausesAuthorReferenceIdToBeSetOnEntryObject()
  • {
  • $entry = new ZFExt_Model_Entry(array(
  • 'id' => 1,
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z'
  • ));
  • // expected rowset result for found entry
  • $dbData = new stdClass;
  • $dbData->id = 1;
  • $dbData->title = 'My Title';
  • $dbData->content = 'My Content';
  • $dbData->published_date = '2009-08-17T17:30:00Z';
  • $dbData->author_id = 5;
  • // set mock expectation on calling Zend_Db_Table::find()
  • $this->_rowset->expects($this->once())
  • ->method('current')
  • ->will($this->returnValue($dbData));
  • $this->_tableGateway->expects($this->once())
  • ->method('find')
  • ->with($this->equalTo(1))
  • ->will($this->returnValue($this->_rowset));
  • $entryResult = $this->_mapper->find(1);
  • $this->assertEquals(5, $entryResult->getReferenceId('author'));
  • }
  • // ...
  • }
  • <?php
  • class ZFExt_Model_EntryTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testAllowsAuthorIdToBeStoredAsAReference()
  • {
  • $entry = new ZFExt_Model_Entry;
  • $entry->setReferenceId('author', 5);
  • $this->assertEquals(5, $entry->getReferenceId('author'));
  • }
  • public function testLazyLoadingAuthorsRetrievesAuthorDomainObject()
  • {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 5,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • $entry = new ZFExt_Model_Entry;
  • $entry->setReferenceId('author', 5);
  • $authorMapper = $this->_getCleanMock('ZFExt_Model_AuthorMapper');
  • $authorMapper->expects($this->once())
  • ->method('find')
  • ->with($this->equalTo(5))
  • ->will($this->returnValue($author));
  • $entry->setAuthorMapper($authorMapper);
  • $this->assertEquals('Joe Bloggs', $entry->author->fullname);
  • }
  • protected function _getCleanMock($className) {
  • $class = new ReflectionClass($className);
  • $methods = $class->getMethods();
  • $stubMethods = array();
  • foreach ($methods as $method) {
  • if ($method->isPublic() || ($method->isProtected()
  • && $method->isAbstract())) {
  • $stubMethods[] = $method->getName();
  • }
  • }
  • $mocked = $this->getMock(
  • $className,
  • $stubMethods,
  • array(),
  • $className . '_EntryTestMock_' . uniqid(),
  • false
  • );
  • return $mocked;
  • }
  • // ...
  • }

Notre point de départ pour l'implémentation est de modifier la classe ZFExt_Model_Entry pour accepter la référence d'id vers un auteur, pour usage ultérieur. Puisque le chargement paresseux se passe dans cet objet, nous devons aussi transférer la connaissance initiale de ZFExt_Model_AuthorMapper depuis ZFExt_Model_EntryMapper vers l'objet lui-même. Techniquement, les références peuvent se produire depuis n'importe quel objet métier en ayant besoin, ce qui signifie que nous pouvons ajouter cette fonctionnalité dans la classe-mère ZFExt_Model_Entity. La classe ZFExt_Model_Entry peut utiliser ces méthodes de la classe parente pour définir et récupérer les informations de la référence.

  • <?php
  • class ZFExt_Model_Entity
  • {
  • protected $_references = array();
  • // ...
  • public function setReferenceId($name, $id)
  • {
  • $this->_references[$name] = $id;
  • }
  • public function getReferenceId($name)
  • {
  • if (isset($this->_references[$name])) {
  • return $this->_references[$name];
  • }
  • }
  • }
  • <?php
  • class ZFExt_Model_Entry extends ZFExt_Model_Entity
  • {
  • protected $_data = array(
  • 'id' => null,
  • 'title' => '',
  • 'content' => '',
  • 'published_date' => '',
  • 'author' => null
  • );
  • protected $_authorMapperClass = 'ZFExt_Model_AuthorMapper';
  • protected $_authorMapper = null;
  • public function __set($name, $value)
  • {
  • if ($name == 'author' && !$value instanceof ZFExt_Model_Author ) {
  • throw new ZFExt_Model_Exception('Author can only be set using'
  • . ' an instance of ZFExt_Model_Author');
  • }
  • parent::__set($name, $value);
  • }
  • public function __get($name)
  • {
  • if ($name == 'author' && $this->getReferenceId('author')
  • && !$this->_data['author'] instanceof ZFExt_Model_Author) {
  • if (!$this->_authorMapper) {
  • $this->_authorMapper = new $this->_authorMapperClass;
  • }
  • $this->_data['author'] = $this->_authorMapper
  • ->find($this->getReferenceId('author'));
  • }
  • return parent::__get($name);
  • }
  • public function setAuthorMapper(ZFExt_Model_AuthorMapper $mapper)
  • {
  • $this->_authorMapper = $mapper;
  • }
  • }

Notez la nouvelle méthode __get(). Elle intercepte toute tentative d'accès à la propriété author de l'objet. A moins que l'objet en question ne contienne déjà un objet author, elle tentera d'en charger un depuis la base de données, mais seulement si un id de référence (autrement dit, l'id de l'auteur) a été défini ; par exemple, lorsque l'entrée a été initialement chargée. Dans le cas contraire, elle retourne null, ce qui arrivera dans le cas où il s'agit d'un nouvel objet pour lequel aucun auteur n'a encore été défini.

Voici la version revue de la classe ZFExt_Model_EntryMapper. La seule modification est la suppression du chargement automatique des objets author, et, à la place, l'initialisation de la valeur de author_id comme référence sur l'objet entry résultant.

  • <?php
  • class ZFExt_Model_EntryMapper
  • {
  • protected $_tableGateway = null;
  • protected $_tableName = 'entries';
  • protected $_entityClass = 'ZFExt_Model_Entry';
  • public function __construct(Zend_Db_Table_Abstract $tableGateway)
  • {
  • if (is_null($tableGateway)) {
  • $this->_tableGateway = new Zend_Db_Table($this->_tableName);
  • } else {
  • $this->_tableGateway = $tableGateway;
  • }
  • }
  • protected function _getGateway()
  • {
  • return $this->_tableGateway;
  • }
  • public function save(ZFExt_Model_Entry $entry)
  • {
  • if (!$entry->id) {
  • $data = array(
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $entry->id = $this->_getGateway()->insert($data);
  • } else {
  • $data = array(
  • 'id' => $entry->id,
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $entry->id);
  • $this->_getGateway()->update($data, $where);
  • }
  • }
  • public function find($id)
  • {
  • $result = $this->_getGateway()->find($id)->current();
  • $entry = new $this->_entityClass(array(
  • 'id' => $result->id,
  • 'title' => $result->title,
  • 'content' => $result->content,
  • 'published_date' => $result->published_date
  • ));
  • $entry->setReferenceId('author', $result->author_id);
  • return $entry;
  • }
  • public function delete($entry)
  • {
  • if ($entry instanceof ZFExt_Model_Entry) {
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $entry->id);
  • } else {
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $entry);
  • }
  • $this->_getGateway()->delete($where);
  • }
  • }

Et voila ! Nous avons modifié notre Data Mapper pour lui faire supporter le chargement paresseux des objets lorsque cela est approprié. Maintenant, j'admet que, techniquement parlant, c'est une forme d'optimisation prématurée : nous ne savons pas si cela aidera les performances d'une quelconque manière, puisque nous n'avons pour l'instant aucun moyen de mesurer toute amélioration. Mais, puisque j'ai déjà fait ça avant, je me permet de supposer que ça aidera au niveau des performances. Les opérations en base de données coûtent cher, et sont souvent les plus coûteuses d'une application.

9.6.5. Eviter les duplications d'entités avec une Identity Map

Un autre point d'amélioration, qui ne soit pas complétement lié aux performances, est l'utilisation d'une Identity Map. Pour expliquer ce que j'entends par là, imaginez un scénario où vous avez récupéré 20 entrées. Chaque entrée est obtenue depuis notre Data Mapper Entry, et laisse l'auteur non chargé, afin qu'il puisse être chargé à la demande, comme nous venons de l'implémenter. Comment est-ce que les auteurs sont chargés ? En utilisant le Data Mapper Author pour les récupérer depuis la base de données. Avec notre implémentation, cela signifie que pour chaque entrée que nous chargeons, nous pouvons aussi charger un auteur. Cela semble raisonable, jusqu'à ce que vous regardiez la relation qui existe entre les entrées et les auteurs. Tout auteur peut écrire plusieurs entrées, ce qui signifie que plusieurs entrées partegeront exactement le même auteur. Autrement dit, nous pouvons supposer que nous chargeront le même auteur depuis la base de données plusieurs fois. C'est de toute évidence un problème : nos objets métier devraient être aussi uniques que possible.

En regardant les chose de l'extérieur, cela n'a pas d'effet bien sérieux, en dehors d'un grand nombre de requêtes inutiles en base de données. Mais que se passe-t'il si nous modifions l'entité author ? Nous en avons un grand nombre ! Modifier une de ces entités ne modifiera pas les autres, ce qui signifie que des entités, au sein du même processus, utiliseront des informations d'auteurs qui ne seront plus à jour. Ce manque de synchronisation doit être éliminé.

Une solution évidente est de rendre chaque entité unique partageable. Si nous chargeons un auteur dans une entée, et qu'une autre entrée a besoin du même auteur, elle pourrait d'une façon ou d'une autre localiser l'instance de ZFExt_Model_Author de la première entrée pour l'utiliser. La solution la plus fréquemment utilisée est le pattern Identity Map. Oui, c'est encore un autre design pattern défini par Martin Fowler... Voici ce que Fowler en dit :

Assure que chaque objet ne sera chargé qu'une seule fois, en gardant une carte de tous les objets chargés. Recherche les objets à l'aide de cette carte lorsqu'on y fait référence.

You the man, Martin! Bien que ça ne soit pas vraiment clair à partir de la définition, l'Identity Map est aussi un type de cache, d'un certain point de vue. Une fois qu'un objet avec un id unique a été créé ou chargé pour la première fois, il est enregistré dans l'Identity Map, afin que d'autres objets métier puissent utiliser cette même instance, s'ils essayent d'en charger un autre avec le même id, sans avoir à effectuer une requête supplémentaire en bae de donnée via le Data Mapper.

Puisque nos Data Mappers prennent déjà en charge la récupération et la création de nos objets, il semblerait qu'ils soient l'emplacement le plus logique pour l'implémentation de cette nouvelle fonctionnalité. Bien entendu, puisque ceci sera une carte générale (il n'y a pas d'implémentation spécifique à un Mapper), il vaut mieux l'ajouter à une classe-mère commune, pour éviter la duplication de code. Cela m'arrange pour une autre raison : c'est l'excuse parfaite pour faire en sorte que nos Data Mappers partagent un type de classe commun, et pousser toute duplication de code depuis les deux Data Mappers vers leur classe parente.

Tant que nous y sommes, nous pouvons déplacer un peu de duplication de code depuis nos deux Data Mappers vers cette classe-mère. Mais, d'abord, de nouveaux tests !

  • <?php
  • class ZFExt_Model_EntryMapperTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testFindsRecordByIdAndReturnsMappedObjectIfExists()
  • {
  • $entry = new ZFExt_Model_Entry(array(
  • 'id' => 1,
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z'
  • ));
  • // expected rowset result for found entry
  • $dbData = new stdClass;
  • $dbData->id = 1;
  • $dbData->title = 'My Title';
  • $dbData->content = 'My Content';
  • $dbData->published_date = '2009-08-17T17:30:00Z';
  • $dbData->author_id = 1;
  • // set mock expectation on calling Zend_Db_Table::find()
  • $this->_rowset->expects($this->once())
  • ->method('current')
  • ->will($this->returnValue($dbData));
  • $this->_tableGateway->expects($this->once())
  • ->method('find')
  • ->with($this->equalTo(1))
  • ->will($this->returnValue($this->_rowset));
  • $mapper = new ZFExt_Model_EntryMapper($this->_tableGateway);
  • $result = $mapper->find(1);
  • $result2 = $mapper->find(1);
  • $this->assertSame($result, $result2);
  • }
  • public function testSavingNewEntryAddsItToIdentityMap() {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 2,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • $entry = new ZFExt_Model_Entry(array(
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author' => $author
  • ));
  • // set mock expectation on calling Zend_Db_Table::insert()
  • $insertionData = array(
  • 'title' => 'My Title',
  • 'content' => 'My Content',
  • 'published_date' => '2009-08-17T17:30:00Z',
  • 'author_id' => 2
  • );
  • $this->_tableGateway->expects($this->once())
  • ->method('insert')
  • ->with($this->equalTo($insertionData))
  • ->will($this->returnValue(123));
  • $mapper = new ZFExt_Model_EntryMapper($this->_tableGateway);
  • $mapper->save($entry);
  • $result = $mapper->find(123);
  • $this->assertSame($result, $entry);
  • }
  • // ...
  • }

Ce nouveau test est proche de celui que nous avons écrit lorsque nous testions le fonctionnement de la méthode find() du Data Mapper. La différence est que, cette fois, nous effectuons un second appel (sans changer l'attente au niveau du mock object, qui dit que Zend_Db_Table_Abstract ne doit être utilisée qu'une seule fois) et vérifions que les objets obtenus sont les mêmes. PHPUnit ira jusqu'à tester les identifiants des objets, afin que nous soyons sûr que les deux résultats référencent exactement le même objet. Nous instancions aussi un nouvel objet Data Mapper pour chaque test plutôt que de réutiliser celui stocké dans le propriété $_mapper de la classe. Cela permet d'éviter que les appels depuis les autres tests ne définissent un membre de l'Identity Map et ne créent de faux positifs. Voici le test supplémentatire, cette fois pour ZFExt_Model_AuthorMapper :

  • <?php
  • class ZFExt_Model_AuthorMapperTest extends PHPUnit_Framework_TestCase
  • {
  • // ...
  • public function testFindsRecordByIdAndReturnsMappedObjectIfExists()
  • {
  • $author = new ZFExt_Model_Author(array(
  • 'id' => 1,
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • // expected rowset result for found entry
  • $dbData = new stdClass;
  • $dbData->id = 1;
  • $dbData->fullname = 'Joe Bloggs';
  • $dbData->username = 'joe_bloggs';
  • $dbData->email = 'joe@example.com';
  • $dbData->url = 'http://www.example.com';;
  • // set mock expectation on calling Zend_Db_Table::find()
  • $this->_rowset->expects($this->once())
  • ->method('current')
  • ->will($this->returnValue($dbData));
  • $this->_tableGateway->expects($this->once())
  • ->method('find')
  • ->with($this->equalTo(1))
  • ->will($this->returnValue($this->_rowset));
  • $mapper = new ZFExt_Model_AuthorMapper($this->_tableGateway);
  • $result = $mapper->find(1);
  • $result2 = $mapper->find(1);
  • $this->assertSame($result, $result2);
  • }
  • public function testSavingNewAuthorAddsItToIdentityMap() {
  • $author = new ZFExt_Model_Author(array(
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • ));
  • // set mock expectation on calling Zend_Db_Table::insert()
  • $insertionData = array(
  • 'username' => 'joe_bloggs',
  • 'fullname' => 'Joe Bloggs',
  • 'email' => 'joe@example.com',
  • 'url' => 'http://www.example.com'
  • );
  • $this->_tableGateway->expects($this->once())
  • ->method('insert')
  • ->with($this->equalTo($insertionData))
  • ->will($this->returnValue(123));
  • $mapper = new ZFExt_Model_AuthorMapper($this->_tableGateway);
  • $mapper->save($author);
  • $result = $mapper->find(123);
  • $this->assertSame($result, $author);
  • }
  • // ...
  • }

Notre implémentation commence par ajouter la classe-mère commune, ZFExt_Model_Mapper. ZFExt_Model_EntryMapper et ZFExt_Model_AuthorMapper vont toutes deux étendre cette classe.

  • <?php
  • class ZFExt_Model_Mapper
  • {
  • protected $_tableGateway = null;
  • protected $_identityMap = array();
  • public function __construct(Zend_Db_Table_Abstract $tableGateway)
  • {
  • if (is_null($tableGateway)) {
  • $this->_tableGateway = new Zend_Db_Table($this->_tableName);
  • } else {
  • $this->_tableGateway = $tableGateway;
  • }
  • }
  • protected function _getGateway()
  • {
  • return $this->_tableGateway;
  • }
  • protected function _getIdentity($id)
  • {
  • if (array_key_exists($id, $this->_identityMap)) {
  • return $this->_identityMap[$id];
  • }
  • }
  • protected function _setIdentity($id, $entity)
  • {
  • $this->_identityMap[$id] = $entity;
  • }
  • }

Tout ce qu'il reste à faire, à présent, est d'apporter les modifications nécessaires aux deux Data Mappers pour qu'ils définissent dans l'Identity Map les objets nouvellement chargés, et, de préférence, les obtiennent depuis celle-ci plutôt que de perdre du temps en d'autres visites à la base de données. Notez que les méthodes _getGateway et __construct() devraient être supprimées des classes Data Mapper, puisqu'elles seront héritées depuis la nouvelle classe parente.

  • <?php
  • class ZFExt_Model_EntryMapper extends ZFExt_Model_Mapper
  • {
  • // ...
  • public function save(ZFExt_Model_Entry $entry)
  • {
  • if (!$entry->id) {
  • $data = array(
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $entry->id = $this->_getGateway()->insert($data);
  • $this->_setIdentity($entry->id, $entry); // add new
  • } else {
  • $data = array(
  • 'id' => $entry->id,
  • 'title' => $entry->title,
  • 'content' => $entry->content,
  • 'published_date' => $entry->published_date,
  • 'author_id' => $entry->author->id
  • );
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $entry->id);
  • $this->_getGateway()->update($data, $where);
  • }
  • }
  • public function find($id)
  • {
  • if ($this->_getIdentity($id)) {
  • return $this->_getIdentity($id);
  • }
  • $result = $this->_getGateway()->find($id)->current();
  • $entry = new $this->_entityClass(array(
  • 'id' => $result->id,
  • 'title' => $result->title,
  • 'content' => $result->content,
  • 'published_date' => $result->published_date
  • ));
  • $entry->setReferenceId('author', $result->author_id);
  • $this->_setIdentity($id, $entry); // add retrieved
  • return $entry;
  • }
  • // ...
  • }
  • <?php
  • class ZFExt_Model_AuthorMapper extends ZFExt_Model_Mapper
  • {
  • // ...
  • public function save(ZFExt_Model_Author $author)
  • {
  • if (!$author->id) {
  • $data = array(
  • 'fullname' => $author->fullname,
  • 'username' => $author->username,
  • 'email' => $author->email,
  • 'url' => $author->url
  • );
  • $author->id = $this->_getGateway()->insert($data);
  • $this->_setIdentity($author->id, $author);
  • } else {
  • $data = array(
  • 'id' => $author->id,
  • 'fullname' => $author->fullname,
  • 'username' => $author->username,
  • 'email' => $author->email,
  • 'url' => $author->url
  • );
  • $where = $this->_getGateway()->getAdapter()
  • ->quoteInto('id = ?', $author->id);
  • $this->_getGateway()->update($data, $where);
  • }
  • }
  • public function find($id)
  • {
  • if ($this->_getIdentity($id)) {
  • return $this->_getIdentity($id);
  • }
  • $result = $this->_getGateway()->find($id)->current();
  • $author = new $this->_entityClass(array(
  • 'id' => $result->id,
  • 'fullname' => $result->fullname,
  • 'username' => $result->username,
  • 'email' => $result->email,
  • 'url' => $result->url
  • ));
  • $this->_setIdentity($id, $author);
  • return $author;
  • }
  • // ...
  • }

Nous sommes arrivés aussi loin que j'irai dans notre implémentation du Modèle pour ce chapitre. Il y a bien d'autres méthodes qui pourraient être ajoutées, et d'autres problèmes qui pourraient être résolus... Nous ajouterons aux fondations que nous venons de créer au fur et à mesure que nous avancerons dans la mise en place de notre application.

9.7. Conclusion

Ce chapitre a été le premier du livre où nous ayons commencé à plonger dans le code source. Comme vous pouvez le constater, le but est moins de vous apprendre à utiliser Zend_Db et ses sous-classes (le Guide de Référence fait ça très bien), et plus de vous montrer comment utiliser ces classes d'accès à la base de données lorsque nous concevons un Modèle. J'ai aussi introduit un des autres points principaux du livre : l'utilisation de tests pour piloter les développements. Bien que des exemples de code préparés à l'avance fonctionnent bien, j'espère que la route un peu plus longue qui passe par le développement de code au fur et à mesure des chapitres, en utilisant des tests unitaires, vous aidera à comprendre pourquoi et comment nous prenons des décisions liées à la conception de notre application.

J'espère aussi que vous avez identifié un problème potentiel : pourquoi construisons-nous un Data Mapper à partir de zéro ?

Je disais dans l'introduction du livre que j'ai tendance à crier au scandale lorsque c'est nécessaire, et c'est le cas ici. PHP offre des bibliothèques pour ce genre de choses. Il existe de très bonnes bibliothèques de Data Mapping, un grand nombre de bibliothèques ORM, et l'Incubateur ZF contient lui-même une solution complète de Data Mapping en cours de développement. Nous devrions les utiliser, à moins qu'il n'y ait des raisons spécifiques pour ne pas le faire. Zend_Db implémente les patterns Row Data Gateway et Table Data Gateway, mais demande elle-même beaucoup de temps si on veut l'utiliser pour quoi que ce soit d'autre qu'une application très simple. En bref, si vous voulez rester sain d'esprit, gagner du temps sur les développements, et, par extension, de l'argent sur les projets, remplacez le par une bibliothèque externe (ou attendez la sortie de la solution de Data Mapping de Zend Framework) pour quoi que ce soit de plus complexe qu'un blog. Je sais que ça sonne un peu violamment, et que ce n'est probablement pas ce que vous souhaitez entendre, mais il vaut mieux le dire tant que vous avez encore pieds.

Est-ce que cela rend Zend Framework, d'une certaine manière, inférieur à ses alternatives, comme Symfony ou Ruby on Rails ? Non ! Symfony lui-même utilise une bibliothèque ORM externe, qui se trouve juste être distribuée avec le Framework ; et rien ne vous empêche d'utiliser quelque chose de semblable pour vos applications (ou d'utiliser la même : Doctrine est très bien, et je l'utilise moi-même). Ruby on Rails utilise une implémentation d'ActiveRecord liée à la couche base de données, mais ça n'a pas empéché la communauté Ruby de développer des solutions comme le Data Mapper merb, de manière à ce que les objets ne soient pas aussi fortement liés au schéma de base de données. Il sera intéressant de voir l'influence que cela aura sur Rails 3.0, puisque merb est resté préféré par ceux privilégiant comme solution un mécanisme pluggable. Si vous ne deviez vous souvenir que de ça : rappelez-vous toujours qu'un Framework vous offre beaucoup de fonctionnalités, mais que vous ne devriez jamais être forcé de toutes les utiliser, si une bibliothèque alternative plus adaptée existe pour l'une d'entre elles. Au cours d'un prochain chapitre, je passera plus de temps sur l'une de ces alternatives à Zend_Db.

Finalement, quel était l'objectif de ce chapitre ? Ce n'est pas parce que des solutions solides existent déjà que nous ne pouvons pas chercher à les comprendre ni à les implémenter nous-même. Un projet peut être trop simple (vous n'utiliseriez pas une bibliothèque ORM dans un simple script), inclure trop d'ancien code qui ne soit pas remplaçable par autre chose qu'une simple abstaction, quelqu'un de plus haut placé que vous pourrait vous dire quoi utiliser, ou vous pourriez juste préférer le faire par vous-même, pour quelque raison que ce soit. Un bon exemple ici sera le cas où la couche de stockage n'est pas une base de données relationnelle : une situation qui devient de plus en plus fréquente, alors que les alternatives orientées document ou objet se matérialisent. Pour quelque raison que ce soit, l'objectif de ce chapitre était de vous montrer comment implémenter une meilleure solution que Zend_Db de base.