An improvement to unbindModel on model side
Not long ago I saw Tom OReilly's great tutorial entitled "Keeping bindModel and unbindModel out of your Controllers." While he showed us some great tips I was not so comfortable having to define my model relations in a different way, but I still wanted the possibility to only specify what relations I want to get when querying a model.
For those of you who didn't, I recommend you read Tom OReilly's Keeping bindModel and unbindModel out of your Controllers. He makes a good argument why it is better to take care of the details of ubinding models on the model itself, and just let the controller specify which models it's expecting to get.
The problem with that solution is that it requires you to change the way you define your model relations. While it may make sense on some specific cases, I am not a fan of changing the way things are done within CakePHP. Rather try to change your code to suit your needs, and let CakePHP do what it does best: act as a framework.
Also it approaches model associations in a different way: model relations get loaded *when* you call expects(), while I wanted more a way to have my associations defined on each model the CakePHP way, and then specify which relations I'm interested in getting back when querying a model. So models behave the way models are supposed: they define the data within and associations to other models, and controllers behave the way controllers do: they query the models and optionally specify what model information they are insterested in.
Furthermore, I wanted to change it in a way that if I don't use expects() I get the standard result CakePHP brings: a model with its related data. With Tom's way, you have to call expects() for model associations to become useful. With this modification, you call expects() when you want to limit the amount of information you get. Makes sense, doesn't it?
UPDATE December 16, 2006: Now we have two ways to call expects. The version 1 way (array of models to include on response) or the new way which is a variable number of arguments including the models, and inner models, that should be returned when querying. Please refer to the section included at the bottom of this article. Also note that those bakers who downloaded the code prior to this update may update your expects() function and yet no change on your code is necessary.
UPDATE February 26, 2007: Two reported issues have been solved: "when defining multiple relations to the same Model, expects() would not work as expected", and "when unattaching inner model relationships through expects, such as by calling expects('InnerModelA', 'InnerModelB.InnerModelB') inner model relations are not restored after find()." This has change the code to the latest version you see below. IMPORTANT: as you see the new version includes a re-definition of afterFind() at the AppModel level. So if you re-define this function on your models, make sure to always call parent::afterFind() (which you should've been doing anyway)
UPDATE March 24, 2007: An issue with afterFind() being executed for inner model relationships (and thus re-binding relationships before main query was executed) has been fixed.
So here's the code. The first thing we will need is to add the following code to our AppModel class:
LATEST CODE UPDATE: March 24, 2007
You don't need to define another variable on your model, just set your relations as you normally do on Cake. For example, let's take Tom's Title example but let's build it the Cake way:
Following his example, we now want to query this model and only return its associations with Story and Post, disregarding the rest:
As you can see you use the expects() function the same way, but you don't need to change the way associations are defined in CakePHP. Furthermore, we make clean calls to CakePHP's bult in unbindModel() function in the model class, so we are safe for any further CakePHP upgrades. Also, there's an easy way to do an unbindAll() as Tom was requested, just call expects() with no parameters:
As noted earlier, on December 16, 2006 I added a new version of the code to allow an easier way to do multiple expects() calls. Let's take this code:
We are here not limiting the Post model, but its related models. You can achieve the same result by using the new method of call:
As you can see in just one call we can provide the necessary restrictions. Note the form of specifying an inner restriction: Model.InnerModel. If you wish to obtain the same effect as: $this->Model->InnerModel->expects() then the inner restriction is of the form: Model.Model
Let's look at another example. On the old form we do:
On the new form we would do:
Or better yet:
A final yet simpler example:
can be also obtained by doing:
Once again I must alert that the previous form of method calling (through array of models) is still valid and will work as expected. This was just a handy modification to further improve the way you use this functionality from your controllers.
The problem with that solution is that it requires you to change the way you define your model relations. While it may make sense on some specific cases, I am not a fan of changing the way things are done within CakePHP. Rather try to change your code to suit your needs, and let CakePHP do what it does best: act as a framework.
Also it approaches model associations in a different way: model relations get loaded *when* you call expects(), while I wanted more a way to have my associations defined on each model the CakePHP way, and then specify which relations I'm interested in getting back when querying a model. So models behave the way models are supposed: they define the data within and associations to other models, and controllers behave the way controllers do: they query the models and optionally specify what model information they are insterested in.
Furthermore, I wanted to change it in a way that if I don't use expects() I get the standard result CakePHP brings: a model with its related data. With Tom's way, you have to call expects() for model associations to become useful. With this modification, you call expects() when you want to limit the amount of information you get. Makes sense, doesn't it?
UPDATE December 16, 2006: Now we have two ways to call expects. The version 1 way (array of models to include on response) or the new way which is a variable number of arguments including the models, and inner models, that should be returned when querying. Please refer to the section included at the bottom of this article. Also note that those bakers who downloaded the code prior to this update may update your expects() function and yet no change on your code is necessary.
UPDATE February 26, 2007: Two reported issues have been solved: "when defining multiple relations to the same Model, expects() would not work as expected", and "when unattaching inner model relationships through expects, such as by calling expects('InnerModelA', 'InnerModelB.InnerModelB') inner model relations are not restored after find()." This has change the code to the latest version you see below. IMPORTANT: as you see the new version includes a re-definition of afterFind() at the AppModel level. So if you re-define this function on your models, make sure to always call parent::afterFind() (which you should've been doing anyway)
UPDATE March 24, 2007: An issue with afterFind() being executed for inner model relationships (and thus re-binding relationships before main query was executed) has been fixed.
So here's the code. The first thing we will need is to add the following code to our AppModel class:
LATEST CODE UPDATE: March 24, 2007
Model Class:
Download code
<?php class AppModel extends Model
{
function afterFind($results)
{
if (isset($this->__runResetExpects) && $this->__runResetExpects)
{
$this->__resetExpects();
unset($this->__runResetExpects);
}
return parent::afterFind($results);
}
/**
* Unbinds all relations from a model except the specified ones. Calling this function without
* parameters unbinds all related models.
*
* @access public
* @since 1.0
*/
function expects()
{
$models = array();
$arguments = func_get_args();
$innerCall = false;
if (!empty($arguments) && is_bool($arguments[0]))
{
$innerCall = $arguments[0];
}
foreach($arguments as $index => $argument)
{
if (is_array($argument))
{
if (count($argument) > 0)
{
$arguments = am($arguments, $argument);
}
unset($arguments[$index]);
}
}
foreach($arguments as $index => $argument)
{
if (!is_string($argument))
{
unset($arguments[$index]);
}
}
if (count($arguments) == 0)
{
$models[$this->name] = array();
}
else
{
foreach($arguments as $argument)
{
if (strpos($argument, '.') !== false)
{
$model = substr($argument, 0, strpos($argument, '.'));
$child = substr($argument, strpos($argument, '.') + 1);
if ($child == $model)
{
$models[$model] = array();
}
else
{
$models[$model][] = $child;
}
}
else
{
$models[$this->name][] = $argument;
}
}
}
$relationTypes = array ('belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany');
foreach($models as $bindingName => $children)
{
$model = null;
foreach($relationTypes as $relationType)
{
$currentRelation = (isset($this->$relationType) ? $this->$relationType : null);
if (isset($currentRelation) && isset($currentRelation[$bindingName]) && is_array($currentRelation[$bindingName]) && isset($currentRelation[$bindingName]['className']))
{
$model = $currentRelation[$bindingName]['className'];
break;
}
}
if (!isset($model))
{
$model = $bindingName;
}
if (isset($model) && $model != $this->name && isset($this->$model))
{
if (!isset($this->__backInnerAssociation))
{
$this->__backInnerAssociation = array();
}
$this->__backInnerAssociation[] = $model;
$this->$model->expects(true, $children);
}
}
if (isset($models[$this->name]))
{
foreach($models as $model => $children)
{
if ($model != $this->name)
{
$models[$this->name][] = $model;
}
}
$models = array_unique($models[$this->name]);
$unbind = array();
foreach($relationTypes as $relation)
{
if (isset($this->$relation))
{
foreach($this->$relation as $bindingName => $bindingData)
{
if (!in_array($bindingName, $models))
{
$unbind[$relation][] = $bindingName;
}
}
}
}
if (count($unbind) > 0)
{
$this->unbindModel($unbind);
}
}
if (!$innerCall)
{
$this->__runResetExpects = true;
}
}
/**
* Resets all relations and inner model relations after calling expects() and find().
*
* @access private
* @since 1.1
*/
function __resetExpects()
{
if (isset($this->__backAssociation))
{
$this->__resetAssociations();
}
if (isset($this->__backInnerAssociation))
{
foreach($this->__backInnerAssociation as $model)
{
$this->$model->__resetExpects();
}
unset($this->__backInnerAssociation);
}
}
}?>
You don't need to define another variable on your model, just set your relations as you normally do on Cake. For example, let's take Tom's Title example but let's build it the Cake way:
Model Class:
Download code
<?php class Title extends AppModel
{
var $belongsTo = array (
'Book' => array (
'className' => 'Book',
'foreignKey' => 'collection_id'
),
'Album' => array (
'className' => 'Album',
'foreignKey' => 'collection_id'
)
);
var $hasOne = array (
'Story' => array (
'className' => 'Story'
),
'Photo' => array (
'className' => 'Photo'
)
);
var $hasMany = array (
'Post' => array (
'className' => 'Post',
'order' => 'Post.id DESC'
)
);
}?>
Following his example, we now want to query this model and only return its associations with Story and Post, disregarding the rest:
Controller Class:
Download code
<?php class TitlesController extends AppController
{
function list($id)
{
// establish necessary associations
$this->Title->expects(array('Story', 'Post'));
$this->Title->Post->expects(array('User'));
$this->Title->recursive = 2;
$results = $this->Title->read(null, $id);
}
}
?>
As you can see you use the expects() function the same way, but you don't need to change the way associations are defined in CakePHP. Furthermore, we make clean calls to CakePHP's bult in unbindModel() function in the model class, so we are safe for any further CakePHP upgrades. Also, there's an easy way to do an unbindAll() as Tom was requested, just call expects() with no parameters:
Controller Class:
Download code
<?php class TitlesController extends AppController
{
function list($id)
{
$this->Title->expects();
$results = $this->Title->read(null, $id);
}
}
?>
Making multiple expects() in one call
As noted earlier, on December 16, 2006 I added a new version of the code to allow an easier way to do multiple expects() calls. Let's take this code:
Controller Class:
Download code
<?php
$this->Post->Author->expects();
$this->Post->Category->expects();
$this->Post->PostDetail->expects(array('PostExtendedDetail', 'PostAttachment'));
?>
We are here not limiting the Post model, but its related models. You can achieve the same result by using the new method of call:
Controller Class:
Download code
<?php
$this->Post->expects('Author.Author', 'Category.Category',
'PostDetail.PostExtendedDetail', 'PostDetail.PostAttachment');
?>
As you can see in just one call we can provide the necessary restrictions. Note the form of specifying an inner restriction: Model.InnerModel. If you wish to obtain the same effect as: $this->Model->InnerModel->expects() then the inner restriction is of the form: Model.Model
Let's look at another example. On the old form we do:
Controller Class:
Download code
<?php
$this->Title->expects(array('Story', 'Post'));
$this->Title->Post->expects(array('User'));
?>
On the new form we would do:
Controller Class:
Download code
<?php
$this->Title->expects('Story', 'Post', 'Post.User');
?>
Or better yet:
Controller Class:
Download code
<?php
$this->Title->expects('Story', 'Post.User');
?>
A final yet simpler example:
Controller Class:
Download code
<?php
$this->Title->expects(array('Story', 'Post'));
?>
can be also obtained by doing:
Controller Class:
Download code
<?php
$this->Title->expects('Story', 'Post');
?>
Once again I must alert that the previous form of method calling (through array of models) is still valid and will work as expected. This was just a handy modification to further improve the way you use this functionality from your controllers.
Comments
Comment
1 AKA unbindAllExcept()
I would still like to use Tom's way when there are associations which I don't often bind to and your way when there are associations which I don't often unbind. So depending on the the context both the things make equal sense for me
Bug
2 Association Name vs. Classname
I came across an issue when using different association names than classnames, ie:
var belongsTo = array('FromUser'=>array('className'=>'User'), 'ToUser'=>array('className'=>'User'));
I changed your code to use the index instead of the classname of the association and it worked.
Comment
3 Recursive
let me try to explain this with an example:
i have a user model which has ratings and the ratings have a ByUser and an AboutUser. so if i need recursive=3 because of other associations with the user model
$this->User->expects('User', 'Rating.ByUser');
results in user plus ratings plus byuser plus all associations to byuser. if i want to cut these associations by using
$this->User->expects('User', 'Rating.ByUser.ByUser');
unbinding affects the user model itself and i get only the user tuple without any associations.
i know that this is a general problem with unbindModel. but maybe someone has any conclusion or hint how to solve this elegantly.
you can solve it by getting the data more manually. for instance you can exclude the ratings in $this->User->read() and make a $this->User->Rating->findAll() with the correct expects. this is okay for me, but you can't even merge this data cause the first results in array('User'..., 'Rating'=>array(0=>..., 1=>)) and the second is array(0=>array('Rating'=>....),1=>...). But this is another problem with cake :) , there are arrays instead of objects (i know there are plans to solve this issue).
sorry if this came up like a rant. just want to get a little discussion about this. if you don't care what i'm talking about, ignore this ;)
Comment
4 Unbind issues
<?php
$this->Post->expects('Author.Author', 'Category.Category',
'PostDetail.PostExtendedDetail', 'PostDetail.PostAttachment');
?>
after running a find query on Post model, all models which were unbinded from 'Post' will be re-binded. But the models which were unbinded from 'PostDetail' will not be re-binded to 'PostDetail'.
So if we run a find query on 'PostDetail' we will not get the associated data. We will have to specifically bind the unbinded models to 'PostDetail' after running the find on 'Post'. This is not a problem if 'PostDetail' is associated with a few models but what if it is associated with quite a handful of? It will be unwise to bind all those again manually.
Perhaps we need a bindAll method.
Bug
5 Association Name vs. Classname
I am having the same problem as reported by Timo Derstappen (Comment 2). But I am not sure where to make appropriate changes. Please let me know the changes that needs to be made.
Comment
6 Re 5 Association Name vs Classname
Not sure if this is 100% correct all the time, but it seemed to work ok for me.
$relations = array ('belongsTo', 'hasOne', 'hasMany', 'hasAndBelongsToMany');
foreach($relations as $relation) {
if (isset($this->$relation)) {
foreach($this->$relation as $association => $currentModel) {
if (!in_array($association, $models)) {
$unbind[$relation][] = $association;
}
}
}
}
Comment
7 Thanks
@ Mariano Iglesias - Thanks for the great extension
Ritesh
Comment
8 New Version
Comment
9 Thanks
Comment
10 Tom OReillys solution is superior (in most cases)
The reason why calling expects() to activate associations is actual a better idea than calling it to deactivate assocations is because the former is more resistant to future extensions of your models (and associations).
A specification of what you need is a definition that will remain constant, even after you added a zillion new associations to corresponding models later. A specification of what you do not need though, is a definition relative to the current definition of a model (its associations) and therefore depends on this model definition. As soon as this model definition changes (you add associations), the result of the unbind will change as well. In my experience, in most cases this is not what you want.
Bug
11 Addition to afterFind() method
function afterFind($results) {
$this->__resetExpects();
return $results;
}
...this replicated the afterFind method in cake's Model class. Without it, the results of my find calls were empty!
Comment
12 Responses
@Neil: Thanks for the heads up! My working copy had the version as you see now on the code, but somehow it didn't make it to the article :)
Question
13 1.2 compatible
Also, does the recursive variable need to be set for this to look below the first level of associations? If so, it would be great if (Comment.Person) would automatically trigger expects() to set the recursive variable.
Thanks, Mariano!
Comment
14 expects Test cases for CakePHP 1.2
Regarding $recursive: it takes precedence over your bindings. This is normal CakePHP behavior, not something that expects() changes (once again I must clarify that expects() relies on stable CakePHP core elements and does not make any hacks to work), so if you set recursive to 1 but you have a third level relationship you wish to obtain, you will have to set recursive to a higher value.
For lack of a better place here's the test case for CakePHP 1.2:
http://bin.cakephp.org/view/1443836304
Question
15 calling expects() before paginate()
is it possible to make expect() more permament ( something like $reset in unbindModel() ) - it would be usefull for $this->paginate().
Cause now when you call expects() before paginate() it only works for the first findCount that cake makes. So the actuall find will have all the models.
greets,
Marcin Domanski aka kabturek
Comment
16 re calling expects() before paginate()
Comment
17 re calling expects() before paginate()
greets,
Marcin Domanski aka kabturek
Comment
18 My response
I know one thing for sure, Mariano and I agree that having dynamic control of your associations is a great and powerful feature. Model relationships *are* integral to CakePHP. I'm only responding (and a little late I might add, sorry :) ) because some of the things written implies we may disagree.
I am always the first to argue that $uses should not be used when there is a "better" association way. In fact that's part of the reason I wrote the tutorial in the first place, and as you can see it doesn't use $uses but instead loads standard cake definitions and even keeps the same definitions format. As close to the 'cake way' as possible and still get the functionality. What would be even more 'cakey' would be to put this into a behavior in some way, but I'm happy as it is.
I also wouldn't suggest having *all* of your associations defined this way. Only the rare ones. The idea being not to hide the definition embedded in a bindModel() string somewhere but right on top of the model where it should be. Calling expects() when you need to load the rare definition is better than the alternative.
I only wish Mariano used different method names, so that they could coexist in harmony - there's really no reason why you wouldn't want to sometimes add, sometimes tweak. Can't we all just get along? ;)
But for those keeping score: my method: approx. 10 lines of code, 0 need for bug fixes. Sorry Mariano ;)
Question
19 hasAndBelongsToMany Problem
--------------
var $hasOne = array (
'Avatar' => array (
'className' => 'Avatar',
'conditions' => '',
'order' => '',
'dependent' => true,
'foreignKey' => 'user_id'
)
);
var $hasAndBelongsToMany = array (
'Friend' => array (
'className' => 'User',
'joinTable' => 'friends',
'foreignKey' => 'user_id',
'associationForeignKey' => 'friend_id'
)
);
My Avatar Model:
----------------
var $belongsTo = array (
'User' => array (
'className' => 'User',
'conditions' => '',
'order' => '',
'foreignKey' => 'user_id'
)
);
In my User Controller I use this:
$this->User->expects('User', 'Friend', 'Avatar');
$results = $this->User->read(null, $id);
I Get The User with the $id and his Avatar and his Friends.
The PROBLEM Is That I Dont Get The Avatar From The Friends :(
So please help me when somebody has an idea....
Thank u very much!
Comment
20 Answer to your problem
The answer lies in recursion. Since User->Friend->Avatar is one layer deeper than User->Avatar and User->Friend, you simply need to set your recursive value a little higher.
Question
21 Still Problems With hasAndBelongsToMany
$results = $this->User->read(null, '$id);
When I Do This Than The Results Are Ok But Without The Avatar For The Friends.
When I Start To Set The Recursion From $this->User->Friend->recursive = 2; The Problem Is That The Friends Table Is A hasAndBelongsToMany Relationship That Points Back To The User Table. So The Recursion Is Set For The User Table And I get To Many Results For The User And The Friends.
Without Recursion:
------------------
Array
(
[User] => Array
(
[id] => 122
[firstname] => xxx
[lastname] => yyy
[birthday] => 1985-04-04
[created] => 2007-05-25 01:18:08
[modified] => 2007-10-05 05:46:08
)
[Avatar] => Array
(
[id] => 10
[user_id] => 122
[avatarlink] => avatars/46561d30c9c12/Foto 16_2.jpg
)
[Friend] => Array
(
[0] => Array
(
[id] => 129
[firstname] => aaa
[lastname] => bbb
[birthday] => 1979-07-28
[created] => 2007-05-25 16:49:50
[modified] => 2007-07-06 11:57:23
)))
With Recursion ($this->User->Friend->recursive = 2;)
----------------------------------------------------
Array
(
[User] => Array
(
[id] => 122
[firstname] => xxx
[lastname] => yyy
[birthday] => 1985-04-04
[created] => 2007-05-25 01:18:08
[modified] => 2007-10-05 05:53:04
)
[Avatar] => Array
(
[id] => 10
[user_id] => 122
[avatarlink] => avatars/46561d30c9c12/Foto 16_2.jpg
[User] => Array
(
[id] => 122
[firstname] => xxx
[lastname] => yyy
[birthday] => 1985-04-04
[created] => 2007-05-25 01:18:08
[modified] => 2007-10-05 05:53:04
)
)
[Friend] => Array
(
[0] => Array
(
[id] => 129
[firstname] => aaa
[lastname] => bbb
[birthday] => 1979-07-28
[created] => 2007-05-25 16:49:50
[modified] => 2007-07-06 11:57:23
[0] => Array
(
[id] => 119
[firstname] => Maidi
[lastname] => Rocks
[birthday] =>
[created] => 2007-05-25 00:44:05
[modified] => 2007-06-14 00:29:04
)
[Avatar] => Array
(
[id] => 13
[user_id] => 129
[avatarlink] => avatars/myavatar/wandern.jpg
)
So It Will Be Great When You Could Help Me To Solve The Problem.
- Thanks -
Best regards,
Maidi
Comment
22 Still Problems With hasAndBelongsToMany
PHP Snippet:
<?php $this->User->expects('Avatar', 'Friend.Friend');$results = $this->User->read(null, $id);?>
To everyone else: I will soon be releasing ExpectsBehavior, the 1.2 version of expects with performance improvement, better test coverage, clearer notation and many features.
Comment
23 Bindable Behavior released
Bindable Behavior: control your model bindings
Question
24 Multiple aliases for same table break behaviour
Excellent work - I've just started with Cake and this code is perfect (personally I think it should be part of the core...)
I have a problem - the following are my associations:
Location belongsTo Dataset // Dataset aliased to MaskDataset
Location hasMany DisplayDatasets
DisplayDataset belongsTo Dataset
Dataset hasOne NamedDataset
I do the following:
$this->Location->recursive = 3;
$this->Location->expects(
'Location.Location',
// 'MaskDataset.MaskDataset',
'DisplayDatasets.DisplayDatasets',
'DisplayDatasets.Dataset',
'DisplayDatasets.Dataset.NamedDataset'
);
This works mostly as required. If I uncomment the commented out line though (retrieving MaskDataset and none of its associations), it prevents any of DisplayDatasets.Dataset's associations - i.e. it doesn't return the NamedDataset.
Am I doing something wrong here or is this broken behaviour?
I'd love to check out Bindable behaviour but can't do anything until 1.2 is stable :(
Comment
25 similar troubles
Faq has many FaqTopic (FaqTopic belongs to Faq)
FaqTopic has many FaqItem (FaqItem belongs to FaqTopic)
doing a findAll with resursive = 2 pulls just what I need.
$this->Faq->recursive = 2;
$this->Faq->findAll();
When I try to do a findAll with expects, I get the oddness:
(i've tried just about every variation I can think of)
$this->Faq->expects('FaqTopic', 'FaqItem');
$this->Faq->expects('FaqTopic.FaqItem');
$this->Faq->expects('FaqTopic', 'FaqTopic.FaqItem')
and so on.. also tried setting
$this->Faq->FaqTopic->expects('FaqItem');
along with everything else.
For now I'll just revert to recursive var, but would love to see if I'm doing something wrong (or if there is a fix) for this lovely 1.1 version of expects();