Pages

2010/01/07

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.

30 comments:

  1. 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.

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

    ReplyDelete
  3. 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.

    ReplyDelete
  4. Anonymous11/9/10 18:38

    don't get the advantage of zend.. just to create a listing so many thing need to be done. if procedural or even simple ooPHP it can be written within less than 5 lines

    ReplyDelete
  5. Anonymous15/9/10 15:50

    Hi Michelangelo, great post. I've got a question though about the toArray function.

    As it is, it's located in the Default_Model_Account class. However, shouldn't it be located in the corresponding mapper class (hence Default_Model_Mapper_Account)? The primary function of a mapper object is to link dbtable fields to object class properties, right?

    In the current example the Model class "knows" about database table fields...

    Like to here you're insights.

    ReplyDelete
  6. Just to follow up on that.

    I would suggest putting up 2 abstract methods in the Mapper_Abstract class:

    abstract public function Populate($model);

    and

    abstract public function toArray($model);

    and than let end users implement populate and toArray in the specific mapper classes.

    ReplyDelete
  7. Hi Michelangelo,
    I keep getting this error:

    Message: Non-existing mapper class provided

    Stack trace:

    #0 D:\Work\testingServer\datamodels\application\models\Accounts.php(68): Default_Model_Abstract->setMapper('Default_Model_M...')
    #1 D:\Work\testingServer\datamodels\application\models\Abstract.php(103): Default_Model_Accounts->getMapper()
    #2 D:\Work\testingServer\datamodels\application\controllers\IndexController.php(14): Default_Model_Abstract->fetchAll()
    #3 D:\Work\testingServer\datamodels\library\Zend\Controller\Action.php(513): IndexController->indexAction()
    #4 D:\Work\testingServer\datamodels\library\Zend\Controller\Dispatcher\Standard.php(295): Zend_Controller_Action->dispatch('indexAction')
    #5 D:\Work\testingServer\datamodels\library\Zend\Controller\Front.php(954): Zend_Controller_Dispatcher_Standard->dispatch(Object(Zend_Controller_Request_Http), Object(Zend_Controller_Response_Http))
    #6 D:\Work\testingServer\datamodels\library\Zend\Application\Bootstrap\Bootstrap.php(97): Zend_Controller_Front->dispatch()
    #7 D:\Work\testingServer\datamodels\library\Zend\Application.php(366): Zend_Application_Bootstrap_Bootstrap->run()
    #8 D:\Work\testingServer\datamodels\public\index.php(26): Zend_Application->run()
    #9 {main}
    Request Parameters:

    array (
    'controller' => 'index',
    'action' => 'index',
    'module' => 'default',
    )

    Any ideas how I can diagnose it?
    Sorry to be so rubbish.
    Phil

    ReplyDelete
  8. @Anonymous,


    The reason one chooses Zend Framework is to have a very powerful, stable and highly extensible framework that already solves about 60% of all your needs. But this power comes with a small cost.

    But nothing prevents you to query your data using static SQL.

    @Anonymous,

    I agree you have a point there, populate and toArray are methods that should reside in the mapper class.

    The reason I had them in my model is that I wanted to populate my model with data (from any source) and represent my model as an array.

    @Niels,

    Yes, in consistency with what @anonymous mentioned, it would make more sense.

    @Tektlab,


    Well, the example presented here uses the autoloading heavily and when it cannot find it's class it can mean one or all of the following:

    1) you haven't set the namespace in your bootstrap
    2) you have your mappers in directory "mapper" in stead of "mappers" (plural)

    Go check your settings and see if you can debug the steps right to the point where you call 'getMapper()' where you set your mapper.

    Let me know if it gives you some results or not.

    ReplyDelete
  9. @Teklab

    I faced the similar problem and fixed it with the help of BootStrap.php.

    You will have to modify BootStrap.php to this :-

    class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
    {
    protected function _initAutoload()
    {
    $autoloader = new Zend_Application_Module_Autoloader(array (
    'namespace' => 'Default',
    'basePath' => APPLICATION_PATH,
    ));

    $autoloader->addResourceType('mappers', 'models/Mapper/');

    return $autoloader;
    }
    }

    ReplyDelete
  10. Hello,
    interesting posting. But what about n:m relations?

    ReplyDelete
  11. Awesome article! I have gradually become fan of your article and would like to suggest putting some new updates to make it more effective.

    ReplyDelete
  12. Thanks Jatin, you the man.

    ReplyDelete
  13. Hi, the information you provide is very informative and I would like to say that the php based application is also easy to use and they are very user friendly. Please keep share the information.

    ReplyDelete
  14. Very nice and helpful tutorial. Helped me much because of the populate and toArray-methods. Thanks a lot.

    ReplyDelete
  15. ZF_Mapper11/1/11 13:39

    I would like a clarification on your following comment:

    "I agree you have a point there, populate and toArray are methods that should reside in the mapper class....The reason I had them in my model is that I wanted to populate my model with data (from any source) and represent my model as an array."

    The "populating of models from any source and representing model as array" can be accomplished even if the toArray and Populate functions are moved to the Mapper class, right? I am not thinking this through correctly?

    BTW, for other who had the class not found error, you probably named your mapper folder "mapper" instead of "mappers". I made that mistake because that is how the folder is named in the svn upload.

    Thanks a zillion for the awsome article Michaelangelo!

    ZF_Mapper

    ReplyDelete
  16. I am satisfied with your blog. Your post is dear. Thanks for sharing your precious comprehension with me.

    ReplyDelete
  17. Warning. dont use "findDependentRowset" in this manner:
    Use joins

    http://stackoverflow.com/questions/356778/php-query-single-value-per-iteration-or-fetch-all-at-start-and-retrieve-from-ar

    ReplyDelete
  18. A very nice tutorial about the data models, I have been testing this also but could not get it to waork as i wanted

    ReplyDelete
  19. I found you have noted each step in a different manner and i like  the conversation  you made on these topic.You definitely put new spin on a subject thats been written about years.I enjoyed reading your blogs.

    ReplyDelete
  20. Anonymous13/6/11 17:32

    Why didnt you show us the Mapper_Abstract? You define the set_mapper function but provide no code. This post seems to be missing a lot of information

    ReplyDelete
  21. This is the simple example I've been seeking!

    Is there an easier way to obtain the four data models, mappers and table gateways? The svn asks for a username and password.

    Thanks!

    ReplyDelete
  22. Well, so much for comprehending well-written documentation. For those as dim as me at times, this worked:

    sudo svn checkout https://svn2.hosted-projects.com/in2it/datamodels/tags/step_1

    ReplyDelete
  23. You can find some helpful articles about Zend Framework here http://plutov.by/tag/zf

    ReplyDelete
  24. If you find that you need a particular model globally throughout your application, you can tell CodeIgniter to auto-load it during system initialization. This is done by opening the application/config/autoload.php file and adding the model to the autoload array. I hope my post will be helpful with respect to some marketing solutions

    ReplyDelete
  25. This is a bad implementation and I don't suggest anyone use it

    You are calling findDependentRowset twice (for contacts and addresses) for every row in the rowset. This is very slow...

    Even worse, if you have a many to one relationship (e.g. many users assigned to one address) then you would end up running the same query multiple times.

    Why not just do this:

    1. Fetch all users

    2. Get all the user IDs in the users rowset (you could extend Zend_Db_Table_Rowset to add this function)

    3. Get all the contacts in 1 query. e.g. $contactTable->find($userIds);

    4. Get all the addresses in 1 query. e.g. $addressTable->find($userIds);

    Then all you have is 3 queries which gets exactly the same data.

    You could also create a rowset class for addresses and contacts to add a function like getByUser so you can return a subset just for 1 user

    ReplyDelete
  26. You Actually shared the valid tutorial about Zend Framework, i read some other blogs as well but there are so many points they lacked with. Good work and keep posting such valuable blogs for who are into Zend and learn some new stuff.

    ReplyDelete
  27. Anonymous12/8/12 21:20

    Boris pointed out something important. i would go even stronger - use lazy loading.

    ReplyDelete