Created
July 2, 2015 20:41
-
-
Save brianally/19657bf31dfb744dd676 to your computer and use it in GitHub Desktop.
Hierarchical categories using MPTT with CakePHP 2.x
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
class Category extends AppModel { | |
public $hasAndBelongsToMany = [ | |
'Item' => [ | |
'className' => 'Item', | |
'with' => 'CategoryItem', | |
'foreignKey' => 'category_id', | |
'associationForeignKey' => 'item_id' | |
] | |
]; | |
public $hasMany = [ | |
'SubCategory' => [ | |
'className' => 'Category', | |
'foreignKey' => 'parent_id', | |
'order' => ['SubCategory.lft' =>'ASC'] | |
], | |
'CategoryItem' | |
]; | |
public $actsAs = [ | |
'Tree' => [ | |
'parent' => 'parent_id', | |
'left' => 'lft', | |
'right' => 'rgt' | |
), | |
'Slugged' => [ | |
'label' => 'name', | |
'case' => 'low', | |
'overwrite' => true, | |
'length' => 64, | |
'unique' => false, | |
'replace' => array( | |
'&' => 'and', | |
'+' => 'and', | |
'#' => 'hash', | |
'.' => '-', | |
'/' => '-', | |
',' => '' | |
] | |
] | |
]; | |
public $validate = [ | |
'name' => [ | |
'provided' => [ | |
'rule' => ['minLength', 1], | |
'required' => true, | |
'message' => 'no name for category' | |
] | |
] | |
]; | |
public function beforeSave($options = []) { | |
if ( $this->data[$this->alias]['parent_id'] ) { | |
$path = $this->field( | |
'slug_path', | |
[$this->alias.'.'.$this->primaryKey => $this->data[$this->alias]['parent_id'] ] | |
); | |
$this->data[$this->alias]['slug_path'] = $path . '_' . $this->data[$this->alias]['slug']; | |
} else { | |
$this->data[$this->alias]['slug_path'] = $this->data[$this->alias]['slug']; | |
} | |
} | |
public function afterSave( $created, $options = [] ) { | |
Cache::clear(); | |
} | |
function beforeDelete($cascade = true) { | |
return ( !$this->hasChildren($this->id) && !$this->hasItems($this->id) ); | |
} | |
function afterDelete() { | |
Cache::clear(); | |
} | |
/** | |
* get the navigation tree | |
* | |
* @return array data | |
*/ | |
public function getNav() { | |
$data = $this->find( | |
'threaded', | |
[ | |
'order'=> [$this->alias.'.lft' =>'ASC'], | |
'recursive' => -1, | |
'contain' => [ | |
'Item' => [ | |
'fields' => ['Item.id', 'Item.inactive'] | |
] | |
] | |
] | |
); | |
// the output of this method will be cached | |
$data = array_map( | |
[$this->name, 'reduceItemsToCount')] | |
$data | |
); | |
return $data; | |
} | |
public function toJson($arr, $stack = [] ) { | |
$set = []; | |
foreach($arr as $datum) { | |
$stack[] = $datum['Category']['name']; | |
$out = [ | |
'name' => $datum['Category']['name'], | |
'slug' => $datum['Category']['slug'], | |
'title_path' => implode(',', $stack), | |
'slug_path'=> strtr( $datum['Category']['slug_path'], '_', ',' ) | |
]; | |
if ( !empty($datum['children']) ) { | |
$out['children'] = toJson($datum['children'], $stack); | |
} | |
$set[] = $out; | |
array_pop($stack); | |
} | |
return $set; | |
} | |
/** | |
* Static callback to remove elements from a Category's Item array | |
* to a simple count, or remove it altogether. | |
* | |
* @param mixed $node element passed by array_map() | |
* @return mixed data | |
*/ | |
private static function reduceItemsToCount($node, $parent_id = null) { | |
if (isset($node['Item'])) { | |
// remove inactive Items | |
$node['Item'] = Hash::remove($node['Item'], '{n}[inactive=1]'); | |
$size = sizeof($node['Item']); | |
if ($size) { | |
$node['Item'] = sizeof($node['Item']); | |
} else { | |
unset($node['Item']); | |
} | |
$node['parent_id'] = $parent_id; | |
if (isset($node['children'])) { | |
foreach( $node['children'] as $k => $child ) { | |
$node['children'][$k] = self::reduceItemsToCount($child, $node['parent_id']); | |
} | |
} | |
} | |
return $node; | |
} | |
private static function removeInactiveItems($el) { | |
if ( isset($el['inactive']) && $el['inactive'] ) { | |
return false; | |
} | |
return true; | |
} | |
/** | |
* Get all categories as a flat list | |
* | |
* @return array data | |
*/ | |
public function getList() { | |
return $this->find( | |
'all', | |
array( | |
'fields' => array('slug', 'slug_path', 'name'), | |
'order' => array('id' => 'ASC'), | |
'recursive' => -1 | |
) | |
); | |
} | |
/** | |
* Get a flat list of just the top-most categories | |
* | |
* @return array data | |
*/ | |
public function fetchTopLevel() { | |
return $this->find( | |
'all', | |
[ | |
'conditions' => [ | |
$this->alias.'.parent_id' => null | |
] | |
] | |
); | |
} | |
/** | |
* get a single Category | |
* | |
* @param string $id model PK or slug | |
* @param boolean $manufacturers include Manufacturers for Items in this Category | |
* @return array data | |
*/ | |
public function fetch($id, $manufacturers = false) { | |
if (empty($id)) return null; | |
$data = Cache::read("Category.${id}"); | |
if (!empty($data)) return $data; | |
$filters = []; | |
if (is_int($id)) { | |
$filters['conditions'] = [$this->alias .'.'. $this->primaryKey => $id]; | |
} else { | |
$filters['conditions'] = [$this->alias .'.slug_path' => $id]; | |
} | |
if ($manufacturers) { | |
$filters['contain'] = [ | |
'Item' => [ | |
'fields' => [ | |
'Item.id', | |
'Item.manufacturer_id', | |
'Item.name', | |
'Item.model' | |
], | |
'Manufacturer' | |
] | |
]; | |
} else { | |
$filters['recursive'] = -1; | |
} | |
$data = $this->find( | |
'first', | |
$filters | |
); | |
$data['Manufacturer'] = Hash::sort( | |
$this->_arrayUnique(Hash::extract($data, 'Item.{n}.Manufacturer')), | |
'{n}.name', | |
'asc' | |
); | |
$data['Item'] = null; | |
unset($data['Item']); | |
Cache::write("Category.${id}", $data);; | |
return $data; | |
} | |
/** | |
* Check if category has child categories | |
* | |
* @param int $id parent category's id | |
* @return boolean | |
*/ | |
public function hasChildren($id) { | |
$count = $this->find( | |
'count', | |
[ | |
'conditions' => [ | |
$this->alias.'.parent_id' => $id | |
] | |
] | |
); | |
return ($count > 0); | |
} | |
/** | |
* Check whether category has items | |
* | |
* @param [int $id | |
* @return boolean | |
*/ | |
public function hasItems($id) { | |
$count = $this->CategoryItem->find( | |
'count', | |
[ | |
'conditions' => [ | |
'CategoryItem.category_id' => $id | |
] | |
] | |
); | |
return ($count > 0); | |
} | |
} | |
// in AppModel: | |
/** | |
* Create Unique Arrays using an md5 hash | |
* | |
* @param array $array | |
* @param boolean $preserveKeys | |
* @return array | |
* @access protected | |
* @link http://phpdevblog.niknovo.com/2009/01/using-array-unique-with-multidimensional-arrays.html | |
*/ | |
protected function _arrayUnique($array, $preserveKeys = false) { | |
// Unique Array for return | |
$arrayRewrite = []; | |
if (sizeof($array)) { | |
// Array with the md5 hashes | |
$arrayHashes = []; | |
foreach($array as $key => $item) { | |
// Serialize the current element and create a md5 hash | |
$hash = md5(serialize($item)); | |
// If the md5 didn't come up yet, add the element to | |
// to arrayRewrite, otherwise drop it | |
if (!isset($arrayHashes[$hash])) { | |
// Save the current element hash | |
$arrayHashes[$hash] = $hash; | |
// Add element to the unique Array | |
if ($preserveKeys) { | |
$arrayRewrite[$key] = $item; | |
} else { | |
$arrayRewrite[] = $item; | |
} | |
} | |
} | |
} | |
return $arrayRewrite; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment