Generator.php 16.5 KB
Newer Older
Qiang Xue committed
1 2 3 4 5 6 7 8 9
<?php
/**
 * @link http://www.yiiframework.com/
 * @copyright Copyright (c) 2008 Yii Software LLC
 * @license http://www.yiiframework.com/license/
 */

namespace yii\gii\generators\model;

Qiang Xue committed
10
use Yii;
Qiang Xue committed
11
use yii\db\ActiveRecord;
Qiang Xue committed
12
use yii\db\Connection;
13
use yii\db\Schema;
Qiang Xue committed
14
use yii\gii\CodeFile;
Qiang Xue committed
15
use yii\helpers\Inflector;
Qiang Xue committed
16

Qiang Xue committed
17
/**
Qiang Xue committed
18
 * This generator will generate one or multiple ActiveRecord classes for the specified database table.
Qiang Xue committed
19 20 21 22 23 24
 *
 * @author Qiang Xue <qiang.xue@gmail.com>
 * @since 2.0
 */
class Generator extends \yii\gii\Generator
{
Qiang Xue committed
25
	public $db = 'db';
Qiang Xue committed
26
	public $ns = 'app\models';
Qiang Xue committed
27 28
	public $tableName;
	public $modelClass;
Qiang Xue committed
29
	public $baseClass = 'yii\db\ActiveRecord';
Qiang Xue committed
30
	public $generateRelations = true;
Qiang Xue committed
31
	public $generateLabelsFromComments = false;
Qiang Xue committed
32 33


Qiang Xue committed
34 35 36
	/**
	 * @inheritdoc
	 */
Qiang Xue committed
37 38 39 40 41
	public function getName()
	{
		return 'Model Generator';
	}

Qiang Xue committed
42 43 44
	/**
	 * @inheritdoc
	 */
Qiang Xue committed
45 46
	public function getDescription()
	{
Qiang Xue committed
47
		return 'This generator generates an ActiveRecord class for the specified database table.';
Qiang Xue committed
48
	}
49

Qiang Xue committed
50 51 52
	/**
	 * @inheritdoc
	 */
Qiang Xue committed
53 54 55
	public function rules()
	{
		return array_merge(parent::rules(), array(
Qiang Xue committed
56 57 58 59
			array('db, ns, tableName, modelClass, baseClass', 'filter', 'filter' => 'trim'),
			array('db, ns, tableName, baseClass', 'required'),
			array('db, modelClass', 'match', 'pattern' => '/^\w+$/', 'message' => 'Only word characters are allowed.'),
			array('ns, baseClass', 'match', 'pattern' => '/^[\w\\\\]+$/', 'message' => 'Only word characters and backslashes are allowed.'),
Qiang Xue committed
60
			array('tableName', 'match', 'pattern' => '/^(\w+\.)?([\w\*]+)$/', 'message' => 'Only word characters, and optionally an asterisk and/or a dot are allowed.'),
Qiang Xue committed
61 62
			array('db', 'validateDb'),
			array('ns', 'validateNamespace'),
Qiang Xue committed
63
			array('tableName', 'validateTableName'),
Qiang Xue committed
64
			array('modelClass', 'validateModelClass'),
Qiang Xue committed
65
			array('baseClass', 'validateClass', 'params' => array('extends' => ActiveRecord::className())),
Qiang Xue committed
66
			array('generateRelations, generateLabelsFromComments', 'boolean'),
Qiang Xue committed
67 68 69
		));
	}

Qiang Xue committed
70 71 72
	/**
	 * @inheritdoc
	 */
Qiang Xue committed
73 74
	public function attributeLabels()
	{
Qiang Xue committed
75
		return array(
Qiang Xue committed
76 77
			'ns' => 'Namespace',
			'db' => 'Database Connection ID',
Qiang Xue committed
78 79 80
			'tableName' => 'Table Name',
			'modelClass' => 'Model Class',
			'baseClass' => 'Base Class',
Qiang Xue committed
81
			'generateRelations' => 'Generate Relations',
Qiang Xue committed
82 83 84 85
			'generateLabelsFromComments' => 'Generate Labels from DB Comments',
		);
	}

Qiang Xue committed
86 87 88
	/**
	 * @inheritdoc
	 */
Qiang Xue committed
89 90 91 92 93 94 95
	public function hints()
	{
		return array(
			'ns' => 'This is the namespace of the ActiveRecord class to be generated, e.g., <code>app\models</code>',
			'db' => 'This is the ID of the DB application component.',
			'tableName' => 'This is the name of the DB table that the new ActiveRecord class is associated with, e.g. <code>tbl_post</code>.
				The table name may consist of the DB schema part if needed, e.g. <code>public.tbl_post</code>.
Qiang Xue committed
96 97 98 99 100
				The table name may contain an asterisk to match multiple table names, e.g. <code>tbl_*</code>
				will match tables who name starts with <code>tbl_</code>. In this case, multiple ActiveRecord classes
				will be generated, one for each matching table name; and the class names will be generated from
				the matching characters. For example, table <code>tbl_post</code> will generate <code>Post</code>
				class.',
Qiang Xue committed
101 102 103 104 105 106
			'modelClass' => 'This is the name of the ActiveRecord class to be generated. The class name should not contain
				the namespace part as it is specified in "Namespace". You do not need to specify the class name
				if "Table Name" contains an asterisk at the end, in which case multiple ActiveRecord classes will be generated.',
			'baseClass' => 'This is the base class of the new ActiveRecord class. It should be a fully qualified namespaced class name.',
			'generateRelations' => 'This indicates whether the generator should generate relations based on
				foreign key constraints it detects in the database. Note that if your database contains too many tables,
Qiang Xue committed
107
				you may want to uncheck this option to accelerate the code generation proc	ess.',
Qiang Xue committed
108 109
			'generateLabelsFromComments' => 'This indicates whether the generator should generate attribute labels
				by using the comments of the corresponding DB columns.',
Qiang Xue committed
110
		);
Qiang Xue committed
111 112
	}

Qiang Xue committed
113 114 115
	/**
	 * @inheritdoc
	 */
Qiang Xue committed
116 117 118 119 120 121 122
	public function requiredTemplates()
	{
		return array(
			'model.php',
		);
	}

Qiang Xue committed
123 124 125
	/**
	 * @inheritdoc
	 */
Qiang Xue committed
126 127
	public function stickyAttributes()
	{
Qiang Xue committed
128
		return array('ns', 'db', 'baseClass', 'generateRelations', 'generateLabelsFromComments');
Qiang Xue committed
129 130
	}

Qiang Xue committed
131
	/**
Qiang Xue committed
132
	 * @inheritdoc
Qiang Xue committed
133
	 */
Qiang Xue committed
134 135
	public function generate()
	{
Qiang Xue committed
136
		$files = array();
Qiang Xue committed
137 138
		$relations = $this->generateRelations();
		$db = $this->getDbConnection();
Qiang Xue committed
139 140
		foreach ($this->getTableNames() as $tableName) {
			$className = $this->generateClassName($tableName);
Qiang Xue committed
141
			$tableSchema = $db->getTableSchema($tableName);
Qiang Xue committed
142
			$params = array(
Qiang Xue committed
143
				'tableName' => $tableName,
Qiang Xue committed
144
				'className' => $className,
Qiang Xue committed
145 146
				'tableSchema' => $tableSchema,
				'labels' => $this->generateLabels($tableSchema),
147
				'rules' => $this->generateRules($tableSchema),
Qiang Xue committed
148
				'relations' => isset($relations[$className]) ? $relations[$className] : array(),
Qiang Xue committed
149
			);
Qiang Xue committed
150
			$files[] = new CodeFile(
Qiang Xue committed
151
				Yii::getAlias('@' . str_replace('\\', '/', $this->ns)) . '/' . $className . '.php',
Qiang Xue committed
152
				$this->render('model.php', $params)
Qiang Xue committed
153 154
			);
		}
Qiang Xue committed
155 156

		return $files;
Qiang Xue committed
157 158
	}

Qiang Xue committed
159 160 161 162 163
	/**
	 * Generates the attribute labels for the specified table.
	 * @param \yii\db\TableSchema $table the table schema
	 * @return array the generated attribute labels (name => label)
	 */
Qiang Xue committed
164 165 166 167
	public function generateLabels($table)
	{
		$labels = array();
		foreach ($table->columns as $column) {
Qiang Xue committed
168
			if ($this->generateLabelsFromComments && !empty($column->comment)) {
Qiang Xue committed
169
				$labels[$column->name] = $column->comment;
Qiang Xue committed
170 171
			} elseif (!strcasecmp($column->name, 'id')) {
				$labels[$column->name] = 'ID';
Qiang Xue committed
172
			} else {
Qiang Xue committed
173
				$label = Inflector::camel2words($column->name);
Qiang Xue committed
174
				if (strcasecmp(substr($label, -3), ' id') === 0) {
Qiang Xue committed
175
					$label = substr($label, 0, -3) . ' ID';
Qiang Xue committed
176 177 178 179 180 181 182
				}
				$labels[$column->name] = $label;
			}
		}
		return $labels;
	}

183
	/**
Qiang Xue committed
184 185 186
	 * Generates validation rules for the specified table.
	 * @param \yii\db\TableSchema $table the table schema
	 * @return array the generated validation rules
187
	 */
Qiang Xue committed
188 189
	public function generateRules($table)
	{
190 191
		$types = array();
		$lengths = array();
Qiang Xue committed
192 193 194 195
		foreach ($table->columns as $column) {
			if ($column->autoIncrement) {
				continue;
			}
196 197
			if (!$column->allowNull && $column->defaultValue === null) {
				$types['required'][] = $column->name;
Qiang Xue committed
198
			}
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
			switch ($column->type) {
				case Schema::TYPE_SMALLINT:
				case Schema::TYPE_INTEGER:
				case Schema::TYPE_BIGINT:
					$types['integer'][] = $column->name;
					break;
				case Schema::TYPE_BOOLEAN:
					$types['boolean'][] = $column->name;
					break;
				case Schema::TYPE_FLOAT:
				case Schema::TYPE_DECIMAL:
				case Schema::TYPE_MONEY:
					$types['number'][] = $column->name;
					break;
				case Schema::TYPE_DATE:
				case Schema::TYPE_TIME:
				case Schema::TYPE_DATETIME:
				case Schema::TYPE_TIMESTAMP:
					$types['safe'][] = $column->name;
					break;
				default: // strings
					if ($column->size > 0) {
						$lengths[$column->size][] = $column->name;
					} else {
						$types['string'][] = $column->name;
					}
Qiang Xue committed
225 226
			}
		}
227 228 229 230

		$rules = array();
		foreach ($types as $type => $columns) {
			$rules[] = "array('" . implode(', ', $columns) . "', '$type')";
Qiang Xue committed
231
		}
232 233
		foreach ($lengths as $length => $columns) {
			$rules[] = "array('" . implode(', ', $columns) . "', 'string', 'max' => $length)";
Qiang Xue committed
234 235 236 237 238
		}

		return $rules;
	}

Qiang Xue committed
239 240 241
	/**
	 * @return array the generated relation declarations
	 */
Qiang Xue committed
242 243
	protected function generateRelations()
	{
Qiang Xue committed
244
		if (!$this->generateRelations) {
Qiang Xue committed
245 246 247
			return array();
		}

Qiang Xue committed
248 249
		$db = $this->getDbConnection();

Qiang Xue committed
250 251
		if (($pos = strpos($this->tableName, '.')) !== false) {
			$schemaName = substr($this->tableName, 0, $pos);
Qiang Xue committed
252 253
		} else {
			$schemaName = '';
Qiang Xue committed
254 255 256
		}

		$relations = array();
Qiang Xue committed
257
		foreach ($db->getSchema()->getTableSchemas($schemaName) as $table) {
Qiang Xue committed
258
			$tableName = $table->name;
Qiang Xue committed
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280
			$className = $this->generateClassName($tableName);
			foreach ($table->foreignKeys as $refs) {
				$refTable = $refs[0];
				unset($refs[0]);
				$fks = array_keys($refs);
				$refClassName = $this->generateClassName($refTable);

				// Add relation for this table
				$link = $this->generateRelationLink(array_flip($refs));
				$relationName = $this->generateRelationName($relations, $className, $table, $fks[0], false);
				$relations[$className][$relationName] = array(
					"return \$this->hasOne('$refClassName', $link);",
					$refClassName,
					false,
				);

				// Add relation for the referenced table
				$hasMany = false;
				foreach ($fks as $key) {
					if (!in_array($key, $table->primaryKey, true)) {
						$hasMany = true;
						break;
Qiang Xue committed
281 282
					}
				}
Qiang Xue committed
283 284 285 286 287 288 289 290 291 292 293
				$link = $this->generateRelationLink($refs);
				$relationName = $this->generateRelationName($relations, $refClassName, $refTable, $className, $hasMany);
				$relations[$refClassName][$relationName] = array(
					"return \$this->" . ($hasMany ? 'hasMany' : 'hasOne') . "('$className', $link);",
					$className,
					$hasMany,
				);
			}

			if (($fks = $this->checkPivotTable($table)) === false) {
				continue;
Qiang Xue committed
294
			}
Qiang Xue committed
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316
			$table0 = $fks[$table->primaryKey[0]][0];
			$table1 = $fks[$table->primaryKey[1]][0];
			$className0 = $this->generateClassName($table0);
			$className1 = $this->generateClassName($table1);

			$link = $this->generateRelationLink(array($fks[$table->primaryKey[1]][1] => $table->primaryKey[1]));
			$viaLink = $this->generateRelationLink(array($table->primaryKey[0] => $fks[$table->primaryKey[0]][1]));
			$relationName = $this->generateRelationName($relations, $className0, $db->getTableSchema($table0), $table->primaryKey[1], true);
			$relations[$className0][$relationName] = array(
				"return \$this->hasMany('$className1', $link)->viaTable('{$table->name}', $viaLink);",
				$className0,
				true,
			);

			$link = $this->generateRelationLink(array($fks[$table->primaryKey[0]][1] => $table->primaryKey[0]));
			$viaLink = $this->generateRelationLink(array($table->primaryKey[1] => $fks[$table->primaryKey[1]][1]));
			$relationName = $this->generateRelationName($relations, $className1, $db->getTableSchema($table1), $table->primaryKey[0], true);
			$relations[$className1][$relationName] = array(
				"return \$this->hasMany('$className0', $link)->viaTable('{$table->name}', $viaLink);",
				$className1,
				true,
			);
Qiang Xue committed
317 318 319 320
		}
		return $relations;
	}

321
	/**
Qiang Xue committed
322 323 324
	 * Generates the link parameter to be used in generating the relation declaration.
	 * @param array $refs reference constraint
	 * @return string the generated link parameter.
325
	 */
Qiang Xue committed
326
	protected function generateRelationLink($refs)
Qiang Xue committed
327
	{
Qiang Xue committed
328 329 330 331 332
		$pairs = array();
		foreach ($refs as $a => $b) {
			$pairs[] = "'$a' => '$b'";
		}
		return 'array(' . implode(', ', $pairs) . ')';
Qiang Xue committed
333 334 335
	}

	/**
Qiang Xue committed
336 337 338 339 340 341
	 * Checks if the given table is a pivot table.
	 * For simplicity, this method only deals with the case where the pivot contains two PK columns,
	 * each referencing a column in a different table.
	 * @param \yii\db\TableSchema the table being checked
	 * @return array|boolean the relevant foreign key constraint information if the table is a pivot table,
	 * or false if the table is not a pivot table.
Qiang Xue committed
342
	 */
Qiang Xue committed
343
	protected function checkPivotTable($table)
Qiang Xue committed
344
	{
Qiang Xue committed
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360
		$pk = $table->primaryKey;
		if (count($pk) !== 2) {
			return false;
		}
		$fks = array();
		foreach ($table->foreignKeys as $refs) {
			if (count($refs) === 2) {
				if (isset($refs[$pk[0]])) {
					$fks[$pk[0]] = array($refs[0], $refs[$pk[0]]);
				} elseif (isset($refs[$pk[1]])) {
					$fks[$pk[1]] = array($refs[0], $refs[$pk[1]]);
				}
			}
		}
		if (count($fks) === 2 && $fks[$pk[0]][0] !== $fks[$pk[1]][0]) {
			return $fks;
Qiang Xue committed
361
		} else {
Qiang Xue committed
362
			return false;
Qiang Xue committed
363
		}
Qiang Xue committed
364
	}
Qiang Xue committed
365

Qiang Xue committed
366 367 368 369 370 371 372 373 374 375 376 377 378
	/**
	 * Generate a relation name for the specified table and a base name.
	 * @param array $relations the relations being generated currently.
	 * @param string $className the class name that will contain the relation declarations
	 * @param \yii\db\TableSchema $table the table schema
	 * @param string $key a base name that the relation name may be generated from
	 * @param boolean $multiple whether this is a has-many relation
	 * @return string the relation name
	 */
	protected function generateRelationName($relations, $className, $table, $key, $multiple)
	{
		if (strcasecmp(substr($key, -2), 'id') === 0 && strcasecmp($key, 'id')) {
			$key = rtrim(substr($key, 0, -2), '_');
Qiang Xue committed
379
		}
Qiang Xue committed
380 381
		if ($multiple) {
			$key = Inflector::pluralize($key);
Qiang Xue committed
382
		}
Qiang Xue committed
383
		$name = $rawName = Inflector::id2camel($key, '_');
Qiang Xue committed
384 385 386 387
		$i = 0;
		while (isset($table->columns[$name])) {
			$name = $rawName . ($i++);
		}
Qiang Xue committed
388 389 390
		while (isset($relations[$className][$name])) {
			$name = $rawName . ($i++);
		}
Qiang Xue committed
391 392 393 394

		return $name;
	}

Qiang Xue committed
395 396 397
	/**
	 * Validates the [[db]] attribute.
	 */
Qiang Xue committed
398
	public function validateDb()
399
	{
Qiang Xue committed
400 401 402 403
		if (Yii::$app->hasComponent($this->db) === false) {
			$this->addError('db', 'There is no application component named "db".');
		} elseif (!Yii::$app->getComponent($this->db) instanceof Connection) {
			$this->addError('db', 'The "db" application component must be a DB connection instance.');
Qiang Xue committed
404 405 406
		}
	}

Qiang Xue committed
407 408 409
	/**
	 * Validates the [[ns]] attribute.
	 */
Qiang Xue committed
410 411
	public function validateNamespace()
	{
Qiang Xue committed
412 413
		$this->ns = ltrim($this->ns, '\\');
		$path = Yii::getAlias('@' . str_replace('\\', '/', $this->ns), false);
Qiang Xue committed
414 415 416
		if ($path === false) {
			$this->addError('ns', 'Namespace must be associated with an existing directory.');
		}
Qiang Xue committed
417 418
	}

Qiang Xue committed
419 420 421
	/**
	 * Validates the [[modelClass]] attribute.
	 */
Qiang Xue committed
422 423 424
	public function validateModelClass()
	{
		if ($this->isReservedKeyword($this->modelClass)) {
Qiang Xue committed
425
			$this->addError('modelClass', 'Class name cannot be a reserved PHP keyword.');
Qiang Xue committed
426 427 428 429 430 431
		}
		if (strpos($this->tableName, '*') === false && $this->modelClass == '') {
			$this->addError('modelClass', 'Model Class cannot be blank.');
		}
	}

Qiang Xue committed
432 433 434
	/**
	 * Validates the [[tableName]] attribute.
	 */
Qiang Xue committed
435 436
	public function validateTableName()
	{
Qiang Xue committed
437 438 439 440
		if (($pos = strpos($this->tableName, '*')) !== false && strpos($this->tableName, '*', $pos + 1) !== false) {
			$this->addError('tableName', 'At most one asterisk is allowed.');
			return;
		}
Qiang Xue committed
441 442
		$tables = $this->getTableNames();
		if (empty($tables)) {
Qiang Xue committed
443
			$this->addError('tableName', "Table '{$this->tableName}' does not exist.");
Qiang Xue committed
444 445 446 447
		} else {
			foreach ($tables as $table) {
				$class = $this->generateClassName($table);
				if ($this->isReservedKeyword($class)) {
Qiang Xue committed
448
					$this->addError('tableName', "Table '$table' will generate a class which is a reserved PHP keyword.");
Qiang Xue committed
449 450 451 452 453
					break;
				}
			}
		}
	}
Qiang Xue committed
454

455 456 457
	private $_tableNames;
	private $_classNames;

Qiang Xue committed
458 459 460
	/**
	 * @return array the table names that match the pattern specified by [[tableName]].
	 */
Qiang Xue committed
461 462
	protected function getTableNames()
	{
463 464 465
		if ($this->_tableNames !== null) {
			return $this->_tableNames;
		}
Qiang Xue committed
466 467
		$db = $this->getDbConnection();
		$tableNames = array();
Qiang Xue committed
468
		if (strpos($this->tableName, '*') !== false) {
Qiang Xue committed
469 470
			if (($pos = strrpos($this->tableName, '.')) !== false) {
				$schema = substr($this->tableName, 0, $pos);
Qiang Xue committed
471
				$pattern = '/^' . str_replace('*', '\w+', substr($this->tableName, $pos + 1)) . '$/';
Qiang Xue committed
472 473
			} else {
				$schema = '';
Qiang Xue committed
474
				$pattern = '/^' . str_replace('*', '\w+', $this->tableName) . '$/';
Qiang Xue committed
475 476
			}

Qiang Xue committed
477 478 479
			foreach ($db->schema->getTableNames($schema) as $table) {
				if (preg_match($pattern, $table)) {
					$tableNames[] = $schema === '' ? $table : ($schema . '.' . $table);
Qiang Xue committed
480 481
				}
			}
Qiang Xue committed
482 483
		} elseif (($table = $db->getTableSchema($this->tableName, true)) !== null) {
			$tableNames[] = $this->tableName;
484 485 486 487 488
			$this->_classNames[$this->tableName] = $this->modelClass;
		}
		return $this->_tableNames = $tableNames;
	}

Qiang Xue committed
489 490 491 492 493
	/**
	 * Generates a class name from the specified table name.
	 * @param string $tableName the table name (which may contain schema prefix)
	 * @return string the generated class name
	 */
494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523
	protected function generateClassName($tableName)
	{
		if (isset($this->_classNames[$tableName])) {
			return $this->_classNames[$tableName];
		}

		if (($pos = strrpos($tableName, '.')) !== false) {
			$tableName = substr($tableName, $pos + 1);
		}

		$db = $this->getDbConnection();
		$patterns = array();
		if (strpos($this->tableName, '*') !== false) {
			$pattern = $this->tableName;
			if (($pos = strrpos($pattern, '.')) !== false) {
				$pattern = substr($pattern, $pos + 1);
			}
			$patterns[] = '/^' . str_replace('*', '(\w+)', $pattern) . '$/';
		}
		if (!empty($db->tablePrefix)) {
			$patterns[] = "/^{$db->tablePrefix}(.*?)|(.*?){$db->tablePrefix}$/";
		} else {
			$patterns[] = "/^tbl_(.*?)$/";
		}

		$className = $tableName;
		foreach ($patterns as $pattern) {
			if (preg_match($pattern, $tableName, $matches)) {
				$className = $matches[1];
			}
Qiang Xue committed
524
		}
525
		return $this->_classNames[$tableName] = Inflector::id2camel($className, '_');
526
	}
Qiang Xue committed
527 528 529 530 531 532 533 534

	/**
	 * @return Connection the DB connection as specified by [[db]].
	 */
	protected function getDbConnection()
	{
		return Yii::$app->{$this->db};
	}
Qiang Xue committed
535
}