Table des matières
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.
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 !
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 :
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 :
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.
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.
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.
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.
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.
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).
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
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.
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)
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.
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.
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.