Table of Contents
Back in Chapter 3: The Model I discussed some of the
concepts surrounding the Model, a representation of the state and business
rules of an entity, such as those governing entries in a blog. We're lucky
in this chapter because our entities are extremely simple and with that
simplicity we can ignore a lot of abstract ideas concerning the Model that
are more relevant to web applications dealing with far more complex
systems. However, even in our current state of simplicity we meet a number
of design challenges to get us started.
Let me start by
acknowledging that this chapter does not teach
Zend_Db, Zend_Db_Table or
Zend_Db_Table_Row in any great depth. Uses of these
classes will be explained throughout the book as they are encountered,
however the focus of this chapter is using
Zend_Db_Table as the basis of designing and
implementing our Model for this blog. Specifically I look at one area of
the Model - entries.
When we discuss a Model there are some standard terms we can apply. Any Model must first of all belong to a domain, basically the overall system in which the Model operates. In our current application this is simply "blogging". Within this domain, the Model is composed of one or more domain objects. A domain object is a representation of an entity, its properties, and the business rules (also known as the domain logic) applied to it. So, for our blogging domain, we may have domain objects representing Entry entities. For the sake of completeness, an entity can be defined as a uniquely identifiable member of the domain with a set of behaviours. All entries, for example, should have a unique title and content but the main unique property will be its id. All entries also have a set of procedural behaviours - they are written, validated, and published.
If you check the explanation, you'll notice that nowhere is it stated that a Model is a singular object. In a planetary climate model we would have thousands of interacting entities, factors, behaviours, constraints, etc. So when we speak of a Model, we're actually referring to all of the entities contained by that Model, within its domain, and how they behave and interact with each other. You may find a lot of Zend Framework terminology that refers to entities as Models, e.g. an Entry Model, an Author Model, etc. In nearly all cases these are domain objects within a single domain model.
We mentioned domain logic which is a more general term referring to business rules and behaviour - not all applications are business related. Often our domain logic will describe constraints on the properties of domain objects. i.e. rules for validation and filtering. I mention this to emphasise that validation is a Model concern, and not a concern for either the Controller or View. You'll see where this comes into play in a future chapter when we talk about forms.
The main problem developers may face when designing the Model layer of their application is exactly how close the Model's storage layer is to the surface.
In very simple
applications, we may directly run SQL queries using
Zend_Db or create domain objects which, for the
sake of expediency, extend Zend_Db_Table (which
implements Martin Fowler's Table Data Gateway pattern from his book,
"Patterns Of Enterprise Application Architecture" (or POEAA)). These bring
the storage mechanism, a relational database, to the surface as something
our application can access directly. The same can be said of
Zend_Db_Table_Row, which implements Fowler's Row
Data Gateway pattern, or Ruby On Rails' ActiveRecord, which implements
(with some improvements which move it a bit closer to a Data Mapper)
Fowler's Active Record pattern. All three of these patterns have something
in common, they each encapsulate database access directly, often by
inheriting from a base class bound to a single database table or
row.
However, in many non-simple applications the domain model can grow in complexity. We may find that the domain model, which is expressed in terms of objects and object properties, cannot be easily mapped to database tables. A simple example of this is a blog entry. While the entry seems like a simple entity which can be assigned an entries table in a database schema, it also contains a reference (e.g. a foreign key) for an author. From the perspective of our domain objects, this would mean the entry object contains an author object. Why? Because the Model is designed from an object oriented perspective and this is the most obvious natural design.
From a database schema perspective, authors would be stored on a different table. What this means, is that our entry object does not map exactly to one single table, but should be mapped to two tables. We can resolve this by perhaps using an SQL table join when retrieving data. Using a join, we would need to filter the author data into an author object or, alternatively, fall back on two separate SQL queries to match the two objects. Either way, we have a problem as our domain object inherits from a class bound to one database table but actually needs to query two.
This serves to demonstrate an inescapable fact of life in application development - objects only map to databases one for one in very simple domain models. Eventually any complex Model needs more complex logic to perform a mapping between the two, and this leads into one of the primary patterns used outside of simple scenarios, the Data Mapper. Guess who defined it by the way? Martin Fowler - and yes, you really should read his book if you can get a copy...
So where am I going with
this? Well, if we leave database access at the surface (for example,
extending from Zend_Db_Table which represents only
one table) we will quickly find ourselves stuck in an inflexible design
which is incapable of easily dealing with any mismatch between the object
domain and the database table domain. For example, if our entry domain
object extends Zend_Db_Table it is directly tied to
an entries table. So where does the author come from? Authors cannot be
retrieved by querying an entries table, so somehow we need to drag in
additional code, or run direct SQL queries (back to joins or two separate
queries), etc. All of which will make it clear that inheriting from a
class bound to only one table isn't working.
The alternative is leaving our domain objects completely free of any database related methods, and instead hide all database access behind a Data Mapper which performs any object to table mapping, and which can be made aware of any number of tables (just throw more Mappers at it). Now our domain objects are decoupled from the database schema.
Let's take a look at Fowler's definition of a Data Mapper.
A layer of Mappers that moves data between objects and a database while keeping them independent of each other and the mapper itself.
The definition confirms our suspicions then, domain objects will not even be aware that a database exists when a Data Mapper is implemented.
If there is anything to
take from this section it's the following. Extending database access
classes like Zend_Db_Table is fine, for very simple
domain models. It's easy to do, easy to understand and requires little
code to accomplish. However, in more complex domain models where domain
objects can contain other domain objects, the inheritance route will not
work well. At this point the need for a better solution like a Data Mapper
will become obvious and justify the need for a slightly more complex class
design.
I've deliberately stuck
to Fowler's patterns for a reason. The more that simple inheritance
manifests itself as a problem, the more likely it is that developers will
begin changing their designs in an attempt to remove the inflexibility. In
the Zend Framework community this has often become a debate over whether
Models (or rather domain objects) should have an is-a (via inheritance) or
has-a (via composition) relationship to the Zend_Db
classes. At other times there are departures into the realm of Data
Containers and Gateways. All of these are talking or coding around an
obvious concept - they are intermediary or near complete steps towards the
Data Mapper solution. It's so obvious as a solution that many developers
will create a complete Data Mapper without ever realising it has a formal
name and a massive body of implementation knowledge in the
literature!
With all these ideas to hand, let's actually get around to examining the requirements of an actual domain object to represent each of our blog entries. We know from the last section that, at a minimum, we need an Entry domain object. At this point we are still not concerned over database schemas and we have only a basic idea of what properties our entries need anyway, so we'll focus on the core needs only.
At this point it is also important to keep in mind the definition of a domain object. It represents a uniquely identifiable entity with a set of behaviours. The key word is "uniquely" since every domain object represents one single entity. If we wish to represent many similar entities we will need a class responsible for containing a collection of entries.
At a minimum, our domain object will expose the following properties:
-
id
-
title
-
content
-
published_date
-
author
These are not all the properties we'll need, but they are enough to get started with. In applying incremental development we'll worry about additional features only when they become a requirement.
We can describe these properties by setting out the constraints that should apply to these in order for our domain object to represent a validated entry. Although we won't immediately deal with validation in this chapter, we'll see these integrated a little later.
-
The id is a positive integer greater than zero uniquely identifying the current entry.
-
The title is a non-empty string containing plain text or XHTML representing a unique entry title.
-
Content is a XHTML 1.0 Strict or plain text formatted non-empty string.
-
Both title and content may only contain a subset of XHTML tags and attributes specified by a whitelist.
-
The author is a valid author domain object representing the author of the entry.
-
The published_date property is a date which can be interpreted using the ISO 8601 standard.
These constraints are how a domain object can tell whether or not its assigned properties make it a valid entry. As we note, our entry will reference an author. Since all of these entities are represented by domain objects, we create a similar profile of an author object. This object's properties may include:
-
id
-
username
-
fullname
-
email
-
url
Where the Entry domain object deals with a single entry, its properties and its validity as an entry, the Data Mapper is concerned more with the persistence of these objects between requests. Its function is to create, read, update and delete (collectively referred to as CRUD operations) domain object data on the database through a database access layer, and of course mapping the properties on those domain objects to their correct tables and column names. The Mapper must also do this without exposing the database schema, the access method or even the mapping logic to the domain objects.
As the Mapper is responsible for retrieving an entities data from the database in order to return a domain object created from that data, it stands to reason it also hosts any utility methods concerning CRUD operations and their selection criteria, i.e. which may relate to the SQL WHERE part of queries or the column name part where only certain details are required for an entity. With all of this going on it becomes more clear that domain objects probably will not be making calls to the Data Mapper. Instead we'll use the Data Mapper within our application and pass it any domain objects to work on. This isn't necessarily the easiest design to use - for obvious reasons it means we need more code in our application's Controller layer since have double the number of objects by introducing Data Mappers.
One common method of alleviating this object count problem is to allow domain objects be aware of their Data Mappers, but ensuring that this awareness does not extend beyond the Data Mapper API. Remember that in applying object oriented programming we should always code to the interface, never the implementation. We won't take any shortcuts here though - our domain model for blogging is simple enough that getting any more complex about our solution really would be pressing past the boundary of how much time we should be spending developing this Data Mapper.
With our Model fleshed out in more detail, we can start to identify some of the Model functionality we can pass out to existing Zend Framework components.
Implementing domain objects in simple forms requires only one thing. PHP5. All domain objects are just plain old PHP objects with nothing particularly special to note. It's disappointing, I know! The Model is supposed to be complex, impossible to understand, and require a PhD. Instead we've boiled it into a system of objects that are not particularly complicated in isolation.
Before our Model can
be stored anywhere, it needs to ensure the data it's holding follows any
constraints and rules we define, i.e. that it is valid and any values
have been filtered as required. While we could start throwing in
validators using Zend_Validate and filters using
Zend_Filter, we already know that any of our
generated forms for the Model will then duplicate these rules.
Duplication is bad, so the logical step is to use
Zend_Form instances as the basis for implementing
this.
Using
Zend_Form does not come without a question mark.
Since it represents a form, it's obviously something we will use a lot
in our View. Isn't this mixing Model and View together inappropriately?
This is one way of looking at it. The other is that any instance of
Zend_Form may perform a dual role as a
presentational element and as a container for Model derived
validation/filtering rules. This may not always be the case, and indeed
in complex Models this may only work for a small part of the Model where
forms correlate fully to the data held by a Model. Our blog is pretty
simple though so there's little need to worry. Technically, an ideal
theoretical solution would be a Form solution which maintains two
independent parts: a data container with validators and filters (much
closer to a domain object) and a set of renderers capable of
transforming these containers into a View included form. Wishful
thinking aside (even the theoretically perfect solutions in this area
tend be as complex if not more so than
Zend_Form), we have to go with the hand we're
dealt and adapt it to our needs.
We will be looking at
Zend_Form and how this is implemented in a
subsequent chapter.
Since this is a Zend
Framework book, we will of course be using
Zend_Db. To be exact,
Zend_Db_Table_Abstract which implements the Table
Data Gateway pattern from Martin Fowler's POEAA. This Gateway pattern
can be defined as a class which provides access to a table on the
database, allowing us to perform inserts, selects, updates, and deletes
for any row or group of rows on that table. Fowler defines this pattern
as follows:
An object that acts as a Gateway to a database table. One instance handles all the rows in the table.
Zend_Db
also offers an implementation of the Row Data Gateway pattern via
Zend_Db_Table_Row which is similar to the Table
Data Gateway except it deals with an individual row on a database table.
In either case, Zend_Db offers abstracted access
via its public API which allows you to construct SQL queries by chaining
together object methods and is used for either pattern
implementation.
For the purposes of
our Model, we will be creating Data Mappers which access the database
via Zend_Db_Table (i.e. the Table Data Gateway
option). This fits in with the purpose of a Data Mapper which can be
used by many domain objects but remains independent of them all, i.e.
it's not concerned with just one specific domain object but may offer
specific concrete subclasses for each to handle any domain object
specific mapping logic.
It is important to note that at no point will our domain objects or Mappers extend from Zend_Db_Table as suggested by the Reference Guide. This forces our Model into one implementation based on database access. It also forces Models to be aware of their storage backend, and encourages developers to freely mix database and non-database code everywhere. Overall it's poor object oriented design unless your objective really is to only use database abstraction. The result is that we emphasising the use of composition over inheritance, a fundamental best practice in object oriented programming. All of our domain objects will have a "has-a" or "has-many" relationship with other classes, except perhaps any abstract parents or interfaces which help ensure all domain objects at least share a similar approach in their API.
As I warned you at the
start of the book, outside of short articles on my blog I always develop
code using Test Driven Design. Therefore all code below is presented with
unit tests at each step. Look on the bright side, at least you have
something to put in the /tests directory! To setup
the initial testing framework, please take a look at Appendix C: Unit Testing
and Test Driven Design (to be added shortly).
The tests for our
Model will be stored at /tests/ZFExt/Model, for
example a test for our entry domain object will exist at
/tests/ZFExt/Model/EntryTest.php. This will require
adding an AllTests.php file in the same directory
containing:
<?phpif (!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();}
As we implement our
Model, you will need to add additional tests to this file in order for
them to be executed. They can be added by following the pattern used for
the ZFExt_Model_EntryTest suite. Since this is
not the top level AllTests.php file, you should add
this one to the root /tests/AllTests.php
using:
<?phpif (!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();}
As described in the
Appendix, tests are run by navigating to
/tests/ZFExt/Model (or /tests
to run every single test across the application) in a console and
running:
phpunit AllTests.php
Since our Entry domain
object is just an ordinary object, we can start if off as a simple data
container. We should always namespace our classes (the old pre-5.3 type
of namespacing) so we'll be using a ZFExt_Model
namespace for any Model related classes (which also applies to the test
files). For now, everything will be stored within the
/library directory. Let's start with an initial
test checking we can set properties on the domain objects, and
instantiate an entry domain object with an array of data. This is the
initial content of
/tests/ZFExt/Model/EntryTest.php:
<?phprequire_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());}}
We can now implement
this (the tests fail anyway without the class written!) at
/library/ZFExt/Model/Entry.php:
<?phpclass 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];}}}
You may ask why we
don't make all properties public. The benefit of using a protected
array, and using PHP's magic methods (like
__set()) to provide access, is that it creates
a gateway through which access passes. Now we can use any magic methods
to run checks on properties and throw Exceptions on any errors.
Our new object is pretty basic. Let's add the rest of the standard magic methods so we can check if properties in the protected array are set and unset them if need be. Only the new tests are shown below.
<?phprequire_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));}}
<?phpclass 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]);}}}
Our domain object is
now better defined. At the moment it offers unrestricted access when
setting properties but our domain objects only needs those defined as
keys on the initial data array. We can remove the ability to set out of
bounds properties, and throw an Exception when it occurs, by adding an
additional check to the __set() method as
follows.
<?phprequire_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) {}}}
<?phpclass 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]);}}}
Next up, our entry
domain object will contain an author object. Since any of these domain
objects will likely duplicate code we just wrote into
ZFExt_Model_Entry, we should refactor our class
to inherit from a parent containing any potentially reusable methods.
Here, we add a new parent class called
ZFExt_Model_Entity to fill this role.
<?phpclass 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]);}}}
<?phpclass ZFExt_Model_Entry extends ZFExt_Model_Entity{protected $_data = array('id' => null,'title' => '','content' => '','published_date' => '','author' => null);}
Running our tests once more will confirm that this was a successful refactoring.
Now, let's add a
similar class for authors. First, to accommodate the new test, edit your
/tests/ZFExt/Model/AllTests.php file to include a
new test located at
/tests/ZFExt/Model/AuthorTest.php. The tests and
the class which contains them will be very similar to that for the entry
domain object. Here are the initial tests which reflect those for the
entry object but with the properties for an author object.
<?phpclass 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) {}}}
Here's the implementation which passes all of these new tests.
<?phpclass ZFExt_Model_Author extends ZFExt_Model_Entity{protected $_data = array('id' => null,'username' => '','fullname' => '','email' => '','url' => '');}
As a final gesture to
ensure our interface is bound to these domain objects, let's ensure that
ZFExt_Model_Entry only accepts a
ZFExt_Model_Author object when setting an author
property. As usual, the test first, and then the code which makes that
test pass.
<?phpclass 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) {}}}
<?phpclass 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);}}
All we've done here,
is to override the parent class' __set() method
to add a new check making sure our object only accepts an author value
that is an object of type ZFExt_Model_Author.
Otherwise, we pass control back to the parent to set the
property.
We're done for now! Let's turn our attention to our Data Mapper implementation so can save these objects to a database or retrieve them.
Our Data Mapper will
utilise Zend_Db_Table in the background, so its
function in this design is to carry out typical CRUD operations. Later,
we will also see that it can carry methods with more specific uses such
as conditional fetching operations. For the moment, let's concentrate on
setting it up. In the first test we'll add, our Data Mapper will be
instantiated, and in turn create a configured instance of
Zend_Db_Table_Abstract with which to work. You
will note that I am not using an actual database. Although it takes a
chunk of code at the start, I am using a mock object (a type of test
double) in place of a real Zend_Db_Table_Abstract
object. This will allow me to control everything this object does,
including return values and setting expectations on what methods should
be called, with what arguments, etc. The main reason I do this is
because actually on a real database offers nothing new - our tests do
not need one. If we did use a database it would work fine also, but then
we are testing Zend_Db_Table_Abstract in addition
to everything else since it's actually called upon. The Zend Framework
already has tests for this component.
As before, to add this
test to your suite, add the file and class to
/tests/ZFExt/Model/AllTests.php, the tests
themselves are written to
/tests/ZFExt/Model/EntryMapperTest.php.
<?phpclass 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;}}
All Data Mapper tests
will rely on mocking out Zend_Db_Table_Abstract -
after all it's already tested by the Zend Framework team so it's
pointless using an actual object connected to a database. Typically we
won't pass in a real instance through the constructor when we use this
in the application, we can instead rely on the constructor creating a
suitable instance. This test skeleton is setup to create a fully mocked
version of Zend_Db_Table_Abstract.
While its hard to
pinpoint from the PHPUnit manual without delving into the code, the
protected _getCleanMock() method I'm using
creates a completely clean mock object with all methods resolved and
mocked. It creates a unique name for the mock on every call ensuring the
mocked class names do not conflict. The only step necessary at the
moment is ensuring all Zend_Db_Table_Abstract
mock objects return a mocked Adapter also. Typically the only reason for
mocking the Adapter is because it has one commonly used method,
quoteInto() for escaping values in an SQL expression or
condition.
Here's our initial (as
yet untested) implementation showing why testing the real instantiation
is not worth the trouble - it's dead simple, and again, testing it is
really only testing
Zend_Db_Table_Abstract.
<?phpclass 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;}}
In the code above we
setup an instance of Zend_Db_Table with which to
access the database. Although this class is called Abstract, it actually
contains no abstract methods. The only configuration needed for now is
to tell this instance which database table to use. We don't need to pass
in any database connection settings because we can set a default
database adapter from our bootstrap later.
Now lets add some
useful methods. We'll start with a method to save a new domain object.
Since we have mocked Zend_Db_Table_Abstract we
are not making any direct assertions for the moment. Our assertions are
actually setup by setting expectations on any mock objects, checking
that our Mapper calls the expected
Zend_Db_Table_Abstract method
insert() with the correct array of data.
<?phpclass 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);}// ...}
Here's the implementation which passes this test.
<?phpclass 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);}}
Saving a new record to
the database involves calling
Zend_Db_Table_Abstract::insert() with an array
of the column names and values to insert in the database table,
"entries". The table name is set within the constructor of the Data
Mapper. The id is omitted since it will be set from the return value of
Zend_Db_Table_Abstract::insert().
As you can see, our
Mapper most definitely knows about the database schema - it maps the
author object's id property to a table field called
author_id. The domain object is not aware that this
database column exists. The rest of the author data is ignored since it
is stored on a different table, and it's not new. Actually this is a not
so subtle point, you cannot save an author in this manner since author
objects can only be saved through a future author Data Mapper.
We may also want to update entries, and these should be easy to spot since they will already have an existing id value. Let's add a test for the updating behaviour and its implementation. Once again, we'll use Mock Object expectations instead of assertions for this one. You should note that mock expectations are checked, of course, so if any of the constraints on method call count and similar are broken by how our implementation works, the test will fail.
<?phpclass 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);}// ...}
<?phpclass 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);}}}
Let's add one more method before we break off - we'll add others over the course of our blogging application. Besides saving and updating, at a bare minimum we will also need to delete and retrieve entries.
This poses at least
one problem in that entries may contain authors. To allow the
EntryMapper to retrieve an author object, we must first add a Data
Mapper for authors. Here's the full set of tests and an implementation
for the class ZFExt_Model_AuthorMapper (most of
which is very similar to the tests we've written so far - and includes a
few we'll explore for the Entry Data Mapper soon).
<?phpclass 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;}}
The implementation of
this class reflects that for the Entry Mapper, and adds an additional
find() and delete()
method.
<?phpclass 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);}}
With this new Author
Data Mapper in tow, we can use it withing our Entry Data Mapper to
retrieve any author object to be included in the resulting entry object
returned from a new find() method. We'll also
add a similar delete() method.
Here are the tests for finding an entry using its id property, and deleting an entry by its id.
<?phpclass 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);}// ...}
Here's our implementation with these two new methods. As the tests suggest, we can delete entries by either passing an integer id value or the domain object itself.
<?phpclass 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;}}
And finally...we have something of a working Data Mapper implementation! Here's what the final test tally should look like from PHPUnit.
PHPUnit 3.3.17 by Sebastian Bergmann. ....................... Time: 0 seconds OK (23 tests, 51 assertions)
Our Data Mapper
implementation in ZFExt_Model_EntryMapper takes
the route of needing two SQL queries to create a full domain object, one
for the entry itself, and another for the referenced author. There may
be times when we really just don't need the author details and at these
times the extra query is pointless. It would make more sense if we
altered the Data Mapper to lazy load the author data on demand,
potentially saving us trips to the database.
We've already seen how
we can override the __set() method to validate
a property being set and we can use the __get()
method to achieve similar functionality by intercepting any attempt to
access the author object in our entry domain object, and only then
firing an request through
ZFExt_Model_AuthorMapper to retrieve that
object.
Since this obviously alters existing tested behaviour, we need to amend at least one test for the Entry Data Mapper. We also need some way of storing the author's id value in the entry domain object so we have something to lazy load, and finally we need to make sure the lazy loading actually works. Here are the new/revised tests for both the Entry Mapper and Entry domain object:
<?phpclass 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'));}// ...}
<?phpclass 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;}// ...}
Our starting point for
implementation, is amending the ZFExt_Model_Entry
class to accept the reference ID for an author for later use. Since the
lazy loading occurs within this object, we also need the transfer the
original awareness of ZFExt_Model_AuthorMapper
within ZFExt_Model_EntryMapper over to the domain
object itself. Technically, references can occur in any domain object
requiring them, so we can add this feature to the parent
ZFExt_Model_Entity class. The
ZFExt_Model_Entry can use these methods from the
parent class to set and retrieve reference information.
<?phpclass 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];}}}
<?phpclass 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;}}
Notice the new
__get() method. This intercepts any attempt to
access the author property of the domain object. Unless the object
already includes an author object, it will attempt to load one from the
database, but only if a reference id (i.e. the author's id) has been
set, for example when the entry was originally loaded. Otherwise it will
still return null which it should in case this is a new object without
any author set.
Here's the revised
ZFExt_Model_EntryMapper class. The only change is
to remove the automatic loading of author objects and instead set the
value of author_id as a reference on the resulting entry
object.
<?phpclass 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 voilá! We have altered our Data Mapper to support lazy loading of objects where appropriate. Now, I admit that this is a technically a form of premature optimisation - we have no idea if this helps performance in any way because we have no way of measuring any improvement just yet. But, since I've done this before, I can assume it will help with performance. Database operations are expensive, often the most expensive operation.
Another improvement point, not completely performance related, is the use of an Identity Map. To explain what I mean by this, imagine a scenario where you have retrieved 20 entries. Each entry is retrieved from the our Entry Data Mapper, and leaves the author unset so it can be lazy loaded as we have just implemented. How are authors loaded? By using the Author Data Mapper to retrieve them from the database. With our implementation, this means every entry we load, may also load an author. This sounds reasonable until you look at the relationship between entries and author. Any author may write many entries, so many entries will share the exact same author. This means we can conceivably load the same author from the database many times. This is obviously a problem - our domain objects should be as unique as possible.
From the outside looking in, this has no serious side effect other than lots of unnecessary database calls. So what happens if we are changing the author entity? We have lots of them! Changing one will not change the others, so entries within the same process will be using out of date author information. This lack of synchronicity must be eliminated.
An obvious solution is
to make each unique entity shareable. If we load an author in one entry,
and another entry needs the same author, they can somehow locate the
first entry's instance of ZFExt_Model_Author for
use. The most common solution is known as the Identity Map pattern. Yes,
it's another Martin Fowler defined design pattern... Here's what Fowler
has to say.
Ensures that each object gets loaded only once by keeping every loaded object in a map. Looks up objects using the map when referring to them.
You the man, Martin! While its not that clear from the definition, the Identity Map is also a form of cache in one respect. Once a domain object with a unique id is created or retrieved for the first time, it's registered on the Identity Map so other domain objects can use that instance, if they try to retrieve one with the same id, without making additional database calls through the Data Mapper.
Since our Data Mapper are already handling the retrieval and creation of domain objects, it seems they are the most logical place to put the implementation. Of course, since this will be a general map - there's no implementation specific to a Mapper - it's best added to a common parent class to avoid code duplication. This makes me happy for another reason! It's the perfect excuse to make all Data Mapper share a common class type and push any code duplication from the two Data Mappers to their shared parent class.
While we're at it, we can shift an code duplication across our two Data Mappers into this parent class. But, new tests first!
<?phpclass 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);}// ...}
The new test is just
like the one we wrote testing the operation of the Data Mapper's
find() method. The difference is that this
time, we make a second call (without changing the mock object
expectations that Zend_Db_Table_Abstract will
only be used once) and check that the resulting objects are the same.
PHPUnit will go so far as to the check the object ids so this does
ensure both results reference the exact same object. We also instantiate
a new Mapper object for each test rather than use the one stored in the
test class $_mapper property. This prevents calls from other tests
setting up a member of the Identity Map and creating a false positive
result. Here's the additional test, this time for
ZFExt_Model_AuthorMapper.
<?phpclass 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);}// ...}
Our implementation
starts by adding the common parent class,
ZFExt_Model_Mapper. Both
ZFExt_Model_EntryMapper and
ZFExt_Model_AuthorMapper will extend this
class.
<?phpclass 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;}}
All that now remains
are making the necessary changes to both Data Mappers to set newly
retrieved objects in the Identity Map and preferentially retrieve them
from the Map instead of wasting time on another visit to the database.
Note that the above methods: _getGateway and
__construct() should be removed from the Data
Mapper classes since they will inherit them from the new parent
class.
<?phpclass 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 retrievedreturn $entry;}// ...}
<?phpclass 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;}// ...}
This is as far as I'll take our domain model implementation in this chapter. There are more methods that can be added, and other problems that could be resolved. We'll add to the foundation we've created here as we go through the application.
This has been the first
chapter in the book to start delving into source code. As you can tell,
the focus is less on teaching Zend_Db and its child
classes (the Reference Guide does this very well) and more on how to
utilise these database access classes when designing a Model. I've also
introduced one of the books other focuses - using testing to drive all
development. While code examples that are prepared in advance work well, I
do hope the slightly longer route of developing code within chapters using
unit tests assists in understanding why and how we are making design
decisions.
I'm also hoping you have spotted a potential problem. Why are we building a Data Mapper from scratch?
I said in the book's
introduction that I have few qualms about crying foul when it's needed,
and this is a case in point. PHP offers libraries for this sort of thing.
There are some great Data Mapper libraries out there, lots of ORM
libraries, and even the ZF Incubator itself has a full Data Mapper
solution in progress. We should use be using them unless there are
specific reasons for not doing so. Zend_Db
implements the Row Data Gateway and Table Data Gateway patterns but by
itself it is time consuming to implement for anything other than a very
simple application. In short, if you want to remain sane, save time on
development, and by extension save money on projects, replace it with an
external library (or wait for ZF's own Data Mapper solution) on anything
more complex than a blog. I know this sounds harsh, and probably it's not
what you expected to hear, but it needs saying while you're still in the
shallow end of this pond.
Does this make Zend Framework somehow less than its alternatives like Symfony or Ruby On Rails? No! Symfony uses an external ORM library itself that just happens to be distributed with the framework - nothing stops you using similar (or the same - Doctrine is very good and I use it myself) with your applications. Ruby On Rails uses an ActiveRecord implementation bound to the database layer but that hasn't stopped the Ruby community developing solutions like merb's datamapper so objects are not as closely tied to the database schema. It will be interesting to see how this influences Rails 3.0 since merb remained agnostic to any one solution preferring a pluggable system. If you remember nothing else, always remember that a framework offers you lots of features but you should never be required to use all of them if a more suitable alternative library for that feature exists. In a future chapter I'll spend more time on one of these Zend_Db alternatives.
So what was the point of
this chapter? Just because ready to rock solutions exist, it doesn't mean
we can't understand them and implement them ourselves. A project may be
too simple (you wouldn't use an ORM library in a simple transaction
script), carry too much legacy code to replace with anything other than
simple abstraction, you might simply be told what to use by someone higher
up the food chain or you may just prefer doing it yourself out of some
other concern. A good example here is when the backend storage is not a
relational database - something becoming more common as document and
object based alternatives materialise. For whatever reason, the chapter's
intent is to show you how to implement a better solution than vanilla
Zend_Db.


