It's not always obvious how to put computed values into a CGridView and make them filter and sort like an ordinary attribute's column.
By convention a solution to this problem is presented with an example. Contrary to Yii convention, it is not a blog site (I've had it up to here with that blog). We are building a book review web app. We have a table of books that relates 1:n to a table of reviews.
Schema
Here is our schema for MySQL:
create table book ( id int unsigned not null auto_increment primary key, title varchar(255) not null, author varchar(255) not null ) engine=innodb default; create table review ( id int unsigned not null auto_increment primary key, book_id int unsigned not null, deleted tinyint(1) not null, completed tinyint(1) not null, rating int(1) unsigned default null, critique varchar(4000) default null, key book_id (book_id), constraint review_ibfk_1 foreign key (book_id) references book (id) on delete cascade on update cascade ) engine=innodb default;
Requirements
The project is to put a CGV on the admin view of the Book model with these columns for each book:
- its title
- its author
- the number of pending reviews (not deleted and not completed) of the book
- the number of completed reviews (not deleted and completed) of the book
Getting ready
Load the schema into a DB. Get a fresh webapp from yiic. Have Gii generate models for the two DB tables and crud for the Book model. Make sure Gii generates relations for the models.
How it is done
Let's start at the end, with the view. This is what we want the CGV to look like:
$this->widget('zii.widgets.grid.CGridView', array( 'id' => 'book-grid', 'dataProvider' => $model->search(), 'filter' => $model, 'columns' => array( 'title', 'author', array( 'name' => 'nPending', 'filter' => CHtml::activeTextField($model, 'n_pending'), ), array( 'name' => 'nCompleted', 'filter' => CHtml::activeTextField($model, 'n_completed'), ), array( 'class' => 'CButtonColumn', ), ), ));
So in the Book model we need pseudo-attributes nPending and nCompleted that carry the data from the table to the data provider. It also needs properties n_pending and n_completed to hold the user inputs into the CGV's filters form. These are declared in the normal fashion, as shown below, but note the comment about validation rules.
class Book extends CActiveRecord { /**#@+ * Property's name is same as the corresponding computed stat column's alias. * @var int */ public $nPending; public $nCompleted; /**#@-*/ /**#@+ * Property to hold CGV filter form user input. This must be the model's rules * as 'safe', 'on' => 'search'. * @var string */ public $n_pending; public $n_completed; /**#@-*/ /** * We put the actual computation of the stats column into the select property of * named scopes. * @return array The model's array of named scope definitions. */ public function scopes() { return array( 'byNPending' => array('select' => '(select count(*) from review r where r.book_id = t.id && !r.deleted && !r.completed ) as nPending'), 'byNCompleted' => array('select' => '(select count(*) from review w where w.book_id = t.id && !w.deleted && w.completed ) as nCompleted'), ); } /** * This is a handy little method for adding compare conditions into a * CDbCriteria instance's having property. These conditions work just like the * regular CDbCriteria::compare() but in the SQL having clause instead of its * where clause. * @param CDbCriteria $criteria The criteria to which the having will be added. * @param string $attribute Column alias of the stats column to be compared. * @param string $input User input with which to compare the stats column. */ private function _compareHaving(&$criteria, $attribute, $input) { if ($this->n_pending !== null) { $crit = new CDbCriteria; $crit->compare($attribute, $this->$input, true); $crit->having = $crit->condition; $crit->condition = ""; $criteria->mergeWith($crit); } } /** * And the model's search method ends up looking like this. About half of this * comes from yiic webapp and the rest is new. * @return CActiveDataProvider */ public function search() { $criteria = new CDbCriteria; // The scopes will reset the default select so we add what we need here $criteria->select = 't.*'; // We don't need to join to the review table for this example but you would // need this if the data provider should deliver data from review. // $criteria->with = array('reviews'); // Add the two scopes from above to the criteria $criteria->scopes = array('byNPending', 'byNCompleted'); $criteria->compare('title', $this->title, true); $criteria->compare('author', $this->author, true); // Add the comparison of user input to computed stat columns using the // method we declared before. $this->_compareHaving($criteria, 'nPending', 'n_pending'); $this->_compareHaving($criteria, 'nCompleted', 'n_completed'); // Sorting is required but we need some custom sorting. $sort = new CSort('Campaign'); $sort->attributes = array( // * means all the attributes in the models '*', // but nPending and nCompleted aren't real attributes so they need // mentions. I don't know why an empty array works here but it does. 'nPending' => array(), 'nCompleted' => array(), ); return new CActiveDataProvider($this, array( 'criteria' => $criteria, 'sort' => $sort, )); } }
One idea to make this more oop and tidy would to put the _compareHaving logic into an extension to or behavior for CActiveRecord. But that's the subject of a different wiki.