Thursday, January 07, 2010

Zend Framework data models

I was struggling getting my data models (as described in the Zend Framework Quickstart) to work with relations. My first solution was to create a database view that merged that data using joins to collect this data in a format that I could use in my data models. This was going great until I looked at my database where it contained over 20 views (along with 20 data models, mappers and db table gateways) ! So I said to myself there had to be another way.

Fortunately there was a solution, hinted by Felix De Vliegher on IRC. He pointed me to the Zend Framework Zend_Db_Table Relationships page on the Zend Framework online manual. And although it's clearly specified how things work, I struggled a bit to see how each component was linked to the other and when I should use what.

So, I decided to work out a small application to test and support this relationship modeling.

Step 1: Preparation
Let's start with creating a new Zend Framework project that we call "datamodels".

zf create project datamodels

This creates a new Zend Framework project that we can use to showcase these examples. We add a few directories to the application structure to store our documentation, our database and our schemas.


These are four tables all related to each other that we use to base our tutorial and test upon.


Translated into SQLite3 this looks like this:
-- filename: schema.sqlite.sql -- 
create table `user` (
    `id` integer primary key autoincrement,
    `username` text not null,
    `password` text not null
);

create table `contact` (
    `id` integer primary key autoincrement,
    `user_id` integer not null,
    `email` text not null,
    `phone` text null,
    `fax` text null
);

create table `address` (
    `id` integer primary key autoincrement,
    `type_id` integer not null default 1,
    `user_id` integer not null,
    `address1` text not null,
    `address2` text null,
    `city` text not null,
    `state` text null,
    `zip` text not null,
    `country` text not null
);

create table `address_type` (
    `id` integer primary key autoincrement,
    `type` text not null
);
Creating the SQLite3 database is now easy as 1,2,3. Just from command line execute sqlite3 ./data/db/datamodels.db < schema.sqlite.sql and see the magic happen. Our database is set and ready to use.

Our test data is also stored in a file, it's contents can be found here:
-- filename: data.sqlite.sql --

-- Data for user table --
INSERT INTO `user` 
    VALUES (1, 'testuser1', 'test123');
INSERT INTO `user` 
    VALUES (2, 'testuser2', 'test234');

-- Data for contact table --
INSERT INTO `contact` 
    VALUES (1, 1, 'test1@example.com', '1-800-555-1234', '1-800-555-1230');
INSERT INTO `contact` 
    VALUES (2, 2, 'test2@example.com', '1-800-555-2234', '1-800-555-2230');

-- Data for address table --
INSERT INTO `address`
    VALUES (1, 1, 1, '1 Test Home', '', 'Testtown', 'ZF', '1234', 'PHP');
INSERT INTO `address`
    VALUES (2, 1, 2, '2 Test Home', '', 'Testtown', 'ZF', '1234', 'PHP');
INSERT INTO `address`
    VALUES (3, 2, 2, 'Test Corp, LTD', '4 Test Ave', 'Testtown', 'ZF', '1234', 'PHP');
    
-- Data for address_type table --
INSERT INTO `address_type`
    VALUES (1, 'Home address');
INSERT INTO `address_type`
    VALUES (2, 'Billing address');


In application/configs/application.ini we need to add the following lines in order to use our newly generated database:

[production]
...
resources.db.adapter = "Pdo_SQLite"
resources.db.params.dbname = APPLICATION_PATH "/../data/db/datamodels.db"

You also need to define our "Default" namespace in the application bootstrap file Bootstrap.php. This is done by adding the following lines there.

protected function _initAutoload()
    {
        $autoloader = new Zend_Application_Module_Autoloader(array (
            'namespace' => 'Default',
            'basePath' => APPLICATION_PATH,
        ));
        return $autoloader;
    }

Creating the four data models along with their mappers and table gateways is a bit of work so I've provided the application as-is on a public SVN repository (https://svn2.hosted-projects.com/in2it/datamodels/tags/step_1). You can export it and start from there.

Step 2: Displaying a user listing
We want to display a listing of users with their contact details and the number of addresses they have, more or less like the following table:

Username
E-mail
Phone
Fax
# addresses
testuser1
test1@example.com
1-800-555-1234
1-800-555-1230
1
testuser2
test2@example.com
1-800-555-2234
1-800-555-2230
2

As you can see now, with this listing we have 3 tables combined and without some relational information between the models it's hard to get this kind of listing.

Let's set up our dbTable classes to work with relationships.
class Default_Model_DbTable_User extends Zend_Db_Table_Abstract
{
    protected $_name = 'user'; 
    protected $_dependentTables = array (
        'Default_Model_DbTable_Contact',
        'Default_Model_DbTable_Address',
    );
}

class Default_Model_DbTable_AddressType extends Zend_Db_Table_Abstract
{
    protected $_name = 'address_type'; 
    protected $_dependentTables = array ('Default_Model_DbTable_Address');
}

class Default_Model_DbTable_Contact extends Zend_Db_Table_Abstract
{
    protected $_name = 'contact';
    protected $_referenceMap = array (
        'User' => array (
            'columns' => array ('user_id'),
            'refTableClass' => 'Default_Model_DbTable_User',
            'refColumns' => array ('id'),
        ),
    );
}

class Default_Model_DbTable_Address extends Zend_Db_Table_Abstract
{
    protected $_name = 'address'; 
    protected $_referenceMap = array (
        'User' => array (
            'columns' => array ('user_id'),
            'refTableClass' => 'Default_Model_DbTable_User',
            'refColumns' => array ('id'),
        ),
        'Type' => array (
            'columns' => array ('type_id'),
            'refTableClass' => 'Default_Model_DbTable_AddressType',
            'refColumns' => array ('id'),
        ),
    );
}

In order to use these relations, we create a new model and mapper for listing these accounts.

class Default_Model_Accounts extends Default_Model_Abstract
{
    protected $_username;
    protected $_email;
    protected $_phone;
    protected $_fax;
    protected $_addressCount;
    
    public function getUsername ()
    {
        return $this->_username;
    }

    public function setUsername ($username)
    {
        $this->_username = (string) $username;
        return $this;
    }

    public function getEmail ()
    {
        return $this->_email;
    }

    public function setEmail ($email)
    {
        $this->_email = (string) $email;
        return $this;
    }

    public function getPhone ()
    {
        return $this->_phone;
    }

    public function setPhone ($phone)
    {
        $this->_phone = (string) $phone;
        return $this;
    }

    public function getFax ()
    {
        return $this->_fax;
    }

    public function setFax ($fax)
    {
        $this->_fax = (string) $fax;
        return $this;
    }

    public function getAddressCount ()
    {
        return $this->_addressCount;
    }

    public function setAddressCount ($addressCount)
    {
        $this->_addressCount = (int) $addressCount;
        return $this;
    }

    public function getMapper()
    {
        if (null === $this->_mapper) {
            $this->setMapper('Default_Model_Mapper_Accounts');
        }
        return $this->_mapper;
    }
    public function populate($row)
    {
        if (is_array($row)) {
            $row = new ArrayObject($row, ArrayObject::ARRAY_AS_PROPS);
        }
        if (isset ($row->id)) {
            $this->setId($row->id);
        }
        if (isset ($row->username)) {
            $this->setUsername($row->username);
        }
        if (isset ($row->email)) {
            $this->setEmail($row->email);
        }
        if (isset ($row->phone)) {
            $this->setPhone($row->phone);
        }
        if (isset ($row->fax)) {
            $this->setFax($row->fax);
        }
    }
    public function toArray()
    {
        return array (
            'id' => $this->getId(),
            'username' => $this->getUsername(),
            'email' => $this->getEmail(),
            'phone' => $this->getPhone(),
            'fax' => $this->getFax(),
            'address_count' => $this->getAddressCount(),
        );
    }
}

class Default_Model_Mapper_Accounts extends Default_Model_Mapper_Abstract
{
    public function getDbTable()
    {
        if (null === $this->_dbTable) {
            $this->setDbTable('Default_Model_DbTable_User');
        }
        return $this->_dbTable;
    }
    
    public function fetchAll($className, $where = null, $order = null, $count = null, $offset = null)
    {
        $entries = array ();
        $model = null;
        if (!is_string($className)) {
            require_once 'Zend/Exception.php';
            throw new Zend_Exception('Model class name should be a string');
        }
        if (is_string($className)) {
            if (!class_exists($className)) {
                require_once 'Zend/Exception.php';
                throw new Zend_Exception('Non-existing model class name provided');
            }
        }
        if (null !== ($resultSet = $this->getDbTable()->fetchAll($where, $order, $count, $offset))) {
            foreach ($resultSet as $row) {
                $model = new $className;
                if (!$model instanceof Default_Model_Abstract) {
                    require_once 'Zend/Exception.php';
                    throw new Zend_Exception('Invalid model class provided');
                }
                
                // let's get contact details first
                $contacts = $row->findDependentRowset(
                    'Default_Model_DbTable_Contact',
                    'User'
                );
                $contact = $contacts->current();
                $model->populate($contact);
                unset ($contacts, $contact);
                
                // let's see how many addresses this contact has
                $addresses = $row->findDependentRowset(
                    'Default_Model_DbTable_Address',
                    'User'
                );
                $addressCount = count($addresses);
                $model->setAddressCount($addressCount);
                unset ($addresses, $addressCount);
                
                // now it's time to add our user details to the model
                $model->populate($row);
                $entries[] = $model;
                unset ($model);
            }
        }
        return $entries;
    }
}

And to view our completed table, we need to modify our view partial userlisting.phtml:

<tr>
  <td><?php echo $this->escape($this->username) ?></td>
  <td><?php echo $this->escape($this->email) ?></td>
  <td><?php echo $this->escape($this->phone) ?></td>
  <td><?php echo $this->escape($this->fax) ?></td>
  <td><?php echo $this->escape($this->address_count) ?></td>
</tr>

Now we have our table set as we want it:



You can download this step from the repo to see the changes yourself: https://svn2.hosted-projects.com/in2it/datamodels/tags/step_2.

Conclusion
Once you have figured out how these relationships work, you can extends your model capabilities without resorting to other solutions (like using database views) or maintaining crappy code.

On the other hand, It would be nice if I could use my already created models with their functionality in one go, so I can link my datamodels instead of my data sources. Need to figure this one out.

4 comments:

erik snoeijs said...

Nice guide, I'll be sure to reference it when I need to. I did notice however that in the sqlite3 command the ">" was displayed as "<".

Also a question, I've been following the quickstart as well to get acquainted with ZF, but I found creating all those get and set methods of the Model a bit tiresome so I created a __call method to handle them.
The downside of this was that some validation somewhere broke because it checked method_exists, but that was easily fixed.

But I wanted to ask if the above is considered not done, or if it's acceptable and just a matter of preference.

erik snoeijs said...

ok fail on my side ;)
the sqlite3 command "<" is displayed as "& lt ;"

QualityPoint said...

Free online Quiz

PHP questions and answers

Leonard Dronkers said...

Hi Michelangelo,

Nice post. I like your approach on creating a model that represents the 'view' of that you wanted to achieve. I am currently also working on some examples using table relations. Which I will post in the near future on my blog. As all roads lead to Rome I have a different take on it…

Instead of creating a model that represents the view I add properties to the parent Model class. In your example this would be the User class.

In the mapping of the User TDG to the User Model I have added something along the lines of(blog example):

Inside my Blog Entry (posting) mapper map() function:
/**
* Get comments, find dependant rows in comments table
*/
$commentMapper = new Blog_Model_CommentMapper();
$commentRowset = $row->findBlog_Model_DbTable_Comment();
// or $commentRowset = $data->findDependentRowset('Blog_Model_DbTable_Comment');
$commentModelSet = $commentMapper->mapToModelSet($commentRowset);
$model->setComments($commentModelSet);

I set the blog entry property comments to contain a set of Blog_Entry_Comment models.
With this solution, I can iterate trough child classes and use the full features in them; such as save, find, fetch all and such.

Enough; I will send you what I am working on so you can see what I mean.

Cheers, Leonard (VNU Media)

Others check www.sreknord.net in the near future.

Post a Comment