HABTM Add & Delete Behavior

By Brandon Parise aka "bparise"
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'11);
// add multiple associations in a single call
$this->Post->habtmAdd('Tag'1, array(123));
?>


Deleting Associations

Download code <?php
// delete a single association
$this->Post->habtmDelete('Tag'11);
// delete multiple associations in a single call
$this->Post->habtmDelete('Tag'1, array(13));
// 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 362

CakePHP team comments Author comments

Comment

1 Extend this concept further

If there is any demand to allow you to save more than just the foreignKey / associationForiegnKey to the HABTM cross table I will add it. Example:

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'),
);

posted Wed, May 9th 2007, 07:49 by Brandon Parise

Comment

2 Add functionality

I could use the functionality that you mentioned above.
posted Mon, May 14th 2007, 10:33 by Scott Penrose

Comment

3 Me too

I would indeed be happy if you added the ability to add extra field data to the HABTM cross table.
posted Fri, May 18th 2007, 15:34 by Thomas Winther

Comment

4 Will DO

I will add the functionality this weekend
posted Fri, May 25th 2007, 18:06 by Brandon Parise

Comment

5 Great...

...looking forward to seeing it :-)
posted Sun, May 27th 2007, 07:03 by Thomas Winther

Comment

6 Awesome

I'm also looking forward to the functionality you describe; it will be very useful!
posted Thu, Jun 21st 2007, 11:46 by AG

Comment

7 How is it coming

Just wondering how the extra field functionality is coming along.
posted Mon, Jun 25th 2007, 16:00 by Scott Penrose

Comment

8 Awesome

Awesome article.. this gets added to my "essential cake reading material" list.
posted Wed, Jun 27th 2007, 19:22 by Rob Conner

Comment

9 Beautiful

This is wonderful, I have been lost for a week trying to get associations to save, then Grant pointed me to this and within 2 minutes it was done. Thank you.
posted Thu, Jul 26th 2007, 20:03 by Mike Arnold

Question

10 Extra data

Brandon Parise: Have you added the extra data support to your code? I'd like to use some extra field in my cross table.
Thank you, and sorry for my bad english.
posted Wed, Aug 8th 2007, 17:22 by Szurovecz Janos

Question

11 advice for 1.1 users

Can anyone provide some direction for modifying this for version 1.1? I've not had much success thus far.

Thanks.
posted Fri, Sep 7th 2007, 22:49 by TJ

Comment

12 Extra Data

It seems you haven't had the time to add extra fields to the association so i modified your add function with this functionality. Its working for me but use with care

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;
}


posted Thu, Oct 11th 2007, 11:10 by Mon Villalon

Comment

13 Data validation issue

I had an issue with adding a HABTM relationship to a self-referencing model, where I needed to turn off auto-validation when saving the data.

I don't think this solves the root of the problem, but turning off validation in habtmAdd() worked:


return $model->save($data, false);
posted Sat, Nov 24th 2007, 07:28 by Jon Gibbins

Bug

14 HABTM

@1 - would love to see your implementation for HABTM relationships that require extra data.

@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 :)
posted Wed, Jan 23rd 2008, 22:35 by Matt Huggins

Comment

15 habtmUpdate additional function for this behaviour

@12:

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.
posted Fri, Feb 8th 2008, 11:06 by Scott Donnelly

Comment

16 I apologize for the lack of update

I apologize for the lack of update on my comment.
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..=)
posted Fri, Feb 8th 2008, 11:17 by Mon Villalon

Comment

17 function habtmCheck

I found that I often needed a method to check whether two models had been associated. I've added this code to mine:


/**
* Checks to see if two models are bound together
*/
function habtmCheck(&$model, $assoc, $id, $assocIds) {
$data = $this->__habtmFind($model, $assoc, $id);
return !empty($data[$assoc][$assoc]);
}
posted Fri, Feb 8th 2008, 14:43 by Brad Beattie

Bug

18 Updating an object issue

I have a strange 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']);

posted Thu, Feb 14th 2008, 09:26 by Cristiano Betta

Comment

19 unbindAll extension

I found it necessary to augment the unbindAll function to allow nested unbindings.


/**
     * 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);
        }
    }
posted Wed, Mar 26th 2008, 15:23 by Brad Beattie

Bug

20 ack not fun

I have a strange issue....
What happens now is that the row is updated but in the join table the "user_id" gets set to NULL.


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.
posted Sun, Apr 27th 2008, 18:57 by JB Hewitt

Login to Submit a Comment