1<?php
2
3class ModelCode extends CCodeModel
4{
5	public $connectionId='db';
6	public $tablePrefix;
7	public $tableName;
8	public $modelClass;
9	public $modelPath='application.models';
10	public $baseClass='CActiveRecord';
11	public $buildRelations=true;
12	public $commentsAsLabels=false;
13
14	/**
15	 * @var array list of candidate relation code. The array are indexed by AR class names and relation names.
16	 * Each element represents the code of the one relation in one AR class.
17	 */
18	protected $relations;
19
20	public function rules()
21	{
22		return array_merge(parent::rules(), array(
23			array('tablePrefix, baseClass, tableName, modelClass, modelPath, connectionId', 'filter', 'filter'=>'trim'),
24			array('connectionId, tableName, modelPath, baseClass', 'required'),
25			array('tablePrefix, tableName, modelPath', 'match', 'pattern'=>'/^(\w+[\w\.]*|\*?|\w+\.\*)$/', 'message'=>'{attribute} should only contain word characters, dots, and an optional ending asterisk.'),
26			array('connectionId', 'validateConnectionId', 'skipOnError'=>true),
27			array('tableName', 'validateTableName', 'skipOnError'=>true),
28			array('tablePrefix, modelClass', 'match', 'pattern'=>'/^[a-zA-Z_]\w*$/', 'message'=>'{attribute} should only contain word characters.'),
29		    array('baseClass', 'match', 'pattern'=>'/^[a-zA-Z_\\\\][\w\\\\]*$/', 'message'=>'{attribute} should only contain word characters and backslashes.'),
30			array('modelPath', 'validateModelPath', 'skipOnError'=>true),
31			array('baseClass, modelClass', 'validateReservedWord', 'skipOnError'=>true),
32			array('baseClass', 'validateBaseClass', 'skipOnError'=>true),
33			array('connectionId, tablePrefix, modelPath, baseClass, buildRelations, commentsAsLabels', 'sticky'),
34		));
35	}
36
37	public function attributeLabels()
38	{
39		return array_merge(parent::attributeLabels(), array(
40			'tablePrefix'=>'Table Prefix',
41			'tableName'=>'Table Name',
42			'modelPath'=>'Model Path',
43			'modelClass'=>'Model Class',
44			'baseClass'=>'Base Class',
45			'buildRelations'=>'Build Relations',
46			'commentsAsLabels'=>'Use Column Comments as Attribute Labels',
47			'connectionId'=>'Database Connection',
48		));
49	}
50
51	public function requiredTemplates()
52	{
53		return array(
54			'model.php',
55		);
56	}
57
58	public function init()
59	{
60		if(Yii::app()->{$this->connectionId}===null)
61			throw new CHttpException(500,'A valid database connection is required to run this generator.');
62		$this->tablePrefix=Yii::app()->{$this->connectionId}->tablePrefix;
63		parent::init();
64	}
65
66	public function prepare()
67	{
68		if(($pos=strrpos($this->tableName,'.'))!==false)
69		{
70			$schema=substr($this->tableName,0,$pos);
71			$tableName=substr($this->tableName,$pos+1);
72		}
73		else
74		{
75			$schema='';
76			$tableName=$this->tableName;
77		}
78		if($tableName[strlen($tableName)-1]==='*')
79		{
80			$tables=Yii::app()->{$this->connectionId}->schema->getTables($schema);
81			if($this->tablePrefix!='')
82			{
83				foreach($tables as $i=>$table)
84				{
85					if(strpos($table->name,$this->tablePrefix)!==0)
86						unset($tables[$i]);
87				}
88			}
89		}
90		else
91			$tables=array($this->getTableSchema($this->tableName));
92
93		$this->files=array();
94		$templatePath=$this->templatePath;
95		$this->relations=$this->generateRelations();
96
97		foreach($tables as $table)
98		{
99			$tableName=$this->removePrefix($table->name);
100			$className=$this->generateClassName($table->name);
101			$params=array(
102				'tableName'=>$schema==='' ? $tableName : $schema.'.'.$tableName,
103				'modelClass'=>$className,
104				'columns'=>$table->columns,
105				'labels'=>$this->generateLabels($table),
106				'rules'=>$this->generateRules($table),
107				'relations'=>isset($this->relations[$className]) ? $this->relations[$className] : array(),
108				'connectionId'=>$this->connectionId,
109			);
110			$this->files[]=new CCodeFile(
111				Yii::getPathOfAlias($this->modelPath).'/'.$className.'.php',
112				$this->render($templatePath.'/model.php', $params)
113			);
114		}
115	}
116
117	public function validateTableName($attribute,$params)
118	{
119		if($this->hasErrors())
120			return;
121
122		$invalidTables=array();
123		$invalidColumns=array();
124
125		if($this->tableName[strlen($this->tableName)-1]==='*')
126		{
127			if(($pos=strrpos($this->tableName,'.'))!==false)
128				$schema=substr($this->tableName,0,$pos);
129			else
130				$schema='';
131
132			$this->modelClass='';
133			$tables=Yii::app()->{$this->connectionId}->schema->getTables($schema);
134			foreach($tables as $table)
135			{
136				if($this->tablePrefix=='' || strpos($table->name,$this->tablePrefix)===0)
137				{
138					if(in_array(strtolower($table->name),self::$keywords))
139						$invalidTables[]=$table->name;
140					if(($invalidColumn=$this->checkColumns($table))!==null)
141						$invalidColumns[]=$invalidColumn;
142				}
143			}
144		}
145		else
146		{
147			if(($table=$this->getTableSchema($this->tableName))===null)
148				$this->addError('tableName',"Table '{$this->tableName}' does not exist.");
149			if($this->modelClass==='')
150				$this->addError('modelClass','Model Class cannot be blank.');
151
152			if(!$this->hasErrors($attribute) && ($invalidColumn=$this->checkColumns($table))!==null)
153					$invalidColumns[]=$invalidColumn;
154		}
155
156		if($invalidTables!=array())
157			$this->addError('tableName', 'Model class cannot take a reserved PHP keyword! Table name: '.implode(', ', $invalidTables).".");
158		if($invalidColumns!=array())
159			$this->addError('tableName', 'Column names that does not follow PHP variable naming convention: '.implode(', ', $invalidColumns).".");
160	}
161
162	/*
163	 * Check that all database field names conform to PHP variable naming rules
164	 * For example mysql allows field name like "2011aa", but PHP does not allow variable like "$model->2011aa"
165	 * @param CDbTableSchema $table the table schema object
166	 * @return string the invalid table column name. Null if no error.
167	 */
168	public function checkColumns($table)
169	{
170		foreach($table->columns as $column)
171		{
172			if(!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/',$column->name))
173				return $table->name.'.'.$column->name;
174		}
175	}
176
177	public function validateModelPath($attribute,$params)
178	{
179		if(Yii::getPathOfAlias($this->modelPath)===false)
180			$this->addError('modelPath','Model Path must be a valid path alias.');
181	}
182
183	public function validateBaseClass($attribute,$params)
184	{
185		$class=@Yii::import($this->baseClass,true);
186		if(!is_string($class) || !$this->classExists($class))
187			$this->addError('baseClass', "Class '{$this->baseClass}' does not exist or has syntax error.");
188		elseif($class!=='CActiveRecord' && !is_subclass_of($class,'CActiveRecord'))
189			$this->addError('baseClass', "'{$this->baseClass}' must extend from CActiveRecord.");
190	}
191
192	public function getTableSchema($tableName)
193	{
194		$connection=Yii::app()->{$this->connectionId};
195		return $connection->getSchema()->getTable($tableName, $connection->schemaCachingDuration!==0);
196	}
197
198	public function generateLabels($table)
199	{
200		$labels=array();
201		foreach($table->columns as $column)
202		{
203			if($this->commentsAsLabels && $column->comment)
204				$labels[$column->name]=$column->comment;
205			else
206			{
207				$label=ucwords(trim(strtolower(str_replace(array('-','_'),' ',preg_replace('/(?<![A-Z])[A-Z]/', ' \0', $column->name)))));
208				$label=preg_replace('/\s+/',' ',$label);
209				if(strcasecmp(substr($label,-3),' id')===0)
210					$label=substr($label,0,-3);
211				if($label==='Id')
212					$label='ID';
213				$label=str_replace("'","\\'",$label);
214				$labels[$column->name]=$label;
215			}
216		}
217		return $labels;
218	}
219
220	public function generateRules($table)
221	{
222		$rules=array();
223		$required=array();
224		$integers=array();
225		$numerical=array();
226		$length=array();
227		$safe=array();
228		foreach($table->columns as $column)
229		{
230			if($column->autoIncrement)
231				continue;
232			$r=!$column->allowNull && $column->defaultValue===null;
233			if($r)
234				$required[]=$column->name;
235			if($column->type==='integer')
236				$integers[]=$column->name;
237			elseif($column->type==='double')
238				$numerical[]=$column->name;
239			elseif($column->type==='string' && $column->size>0)
240				$length[$column->size][]=$column->name;
241			elseif(!$column->isPrimaryKey && !$r)
242				$safe[]=$column->name;
243		}
244		if($required!==array())
245			$rules[]="array('".implode(', ',$required)."', 'required')";
246		if($integers!==array())
247			$rules[]="array('".implode(', ',$integers)."', 'numerical', 'integerOnly'=>true)";
248		if($numerical!==array())
249			$rules[]="array('".implode(', ',$numerical)."', 'numerical')";
250		if($length!==array())
251		{
252			foreach($length as $len=>$cols)
253				$rules[]="array('".implode(', ',$cols)."', 'length', 'max'=>$len)";
254		}
255		if($safe!==array())
256			$rules[]="array('".implode(', ',$safe)."', 'safe')";
257
258		return $rules;
259	}
260
261	public function getRelations($className)
262	{
263		return isset($this->relations[$className]) ? $this->relations[$className] : array();
264	}
265
266	protected function removePrefix($tableName,$addBrackets=true)
267	{
268		if($addBrackets && Yii::app()->{$this->connectionId}->tablePrefix=='')
269			return $tableName;
270		$prefix=$this->tablePrefix!='' ? $this->tablePrefix : Yii::app()->{$this->connectionId}->tablePrefix;
271		if($prefix!='')
272		{
273			if($addBrackets && Yii::app()->{$this->connectionId}->tablePrefix!='')
274			{
275				$prefix=Yii::app()->{$this->connectionId}->tablePrefix;
276				$lb='{{';
277				$rb='}}';
278			}
279			else
280				$lb=$rb='';
281			if(($pos=strrpos($tableName,'.'))!==false)
282			{
283				$schema=substr($tableName,0,$pos);
284				$name=substr($tableName,$pos+1);
285				if(strpos($name,$prefix)===0)
286					return $schema.'.'.$lb.substr($name,strlen($prefix)).$rb;
287			}
288			elseif(strpos($tableName,$prefix)===0)
289				return $lb.substr($tableName,strlen($prefix)).$rb;
290		}
291		return $tableName;
292	}
293
294	protected function generateRelations()
295	{
296		if(!$this->buildRelations)
297			return array();
298
299		$schemaName='';
300		if(($pos=strpos($this->tableName,'.'))!==false)
301			$schemaName=substr($this->tableName,0,$pos);
302
303		$relations=array();
304		foreach(Yii::app()->{$this->connectionId}->schema->getTables($schemaName) as $table)
305		{
306			if($this->tablePrefix!='' && strpos($table->name,$this->tablePrefix)!==0)
307				continue;
308			$tableName=$table->name;
309
310			if ($this->isRelationTable($table))
311			{
312				$pks=$table->primaryKey;
313				$fks=$table->foreignKeys;
314
315				$table0=$fks[$pks[0]][0];
316				$table1=$fks[$pks[1]][0];
317				$className0=$this->generateClassName($table0);
318				$className1=$this->generateClassName($table1);
319
320				$unprefixedTableName=$this->removePrefix($tableName);
321
322				$relationName=$this->generateRelationName($table0, $table1, true);
323				$relations[$className0][$relationName]="array(self::MANY_MANY, '$className1', '$unprefixedTableName($pks[0], $pks[1])')";
324
325				$relationName=$this->generateRelationName($table1, $table0, true);
326
327				$i=1;
328				$rawName=$relationName;
329				while(isset($relations[$className1][$relationName]))
330					$relationName=$rawName.$i++;
331
332				$relations[$className1][$relationName]="array(self::MANY_MANY, '$className0', '$unprefixedTableName($pks[1], $pks[0])')";
333			}
334			else
335			{
336				$className=$this->generateClassName($tableName);
337				foreach ($table->foreignKeys as $fkName => $fkEntry)
338				{
339					// Put table and key name in variables for easier reading
340					$refTable=$fkEntry[0]; // Table name that current fk references to
341					$refKey=$fkEntry[1];   // Key in that table being referenced
342					$refClassName=$this->generateClassName($refTable);
343
344					// Add relation for this table
345					$relationName=$this->generateRelationName($tableName, $fkName, false);
346					$relations[$className][$relationName]="array(self::BELONGS_TO, '$refClassName', '$fkName')";
347
348					// Add relation for the referenced table
349					$relationType=$table->primaryKey === $fkName ? 'HAS_ONE' : 'HAS_MANY';
350					$relationName=$this->generateRelationName($refTable, $this->removePrefix($tableName,false), $relationType==='HAS_MANY');
351					$i=1;
352					$rawName=$relationName;
353					while(isset($relations[$refClassName][$relationName]))
354						$relationName=$rawName.($i++);
355					$relations[$refClassName][$relationName]="array(self::$relationType, '$className', '$fkName')";
356				}
357			}
358		}
359		return $relations;
360	}
361
362	/**
363	 * Checks if the given table is a "many to many" pivot table.
364	 * Their PK has 2 fields, and both of those fields are also FK to other separate tables.
365	 * @param CDbTableSchema table to inspect
366	 * @return boolean true if table matches description of helper table.
367	 */
368	protected function isRelationTable($table)
369	{
370		$pk=$table->primaryKey;
371        $count=is_array($pk) ? count($pk) : 1;
372        return ($count === 2 // we want 2 columns
373			&& isset($table->foreignKeys[$pk[0]]) // pk column 1 is also a foreign key
374			&& isset($table->foreignKeys[$pk[1]]) // pk column 2 is also a foriegn key
375			&& $table->foreignKeys[$pk[0]][0] !== $table->foreignKeys[$pk[1]][0]); // and the foreign keys point different tables
376	}
377
378	protected function generateClassName($tableName)
379	{
380		if($this->tableName===$tableName || ($pos=strrpos($this->tableName,'.'))!==false && substr($this->tableName,$pos+1)===$tableName)
381			return $this->modelClass;
382
383		$tableName=$this->removePrefix($tableName,false);
384		if(($pos=strpos($tableName,'.'))!==false) // remove schema part (e.g. remove 'public2.' from 'public2.post')
385			$tableName=substr($tableName,$pos+1);
386		$className='';
387		foreach(explode('_',$tableName) as $name)
388		{
389			if($name!=='')
390				$className.=ucfirst($name);
391		}
392		return $className;
393	}
394
395	/**
396	 * Generate a name for use as a relation name (inside relations() function in a model).
397	 * @param string the name of the table to hold the relation
398	 * @param string the foreign key name
399	 * @param boolean whether the relation would contain multiple objects
400	 * @return string the relation name
401	 */
402	protected function generateRelationName($tableName, $fkName, $multiple)
403	{
404		if(strcasecmp(substr($fkName,-2),'id')===0 && strcasecmp($fkName,'id'))
405			$relationName=rtrim(substr($fkName, 0, -2),'_');
406		else
407			$relationName=$fkName;
408		$relationName[0]=strtolower($relationName);
409
410		if($multiple)
411			$relationName=$this->pluralize($relationName);
412
413		$names=preg_split('/_+/',$relationName,-1,PREG_SPLIT_NO_EMPTY);
414		if(empty($names)) return $relationName;  // unlikely
415		for($name=$names[0], $i=1;$i<count($names);++$i)
416			$name.=ucfirst($names[$i]);
417
418		$rawName=$name;
419		$table=Yii::app()->{$this->connectionId}->schema->getTable($tableName);
420		$i=0;
421		while(isset($table->columns[$name]))
422			$name=$rawName.($i++);
423
424		return $name;
425	}
426
427	public function validateConnectionId($attribute, $params)
428	{
429		if(Yii::app()->hasComponent($this->connectionId)===false || !(Yii::app()->getComponent($this->connectionId) instanceof CDbConnection))
430			$this->addError('connectionId','A valid database connection is required to run this generator.');
431	}
432}
433