diff --git a/src/Shell/Task/BakeTemplateTask.php b/src/Shell/Task/BakeTemplateTask.php index 98394c9d4..fe0f87f5c 100644 --- a/src/Shell/Task/BakeTemplateTask.php +++ b/src/Shell/Task/BakeTemplateTask.php @@ -56,7 +56,10 @@ public function getView() $theme = isset($this->params['theme']) ? $this->params['theme'] : ''; $viewOptions = [ - 'helpers' => ['Bake.Bake'], + 'helpers' => [ + 'Bake.Bake', + 'Bake.DocBlock' + ], 'theme' => $theme ]; $view = new BakeView(new Request(), new Response(), null, $viewOptions); diff --git a/src/Shell/Task/ModelTask.php b/src/Shell/Task/ModelTask.php index e5d8bd9b7..5edd2025c 100644 --- a/src/Shell/Task/ModelTask.php +++ b/src/Shell/Task/ModelTask.php @@ -112,6 +112,7 @@ public function bake($name) $primaryKey = $this->getPrimaryKey($model); $displayField = $this->getDisplayField($model); + $propertySchema = $this->getEntityPropertySchema($model); $fields = $this->getFields(); $validation = $this->getValidation($model, $associations); $rulesChecker = $this->getRules($model, $associations); @@ -123,6 +124,7 @@ public function bake($name) 'primaryKey', 'displayField', 'table', + 'propertySchema', 'fields', 'validation', 'rulesChecker', @@ -424,6 +426,69 @@ public function getPrimaryKey($model) return (array)$model->primaryKey(); } + /** + * Returns an entity property "schema". + * + * The schema is an associative array, using the property names + * as keys, and information about the property as the value. + * + * The value part consists of at least two keys: + * + * - `kind`: The kind of property, either `column`, which indicates + * that the property stems from a database column, or `association`, + * which identifies a property that is generated for an associated + * table. + * - `type`: The type of the property value. For the `column` kind + * this is the database type associated with the column, and for the + * `association` type it's the FQN of the entity class for the + * associated table. + * + * For `association` properties an additional key will be available + * + * - `association`: Holds an instance of the corresponding association + * class. + * + * @param \Cake\ORM\Table $model The model to introspect. + * @return array The property schema + */ + public function getEntityPropertySchema(Table $model) + { + $properties = []; + + $schema = $model->schema(); + foreach ($schema->columns() as $column) { + $properties[$column] = [ + 'kind' => 'column', + 'type' => $schema->columnType($column) + ]; + } + + foreach ($model->associations() as $association) { + $entityClass = '\\' . ltrim($association->target()->entityClass(), '\\'); + + if ($entityClass === '\Cake\ORM\Entity') { + $namespace = Configure::read('App.namespace'); + + list($plugin, ) = pluginSplit($association->target()->registryAlias()); + if ($plugin !== null) { + $namespace = $plugin; + } + $namespace = str_replace('/', '\\', trim($namespace, '\\')); + + $entityClass = $this->_entityName($association->target()->alias()); + $entityClass = '\\' . $namespace . '\Model\Entity\\' . $entityClass; + } + + $properties[$association->property()] = [ + 'kind' => 'association', + 'association' => $association, + 'type' => $entityClass + ]; + } + + return $properties; + } + /** * Evaluates the fields and no-fields options, and * returns if, and which fields should be made accessible. diff --git a/src/Template/Bake/Model/entity.ctp b/src/Template/Bake/Model/entity.ctp index 2071e5fb6..ad4391423 100644 --- a/src/Template/Bake/Model/entity.ctp +++ b/src/Template/Bake/Model/entity.ctp @@ -13,6 +13,11 @@ * @license http://www.opensource.org/licenses/mit-license.php MIT License */ +$propertyHintMap = null; +if (!empty($propertySchema)) { + $propertyHintMap = $this->DocBlock->buildEntityPropertyHintTypeMap($propertySchema); +} + $accessible = []; if (!isset($fields) || $fields !== false) { if (!empty($fields)) { @@ -34,6 +39,16 @@ use Cake\ORM\Entity; /** * <%= $name %> Entity. +<% if ($propertyHintMap): %> + * +<% foreach ($propertyHintMap as $property => $type): %> +<% if ($type): %> + * @property <%= $type %> $<%= $property %> +<% else: %> + * @property $<%= $property %> +<% endif; %> +<% endforeach; %> +<% endif; %> */ class <%= $name %> extends Entity { diff --git a/src/View/Helper/DocBlockHelper.php b/src/View/Helper/DocBlockHelper.php new file mode 100644 index 000000000..c8399bee9 --- /dev/null +++ b/src/View/Helper/DocBlockHelper.php @@ -0,0 +1,143 @@ +type() === Association::MANY_TO_MANY || + $association->type() === Association::ONE_TO_MANY + ) { + return $type . '[]'; + } + return $type; + } + + /** + * Builds a map of entity property names and DocBlock types for use + * in generating `@property` hints. + * + * This method expects a property schema as generated by + * `\Bake\Shell\Task\ModelTask::getEntityPropertySchema()`. + * + * The generated map has the format of + * + * ``` + * [ + * 'property-name' => 'doc-block-type', + * ... + * ] + * ``` + * + * @see \Bake\Shell\Task\ModelTask::getEntityPropertySchema + * + * @param array $propertySchema The property schema to use for generating the type map. + * @return array The property DocType map. + */ + public function buildEntityPropertyHintTypeMap(array $propertySchema) + { + $properties = []; + foreach ($propertySchema as $property => $info) { + switch ($info['kind']) { + case 'column': + $properties[$property] = $this->columnTypeToHintType($info['type']); + break; + + case 'association': + $type = $this->associatedEntityTypeToHintType($info['type'], $info['association']); + if ($info['association']->type() === Association::MANY_TO_ONE) { + $properties = $this->_insertAfter( + $properties, + $info['association']->foreignKey(), + [$property => $type] + ); + } else { + $properties[$property] = $type; + } + break; + } + } + return $properties; + } + + /** + * Converts a column type to its DocBlock type counterpart. + * + * This method only supports the default CakePHP column types, + * custom column/database types will be ignored. + * + * @see \Cake\Database\Type + * + * @param string $type The column type. + * @return null|string The DocBlock type, or `null` for unsupported column types. + */ + public function columnTypeToHintType($type) + { + switch ($type) { + case 'string': + case 'text': + case 'uuid': + return 'string'; + + case 'integer': + case 'biginteger': + return 'int'; + + case 'float': + case 'decimal': + return 'float'; + + case 'boolean': + return 'bool'; + + case 'binary': + return 'string|resource'; + + case 'date': + case 'datetime': + case 'time': + case 'timestamp': + return '\Cake\I18n\Time'; + } + + return null; + } + + /** + * Inserts a value after a specific key in an associative array. + * + * In case the given key cannot be found, the value will be appended. + * + * @param array $target The array in which to insert the new value. + * @param string $key The array key after which to insert the new value. + * @param mixed $value The entry to insert. + * @return array The array with the new value inserted. + */ + protected function _insertAfter(array $target, $key, $value) + { + $index = array_search($key, array_keys($target)); + if ($index !== false) { + $target = array_merge( + array_slice($target, 0, $index + 1), + $value, + array_slice($target, $index + 1, null) + ); + } else { + $target += (array)$value; + } + return $target; + } +} diff --git a/tests/TestCase/Shell/Task/ModelTaskTest.php b/tests/TestCase/Shell/Task/ModelTaskTest.php index 911177a0a..e018cdff7 100644 --- a/tests/TestCase/Shell/Task/ModelTaskTest.php +++ b/tests/TestCase/Shell/Task/ModelTaskTest.php @@ -517,6 +517,63 @@ public function testHasAndBelongsToManyGeneration() $this->assertEquals($expected, $result); } + /** + * Test getting the entity property schema. + * + * @return void + */ + public function testGetEntityPropertySchema() + { + $model = TableRegistry::get('BakeArticles'); + $model->belongsTo('BakeUsers'); + $model->hasMany('BakeTest.Authors'); + $model->schema()->columnType('created', 'timestamp'); + $model->schema()->columnType('updated', 'timestamp'); + + $result = $this->Task->getEntityPropertySchema($model); + $expected = [ + 'id' => [ + 'kind' => 'column', + 'type' => 'integer' + ], + 'title' => [ + 'kind' => 'column', + 'type' => 'string' + ], + 'body' => [ + 'kind' => 'column', + 'type' => 'text' + ], + 'created' => [ + 'kind' => 'column', + 'type' => 'timestamp' + ], + 'bake_user_id' => [ + 'kind' => 'column', + 'type' => 'integer' + ], + 'published' => [ + 'kind' => 'column', + 'type' => 'boolean' + ], + 'updated' => [ + 'kind' => 'column', + 'type' => 'timestamp' + ], + 'bake_user' => [ + 'kind' => 'association', + 'association' => $model->association('BakeUsers'), + 'type' => '\App\Model\Entity\BakeUser' + ], + 'authors' => [ + 'kind' => 'association', + 'association' => $model->association('Authors'), + 'type' => '\BakeTest\Model\Entity\Author' + ] + ]; + $this->assertEquals($expected, $result); + } + /** * Test getting accessible fields. * @@ -963,6 +1020,29 @@ public function testBakeEntity() $this->assertSameAsFile(__FUNCTION__ . '.php', $result); } + /** + * test baking an entity with DocBlock property type hints. + * + * @return void + */ + public function testBakeEntityWithPropertyTypeHints() + { + $model = TableRegistry::get('BakeArticles'); + $model->belongsTo('BakeUsers'); + $model->hasMany('BakeTest.Authors'); + $model->schema()->addColumn('unknown_type', [ + 'type' => 'unknownType' + ]); + + $config = [ + 'fields' => false, + 'propertySchema' => $this->Task->getEntityPropertySchema($model) + ]; + + $result = $this->Task->bakeEntity($model, $config); + $this->assertSameAsFile(__FUNCTION__ . '.php', $result); + } + /** * test baking an entity class * diff --git a/tests/comparisons/Model/testBakeEntityWithPropertyTypeHints.php b/tests/comparisons/Model/testBakeEntityWithPropertyTypeHints.php new file mode 100644 index 000000000..cc04d6a68 --- /dev/null +++ b/tests/comparisons/Model/testBakeEntityWithPropertyTypeHints.php @@ -0,0 +1,23 @@ +