HABTM Add & Delete Behavior
Many people gripe about the HABTM associations in CakePHP and how difficult it is to add or delete a single record. This behavior takes care of the task for you!
CakePHP makes a developers day-to-day grind very easy! But when it comes to hasAndBelongsToMany relationships (or more commonly referred to HABTM) many people find themselves confused -- especially when you try to add or delete associations multiple times.
Throughout this article I will use the good ole Post hasAndBelongsToMany Tags relationship for easy clarification.
The Age-Old Problem
(Using our Post HABTM Tag concept) We want to tag a Post (id=1) with a Tag (id=1). Our code is as follows:
Download code
<?php
$this->Post->save(array(
'Post' => array(
'id' => 1,
),
'Tag' => array(
'Tag' => array(1),
),
));
?>
No secret that this works correctly but look what happens when you try to add (or append) another Tag say (id=2) to our Post(id=1):
Download code
<?php
$this->Post->save(array(
'Post' => array(
'id' => 1,
),
'Tag' => array(
'Tag' => array(2),
),
));
// Foreward: Check out the debug SQL for this save ...
// DELETE FROM `posts_tags` WHERE post_id = '1'
// INSERT INTO `posts_tags` (post_id,tag_id) VALUES (1,2)
?>
Many bakers at this point throw their hands up in the air in frustration, cursing to the Cake gods (or us in the IRC chat) in futility "WHERE DID THE FIRST TAG GO?". Yes, it's true, CakePHP has deleted Tag (id=1) for Post (id=1) in the cross table * Check out the debug SQL *. The evil masterminds behind CakePHP designed it this way and it works like it should under EVERY circumstance other than 'adding' (like this example) and 'deleting' associations one at a time.
The Solution - ExtendAssociations Behavior
This behavior allows you to easily add or delete HABTM associations! (It also includes a cool unbindAll() method).
Installation
1. Create a file named 'extend_associations.php' in your ./app/models/behaviors folder and copy the following code into it.
Download code
<?php
/**
* Extend Associations Behavior
* Extends some basic add/delete function to the HABTM relationship
* in CakePHP. Also includes an unbindAll($exceptions=array()) for
* unbinding ALL associations on the fly.
*
* This code is loosely based on the concepts from:
* http://rossoft.wordpress.com/2006/08/23/working-with-habtm-associations/
*
* @author Brandon Parise <brandon@parisemedia.com>
* @package CakePHP Behaviors
*
*/
class ExtendAssociationsBehavior extends ModelBehavior {
/**
* Model-specific settings
* @var array
*/
var $settings = array();
/**
* Setup
* Noething sp
*
* @param unknown_type $model
* @param unknown_type $settings
*/
function setup(&$model, $settings = array()) {
// no special setup required
$this->settings[$model->name] = $settings;
}
/**
* Add an HABTM association
*
* @param Model $model
* @param string $assoc
* @param int $id
* @param mixed $assoc_ids
* @return boolean
*/
function habtmAdd(&$model, $assoc, $id, $assoc_ids) {
if(!is_array($assoc_ids)) {
$assoc_ids = array($assoc_ids);
}
// make sure the association exists
if(isset($model->hasAndBelongsToMany[$assoc])) {
$data = $this->__habtmFind($model, $assoc, $id);
// no data to update
if(empty($data)) {
return false;
}
// important to use array_unique() since merging will add
// non-unique values to the array.
$data[$assoc][$assoc] = array_unique(am($data[$assoc][$assoc], $assoc_ids));
return $model->save($data);
}
// association doesn't exist, return false
return false;
}
/**
* Delete an HABTM Association
*
* @param Model $model
* @param string $assoc
* @param int $id
* @param mixed $assoc_ids
* @return boolean
*/
function habtmDelete(&$model, $assoc, $id, $assoc_ids) {
if(!is_array($assoc_ids)) {
$assoc_ids = array($assoc_ids);
}
// make sure the association exists
if(isset($model->hasAndBelongsToMany[$assoc])) {
$data = $this->__habtmFind($model, $assoc, $id);
// no data to update
if(empty($data)) {
return false;
}
// if the * (all) is set then we want to delete all
if($assoc_ids[0] == '*') {
$data[$assoc][$assoc] = array();
} else {
// use array_diff to see what values we DONT want to delete
// which is the ones we want to re-save.
$data[$assoc][$assoc] = array_diff($data[$assoc][$assoc], $assoc_ids);
}
return $model->save($data);
}
// association doesn't exist, return false
return false;
}
/**
* Delete All HABTM Associations
* Just a nicer way to do easily delete all.
*
* @param Model $model
* @param string $assoc
* @param int $id
* @return boolean
*/
function habtmDeleteAll(&$model, $assoc, $id) {
return $this->habtmDelete($model, $assoc, $id, '*');
}
/**
* Find
* This method allows cake to do the dirty work to
* fetch the current HABTM association.
*
* @param Model $model
* @param string $assoc
* @param int $id
* @return array
*/
function __habtmFind(&$model, $assoc, $id) {
// temp holder for model-sensitive params
$tmp_recursive = $model->recursive;
$tmp_cacheQueries = $model->cacheQueries;
$model->recursive = 1;
$model->cacheQueries = false;
// unbind all models except the habtm association
$this->unbindAll($model, array('hasAndBelongsToMany' => array($assoc)));
$data = $model->find(array($model->name.'.'.$model->primaryKey => $id));
$model->recursive = $tmp_recursive;
$model->cacheQueries = $tmp_cacheQueries;
if(!empty($data)) {
// use Set::extract to extract the id's ONLY of the $assoc
$data[$assoc] = array($assoc => Set::extract($data, $assoc.'.{n}.'.$model->primaryKey));
}
return $data;
}
/**
* UnbindAll with Exceptions
* Allows you to quickly unbindAll of a model's
* associations with the exception of param 2.
*
* Usage:
* $this->Model->unbindAll(); // unbinds ALL
* $this->Model->unbindAll(array('hasMany' => array('Model2')) // unbind All except hasMany-Model2
*
* @param Model $model
* @param array $exceptions
*/
function unbindAll(&$model, $exceptions = array()) {
$unbind = array();
foreach($model->__associations as $type) {
foreach($model->{$type} as $assoc=>$assocData) {
// if the assoc is NOT in the exceptions list then
// add it to the list of models to be unbound.
if(@!in_array($assoc, $exceptions[$type])) {
$unbind[$type][] = $assoc;
}
}
}
// if we actually have models to unbind
if(count($unbind) > 0) {
$model->unbindModel($unbind);
}
}
}
?>
2. Add the following line of code to your model.
Download code
<?php
var $actsAs = 'ExtendAssociations';
?>
Example Usage
Our Post model:
Model Class:
Download code
<?php
class Post extends AppModel {
var $name = 'Post';
var $actsAs = 'ExtendAssociations';
var $hasAndBelongsToMany = array(
'Tag' => array(
'className' => 'Tag',
'joinTable' => 'posts_tags',
'foreignKey' => 'post_id',
'associationForeignKey' => 'tag_id',
),
);
}
?>
Adding Associations
Download code
<?php
// add a single association
$this->Post->habtmAdd('Tag', 1, 1);
// add multiple associations in a single call
$this->Post->habtmAdd('Tag', 1, array(1, 2, 3));
?>
Deleting Associations
Download code
<?php
// delete a single association
$this->Post->habtmDelete('Tag', 1, 1);
// delete multiple associations in a single call
$this->Post->habtmDelete('Tag', 1, array(1, 3));
// want to delete all associations?
$this->Post->habtmDeleteAll('Tag', 1);
?>
Unbinding All Associations (with Exceptions)
Download code
<?php
// unbind ALL associations
$this->Post->unbindAll();
// unbind ALL except hasAndBelongsToMany['Tag']
$this->Post->unbindAll(array('hasAndBelongsToMany' => array('Tag')));
?>
I am sure in due time this will be added to the core but in the meantime this should suffice!
Comments
Comment
1 Extend this concept further
Post HABTM Tag and `posts_tags` has a field `other_field`
$this->Post->habtmAdd('Tag', 1, array(
array('tag_id' => 1, 'other_field' => '123ABC'),
array('tag_id' => 2, 'other_field' => '123ABC'),
);
Comment
2 Add functionality
Comment
3 Me too
Comment
4 Will DO
Comment
5 Great...
Comment
6 Awesome
Comment
7 How is it coming
Comment
8 Awesome
Comment
9 Beautiful
Question
10 Extra data
Thank you, and sorry for my bad english.
Question
11 advice for 1.1 users
Thanks.
Comment
12 Extra Data
To use:
$this->Post->habtmAdd('Tag', 1, 1 , array( 'tag_type' => 2) );
function habtmAdd(&$model, $assoc, $id, $assoc_ids , $extra = array() ) {
if(!is_array($assoc_ids)) {
$assoc_ids = array($assoc_ids);
}
// make sure the association exists
if(isset($model->hasAndBelongsToMany[$assoc])) {
$data = $this->__habtmFind($model, $assoc, $id);
// no data to update
if(empty($data)) {
return false;
}
// important to use array_unique() since merging will add
// non-unique values to the array.
$data[$assoc][$assoc] = array_unique(am($data[$assoc][$assoc], $assoc_ids));
$success = $model->save($data);
// save extra fields
$joinTable = $model->hasAndBelongsToMany[$assoc]['joinTable'];
$associationForeignKey = $model->hasAndBelongsToMany[$assoc]['associationForeignKey'];
if( $success && !empty( $joinTable ) && count( $extra) ){
$sql = array();
foreach( $extra as $key => $value )
$sql[] = $key . " = '". addslashes($value) . "'";
$model->query( "UPDATE $joinTable set ".implode( "," , $sql )." WHERE $associationForeignKey IN ( ". implode( " , " , $assoc_ids ) ." )");
}
return $success;
}
// association doesn't exist, return false
return false;
}
Comment
13 Data validation issue
I don't think this solves the root of the problem, but turning off validation in habtmAdd() worked:
return $model->save($data, false);
Bug
14 HABTM
@12 - this doesn't seem to fully work, as the extra fields end up being emptied during the add process. Currently, all foreign keys are temporarily stored, then all HABTM relationships are deleted, then all HABTM relationships are re-added but without the extra data they had before. Only the new HABTM relationship being added has its extra data stored.
I'd love to see if someone has some insight on getting this to work with extra data. Thanks :)
Comment
15 habtmUpdate additional function for this behaviour
the following function can be used in conjunction with that specified in comment 12 above, in order to update the extra fields associated with the habtm join table.
for example:
post_id tag_id relevance
1 2 50
$this->Post->habtmUpdate('Tag', 1, 2, array('relevance' => 60));
yields
post_id tag_id relevance
1 2 60
function habtmUpdate(&$model, $assoc, $id, $assoc_ids, $extra = array() ) {
if(!is_array($assoc_ids)) {
$assoc_ids = array($assoc_ids);
}
// make sure the association exists
if(isset($model->hasAndBelongsToMany[$assoc])) {
$data = $this->__habtmFind($model, $assoc, $id);
debug($data);
// no data to update
if(empty($data)) {
return false;
}
// save extra fields
$joinTable = $model->hasAndBelongsToMany[$assoc]['joinTable'];
$associationForeignKey = $model->hasAndBelongsToMany[$assoc]['associationForeignKey'];
$foreignKey = $model->hasAndBelongsToMany[$assoc]['foreignKey'];
$thisid = $data['Sale']['id'];
if( !empty( $joinTable ) && count( $extra) ){
$sql = array();
foreach( $extra as $key => $value )
$sql[] = $key . " = '". addslashes($value) . "'";
$success = $model->query( "UPDATE $joinTable set ".implode( "," , $sql )." WHERE $foreignKey = $thisid AND $associationForeignKey IN ( ". implode( " , " , $assoc_ids ) ." )");
}
return $success;
}
// association doesn't exist, return false
return false;
}
the code above zeroes out all extra data entries for every association for a model when deleting one of a model's associations by using habtmDelete above. I am working on a functioning version.
Comment
16 I apologize for the lack of update
Franlkly at the moment i did not need it. It shoudn't be hard to implement. Hopefully in the next few days I will post an updated version. BTW thanks, Brandon Parise for the behaviour..=)
Comment
17 function habtmCheck
Bug
18 Updating an object issue
I have two models that have a HABTM connection. The models are User and Publication. When I get an object (of the model Publication) and change a value like this:
$data['Publication']['key'] = $value;
$this->Publication->save($data['Publication']);
What happens now is that the row is updated but in the join table the "user_id" gets set to NULL.
What is going on here?
I can fix it by doing this after the store, but this seems a lame way of doing it:
$this->Publication->habtmAdd('User', $data['Publication']['id'], $user['id']);
Comment
19 unbindAll extension
/**
* Allows you to quickly unbind all of a model's associations and exempts
* those specified in the second parameter.
*
* Usage:
* $this->Model->unbindAll(); // unbinds associations
* $this->Model->unbindAll(array(
* 'Child1',
* 'Child2' => array('GrandChild1' => array('GreatGrandChild1')),
* ));
*/
function unbindAll(&$model, $exceptions = array()) {
// Make sure array keys are all on they key side
foreach($exceptions as $key => $value) {
if (is_numeric($key)) {
unset($exceptions[$key]);
$exceptions[$value] = null;
}
}
// Determine which models need unbinding
$unbind = array();
$exceptionKeys = array_keys($exceptions);
foreach($model->__associations as $type) {
foreach($model->{$type} as $assoc=>$assocData) {
if(!in_array($assoc, $exceptionKeys)) {
$unbind[$type][] = $assoc;
}
}
}
// Recursively unbind child models
foreach ($exceptions as $key => $value) {
$this->unbindAll($model->$key, (!empty($value) ? $value : array()));
}
// Unbind any models not exempted
if (count($unbind) > 0) {
$model->unbindModel($unbind);
}
}
Bug
20 ack not fun
I am having this issue as well, updating to the latest SVN cakephp doesn't help. Ended up writing my own query to work around the issue for now.