Yii properties for PHPStorm

Many features of PHPStorm aren't possible because, it doesn't know the class attributes of a CActiveRecord class.
I wrote a command line util, which fixes this.

What is the basic concept?

It is possible to tell PHPStorm which attributes exist, if you add a Phpdoc comment before the class with @property type name.
It is possible to tell PHPStorm the class it is using, with adding a @return before the model() function which hints the classname and adding @method before the class which hints, the classname again.
For example:
	
/**
 * @property int id
 * @property string name
 * @method User|CActiveRecord findByPk()
 */
class User extends CActiveRecord
{
	/** @return User|CActiveRecord */
	public static function model($classname=__CLASS__) {
...
	

What does my script do?

To simplify this process my Script adds the @property and @method before the all classes. Additionally it would also add the stuff from relations() as @property with the correct type.

How the script works internally

  1. Walk through all files in a "/models/" directory
  2. Extract the tablename from the tableName() function
  3. Fetch the database information with yii - and map the database type into phpdoc types
  4. Extract the relations from the relations() function
  5. Find the class docstring and delete all @property and @method from there
  6. Add both above informations as @property
  7. Add some predefined functions as @method

How to run the script?

  • Backup your project (It's not my fault if your stuff gets deleted!)
  • Copy the 2 files inside your yii project under: protected/commands
  • Edit protected/commands/PropertyConfig.php
  • cd into protected
  • run: php yiic.php property commands/PropertyConfig.php
  • done

Where is the script?

Here:
Sorry for not providing a downloadable format. If you poke me I would do it.
PropertyCommand.php:
	
<?php
/**
 * PropertyCommand tries to add all properties from the database to your CActiveRecord classes
 * also it will add some @method and will add the relations as properties
 *
 * Assumptions which are made:
 * all use the same database
 * every class has a "function tableName" with a default return '{table}' or return '{{table}}' inside
 * the @property elements inside this defintion can all be deleted (protect yourself with appending (internal)
 * the @method   elements inside this defintion can all be deleted (protect yourself with appending (internal)
 */
class PropertyCommand extends CConsoleCommand
{
	public function getHelp()
	{
		return <<<EOD
USAGE
  yiic property <config-file>
EOD;
	}

	/**
	 * Execute the action.
	 * @param array command line parameters specific for this command
	 */
	public function run($args)
	{
		if(!isset($args[0]))
			$this->usageError('the configuration file is not specified.');
		if(!is_file($args[0]))
			$this->usageError("the configuration file {$args[0]} does not exist.");

		$config = require_once($args[0]);

		// setup database
		$connection = new CDbConnection($config['db_dsn'],$config['db_user'],$config['db_pass']);
		$connection->active=true;
		$connection->tablePrefix = $config['db_prefix'];
		$schema = $connection->getSchema();

		// iterate all files
		$files = CFileHelper::findFiles(realpath($config['sourcePath']), $config['pathOptions']);
		foreach($files as $file)
		{
			// only look at files inside /models/ directory
			if (strpos($file, '/models/') === false)
				continue;
			$className = str_replace('.php', '', basename($file));
			$content = file_get_contents($file);
			$tableName = $this->extractTableName($content, $config['db_prefix']);
			if (!$tableName)
			{
				echo $file.' couldn\'t find tableName'."\n";
				continue;
			}
			$table = $schema->getTable($tableName);
			$properties = $this->getProperties($table->columns);
			$relationProperties = $this->getRelationProperties($className, $content);

			$this->checkProperties($className, $properties, $relationProperties);

			$docstring = '';
			$docstring.= $this->getPropertiesDocstring($properties);
			$docstring.= $this->getPropertiesDocstring($relationProperties);
			$docstring.= $this->getMethodsDocstring($className);

			$content = $this->applyDocstring($className, $content, $docstring);

			file_put_contents($file, $content);
		}
	}

	// returns the tablename from a class
	protected function extractTableName($content)
	{
		// find the function for the tableName
		$pos = strpos($content, 'function tableName');
		// extract the function body
		$funcBody = substr($content, $pos, strpos($content, ';', $pos));
		if (preg_match('/(\'|")(\{{1,2}([^\}]+)\}{1,2})(\'|")/', $funcBody, $matches))
		{
			$tablename = $matches[2];
			return $tablename;
		}
		return '';
	}

	// maps all db columns to name=>type array
	protected function getProperties($columns)
	{
		$allMaps = array(
			'dbType'=>array(
				'tinyint(1)'=>'bool', // this guess could be wrong sometimes
			),
			'type'=>array(
				'integer'=>'int',
				'string'=>'string',
				'double'=>'double',
			),
		);
		$properties = array();
		foreach ($columns as $col)
		{
			$found = false;
			foreach ($allMaps as $type=>$map)
			{
				if (isset($map[$col->$type]))
				{
					$properties[$col->name] = $map[$col->$type];
					$found = true;
					break;
				}
			}
			if (!$found)
				die(print_r($col));
		}
		return $properties;
	}

	protected function getPropertiesDocstring($properties)
	{
		$docstring = '';
		foreach ($properties as $name=>$type)
			$docstring .= sprintf(' * @property %s %s'."\n", $type, $name);
		return $docstring;
	}

	// returns all relations which it could transform into a property
	protected function getRelationProperties($className, $content)
	{
		// find the function for relations
		$pos = strpos($content, 'function relations(');
		// extract the function body (it is expected that the function just contains the return array(.. ); in the default style
		// also each relation must be on one line
		$startArray = strpos($content, 'return', $pos);
		$endArray = strpos($content, ');', $startArray);
		if ($pos === false || $startArray === false || $endArray === false)
		{
			echo $className.": No relations found\n";
			return array();
		}

		$funcBody = substr($content, $startArray, $endArray);
		// only 'users' => array(self::MANY_MANY, 'User',
		// is matched
		if (preg_match_all('/(\'|")([^\'"]+)(\'|")\s*=>\s*array\(\s*self::([a-zA-Z_]+)\s*,\s*(\'|")([^\'"]+)(\'|")/', $funcBody, $matches))
		{
			$properties = array();
			// mapping maps to a type or "match" which says, it should take the object from the matches
			$mapping = array(
				'MANY_MANY'=>'array',
				'HAS_MANY'=>'array',
				'HAS_ONE'=>'match',
				'BELONGS_TO'=>'match',
				'STAT'=>'int', // TODO is that right?
			);
			foreach (array_keys($matches[0]) as $k)
			{
				$rel = $matches[4][$k];
				if (!isset($mapping[$rel]))
					die("mapping $rel doesnt exist");
				$type = $mapping[$rel];
				if ($type == 'match')
					$type = $matches[6][$k];
				$properties[$matches[2][$k]] = $type;
			}
			return $properties;
		}
		echo $className.": Strange relations Format or empty\n";
		return array();
	}

	// a small helper which looks if your relations overlap the db fields
	protected function checkProperties($className, $propA, $propB)
	{
		foreach ($propB as $name=>$type)
			if (isset($propA[$name]))
				echo $className.' has duplicate db and relations properties'."\n";
	}

	// apply a docstring - would remove if @method/@property exists before
	protected function applyDocstring($className, $content, $docstring)
	{
		// 1. search where class definition begins
		// 2. search above for a docstring
			// true) search for existing @property and delete them
			// false) create one
		// 3. insert properties

		if (!preg_match('/(class [^ ]+ extends )/m', $content, $matches))
			die("could not find a class definition for ".$className);

		$classString = $matches[1]; // contains something like "class User extends "
		$pos = strpos($content, $classString);
		// now look above if we have a docstring or comment
		$endComment = strrpos($content, '*/', (-1)*(strlen($content)-$pos));
		if ($endComment === false)
		{
			$content = substr_replace($content, "/**\n * Autogenerated for $className\n*/\n", $pos, 0);
		}
		else
		{
			// look if there are already @property or @method inside and remove them
			$startComment = strrpos($content, '/*', (-1)*(strlen($content)-$endComment));
			$commentBlock = substr($content, $startComment, $endComment);
			$commentBlock = $this->removePropertiesAndMethods($commentBlock);
			$content = substr_replace($content, $commentBlock, $startComment, $endComment);
		}
		// it is quite possible that the position has changed - so search again
		$pos = strpos($content, $classString);
		$endComment = strrpos($content, '*/', (-1)*(strlen($content)-$pos));

		if (substr($content, $endComment-1, 2) == ' *')
			$endComment--;
		// add docstring before endComment
		$content = substr_replace($content, $docstring, $endComment, 0);
		return $content;
	}

	// removes all @property and @method from a docstring
	protected function removePropertiesAndMethods($commentBlock)
	{
		if (preg_match_all('/\\n(\s*\*\s*@(property|method) [^ ]+\s(.*))/', $commentBlock, $matches))
		{
			$replaces = array();
			foreach ($matches[1] as $match)
			{
				// an identifier to make some properties immutable
				if (strpos($match, '(internal)') !== false)
					continue;
				// else remove the line
				$replaces[] = $match."\n";
			}
			$commentBlock = str_replace($replaces, '', $commentBlock);
		}
		return $commentBlock;
	}

	// returns some CActiveRecord methods I use often with correct class return
	protected function getMethodsDocstring($className)
	{
		$docstring = '';
		$docstring.= ' * @method '.$className.'|CActiveRecord find()'."\n";
		$docstring.= ' * @method '.$className.'|CActiveRecord findByPK()'."\n";
		$docstring.= ' * @method '.$className.'|CActiveRecord findByAttributes()'."\n";
		return $docstring;
	}
}
	
PropertyConfig.php:
	
<?php

return array(
	// the path from where the script should search everything
	// following command would search inside protected/
	'sourcePath'=>dirname(__FILE__).DIRECTORY_SEPARATOR.'..',
	// some options like exclude you might want to specify
	'pathOptions'=>array(),

	// your db connection
	'db_dsn'=>'mysql:host=localhost;dbname=yourdb',
	'db_user'=>'root',
	'db_pass'=>'',
	'db_prefix'=>'yourprefix_',
);