Modeling relationships in CakePHP (faking Rails' ThroughAssociation)
Posted by Felix Geisendörfer, on Oct 26, 2006 - in PHP & CakePHP » DataSources, Models & Behaviors
Ok, this has been something I found very delightful when I first heard about it (see this speech by DHH and slides). But somehow I didn't really manage to blog about so far, which means it's about time to do this right now. The topic can be simply discribed as: How to implement ThroughAssociations (as they are known in Ruby on Rails) in CakePHP. The idea behind it is pretty simple: Often you have two Models that are associated with each other where setting the associations themself via hasAndBelongsToMany isn't quite enough for what you try to to. Let's say you have Users and Groups and you want to not only store what Users belong to what groups, as you would normally do with a join table and a hasAndBelongsToMany relationship, but you would also like to track when a User became a Member of a certain group and maybe even add a note about this. If you try to add additional fields to your Users or Group tables you'll certainly run into issues very soon, because this information simply does not belong into any of those Models. Same goes for creating the relationship (i.e. adding a User to a Group) with the Controllers. There is no way to do this in a CRUDful way. You'll have to add odball functions like UsersController::add_to_group() or in other words create messy code.
I don't like to break the CRUD pattern, so when I first heard about the concept of modeling relationship I instantly fell in love with it. Basically what this is all about, is to create a Model and a Controller for your relationships. So in our Users<-HABTM->Groups example we would simply create an additonal Memberships Model. Our new relationship would be: User->hasMany->Groups through Membership. And Group->hasMany->Users through Membership. Now unfortunatly CakePHP doesn't support the through association, but there is a simple workaround to this. Instead of using the through operator we do this:
User->hasMany->Membership
Group->hasMany->Membership
Membership->belongsTo->User
Membership->belongsTo->Group
Because of CakePHP's recursive associations, we achieve a similar effect as the trough operator would have in Rails. Now the cool thing is that you can add additional fields to your memberships table like 'id', 'created', 'updated', 'note' and others.
In case you want to see it working, I setup a little scaffolding demo here:
http://demos.thinkingphp.org/relationship_modeling/
I'll probably have to take it down due to Spam soon, but meanwhile feel free to browse around and to perform non-destructive operations. I didn't add any validation, nor a check for forbidding double Memberships, but this would be easy.
In case you are interested in the code, here it comes:
-
class User extends AppModel
-
{
-
var $name = "User";
-
}
-
class Group extends AppModel
-
{
-
var $name = "Group";
-
}
-
class Membership extends AppModel
-
{
-
var $name = "Membership";
-
}
-
class UsersController extends AppController
-
{
-
var $name = "Users";
-
var $scaffold;
-
}
-
class GroupsController extends AppController
-
{
-
var $name = "Groups";
-
var $scaffold;
-
}
-
class MembershipsController extends AppController
-
{
-
var $name = "Memberships";
-
var $scaffold;
-
}
And not to forget the SQL Dump:
[sql]CREATE TABLE `groups` (
`id` int(11) NOT NULL auto_increment,
`name` varchar(11) default NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `memberships` (
`id` int(11) NOT NULL auto_increment,
`user_id` int(11) default NULL,
`group_id` int(11) default NULL,
`created` datetime default NULL,
`updated` datetime default NULL,
`note` text,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `users` (
`id` int(11) NOT NULL auto_increment,
`name` varchar(255) default NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;[/sql]
Alright, I hope this is similiar enough to the ThroughAssociation in Rails to scale, in case there are some RoR folks listening, let me know if I missed something.
-- Felix Geisendörfer aka the_undefined