Alternative Methods for Working with hasAndBelongsToMany Associations

By John Hanauer (glite)
As of 1.1.12.4205, CakePHP's current functionality for working with HABTM associations works very well for most applications, particularly those that lend themselves well to using multiple-selectTags or checkboxes. This article looks at proposed methods for adding or removing join records between two models that don't rely on bulky arrays and that minimize the amount of SQL executed on the database.
This article offers proposed code to be added either to the AppModel or Model level which can be used for establishing join records between two models in a simple, lean fashion for applications that find themselves limited by Model::save.

First, a little background to justify the need; code will follow, ending with a tutorial on page 2:

For hasAndBelongsToMany associations, CakePHP has built into Model::save the functionality to store the appropriate join records. However, there are two limitations to the Model::save methods.

First, Model::save requires that all associatedForeignKeys be passed to the model. This lends itself very well to using multiple select tags; however, thse are sometimes impractical when the number of joins or even the number of possible joins between two models is very large. The view elements themselves become quite daunting to the user. Also, the controller has to query the preexisting joins to pre-populate the selectTags or in the very least, provide Model::save with all the necessary data.

The second limitation is that Model::_saveMulti, which is used by Model::save to store these joins, works by deleting all join records and reinserting the join records for each element found in $this->data['assocModel']['assocModel']. Again, when the number of joins between two models is very large, a large number of INSERTs are executed on the database unnecessarily.

There are alternatives like RosSoft's method in ticket #1348 which take care of the first limitation with using multiple select tags; however, this method still pulls down all the preexisting joins to pass to Model::save with the insert, and the second limitation is still a problem.

One-at-a-time inserts and deletes become necessary for some applications requiring non-array based view elements and/or for applications requiring better performance from the database. The following code proposal is my attempt at rewriting RosSoft's code. The only major difference in functionality is that $id is now optional, just like in Model::read() if the developer chooses to first store the id into Model->id. In fact, the code for this was taken directly from Model::read. The rest of the code is largely based on Model::_saveMulti and I have done my best to mimic the coding style and conventions.

This code could be placed into app_model by users who find themselves here or could (hopefully) be included, probably in some modified fashion, in the core model class as an enhancement (I'll keep my fingers crossed).

Model Class:

Download code <?php 
    
/**
     * Adds a join record between two records of a hasAndBelongsToMany association
     *
     * @param mixed $assoc The name of the HABTM association
     * @param mixed $assoc_ids The associated id or an array of associated ids
     * @param integer $id The id of the record in this model
     * @return boolean Success
     */
    
function addAssoc($assoc$assoc_ids$id null)
    {
        if (
$id != null) {
            
$this->id $id;
        }

        
$id $this->id;

        if (
is_array($this->id)) {
            
$id $this->id[0];
        }
        
        if (
$this->id !== null && $this->id !== false) {
            
$db =& ConnectionManager::getDataSource($this->useDbConfig);
            
            
$joinTable $this->hasAndBelongsToMany[$assoc]['joinTable'];
            
$table $db->name($db->fullTableName($joinTable));
            
            
$keys[] = $this->hasAndBelongsToMany[$assoc]['foreignKey'];
            
$keys[] = $this->hasAndBelongsToMany[$assoc]['associationForeignKey'];
            
$fields join(','$keys);
            
            if(!
is_array($assoc_ids)) {
                
$assoc_ids = array($assoc_ids);
            }
        
            
// to prevent duplicates
            
$this->deleteAssoc($assoc,$assoc_ids,$id);
            
            foreach (
$assoc_ids as $assoc_id) {
                
$values[]  = $db->value($id$this->getColumnType($this->primaryKey));
                
$values[]  = $db->value($assoc_id);
                
$values    join(','$values);
                
                
$db->execute("INSERT INTO {$table} ({$fields}) VALUES ({$values})");
                unset (
$values);
            }
            
            return 
true;
        } else {
            return 
false;
        }
    }

    
/**
     * Deletes any join records between two records of a hasAndBelongsToMany association
     *
     * @param integer $id The id of the record in this model
     * @param mixed $assoc The name of the HABTM association
     * @param mixed $assoc_ids The associated id or an array of associated ids
     * @return boolean Success
     */
    
function deleteAssoc($assoc$assoc_ids$id null)
    {
        if (
$id != null) {
            
$this->id $id;
        }

        
$id $this->id;

        if (
is_array($this->id)) {
            
$id $this->id[0];
        }
        
        if (
$this->id !== null && $this->id !== false) {
            
$db =& ConnectionManager::getDataSource($this->useDbConfig);
            
            
$joinTable $this->hasAndBelongsToMany[$assoc]['joinTable'];    
            
$table $db->name($db->fullTableName($joinTable));
            
            
$mainKey $this->hasAndBelongsToMany[$assoc]['foreignKey'];
            
$assocKey $this->hasAndBelongsToMany[$assoc]['associationForeignKey'];
            
            if(!
is_array($assoc_ids)) {
                
$assoc_ids = array($assoc_ids);
            }
            
            foreach (
$assoc_ids as $assoc_id) {
                
$db->execute("DELETE FROM {$table} WHERE {$mainKey} = '{$id}' AND {$assocKey} = '{$assoc_id}'");
            }
            
            return 
true;
        } else {
            return 
false;
        }
    }
?>

Page 2: Tutorial

Comments 202

CakePHP Team Comments Author Comments
 

Comment

1 Works great

I haven't tested this in 1.2, but in my current version (1.1.14.4797) it works flawlessly.

Thanks :-)
Posted Apr 20, 2007 by Thomas Winther
 

Comment

2 a

I haven't tested this in 1.2, but in my current version (1.1.14.4797) it works flawlessly.

Thanks :-)

Could you please post an example, I don't get it at all.

Thanks
Posted May 9, 2008 by marcelo garcia
 

Comment

3 Holy simplification batman

Thanks a bunch for this. Helps alot.
Posted May 12, 2008 by afmyers
 

Comment

4 Greate work

1000 Thanks for that so helpfull code!!

I spent 2 (pain)full days to find a way just to add data in HABTM association table...

I haven't tested this in 1.2, but in my current version (1.1.14.4797) it works flawlessly.


I just test addAssoc and it also works pretty well with cake 1.2, not tried deleteAssoc yet

Again, many thanks!
Posted Mar 11, 2009 by Bambou
 

Comment

5 also there's the 'unique' attribute

hey, this was a great solution to the problem.

also when trying to figure this out, i found out about the 'unique' key. just for other people who might find this easier, setting the 'unique' key to false when saving seems to at least cover the idea of saving a single HABTM association without deleting everything else first. it won't check for duplicates, though, as this code will.

http://book.cakephp.org/view/83/hasAndBelongsToMany-HABTM
Posted Jun 1, 2009 by Trey Philips