HABTM Add & Delete Behavior

By Brandon Parise (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 May 9, 2007 by Brandon Parise
 

Comment

2 Add functionality

I could use the functionality that you mentioned above.
Posted May 14, 2007 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 May 18, 2007 by Thomas Winther
 

Comment

4 Will DO

I will add the functionality this weekend
Posted May 25, 2007 by Brandon Parise
 

Comment

5 Great...

...looking forward to seeing it :-)
Posted May 27, 2007 by Thomas Winther
 

Comment

6 Awesome

I'm also looking forward to the functionality you describe; it will be very useful!
Posted Jun 21, 2007 by AG
 

Comment

7 How is it coming

Just wondering how the extra field functionality is coming along.
Posted Jun 25, 2007 by Scott Penrose
 

Comment

8 Awesome

Awesome article.. this gets added to my "essential cake reading material" list.
Posted Jun 27, 2007 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 Jul 26, 2007 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 Aug 8, 2007 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 Sep 7, 2007 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 Oct 11, 2007 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 Nov 24, 2007 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 Jan 23, 2008 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 Feb 8, 2008 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 Feb 8, 2008 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 Feb 8, 2008 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 Feb 14, 2008 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 Mar 26, 2008 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 Apr 27, 2008 by JB Hewitt
 

Comment

21 Really Odd behavior.. no pun intended

I am trying to use this code and when i do this:

$this->Model->habtmDelete('Blah', 1, 1);

it runs this sql query:

Query: habtmDelete

I am running this from the controller, so is that the problem or is it something with the new cake 1.2.x.x ?
Please Help.
Posted Aug 12, 2008 by Clint
 

Bug

22 Really Odd behavior extended

Firstly:

I am using Cake Version 1.2.0.7692-rc3

I am sure I did everything stated here correctly. I implemented a function, which checks id a user has logged in, else he or she would be redirected to the login page.

function __isEingeloggt(){
if($this->Session->read('login')== null)
{Controller::flash('Login!',(array('controller'=>'login','action'=>'index')),1);}}


This should be called before an action is carried out. That is why I call this function in the beforeFilter() function.

Problems 1: The actions of the controllers which do have models using this behaviours cannot be rendered except after uncommenting the asActs-line which means I am no longer using the behaviour.

Problem 2: If I remove my authenticion-function ( I mean the whole I wrote myself because I am at the moment not an expert using the built-in Auth-Component) and want for example to get something from the Database I get problems, when transforming these results to a Javascript object.

$machineA = $this->Machine->findAll(null, '*', 'id ASC');
$machineArray = Set::extract($machineA);
$this->set('machines',$machineArray);

//in the view of this function
echo $javascript->Object($machines);


<[{"Machine":{"id":"1","bezeichnung":"dx34","beschreibung":"dx11","typ":"Jack","seriennummer":"texte"
,"created":null,"modified":null},"Module":[{"id":"11","bezeichnung":"back_front_front","beschreibung"
:"bff","typ":null,"seriennummer":null,"haupt_baugruppe_id":"6","MachinesModule":{"id":"0000000059","module_id":"0000000011","machine_id":"1","beschreibung":"","created":null,"modified":null}},{"id":"10","bezeichnung"
:"front_front_front","beschreibung":"fff","typ":null,"seriennummer":null,"haupt_baugruppe_id":"6","MachinesModule"
.................................

if you look at the start of the JSon object you would notice "<" at the beginning line. This makes it difficult to consume the data with javascript.
Posted Nov 14, 2008 by Alvin
 

Comment

23 RE: Really Odd behavior.. no pun intended

I am trying to use this code and when i do this:

$this->Model->habtmDelete('Blah', 1, 1);

it runs this sql query:

Query: habtmDelete

I am running this from the controller, so is that the problem or is it something with the new cake 1.2.x.x ?
Please Help.

I ran into this problem just now and quickly realized I had forgotten to add the behavior to the model I was trying to use the habtm* methods on. Make sure you have the "var $actsAs = 'ExtendAssociations';" line in your model definition file.
Posted Jan 10, 2009 by Brian
 

Comment

24 Worked in one controller and not another. Carefull of Validation

It was previously mentioned above about validation. Just to iterate to anyone that has had problems like me, it finds all the exsiting fields, so any of thouse 'isEmpty' validations will be flagged.

If i have time i will upload a version with more commenting and checking.
As i was ripping my hair out wondering why it wasnt working when it came down to validation.

Cheers for the orginal code though!
Posted Jan 12, 2009 by Christopher Jenkins
 

Comment

25 This solution works great

I'm using 1.2 final.

Thanks!
Posted Jan 21, 2009 by Paul
 

Comment

26 Just a silly doubt

Wouldn't the Add behaviour be the same as setting to false the unique condition in the HABTM relation in article model?
Posted Apr 14, 2009 by Luis Oliva