Data Filter - CWebScope

In order to provide filtering capabilities for data grids and views including ‘list’ and ‘admin’ and make its usage in a way similar to CSort and CPagination where it requires no change to the model or the controller, I created a CWebScope class to manage filtering of query results based on criteria defined by the end-user.

To use it, CWebScope can be used in a similar way the CSort and CPagination are used like the following:

[indent]




public function actionList()

{

	$criteria=new CDbCriteria;


	$scopes = new CWebScope('Post');

	$scopes->attributes=array_slice(Post::model()->attributeLabels(), 8, 5);

	$scopes->applyScope($criteria);


	$pages=new CPagination(Post::model()->count($criteria));

	$pages->pageSize=self::PAGE_SIZE;

	$pages->applyLimit($criteria);


	$sort=new CSort('Post');

	$sort->attributes=array_slice(Post::model()->attributeLabels(), 7, 6);

	$sort->multiSort=false;

	$sort->applyOrder($criteria);


	$models=Post::model()->findAll($criteria);

	$this->render('list',array(

		'models'=>$models,

		'pages'=>$pages,

		'sort'=>$sort,

		'scopes'=>$scopes,

	));

}



[/indent]

The CWebScope uses the same concept of CSort (actually the same code :P) when it comes to defining what attributes are allowed to be used for filtering (sorting in the case of CSort) and attribute aliasing to hide the actual database column names from the end user when the page is rendered, both for security and for aesthetic reasons.

In the appropriate view, you can do the following to show a filtering bar. The bar will show a text box for every defined attribute along with a submit button for filtering and a link to clear the filter scope. The clear filter link is designed in a way to maintain any sorting and/or other URL querystring parameters.

[indent]




<tr>

<?php echo CWebScope::beginForm() ?>

<td><?php echo $scopes->textField('post_field_1', array('size'=>'10'));?></td>

<td><?php echo $scopes->textField('post_field_2', array('size'=>'10')); ?></td>

<td><?php echo $scopes->textField('post_field_3', array('size'=>'10')); ?></td>

<td><?php echo $scopes->textField('post_field_4', array('size'=>'10')); ?></td>

<td>&nbsp;</td>

<td><?php echo CWebScope::submitButton(); ?>

<?php echo CWebScope::linkClear(); ?></td>

<?php echo CWebScope::endForm(); ?>

<tr>



[/indent]

[b]

Other than the currently available capabilities, what do you think the CWebScope class should offer as options and methods. I thought of providing a drop down option to the users similar to the text box… What else do you think is appropriate for such a filtering class. -= Who knows, it might make it to an official Yii release and become part of the framework =-

[/b]

Below is the CWebScope code for your review. Please also do let me know of any enhancements related to security, SQL injections, URL tampering and other important issues to consider.

Thank You

/Kaf

[indent]




<?php

/**

 * CWebScope class file.

 *

 * @author Khaled Afiouni <>

 * @link http://www.afiouni.com/

 */


/**

 * CWebScope provides services related to data filtering for Controller actions

 *

 *

 * When data needs to be filtered according to one or several attributes,

 * we can use CWebScope to manage the filtering information and add

 * appropriate filtering criteria to the database SQL statements.

 *

 * CWebScope is designed to be used together with {@link CActiveRecord}.

 * You can use CWebScope to modify a {@link CDbCriteria} instance so that

 * it can limit the query results according to the defined filter

 *

 *

 */

class CWebScope extends CComponent

{

	/**

	 * @var string the class name of data models that need to be filtered.

	 * This should be a child class of {@link CActiveRecord}.

	 */

	public $modelClass;


	/**

	 * @var array list of attributes that are allowed for filtering.

	 * For example, array('user_id','create_time') would specify that only 'user_id'

	 * and 'create_time' can be used for filtering.

	 * Defaults to null, meaning all attributes of the {@link modelClass} can be used.

	 * This property can also be used to specify attribute aliases that should appear

	 * in the 'scope' GET parameter in place of the original attribute names.

	 * In this case, the aliases should be array values while the attribute names

	 * should be the corresponding array keys. Do not use '-' and '.' in the aliases

	 * as they are used as {@link separators}.

	 */

	public $attributes;


	/**

	 * @var string the name of the GET parameter that specifies which attributes to 

	 * be used for filtering

	 */

	public $scopeVar='scope';


	/**

	 * Constructor.

	 * @param string the class name of data models that need to be filtered.

	 * This should be a child class of {@link CActiveRecord}.

	 */

	public function __construct($modelClass)

	{

		$this->modelClass=$modelClass;

	}







	/**

	 * Modifies the query criteria by changing its {@link CDbCriteria::condition} property.

	 * Appends the current search criteria to the passed {@link CDbCriteria

	 * @param CDbCriteria the query criteria

	 * @return an updated {@link CDbCriteria}

	 */

	public function applyScope ($criteria)

	{

		if(!isset($_GET[$this->scopeVar]))

			return true;


		$conditions = $_GET[$this->scopeVar];


		if (isset($conditions['action']))

			unset ($conditions['action']);


		$scopes = array();

		foreach ($conditions as $key=>$value)

		{

			if (($key=$this->validateAttribute($key))!==false)

				if (isset($value) && $value != NULL && $value != '')

					$scopes[] = $key . " like '%" . $value . "%'";

		}


		$scope = implode (' AND ', $scopes);

		return $criteria->mergeWith(array('condition'=>$scope));

	}




	/**

	 Originally Written for CSort... Copied here for Convenience

	 * Resolves the attribute label based on label definition in the AR class.

	 * This will invoke {@link CActiveRecord::getAttributeLabel} to determine what label to use.

	 * @param string the attribute name.

	 * @return string the attribute label

	 */

	public function resolveLabel($attribute)

	{

		if(($pos=strpos($attribute,'.'))!==false)

		{

			$baseModel=CActiveRecord::model($this->modelClass);

			if(($relation=$baseModel->getActiveRelation(substr($attribute,0,$pos)))!==null)

				return CActiveRecord::model($relation->className)->getAttributeLabel(substr($attribute,$pos+1));

			else

				return $baseModel->getAttributeLabel(substr($attribute,$pos+1));

		}

		return CActiveRecord::model($this->modelClass)->getAttributeLabel($attribute);

	}


	/**

	 * Originaly written for CSort... Copied here for convenience

	 * Validates an attribute that is requested to be filtered.

	 * The validation is based on {@link attributes} and {@link CActiveRecord::attributeNames}.

	 * False will be returned if the attribute is not allowed to be used for Filtering.

	 * If the attribute is aliased via {@link attributes}, the original

	 * attribute name will be returned.

	 * @param string the attribute name (could be an alias) that the user requests to sort on

	 * @return string the real attribute name. False if the attribute cannot be used for filtering

	 */

	public function validateAttribute($attribute)

	{

		if(empty($this->attributes))

			$attributes=CActiveRecord::model($this->modelClass)->attributeNames();

		else

			$attributes=$this->attributes;

		foreach($attributes as $name=>$alias)

		{

			if($alias===$attribute)

				return is_string($name) ? $name : $alias;

		}

		return false;

	}




	/**

	 * Uses the {@link CHtml::beginForm} to generate an opening form tag.

	 * Note, only the open tag is generated. A close tag should be placed manually

	 * at the end of the form.

	 * @param array additional HTML attributes (see {@link tag}).

	 * @return string the generated form tag.

	 * @see endForm

	 */

	public static function beginForm($htmlOptions=array())

	{

		// The form should submit the scope (filter) values as 'get' 

		// for the values to remain in the URL for the filtering to

		// work hand in hand with sorting

		return CHtml::beginform ('', 'get', $htmlOptions);

	}




	/**

	 * Uses the {@link CHtml::beginForm} to generate a closing form tag.

	 * @return string the generated tag

	 * @see beginForm

	 */

	public static function endForm()

	{

		return CHtml::endForm();

	}


	/**

	 * Uses the {@link CHtml::submitButton}to generate a submit button.

	 * @param string the button label. Defaults to 'Filter'

	 * @param array additional HTML attributes. Besides normal HTML attributes, a few special

	 * attributes are also recognized (see {@link clientChange} and {@link tag} for more details.)

	 * @return string the generated button tag

	 */

	public static function submitButton($label='Filter',$htmlOptions=array())

	{

		if (!isset($htmlOptions['name']))

			$htmlOptions['name'] = 'scope[action]';

		return CHtml::submitButton($label,  $htmlOptions);

	}




	/**

	 * Uses the {@link CHtml::textField} to generate a text field input.

	 * @param string the input name

	 * @param string the input value

	 * @param array additional HTML attributes. Besides normal HTML attributes, a few special

	 * attributes are also recognized (see {@link clientChange} and {@link tag} for more details.)

	 * @return string the generated input field

	 */

	public function textField($attribute,$htmlOptions=array())

	{

		$attribute = $this->resolveLabel($attribute);


		if (isset($_GET[$this->scopeVar][$attribute]))

			$value = $_GET[$this->scopeVar][$attribute];


		$name = $this->scopeVar.'['.$attribute.']';


		return CHtml::textField($name,$value,$htmlOptions);

	}


	/**

	 * Creates a link that clears the filtering options and removes all filtering parameters

	 * from URL. Ues the {@link CHtml::link} to generate the URL.

	 * @param string the label of the hyperlink

	 * @param array additional HTML options

	 * @return string the generated hyperlink

	 */

	public static function linkClear($label='Clear Filter',$htmlOptions=array())

	{

		$urlVars = $_GET;

		$urlVarsClean = array();

		foreach ($urlVars as $key=>$value)

		{

			if ($key != 'scope')

				 $urlVarsClean[$key]=$value;

		}


		$url=Yii::app()->getController()->createUrl('',$urlVarsClean);

		return CHtml::link($label,$url,$htmlOptions);

	}




}

?>



[/indent]

This is interesting to me considering I just whipped something up pretty similar for a client (hadn’t put as much thought into it as you did though). This was my solution (not directly reusable though):




<?php

class ItemSearchForm extends CFormModel

{

	public $id;

	public $company_id;

	public $interchange;

	public $sku;

	public $caryear;

	public $carmodel_id;

	public $inv_title;

	public $type;

	public $marketplace;

	public $status;

	public $lead;

	

	protected $_model = 'item';

	

	public function searchableAttributes() {

		return $this->attributeNames();	

	}

	

	public function search() {

		$criteria = new CDbCriteria();

		$first=true;

		foreach ($this->searchableAttributes() as $attribute) {

			if (empty($this->$attribute)) continue;

			if ($first===false)

				$criteria->condition .= ' AND ';

			else

				$first=false;

			$criteria->condition .= $this->_model.'.'.$attribute.' LIKE :'.$attribute;

			$criteria->params[$attribute] = $this->$attribute.'%';

		}

		

		return $first ? array() : Item::model()->with('carmodel')->findAll($criteria);

	}

}



Controller logic:




if (isset($_POST['ItemSearchForm']['search'])) {

	if ($itemSearchForm->validate())

		$itemSearchResults = $itemSearchForm->search();

}



View (top row of table):




  <tr class="even">

    <td><?php echo CHtml::activeTextField($searchForm, 'id', array('style'=>'width:20px')); ?></td>

    <td><?php echo CHtml::activeDropDownList($searchForm, 'company_id', Company::getDropdown(), array('style'=>'width:55px')); ?></td>

    <td><?php echo CHtml::activeTextField($searchForm, 'interchange', array('style'=>'width:60px')); ?></td>

    <td><?php echo CHtml::activeTextField($searchForm, 'sku', array('style'=>'width:50px')); ?></td>

    <td><?php echo CHtml::activeTextField($searchForm, 'caryear', array('style'=>'width:35px')); ?></td>

    <td><?php echo CHtml::activeDropDownList($searchForm, 'carmodel_id', CarModel::getDropdown(),array('style'=>'width:55px')); ?></td>

    <td><?php echo CHtml::activeTextField($searchForm, 'inv_title', array('style'=>'width:80px')); ?></td>

    <td></td>

    <td></td>

    <td><?php echo CHtml::activeDropDownList($searchForm, 'type', ItemType::getDropdown(),array('style'=>'width:55px')); ?></td>

    <td><?php echo CHtml::activeDropDownList($searchForm, 'marketplace', ItemMarket::getDropdown(), array('style'=>'width:55px')); ?></td>

    <td><?php echo CHtml::activeDropDownList($searchForm, 'status',ItemStatus::getDropdown(), array('style'=>'width:55px')); ?></td>

    <td><?php echo CHtml::activeDropDownList($searchForm, 'lead', Lead::getDropdown(),array('style'=>'width:55px')); ?></td>

    <td></td>

    <td></td>

    <td><?php echo CHtml::submitButton('Filter', array('name'=>'ItemSearchForm[search]')); ?></td>

  </tr>



The pros of my solution is that I could also use drop-down fields, etc. I probably should create a SearchForm parent model that implants search() and searchableAttributes(), then specific search models could inherit from it. I like your solution though because instead of simply executing a search query, it returns a $criteria so you can perform other things on it. Liking your linkClear() idea too.

Everyone seems to have their own ideas to handle filtering of data. :)

I also came up with an even simpler class. My idea was to create a very slim base class for all FilterForms. That way a concrete filter form is just a special case of a standard FormModel. It can create a pagination object with filter params applied. And a concrete filter form should implement getDbCriteria() and build a criteria from current filter settings there. As this depends a lot on business logic of the data to be filtered this should be handled in the concrete implementation.


<?php

/**

 * FilterForm class

 *

 * @copyright 2009 Michael Härtl

 * @author Michael Härtl <haertl.mike@google.com>

 */


/**

 * FilterForm class

 *

 * FilterForm is the base class for filter forms on paginated lists.

 *

 * The filter form attributes are loaded automatically from $_GET/$_POST.

 * 

 * To ease pagination a CPagination object can be obtained from {@link paginate()}. 

 * It has the current filter params configured and will be reset to first

 * page if the filter has changed.

 *

 * Override {@link getDbCriteria} to ease creation of a CDbCriteria from the

 * current filter settings.

 *

 * Also make sure you apply the limits to the criteria. An example usage 

 * in a controller would look like:

 *

 * $filter = new MyFilterForm;

 * if ($filter->isSet && $filter->validate()) 

 * {

 *      $criteria = $filter->dbCriteria;

 *      $totals = MyRecord::model()->count($criteria);

 *      $pages=$filter->paginate($totals, $rowsPerPage);

 *      $pages->applyLimit($criteria);

 *

 *      $result=MyRecord::model()->findAll($criteria);

 *  } 

 * 

 * @copyright 2009 Michael Härtl

 * @author Michael Härtl <haertl.mike@google.com>

 */

class FilterForm extends CFormModel {


    public $isSet=true;


    private $_isChanged=false;


    public function __construct($attributes=array(),$scenario='')

    {

        parent::__construct($attributes,$scenario);


        $name=get_class($this);

        

        if (isset($_POST[$name])) {

            $this->_isChanged=true;     // Change is indicated by POST of this form

            $this->attributes=$_POST[$name];

        } elseif (isset($_GET[$name]))

            $this->attributes=$_GET[$name];

        else 

            $this->isSet=false;

    }


    /**

     * Return a CPagination object.

     * 

     * @param int $totals total number of hits

     * @param int $perPage number of hits per page

     * @access public

     * @return CPagination pagination with filter params appended

     */

    public function paginate($totals=0, $perPage=15)

    {

        $pages=new CPagination($totals);

        $pages->params=array( get_class($this) => $this->attributes);

        $pages->pageSize=$perPage;

        if ($this->_isChanged)

            $pages->setCurrentPage(0);


        return $pages;

    }


    /**

     * Returns the CDbCriteria object that represents the current

     * filter settings. Override this method in your actual filter form

     * to build the criteria from the current filter settings.

     *

     * Note:

     * You should not set limit and offset on the criteria as this

     * will be applied by the pagination object.

     * 

     * @access public

     * @return CDbCriteria representing this filter settings.

     */

    public function getDbCriteria()

    {

        return new CDbCriteria;

    }


    /**

     * @return bool wether filter settings have been changed. 

     */

    public function getIsChanged()

    {

        return $this->_isChanged;

    }


}



Nice job, guys!

Previously I was implementing and using something similar to Jonah’s. Now I am approaching Mike’s approach with a bit more enhancements. I call it “data provider” which combines filter condition, sort and pagination to be applied to an AR class or its related models. I plan to use the data provider to serve data for widgets such as data grid (almost done), data list.

I surely agree with Mike’s approach… and like the concept… thank you for sharing it…

Qiang… You’re the master :) way to go! can’t wait to see your version

Thank you Jonah for your comments as well…

Just checked in CDataProvider and CActiveDataProvider.

Nice, thanks Qiang.

But i don’t see yet how to get the same functionality like from my approach above. Could you give us a hint and maybe show an example on how to use these classes in a filter scenario?

The data provider can be used together with a search form, like the following:




class SearchPostForm extends CFormModel

{

    ......

    public function search()

    {

        return new CActiveDataProvider('Post',array(

            'criteria'=>$this->getSearchCriteria(),

        ));

    }

    

    protected function getSearchCriteria()

    {

        // assemble search criteria here based on the form input

        // e.g.

        // return array('

        //     'condition'=>'title LIKE :title',

        //     'parmas'=>array(':title'=>'%'.$this->title.'%'),

        // );

    }

}



Your approach is more or less on how to create the search criteria above.

Hi,

I also developed an extension "datafilter" (can be found here). It is more like Khaled Afiouni solution.

Sample of controller code:




    public function actionAdmin()

    {

        $this->processAdminCommand();

 

        $criteria=new CDbCriteria;

 

        $filters = new CDataFilter(User::model());

        $filters->addFilter(new CFilterSearch('userFieldsSearch'), 'Search');

    

        $filters->applyCriteria($criteria);

 

        $pages=new CPagination(User::model()->count($criteria));

        $pages->pageSize=self::PAGE_SIZE;

        $pages->applyLimit($criteria);

 

        $sort=new CSort('User');

        $sort->applyOrder($criteria);

 

        $models=User::model()->findAll($criteria);

 

        $this->render('admin',compact(

            'models', 'pages', 'sort', 'filters'

        ));

    }



View code:




<?php $this->widget('CDataFilterWidget',array('filters'=>$filters)); ?>



Filter form will be automatically generated. User can customize form by seting CSS classes or writing custom views.

And model code:




    // Returns list of searchable fileds for DataFilter widget

    public function getDataFilterSearchFields($filterName)

    {

       //return searchable fields array for search-by-field filters

    }

 

    // Applies search criteria enterd using DataFilter widget

    public function applyDataSearchCriteria(&$criteria, $filterName, $searchField, $searchValue)

    {

        //add search-by-field filter conditions to criteria  

    }

 

    // Returns options for DataFilter widget

    public function getDataFilterOptions($filterName)

    {

        //return searchable fields array for drop-down / link filters

    }

 

    // Applies filter criteria enterd using DataFilter widget

    public function applyDataFilterCriteria(&$criteria, $filterName, $filterValue)

    {

        //add drop-down / link filter conditions to criteria

    }



Now it supports three types of filters: DropDown, Search (user selects filed to search by and enters search text) and Link (like drop-down, but possible values listed as links). New filter types can be added by extending CFilterBase class.

My solution possibly is less flexibly (and more heavy) then Mike’s, but I think it is more easy to use it - you need just put some standard code into view and controller and and filtration code to the model class.