Extending the class factory

I wanted to extend/modify the class factory behavior to cope with CActiveDataProvider restrictions.

  1. What I want to do:
  • Show the contents of my table with CGridView.

  • Use CActiveDataProvider to provide the data.

  • Feed a generic class in CActiveDataProvider and parameters to specialise it.

  1. Background
  • My database has many tables that are dedicated to a (user,device). A user can have many devices and a device can have several users over its lifetime. So these tables get a specific name ‘devicelog_u1_d2’ where 1 is the userid and 2 the deviceid. There are some good reasons to do seperate tables.

  • These tables have the same structure, so I defined a generic class ‘Devicelog’ which can get parameters on instantiation (userid and deviceid). So I want to reuse the model.

  1. How I thought this would work:
  • Use the model as an entry to the CActiveDataProvider which is instantiated like this:

$dataprovider = new CActiveDataProvider(‘Devicelog’,$config). I was hoping that I could pass parameters in $config for the ‘Devicelog’ class, but I can’t.

  • In ‘Devicelog’ the ‘tableName’ method would simply return the right name for the table.
  1. What does not work.
  • Unfortunately parameters can not be passed to ‘Devicelog’ through the CActiveDataProvider instantiation.

  • I can not name ‘Devicelog’ something like ‘Devicelog_u1_d2’ and use that string upon instantiation to get the parameters. I looked at ‘autoload’ and ‘import’ in YiiBase.php. There may be some trick to preset the $alias as ‘imported’ but I think to get ‘Devicelog_XX’ Yii reverts to ‘autoload’. Anyway, analysis is pretty timeconsuming to know what would work, and it would only be a hack on the system (for as far as I know it righ now).

  1. So basically, it would be nice:
  1. if we could define any ‘classname’ and have our implementation code behave as the factory for it. That would allow great flexibility;

  2. if we could pass parameters to the model instantiation for the CActiveDataProvider.

In the mean time, it would be nice if somebody can make a nice suggestion on how to achieve what I want in an easy, clean way. I agree that I could extend the CActiveDataProvider class and adapt it in the required places. Unless there are some good suggestions, that is I guess what I will do. And of course, maybe yii can evolve to make this more flexible (but that is not for the short term).

Thanks for reading.

P.S.: Yii looked cool, and after going through the learning curve, it does look pretty much cool ;-).

Follow up.

I started creating a Wrapper aroun dthe CActiveDataProvider so that it takes a ‘tablename’ parameter in account:


<?php

class MyTableActiveDataProvider extends CActiveDataProvider

{

	// Parameter (in $config on new) to allow a tablename different

	// from the class.

	protected $tablename = null;

	

	private function getActiveRecordModel() {

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

		try {

			$model->setTablename($this->tablename);	

		} catch (Exception $e) {

			Yii::log("Issue setting table for {$this->modelClass}",'error','tableprovider');

		}

		return $model;

	}

	

	/**

	 * Fetches the data from the persistent data storage.

	 * @return array list of data items

	 */

	protected function fetchData()

	{

		$criteria=clone $this->getCriteria();

		if(($pagination=$this->getPagination())!==false)

		{

			$pagination->setItemCount($this->getTotalItemCount());

			$pagination->applyLimit($criteria);

		}

		if(($sort=$this->getSort())!==false)

			$sort->applyOrder($criteria);


		return $this->getActiveRecordModel()->findAll($criteria);

	}

	

	

    /**

	 * Calculates the total number of data items.

	 * @return integer the total number of data items.

	 */

	protected function calculateTotalItemCount()

	{

		return $this->getActiveRecordModel()->count($this->getCriteria());

	}	

}

?>

But that does not fix the problem. CActiveRecord actually creates an instance of the ‘modelClass’ and then uses it as a parameter to CActiveMetaRecord before any initialisation is done on ‘modelClass’. CActiveMetaRecord then uses the ‘tableName’ to get the table information from the database.

Further a ‘modelClass’ is only instantiated once, so the modelClass name needs to have a 1-1 relationship with the table making what I want to do even more difficult - in other words, it is best that the ‘modelClass’ name corresponds to the tableName.

Consequently, I’ll need to modify the behaviour of CActiveRecord to extract the parameters from the ‘className’ and instantiate the generic class which then gets listed in the internal $_models. However, because $_models is private, that will be another $_models, private to the extension.

In the above code, CActiveRecord has to be replace with MyTableActiveRecord which is yet to be written.

In the latest SVN trunk, CActiveDataProvider has a new property named ‘model’. You can assign an AR finder to it (e.g. Post::model()->published())

Thanks for the reply.

I took CActiveDataProvider from the trunk now.

I did:


$devicelog = new Devicelog($device_id,$user_id);

$dataprovider = new CActiveDataProvider($devicelog->tableName(),

   array(

   'model'=>$devicelog,

));

And then applied to the CGridView widget.

I still have CActiveRecord trying to instantiate ‘Devicelog’ in ‘model()’.

I had a look at CActiveRecord in the SVN Trunk and I do not see a change in that respect there.

Calling Devicelog::model() is not a solution either.

I guess that I missed something - still looking.

Hi, the new issue is in this piece of code in CActiveRecord:


        public function getMetaData()

        {

                if($this->_md!==null)

                        return $this->_md;

                else

                        return $this->_md=self::model(get_class($this))->_md;

        }

where:


self::model(get_class($this))

results in an attempt of instantiating the generic class.

As the constructor of that class takes two arguments, this simply fails.

You seem to have override the constructor of the AR class. Please try not to do so because there is some special requirement on how the constructor signature should be.

Hi

Conclusion regarding failure:

  • fails because $model->_md is not set;

  • because $model->_md is not set, calls model() to initialise $_md;

  • model() does not find the Generic class in the list, so tries to construct it.

$_md can not be set in the derived class (because it is private). The CActiveMetaData record can be created, but not assigned to $_md.

I do not see the way out. Maybe I should continue on my previous ‘hack’.

I did not see your previous post regarding my last post.

So what is the suggested method? Overriding ‘model’ is not the solution either I think.

Don’t override them. If you need to assign two attributes, do so using assignments after you create the AR object.

Still trying, but seeing more trouble. I continued on my initial approach.

I discovered that:

CGridView and CSort call:

CActiveRecord::model($this->dataProvider->modelClass)

or

CActiveRecord::model($this->modelClass)

In CGridView, I replace this with a call to getModel() on the dataProvider. In CSort, things are not that obvious.

In principle, if there was a single Model Object pool using a consistent key that would work. However, since the model object pool is private to the CActiveRecord it is not possible to populate it externally. The way it is currently done does not seem to (allow) (indirect) population by CActiveRecord derived classes.

If I do the assignments ‘after’ the AR object creation, the tableName is already used somewhere and incorrect.

I want the classname to be unique and different from the Generic class - otherwise the AR factory would reuse the same object for different tables.

I guess that if the className is the same as the AR Object Class, there is no issue in the system as long as there is only 1 instantiation of that class.

I did another test:

  1. I derived a class for a specific table from the base class. The tablename is the same as the class name.

=> Things are ok.

  1. I set the default table name to a table name that does not exist and I configure that table name after instantiation.

=> Initial construction fails.

  1. I got further with derived classes of CActiveRecord and CActiveDataProvider, but that fails too eventually - some reasons were given before.

Hi

I guess that a concrete case is most efficient, so here is an example that anyone can try on an existing database.

I create a ‘DummyModel’ class where the tablename depends on an attribute:


<?php

class DummyModel extends CActiveRecord

{

	public $tablename="";

	public function tableName()

	{

		return 'tbl_'.$this->tablename;

	}

}

?>

In my database, I have a table called ‘tbl_user’, so I get a model for the class and set the attribute to ‘user’. I then feed that to CActiveDataProvider and then to the CGridView which I insert in a page:


$dummy = new DummyModel;

$dummy->tablename = 'user';

$dataprovider = new CActiveDataProvider('DummyModel', array('model'=>$dummy));

$this->widget('zii.widgets.grid.CGridView', array(

'dataProvider'=>$dataprovider));

The description of the error that is returned is:

with the following head of the trace:


#0 D:\workspace\Web\framework\db\ar\CActiveRecord.php(342): CActiveRecordMetaData->__construct(Object(DummyModel))

#1 D:\workspace\Web\framework\db\ar\CActiveRecord.php(356): CActiveRecord::model('DummyModel')

#2 D:\workspace\Web\framework\db\ar\CActiveRecord.php(59): CActiveRecord->getMetaData()

#3 D:\workspace\Web\access\protected\views\sample\index.php(23): CActiveRecord->__construct()



Hopefully that clarifies the situation.

Following the SVN update, I updated my code and the test code. The issue in the post just before this one still exists.

My new test code is:


$dummy = new DummyModel;

$dummy->tablename = 'user';

$dataprovider = new CActiveDataProvider($dummy);

$this->widget('zii.widgets.grid.CGridView', array(

'dataProvider'=>$dataprovider));

[edit: added following example for completeness]:

In the end, this should be possible (if ‘tbl_user0’ and ‘tbl_user1’ exist):


$dummy0 = new DummyModel;

$dummy0->tablename = 'user0';

$dummy1 = new DummyModel;

$dummy1->tablename = 'user1';

$dataprovider0 = new CActiveDataProvider($dummy0);

$this->widget('zii.widgets.grid.CGridView', array(

'dataProvider'=>$dataprovider0));

$dataprovider1 = new CActiveDataProvider($dummy1);

$this->widget('zii.widgets.grid.CGridView', array(

'dataProvider'=>$dataprovider1));

I made it work following further modifications of CActiveRecord.

First the code that I tested on my database knowing that I have ‘tbl_c1_l1’ and ‘tbl_c1_l2’.




	$dummy = DummyModel::getModel('c1_l1');

	$dummy1 = DummyModel::getModel('c1_l2');

	$dataprovider = new CActiveDataProvider($dummy);

	$this->widget('zii.widgets.grid.CGridView', array(

'dataProvider'=>$dataprovider));

	$dataprovider1 = new CActiveDataProvider($dummy1);

	$this->widget('zii.widgets.grid.CGridView', array(

'dataProvider'=>$dataprovider1));

And the updated DummyModel:


<?php

class DummyModel extends CActiveRecord

{

	public $tablename="";

	public function tableName()

	{

		return 'track_'.$this->tablename;

	}

	public function getClassName() {

		return $this->tableName();

	}

	public function getModel($tablename) {

		$model = new DummyModel(null);

		$model->tablename = $tablename;

		return Dummymodel:model($model); // Register the instance

	}

	protected function instantiate($attributes) {

		$model = new DummyModel(null);

		$model->tablename = $this->tablename;

		return $model;

	}

}

?>

getClassName() is a new method in CActiveRecord which enables us to give a ‘virtual’ classname to our specialized instance. It has to be public because unfortunately some classes require a string … .

getModel() encapsulates the steps needed to get a parametrised instance of the Model that will be registered in ‘CActiveModel’'s internal object pool.

instantiate() was already available but had to be specialised to copy the ‘specialisation’ parameter.

In CActiveRecord all calls of type ‘get_class($p)’ changed to ‘$p->getClassName()’, except for the one that got added in the getClassName method:


	public function getClassName() {

		return get_class($this);

	}

Also in CActiveRecord, I changed ‘model’ so that it can take a CActiveRecord as a parameter and add it to the object pool.




	public static function model($className=__CLASS__)

	{

		if(is_string($className) && isset(self::$_models[$className]))

		    return self::$_models[$className];

		else if(($className instanceof CActiveRecord) &&

	             isset(self::$_models[$className->getClassName()]))

		    return self::$_models[$className->getClassName()];

		else {

			if($className instanceof CActiveRecord) {

				$model = $className;

				$className = $model->getClassName();

			} else {

				$model=new $className(null);

			}

			self::$_models[$className]=$model;

			$model->_md=new CActiveRecordMetaData($model);

			$model->attachBehaviors($model->behaviors());

			return $model;

		}

	}



In ‘__construct()’ of CActiveDataProvider, I changed ‘$this->modelClass=get_class($modelClass);’ to:


$this->modelClass=$modelClass;

Basically, any use of ‘get_class’ can be considered offending code here

In CGridView I suggest to change the following line in initColumns() too although the above works without that change:


$this->dataProvider->modelClass

Finally, I did a brute force search on the database looking for potential other locations where change would be needed. I then made a selection of the lines that are good candidates based on the line alone. I noticed that some of the changes already applied did not appear in yiilite.php which therefore needs changes too.


./framework/gii/generators/model/templates/default/model.php:		return new CActiveDataProvider(get_class($this), array(

./framework/web/form/CForm.php:			$class=get_class($this->_model);

./framework/web/form/CFormElementCollection.php:					$class=$value['type']==='form' ? get_class($this->_form) : Yii::import($value['type']);

./framework/web/helpers/CHtml.php:		return get_class($model).(isset($i) ? '['.$i.']' : '').'['.$attribute.']'.(false!==$index ? '['.$index.']' : '');

./framework/web/widgets/CActiveForm.php:			if($loadInput && isset($_POST[get_class($model)]))

./framework/web/widgets/CActiveForm.php:				$model->attributes=$_POST[get_class($model)];

./framework/yiilite.php:		return get_class($model).(isset($i) ? '['.$i.']' : '').'['.$attribute.']'.(false!==$index ? '['.$index.']' : '');

./framework/yiilite.php:		$className=get_class($this);

I hope that is it. If not I’ll notice once the changes are done in the official database and I try them.

An extra update for completeness:

I noticed that in the generated model the class’s name is defined as a string in the ‘search’ function.

Here is how I suggest to change it following the changes proposed in my previous post:


return new CActiveDataProvider($this->getClassName(), array(

	'criteria'=>$criteria,

));

This ensures that the parameter given as the reference for the Model class is in line with the parametrized instance of the class.