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:
<?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):
<?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.
<?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.
<?php
var $actsAs = 'ExtendAssociations';
?>
Example Usage
Our Post model:Model Class:
<?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
<?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
<?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)
<?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!

Warning (2): Cannot modify header information - headers already sent by (output started at C:\xampp\htdocs\PSPMS\PSPMS\app\models\behaviors\extend_associations.php:181) [CORE\cake\libs\controller\controller.php, line 746
how can i fix this?
Any answer will be a very big help for me.
Thank You
This is not working for me.. my cake version is 1.3
the habtmAdd is not saving
i have a code
if($this->Task->habtmAdd('Employee',1,1))
echo "saved";
else echo "not saved";
And the output says always "not saved"
I have associated the Task to Employee correctly
var $hasAndBelongsToMany = array('Employee' => array('className'=> 'Employee', 'joinTable' => 'emptasks','with'=>'Emptask','foreignKey'=>'task_id','associationForeignKey'=>'employee_id','dependent'=>'true'));
what can be the problem here?
Pls help me. Im stuck in here for about a week.
T_T :(
Cannot modify header information - headers already sent by (output started at /home/damian15/public_html/dyplom/app/models/behaviors/extend_associations.php:180) [CORE/cake/libs/controller/controller.php, line 746]
How I can repait it?
$data[$assoc] = array($assoc => Set::extract($data, $assoc.'.{n}.'.$model->primaryKey));
You're using the primary key of the current model to lookup the primary key of the associated model. This made it blow up for me. I instantiated the associated model and called primaryKey on it and it fixed the problem for me.
$assocModel = ClassRegistry::init($assoc);
$data[$assoc] = array($assoc => Set::extract($data, $assoc.'.{n}.'.$assocModel->primaryKey));
Big thanks to all of you who worked on this behavior!
SQL Error: 1064: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'habtmAdd' at line 1 [CORE/cake/libs/model/datasources/dbo_source.php, line 666] Code | Context
$out = null;
if ($error) {
trigger_error('' . __('SQL Error:', true) . " {$this->error}", E_USER_WARNING);
$sql = "habtmAdd"
$error = "1064: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'habtmAdd' at line 1"
$out = null
DboSource::showQuery() - CORE/cake/libs/model/datasources/dbo_source.php, line 666
DboSource::execute() - CORE/cake/libs/model/datasources/dbo_source.php, line 256
DboSource::fetchAll() - CORE/cake/libs/model/datasources/dbo_source.php, line 400
DboSource::query() - CORE/cake/libs/model/datasources/dbo_source.php, line 354
Model::call__() - CORE/cake/libs/model/model.php, line 510
Overloadable::__call() - CORE/cake/libs/overloadable_php5.php, line 50
AppModel::addAssoc() - [internal], line ??
AccommodationsController::admin_edit() - APP/plugins/booking/controllers/accommodations_controller.php, line 124
Dispatcher::_invoke() - CORE/cake/dispatcher.php, line 204
Dispatcher::dispatch() - CORE/cake/dispatcher.php, line 171
[main] - APP/webroot/index.php, line 83
Query: habtmAdd
I dont know where is problem?
all my models name are in lowercase
for example
var $name = 'post';
i never had problems whit that but now whit this behavior i have
i change a little bit the code at line 142 by adding "ucwords()" to converts the first character in a string name to uppercase
$data = $model->find(array(ucwords($model->name).'.'.$model->primaryKey => $id));
You'd know what can be?
corrected:
Model Class:
<?php
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)) {
// debug('no data to update');
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
if(count($extra)) {
$joinTable = $model->tablePrefix.$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;
}
?>
Thanks!
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!
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(){
$machineA = $this->Machine->findAll(null, '*', 'id ASC');
echo $javascript->Object($machines);
<[{"Machine":{"id":"1","bezeichnung":"dx34","beschreibung":"dx11","typ":"Jack","seriennummer":"texte"
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.
$machineArray = Set::extract($machineA);
$this->set('machines',$machineArray);
//in the view of this function
,"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.
$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.
/**
* 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);
}
}
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']);
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.
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..=)
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.
@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 :)
I don't think this solves the root of the problem, but turning off validation in habtmAdd() worked:
return $model->save($data, false);
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;
}
Thanks.
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'),
);
Thank you, and sorry for my bad english.