Attachable Behavior for Dynamic HABTM Relationships in CakePHP 1.2

It’s not normally a pain to add new HABTM relationships in CakePHP: just edit two the two model files, throw in some almost-stock code, and bob’s yer uncle. But I found myself in a sticky situation: I’m writing (almost done!) a general-purpose CMS, the core of which will be used by multiple sites. The ‘base’ of the CMS, such as the pages controller, the user auth system, and the media attachment system, are mostly contained within the core - only the view files are installed on a site-by-site basis. Since my Attachment model (the model that deals with attachments such as images, videos and documents to other models) is part of the core, I couldn’t just edit the HABTM array in the Attachment class every time I wanted to add a new association (since all relationships should be reciprocal).

So, the solution I came up with is a behavior that just binds the HABTM associations on the fly, with the only configuration being the addition of Attachable to a model’s $actsAs array. Is this the best way to do things? I’m not sure. But it’s a solution that works for me, so I thought I’d share it with everyone else. It’s a bit limited in that only you need to know one of the model names in advance - which is exactly what my CMS needs - but it would be easy enough to modify it so that it’s completely dynamic. If there are enough requests for such a version I’ll do one up and post it.

I’ll briefly run over its implementation here, but I’ve also provided a more permanent home:
http://jamienay.com/code/attachable-behavior

The Behavior

/app/models/behaviors/attachable.php

<?php
/**
 * Attachable Behavior class file.
 *
 * Allow a model to be associated another model via an on-the-fly HABTM binding
 *
 * @filesource
 * @author			Jamie Nay
 * @copyright       Jamie Nay
 * @license			http://www.opensource.org/licenses/mit-license.php The MIT License
 * @link            http://jamienay.com/code/attachable-behavior
 */
class AttachableBehavior extends ModelBehavior {

    /**
	 * Behavior settings
	 *
	 * @access public
	 * @var array
	 */
	public $settings = array();

	/**
	 * Default values for settings.
	 *
	 * - attachmentClass: the class to which the Attachable models will be attaching.
     * - joinTable: the HABTM join table used to, well, join the tables.
     * - attachmentTable: the table with which attachmentClass is associated.
     * - attachmentForeignKey: the foreign key for attachmentClass in joinTable.
     * - foreignKey: the foreign key field for the Attachable model in joinTable.
     * - modelField: the field that stores the name of the Attachable model in joinTable.
     * - And then the rest of the HABTM config options, with the except of condition, which is hard-coded in conjunction with modelField.
	 *
	 * @access private
	 * @var array
	 */
    private $defaults = array(
        'attachmentClass' => 'Attachment',
    	'joinTable' => 'attachments_models',
    	'attachmentTable' => 'attachments',
    	'attachmentForeignKey' => 'attachment_id',
    	'foreignKey' => 'foreign_id',
    	'modelField' => 'model',
    	'unique' => true,
    	'fields' => '',
    	'order' => '',
    	'limit' => '',
    	'offset' => '',
    	'finderQuery' => '',
    	'deleteQuery' => '',
    	'insertQuery' => ''
    );    

    /**
     * Configuration method.
     *
     * @access public
     * @param object $Model
     * @param array $config
     */

    public function setup(&$Model, $config = null) {
		if (is_array($config)) {
			$this->settings[$Model->alias] = array_merge($this->defaults, $config);
		} else {
			$this->settings[$Model->alias] = $this->defaults;
		}
	}

	/**
	 * Attach our HABTM relationships to both the Attachment model and the
	 * model we're dealing with.
	 *
	 * @access public
	 * @param object $model
	 * @param array $query
	 *
	 */
	function beforeFind(&$model, $query) {
	    $class = $model->alias;
  	    $model->bindModel(array('hasAndBelongsToMany' => array(
  	        $this->settings[$class]['attachmentClass'] => array(
  	            'className' => $this->settings[$class]['attachmentClass'],
  	            'joinTable' => $this->settings[$class]['joinTable'],
  	            'foreignKey' => $this->settings[$class]['foreignKey'],
  	            'associationForeignKey' => $this->settings[$class]['attachmentForeignKey'],
  	            'conditions' => $this->settings[$class]['modelField']." = '".$class."'",
  	            'unique' => $this->settings[$class]['unique'],
  	            'fields' => $this->settings[$class]['fields'],
  	            'order' => $this->settings[$class]['order'],
  	            'limit' => $this->settings[$class]['limit'],
  	            'offset' => $this->settings[$class]['offset'],
  	            'finderQuery' => $this->settings[$class]['finderQuery'],
  	            'deleteQuery' => $this->settings[$class]['deleteQuery'],
  	            'insertQuery' => $this->settings[$class]['insertQuery']
  	        )
  	    )));
  	    $model->{$this->settings[$class]['attachmentClass']}->bindModel(array('hasAndBelongsToMany' => array(
  	        $class => array(
  	            'className' => $class,
  	            'joinTable' => $this->settings[$class]['joinTable'],
  	            'foreignKey' => $this->settings[$class]['attachmentForeignKey'],
  	            'associationForeignKey' => $this->settings[$class]['foreignKey'],
  	        	'conditions' => array($this->settings[$class]['modelField'] => $class),
  	            'unique' => $this->settings[$class]['unique'],
  	            'fields' => $this->settings[$class]['fields'],
  	            'order' => $this->settings[$class]['order'],
  	            'limit' => $this->settings[$class]['limit'],
  	            'offset' => $this->settings[$class]['offset'],
  	            'finderQuery' => $this->settings[$class]['finderQuery'],
  	            'deleteQuery' => $this->settings[$class]['deleteQuery'],
  	            'insertQuery' => $this->settings[$class]['insertQuery']
  	        )
  	    )));
	}

}
?>

SQL

The table you use as the join table should look something like this (the default table):

CREATE TABLE IF NOT EXISTS `attachments_models` (
  `id` int(11) NOT NULL auto_increment,
  `created` datetime default NULL,
  `modified` datetime default NULL,
  `attachment_id` int(11) NOT NULL,
  `foreign_id` int(11) NOT NULL,
  `model` varchar(75) NOT NULL,
  `rank` int(11) NOT NULL default '1',
  PRIMARY KEY  (`id`)
) ENGINE=InnoDB ;

Usage

To use the Attachable behavior, simply add it to the $actsAs array for a model. For example:

class PressRelease extends AppModel {
 var $name = 'PressRelease';
 var $actsAs = array('Attachable');
}

And that’s it. You’ll have an on-the-fly and reciprocal HABTM relationship between PressRelease and whatever you define the attachment model as, by default Attachment. Speaking of which…

Configuration Options

There are a number of configuration options when attaching the Attachable behavior to a model, most of which are the same as the HABTM config options. A quick rundown:

  • attachmentClass - default ‘Attachment’: the class to which the Attachable models will be attaching.
  • joinTable - default ‘attachments_models’: the HABTM join table used to, well, join the tables.
  • attachmentTable - default ‘attachments’: the table with which attachmentClass is associated.
  • attachmentForeignKey - default ‘attachment_id’: the foreign key for attachmentClass in joinTable.
  • foreignKey - default ‘foreign_id’: the foreign key field for the Attachable model in joinTable.
  • modelField - default ‘model’: the field that stores the name of the Attachable model in joinTable.
  • And then the rest of the HABTM config options, with the except of condition, which is hard-coded in conjunction with modelField.

And that’s it - I’d love to hear questions, comments, suggestions, critiques…