Extending PHP Doctrine Record - Check Box Groups

I simply knew we would need the extension capability that the Mdl class allows for sooner or later, I didn’t expect it to be this soon though. The main problem here is saving a many to many relationship straight to the database from the $_POST array, to do that we can extend Doctrine Record with a new function I have named fromArrayExt which adds something extra to the normal fromArray method.

The problem is a form looking like this:

Check Box Group

So we have a checkbox group, when the form is posted we will get a $_POST array looking like this:

Array
(
    [name] => Similan
    [article_id] => 20
    [Asp] => Array
        (
            [0] => 1
            [1] => 2
        )

)

As you probably have realized already ‘Asp’ is an alias of a relation and is the name of the checkbox group, the models look like this:

class Destination extends Mdl{
  public function setTableDefinition(){
    $this->hasColumn('article_id', 	'integer', 	20);
    $this->hasColumn('name', 		'string', 	100);
  }
  
  public function setUp(){
    $this->hasMany('Aspect as Asp', array('local' => 'dest_id', 'foreign' => 'aspect_id', 'refClass' => 'Aspect_list'));
  }
}

class Aspect extends Mdl{
  public function setTableDefinition(){
    $this->hasColumn('name', 	'string', 	100);
    $this->hasColumn('descr', 	'string', 	5000);
  }
  
  public function setUp(){
    $this->hasMany('Destination as Dest', array('local' => 'aspect_id', 'foreign' => 'dest_id','refClass' => 'Aspect_list'));
  }
}

class Aspect_list extends Mdl{
	
	public function setTableDefinition(){
	  $this->hasColumn('dest_id', 'integer', 20);
	  $this->hasColumn('aspect_id', 'integer', 20);
	}
	
	public function setUp(){
	  $this->hasOne('Destination as Dest', array('local' => 'dest_id', 'foreign' => 'id'));
	  $this->hasOne('Aspect as Asp', array('local' => 'aspect_id', 'foreign' => 'id'));
	}
}

Each destination will be connected to a MODx article through the article_id, that logic is not covered here though (or even finished), and it might have many Aspects (fancy name for a tag). For now we have only two aspects, Whale Sharks and Hammerhead Sharks. In this case both of them will be applied to the destination. As you can see the Asp alias connects a destination with one or more Aspects. If you try calling $destination->fromArray($_POST) followed by $destination->save() you’re going to get an exception though, something extra is needed:

function fromArrayExt($arr){
  $relations = $this->getTable()->getRelations();
  foreach($arr as $key => $value){
    if(empty($relations[$key])) $from_arr[$key] = $value;
    else						$rel_arr[$key] 	= $value;
  }
  parent::fromArray($from_arr);
  if(!empty($rel_arr)){
    foreach($rel_arr as $rel => $sub){
      try{
        $this->unlink($rel);
      }catch(Doctrine_Connection_Mysql_Exception $e){}
      $query = $relations[$rel]->getTable()->createQuery('item');
      if(count($sub) > 1)			$to_add = $query->whereIn('item.id', $sub)->execute();
      else if(count($sub) == 1)	$to_add = $query->where('item.id = ?', $sub)->execute();
      foreach($to_add as $add_this)
          $this->$rel->add($add_this);
    }
  }	
}

First we will store all relations in the $relations array, the relation objects will have their alias as key and in this case we will get an array with only one object (array(’Asp’ => … )). Hence we are able to test with empty when looping the $_POST (passed in the $arr variable).


It’s simple, we split the post array into ‘normal’ column names and another array with the aliases. The normal array can then be passed to fromArray() without exceptions being thrown. So having populated the columns it’s time to connect our new destination to the checked aspects.

We loop throw them all, in this case only one loop with $rel containing ‘Asp’ and $sub; the array with 1 and 2. First we try to unlink all relations, this is possible in an update scenario, not when inserting. That is why we have to catch Doctrine_Connection_Mysql_Exception. In case we update they will be removed all of them, further down the currently checked aspects will be added. Codewise this is the simplest way of doing this, another way would be to determine which old relations aren’t in the new update array and have only them removed (unlink will take an array of ids of relations to be removed as a second argument). Followed by determining which relations in the update array are not in the old relations and have only them added.

We get the Aspect table object through Doctrine_Relation::getTable(). The table object can then be used to start a DQL query with from etc already set, we only need to attach the where clause and we use ‘item’ as our alias (… FROM Aspect item WHERE …). In this case we will use whereIn() which will accept the column to use (item.id) and an array that I suppose will be used to create the IN clause, IN (1,2) in our case. The result will be a Doctrine_Collection which we can loop through to access each Doctrine_Record object. In case there is only one box checked we use where() instead.

In the above case $this->$rel will expand into $this->Asp of course which is yet another collection so we can use the add method to add each record. Finally save() can be called on the object, the save method in Ctrl now looks like this:

function preSave(&$obj){}
function afterSave(&$obj){}

function save(){
  print_r($this->p);
  $custom_validation = $this->validation();
  if($custom_validation !== true){
    list($key, $error) = $custom_validation;
    return $this->setErrQuit($key, $error);
  }
  $obj = empty($this->g['id']) ? $this->myMdl() : $this->find();
  try{
    $obj->fromArrayExt($this->p);
    $this->preSave($obj);
    $obj->save();
    $this->afterSave($obj);
    return $this->onSave();
  }catch(Doctrine_Validator_Exception $e){
    foreach($obj->getErrorStack()->toArray() as $key => $value)
      return $this->setErrQuit($key, $value[0]);
  }
}

I’ve added two hooks in the form of preSave() and afterSave(), as of yet unused but they will probably be needed sooner or later :) . Anyway after hitting Submit in the above form I exported the data:

INSERT INTO `aspect` (`id`, `name`, `descr`) VALUES
(1, 'Whale Sharks', 'The biggest fish on earth.'),
(2, 'Hammerhead Sharks', '');

INSERT INTO `aspect_list` (`id`, `dest_id`, `aspect_id`) VALUES
(1, 1, 1),
(2, 1, 2);

INSERT INTO `destination` (`id`, `article_id`, `name`) VALUES
(1, 20, 'Similan');

So it works, great but be careful, there are no checks to prevent overlapping column and alias names. In the above case, a column name of ‘asp’ would have resulted in a total mess.


Related Posts

Tags: , ,