CakePHP Filter Component

5th June 2009 CakePHP
Filter Index View

For a recent project I've been working on the index pages required the data to be both sortable and filterable. Luckily CakePHP comes with the Pagination class which covers the directional sorting of data and I went looking online for something that will filter the data.

I'm going to be using the most upto date version of CakePHP which at this time is 1.2.3.8166

Existing Solutions

I found two existing components that deal with filtering data, the second one I found builds upon the first and I've taken the solution and altered it slightly so that it's a little bit more efficient code wise and also to get my head around how it works. These can be found here and here.

Filter Component

Here is the filter component in its entirety, the component is quite complex but it basically loops through the Controller's filter data and builds up a number of SQL where clauses that will be used to filter the data. Filtering works on both the current Model and also to related Models that use the "belongsTo" association.

<?php
// file: /app/controllers/components/filter.php
/**
 * Filter component
 *
 * @original concept by Nik Chankov - http://nik.chankov.net
 * @modified and extended by Maciej Grajcarek - http://blog.uplevel.pl
 * @modified again by James Fairhurst - http://www.jamesfairhurst.co.uk
 * @version 0.1
 */
class FilterComponent extends Object {
    /**
     * fields which will replace the regular syntax in where i.e. field = 'value'
     */
    var $fieldFormatting = array(
		"string"	=> "LIKE '%%%s%%'",
		"text"		=> "LIKE '%%%s%%'",
		"datetime"	=> "LIKE '%%%s%%'"
	);

	/**
	 * Paginator params sent in URL
	 */
   	var $paginatorParams = array(
		'page',
		'sort',
		'direction'
   	);

   	/**
   	 *  Url variable used in paginate helper (array('url'=>$url));
   	 */
   	var $url = '';

    /**
     * Function which will change controller->data array
     * @param object $controller the class of the controller which call this component
     * @param array $whiteList contains list of allowed filter attributes
     * @access public
     */
	function process($controller, $whiteList = null){
        $controller = $this->_prepareFilter($controller);
        $ret = array();
        if(isset($controller->data)){
            // loop models
            foreach($controller->data as $key=>$value) {
				// get fieldnames from database of model
				$columns = array();
                if(isset($controller->{$key})) {
                    $columns = $controller->{$key}->getColumnTypes();
				} elseif (isset($controller->{$controller->modelClass}->belongsTo[$key])) {
                    $columns = $controller->{$controller->modelClass}->{$key}->getColumnTypes();
				}

				// if columns exist
				if(!empty($columns)) {
					// loop through filter data
                    foreach($value as $k=>$v) {
						// JF: deal with datetime filter
						if(is_array($v) && $columns[$k]=='datetime') {
							$v = $this->_prepare_datetime($v);
						}

						// if filter value has been entered
                        if($v != '') {
							// if filter is in whitelist
                        	if(is_array($whiteList) && !in_array($k,$whiteList) ){
                        		continue;
                        	}

							// check if there are some fieldFormatting set
							if(isset($this->fieldFormatting[$columns[$k]])) {
								// insert value into fieldFormatting
								$tmp = sprintf($this->fieldFormatting[$columns[$k]], $v);
								// don't put key.fieldname as array key if a LIKE clause
								if (substr($tmp,0,4)=='LIKE') {
									$ret[] = $key.'.'.$k . " " .  $tmp;
								} else {
									$ret[$key.'.'.$k] = $tmp;
								}
							} else {
								// build up where clause with field and value
								$ret[$key.'.'.$k] = $v;
							}

							// save the filter data for the url
							$this->url .= '/'.$key .'.'.$k.':'.$v;
                        }
                    }

                    //unsetting the empty forms
                    if(count($value) == 0){
                        unset($controller->data[$key]);
                    }
				}
            }
        }

	return $ret;
    }


    /**
     * function which will take care of the storing the filter data and loading after this from the Session
	 * JF: modified to not htmlencode, caused problems with dates e.g. -05-
	 * @param object $controller the class of the controller which call this component
     */
    function _prepareFilter($controller) {
		$filter = array();
        if(isset($controller->data)) {
			//pr($controller);
            foreach($controller->data as $model=>$fields) {
				if(is_array($fields)) {
					foreach($fields as $key=>$field) {
						if($field == '') {
							unset($controller->data[$model][$key]);
						}
					}
				}
            }

            App::import('Sanitize');
            $sanit = new Sanitize();
            $controller->data = $sanit->clean($controller->data, array('encode' => false));
            $filter = $controller->data;
        }

        if (empty($filter)) {
      		$filter = $this->_checkParams($controller);
        }
        $controller->data = $filter;
		
	return $controller;
    }


    /**
     * function which will take care of filters from URL
	 * JF: modified to not encode, caused problems with dates
	 * @param object $controller the class of the controller which call this component
     */
     function _checkParams($controller) {
     	if (empty($controller->params['named'])) {
     		$filter = array();
     	}

        App::import('Sanitize');
        $sanit = new Sanitize();
        $controller->params['named'] = $sanit->clean($controller->params['named'],array('encode' => false));

     	foreach($controller->params['named'] as $field => $value) {
     		if(!in_array($field, $this->paginatorParams)) {
				$fields = explode('.',$field);
				if (sizeof($fields) == 1) {
	     			$filter[$controller->modelClass][$field] = $value;
				} else {
	     			$filter[$fields[0]][$fields[1]] = $value;
				}
     		}
     	}

     	if (!empty($filter))
     		return $filter;
     	else
     		return array();
     }


	/**
	 * Prepares a date array for a Mysql where clause
	 * @author James Fairhurst
	 * @param array $arr
	 * @return string
	 */
	function _prepare_datetime($date) {
		// init
		$str = '';
		// reverse array so that dd-mm-yyyy becomes yyyy-mm-dd
		$date = array_reverse($date);
		// loop through date
		foreach($date as $key=>$value) {
			// if d/m/y has been entered
			if(!empty($value)) {
				// seperate with '-'
				$str .= '-'.$value;
				// remove first '-'
				if($key=='year') {
					$str = str_replace('-', '', $str);
				}
				// only add if day is empty
				if($key=='month' && empty($date['day'])) {
					$str .= '-';
				}
				// add final space
				if($key=='day') {
					$str.=' ';
				}
			}
		}

	return $str;
	}
}

?>

App Controller

The Filter Component is included at the top of the file along with a variables to save the Filter data and a default form options array. The filtering logic will only take place on "index" actions and the url that was created by the component will be saved in the "filter_options" variable. This will be used to keep the filter data when pagination is required.

<?php
// file: /app/app_controller.php

class AppController extends Controller {
	// class variables
	var $_Filter = array();

	// setup components
	var $components = array('Filter');
	// default datetime filter
	var $_Form_options_datetime = array();

	/**
	 * Before any Controller action
	 */
	function beforeFilter() {
		// for index actions
		if($this->action == 'index') {
			// setup filter component
			$this->_Filter = $this->Filter->process($this);
			$url = $this->Filter->url;
			if(empty($url)) {
				$url = '/';
			}
			$this->set('filter_options',array('url'=>array($url)));
			// setup default datetime filter option
			$this->_Form_options_datetime = array('type'=>'date','dateFormat'=>'DMY','empty'=>'-','minYear'=>date("Y")-2,'maxYear'=>date("Y"));
			// reset filters
			if(isset($this->data['reset']) || isset($this->data['cancel'])) {
				$this->redirect(array('action'=>'index'));
			}
		}
	}

	/**
	 * Builds up a selected datetime for the form helper
	 * @param string $fieldname
	 * @return null|string
	 */
	function process_datetime($fieldname) {
		$selected = null;
		if(isset($this->params['named'][$fieldname])) {
			$exploded = explode('-',$this->params['named'][$fieldname]);
			if(!empty($exploded)) {
				$selected = '';
				foreach($exploded as $k=>$e) {
					if(empty($e)) {
						$selected .= (($k==0) ? '0000' : '00');
					} else {
						$selected .= $e;
					}
					if($k!=2) {$selected.='-';}
				}
			}
		}
	return $selected;
	}
}
?>

Your Controller

To use the filter data in your Controller you simply need to pass the data in the $this->paginate() method, if you print out the data you will see an array of conditions that will be used to find specific data from the database.

I've included the datetime options so that you have an idea of how to filter your data by dates. I've had to process the date input so that you can seperately filter by day, month and year or a combination of the three.

// file: app/controllers/your_controller.php

function index() {
	$this->set('date_options_created', array_merge($this->_Form_options_datetime, array('selected'=>$this->process_datetime('Model.created'))));
	$this->set('date_options_modified', array_merge($this->_Form_options_datetime, array('selected'=>$this->process_datetime('Model.modified'))));
	$this->Model->recursive = 0;
	$this->set('data', $this->paginate(null, $this->_Filter));
}

Your Index View

I've included a form input for each column which allows the columns to be filtered. For date inputs I've passed the options from the Controller, this is only because these are quite specific and the processing of these is a little different from normal fields.

// file: /app/views/model/index.ctp

<?php echo $form->create('Model',array('action'=>'index','id'=>'filters')); ?>
<table cellpadding="0" cellspacing="0">
	<thead>
		<tr>
			<th><?php echo $paginator->sort('Name', 'name', $filter_options);?></th>
			<th><?php echo $paginator->sort('Created', 'created', $filter_options);?></th>
			<th><?php echo $paginator->sort('Modified', 'modified', $filter_options);?></th>
			<th class="actions">Actions</th>
		</tr>
		<tr>
			<th><?php echo $form->input('name'); ?></th>
			<th><?php echo $form->input('created', $date_options_created); ?></th>
			<th><?php echo $form->input('modified', $date_options_modified); ?></th>
			<th>
				<button type="submit" name="data[filter]" value="filter">Filter</button>
				<button type="submit" name="data[reset]" value="reset">Reset</button>
			</th>
		</tr>
	</thead>
	<tbody>
		// loop through and display your data
	</tbody>
</table>
<?php echo $form->end(); ?>
<div class="paging">
	<?php echo $paginator->prev('<< '.__('previous', true), $filter_options, null, array('class'=>'disabled'));?>
 | 	<?php echo $paginator->numbers($filter_options);?>
	<?php echo $paginator->next(__('next', true).' >>', $filter_options, null, array('class'=>'disabled'));?>
</div>

Note that I've also passed the filter options to the Paginator links so that the filters will be saved when passing from one page to the next.

Filtering Related Data

To filter related data just change the form inputs slightly to include the related Model name, note that only the belongsTo association is currently supported.

<?php echo $form->input('Model.name'); ?>

Wrapping Up

Theres a lot of code in this article and most of the credit goes to original people who created and extended the Filter Component. This will probably be quite complicated for a beginner but hopefully if you're trying to use this in your application the process explained here will be relatively easy to follow. As usual any questions just leave a comment.

Back to Home Page

Comments

Jacques (15/06/2009 - 12:27)

A little new to cakephp, I previously did something similar myself although I did not use a component. So a fair amount of coding for each page and many copy and paste errors :-)

Tried to get this working, but could not resolve an undefined process_datetime error coming from the controller.

Will give it a bash another time.

Mario (18/06/2009 - 00:43)

I'd like the idea. But I'm having the same error:
Fatal error: Call to undefined method EventsController::process_datetime()

Craig Morris (18/06/2009 - 21:35)

Hey,

I have developed an Advanced Index plugin which provides

- Filtering (with from and to ranges for dates and numbers)
- Toggler button for boolean fields via AJAX
- Elements for table headers and table footers which can be configured
- Ability to change the number of records per page
- Import and export.

I would love to get your thoughts on it! Its on a github repo at http://github.com/morrislaptop/advindex/tree/master

Talk soon,
Craig

Craig (23/06/2009 - 14:04)

... Excellent work!! Got it working pretty quickly, and actually understand it!! :-D

I can't see how the 'Reset' should work though?

James (25/06/2009 - 12:16)

@Jacques & @Mario: Hey thanks for commenting and apologies for taking ages to respond. I forgot to include the process_datetime() method so that's now been added to the above source code! whoops!

@Craig Morris: Hey thanks for the link, I'll try and check it when I've got some free time. It sounds promising tho! I also forgot to include the reset logic, thats been fixed but it simply redirects you back to the index. Nothing fancy.

Jose Diaz-Gonzalez (04/07/2009 - 18:54)

I've updated your component a bit, removed the dependency upon the app_controller.php (I hate placing extra code in there, it's highly annoying and doesn't seem clean to me).

It does require a few changes to utilization of the component, but some of it is merely cosmetic. Shouldn't require much extra work to integrate, I've outlined everything on the github repository. You can check it out at http://github.com/josegonzalez/filter-component/tree/master

Manu (13/07/2009 - 12:56)

Thanks,
thats exactly what I was looking for and works great! Should save me a lot of time.

PorKaria (05/08/2009 - 13:42)

Thanks! great Job! =]

Jose Diaz-Gonzalez (05/08/2009 - 15:06)

I've updated where the component is included, and have it at http://github.com/josegonzalez/cakephp-filter-component/tree/master . It is now a plugin, and I have some changes to make to it (including having a configurable element that will automatically build your view filter for you). Thanks for the original code, James Fairhurst!

Onur (26/01/2010 - 04:17)

Is there HABTM support?

James (26/01/2010 - 04:55)

@Onur: Nope, only belongsTo associations are supported, HABTM are particularly difficult but you'll have to come up with your own solution. I'm not aware of anything that's out there at the moment.

Onur (27/01/2010 - 06:40)

@James yeah I couldn't find a component-ish solution. But I'll try to integrate filter comp. with some custom query. It looks like there might be a solution in binding models. Thanks anyway.

Twitchel (08/03/2010 - 16:25)

I just did a real dodgy on an internal cake site i did. Just got a quick jQuery filter script going... works fairly well except when the amount of rows im my table gets above 200...

http://pastebin.com/6Ysfdpdc

Sarah (09/03/2010 - 02:53)

Hi

Thank you. Works like a dream. Just made a small change to line 60 of filter.php

if(is_array($v) && ($columns[$k]=='datetime' || $columns[$k]=='date')) {

Sarah (09/03/2010 - 03:35)

One other thing worth noting. Global filters can be added to app_controller.php such as

$this->_Filter = $this->Filter->process($this);
$this->_Filter['franchise_id'] = $this->Authake->getFranchiseId();

This means I don't have to put the franchise_id field on the forms and it's there the first time the index is displayed.

James (09/03/2010 - 11:48)

@Sarah: good work, the global filters are very useful!

James (09/03/2010 - 11:50)

@Twitchel: yeah Javascript filtering is another way to go but as you mentioned the downside is that it can be quite slow on large tables. Good enough in some circumstances though.

luke (10/03/2010 - 08:52)

hey Jmaes thanks for this, came in useful. I like it's simplicity.

Is there a way to use a ddropdown for the belongsTo filter, eg. a category for a news section would have a categories dropdown (not textbox).

And it would filter by ID that way?

luke (10/03/2010 - 09:05)

hi jmaes, my bad - I had forgotten to add in the extra call to this->_filter in my paginate call. got it with category_id and passed options of $ctageories

Twitchel (21/03/2010 - 20:59)

No Matter what i Do i cant get this to work... keep getting errors such as "Notice (8): Undefined variable: filter_options [APP\views\suppliers\index.ctp, line 75]" and "Warning (2)
: array_merge() [
function.array-merge
]: Argument #2 is not an array [CORE\cake\libs\view\helpers\paginator.php, line 219]" and "Notice (8)
: Undefined variable: filter_options [APP\views\suppliers\index.ctp, line 20]"

any ideas?

Twitchel (21/03/2010 - 21:24)

Dont worry mate, worked out what the problem was.

In this tut you told us to create the beforeFilter fucntion in the app controller file... I already had one sitting in my suppliers_controller file. it seemed to crap itself with the two beforeFilter functions in the two controllers.

Dan

Max (05/04/2010 - 15:23)

Hey just a little heads up. This component and all the forks of this component do not work in cake 1.3 - It looks like they have changed the Controller data structure so $controller->{$key} doesn't match up with anything.

Mark (14/05/2010 - 08:24)

Any news on making this work with Cake 1.3 ?

James (14/05/2010 - 09:02)

@Mark: Unfortunately not, I've been swamped and not had time. Not sure if I'll have chance to revisit it the next few weeks. You struggling with it?

Mark (14/05/2010 - 11:46)

@James: well I am investigating what suits my needs for a new project. I was missing off-the-shelf filtering with Cake. Right now, I'm investigating symfony. ;)

rcb (30/06/2010 - 07:42)

The problem is that $key is wrong, it is the name of the controller (e.g. Users), not of the model (e.g. User).
This can be solved by using $controller->modelClass instead of $key.
A quick & dirty hack is to place this in the foreach loop:
$key = $controller->modelClass;

rcb (08/07/2010 - 02:44)

The above comment is wrong, it does work in cakephp 1.3. The problem was the naming of the form. A mistake on my side.

James (08/07/2010 - 03:21)

@rcb: Thanks for clarifying and glad it works!

tal (02/09/2010 - 18:11)

nice script.
it search for case sensitive when searching texts, and this can be annoying.

i added LOWER() & strtolower() to make it case-insensitive

changed:
$ret[] = $key.'.'.$k . " " . $tmp;

to:
$ret[] = "LOWER($key.$k) ". strtolower($tmp);

Add Your Comment

Full Apps

Request a Feature Application Ajax Store Locator Application FilmDB Application TVDB Application CakeBattles Application CakeCatalog Application

Recently Watched Films

Mr Brooks Rec Pathology Diary of the Dead

TV Shows

The Shield Flight of the Conchords

Hosting

I've been using Dreamhost for my hosting for over 2 years and it's pretty good for what you pay. If you're signing up how about showing me some love and use this link so I get a referal bonus to help pay my server costs.

Site by James Fairhurst 2008-2010, all rights reserved and all that malarky