diff --git a/CHANGELOG.md b/CHANGELOG.md index caa9a588e..0dbcbdc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Added +- [#1575](https://github.com/hyperf/hyperf/pull/1575) Added document of property with relation, scope and attributes. - [#1586](https://github.com/hyperf/hyperf/pull/1586) Added conflict of symfony/event-dispatcher which < 4.3. # v1.1.26 - 2020-04-16 diff --git a/src/database/composer.json b/src/database/composer.json index d25704a7a..55b3e8055 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -30,7 +30,7 @@ }, "suggest": { "doctrine/dbal": "Required to rename columns (^2.6).", - "nikic/php-parser": "Required to use ModelCommand (^4.1)." + "roave/better-reflection": "Required to use ModelCommand (^4.0)." }, "autoload": { "psr-4": { diff --git a/src/database/src/Commands/Ast/ModelUpdateVisitor.php b/src/database/src/Commands/Ast/ModelUpdateVisitor.php index 94b4d5f12..c115e4c35 100644 --- a/src/database/src/Commands/Ast/ModelUpdateVisitor.php +++ b/src/database/src/Commands/Ast/ModelUpdateVisitor.php @@ -12,13 +12,51 @@ declare(strict_types=1); namespace Hyperf\Database\Commands\Ast; use Hyperf\Database\Commands\ModelOption; +use Hyperf\Database\Model\Builder; +use Hyperf\Database\Model\Collection; +use Hyperf\Database\Model\Model; +use Hyperf\Database\Model\Relations\BelongsTo; +use Hyperf\Database\Model\Relations\BelongsToMany; +use Hyperf\Database\Model\Relations\HasMany; +use Hyperf\Database\Model\Relations\HasManyThrough; +use Hyperf\Database\Model\Relations\HasOne; +use Hyperf\Database\Model\Relations\HasOneThrough; +use Hyperf\Database\Model\Relations\MorphMany; +use Hyperf\Database\Model\Relations\MorphOne; +use Hyperf\Database\Model\Relations\MorphTo; +use Hyperf\Database\Model\Relations\MorphToMany; +use Hyperf\Database\Model\Relations\Relation; use Hyperf\Utils\Str; use PhpParser\Comment\Doc; use PhpParser\Node; use PhpParser\NodeVisitorAbstract; +use Roave\BetterReflection\BetterReflection; +use Roave\BetterReflection\Reflection\ReflectionClass; +use Roave\BetterReflection\Reflection\ReflectionMethod; +use Roave\BetterReflection\Reflector\ClassReflector; +use Roave\BetterReflection\TypesFinder\FindReturnType; class ModelUpdateVisitor extends NodeVisitorAbstract { + const RELATION_METHODS = [ + 'hasMany' => HasMany::class, + 'hasManyThrough' => HasManyThrough::class, + 'hasOneThrough' => HasOneThrough::class, + 'belongsToMany' => BelongsToMany::class, + 'hasOne' => HasOne::class, + 'belongsTo' => BelongsTo::class, + 'morphOne' => MorphOne::class, + 'morphTo' => MorphTo::class, + 'morphMany' => MorphMany::class, + 'morphToMany' => MorphToMany::class, + 'morphedByMany' => MorphToMany::class, + ]; + + /** + * @var string + */ + protected $class; + /** * @var array */ @@ -29,31 +67,48 @@ class ModelUpdateVisitor extends NodeVisitorAbstract */ protected $option; - public function __construct($columns = [], ModelOption $option) + /** + * @var array + */ + protected $methods = []; + + /** + * @var array + */ + protected $properties = []; + + /** + * @deprecated v2.0 + * @var ClassReflector + */ + protected static $reflector; + + /** + * @deprecated v2.0 + * @var FindReturnType + */ + protected static $return; + + public function __construct($class, $columns, ModelOption $option) { + $this->class = $class; $this->columns = $columns; $this->option = $option; + $this->initPropertiesFromMethods(); } public function leaveNode(Node $node) { switch ($node) { case $node instanceof Node\Stmt\PropertyProperty: - if ($node->name == 'fillable' && $this->option->isRefreshFillable()) { + if ((string) $node->name === 'fillable' && $this->option->isRefreshFillable()) { $node = $this->rewriteFillable($node); - } elseif ($node->name == 'casts') { + } elseif ((string) $node->name === 'casts') { $node = $this->rewriteCasts($node); } - return $node; case $node instanceof Node\Stmt\Class_: - $doc = '/**' . PHP_EOL; - foreach ($this->columns as $column) { - [$name, $type, $comment] = $this->getProperty($column); - $doc .= sprintf(' * @property %s $%s %s', $type, $name, $comment) . PHP_EOL; - } - $doc .= ' */'; - $node->setDocComment(new Doc($doc)); + $node->setDocComment(new Doc($this->parseProperty())); return $node; } } @@ -91,6 +146,147 @@ class ModelUpdateVisitor extends NodeVisitorAbstract return $node; } + protected function parseProperty(): string + { + $doc = '/**' . PHP_EOL; + foreach ($this->columns as $column) { + [$name, $type, $comment] = $this->getProperty($column); + $doc .= sprintf(' * @property %s $%s %s', $type, $name, $comment) . PHP_EOL; + } + foreach ($this->properties as $name => $property) { + if ($property['read'] && $property['write']) { + $doc .= sprintf(' * @property %s $%s', $property['type'], $name) . PHP_EOL; + continue; + } + if ($property['read']) { + $doc .= sprintf(' * @property-read %s $%s', $property['type'], $name) . PHP_EOL; + continue; + } + if ($property['write']) { + $doc .= sprintf(' * @property-write %s $%s', $property['type'], $name) . PHP_EOL; + continue; + } + } + $doc .= ' */'; + return $doc; + } + + protected function initPropertiesFromMethods() + { + /** @var ReflectionClass $reflection */ + $reflection = self::getReflector()->reflect($this->class); + $methods = $reflection->getImmediateMethods(); + $namespace = $reflection->getDeclaringNamespaceAst(); + if (empty($methods)) { + return; + } + + sort($methods); + /** @var ReflectionMethod $method */ + foreach ($methods as $method) { + if (Str::startsWith($method->getName(), 'get') && Str::endsWith($method->getName(), 'Attribute')) { + // Magic getAttribute + $name = Str::snake(substr($method->getName(), 3, -9)); + if (! empty($name)) { + $type = self::getReturnFinder()->__invoke($method, $namespace); + $this->setProperty($name, $type, true, null); + } + continue; + } + + if (Str::startsWith($method->getName(), 'set') && Str::endsWith($method->getName(), 'Attribute')) { + // Magic setAttribute + $name = Str::snake(substr($method->getName(), 3, -9)); + if (! empty($name)) { + $this->setProperty($name, null, null, true); + } + continue; + } + + if (Str::startsWith($method->getName(), 'scope') && $method->getName() !== 'scopeQuery') { + $name = Str::camel(substr($method->getName(), 5)); + if (! empty($name)) { + $args = $method->getParameters(); + // Remove the first ($query) argument + array_shift($args); + $this->setMethod($name, [Builder::class, $method->getDeclaringClass()->getName()], $args); + } + continue; + } + + if ($method->getNumberOfParameters() > 0) { + continue; + } + + $return = $method->getReturnStatementsAst(); + // Magic Relation + if (count($return) === 1 && $return[0] instanceof Node\Stmt\Return_) { + $expr = $return[0]->expr; + if ( + $expr instanceof Node\Expr\MethodCall + && $expr->name instanceof Node\Identifier + && is_string($expr->name->name) + && isset($expr->args[0]) + && $expr->args[0] instanceof Node\Arg + ) { + $name = $expr->name->name; + if (array_key_exists($name, self::RELATION_METHODS)) { + if ($expr->args[0]->value instanceof Node\Expr\ClassConstFetch) { + $related = $expr->args[0]->value->class->toCodeString(); + } else { + $related = (string) ($expr->args[0]->value); + } + + if (strpos($name, 'Many') !== false) { + // Collection or array of models (because Collection is Arrayable) + $this->setProperty($method->getName(), [$this->getCollectionClass($related), $related . '[]'], true); + } elseif ($name === 'morphTo') { + // Model isn't specified because relation is polymorphic + $this->setProperty($method->getName(), [Model::class], true); + } else { + // Single model is returned + $this->setProperty($method->getName(), [$related], true); + } + } + } + } + } + } + + protected function setProperty(string $name, array $type = null, bool $read = null, bool $write = null, string $comment = '', bool $nullable = false) + { + if (! isset($this->properties[$name])) { + $this->properties[$name] = []; + $this->properties[$name]['type'] = 'mixed'; + $this->properties[$name]['read'] = false; + $this->properties[$name]['write'] = false; + $this->properties[$name]['comment'] = (string) $comment; + } + if ($type !== null) { + if ($nullable) { + $type[] = 'null'; + } + $this->properties[$name]['type'] = implode('|', array_unique($type)); + } + if ($read !== null) { + $this->properties[$name]['read'] = $read; + } + if ($write !== null) { + $this->properties[$name]['write'] = $write; + } + } + + protected function setMethod(string $name, array $type = [], array $arguments = []) + { + $methods = array_change_key_case($this->methods, CASE_LOWER); + + if (! isset($methods[strtolower($name)])) { + $this->methods[$name] = []; + $this->methods[$name]['type'] = implode('|', $type); + $this->methods[$name]['arguments'] = $arguments; + } + } + protected function getProperty($column): array { $name = $this->option->isCamelCase() ? Str::camel($column['column_name']) : $column['column_name']; @@ -142,4 +338,35 @@ class ModelUpdateVisitor extends NodeVisitorAbstract return $cast; } + + protected function getCollectionClass($className): string + { + // Return something in the very very unlikely scenario the model doesn't + // have a newCollection() method. + if (! method_exists($className, 'newCollection')) { + return Collection::class; + } + + /** @var Model $model */ + $model = new $className(); + return '\\' . get_class($model->newCollection()); + } + + protected static function getReturnFinder(): FindReturnType + { + if (static::$return instanceof FindReturnType) { + return static::$return; + } + + return static::$return = new FindReturnType(); + } + + protected static function getReflector(): ClassReflector + { + if (self::$reflector instanceof ClassReflector) { + return self::$reflector; + } + + return self::$reflector = (new BetterReflection())->classReflector(); + } } diff --git a/src/database/src/Commands/ModelCommand.php b/src/database/src/Commands/ModelCommand.php index b055eee47..5bf05c3ac 100644 --- a/src/database/src/Commands/ModelCommand.php +++ b/src/database/src/Commands/ModelCommand.php @@ -183,6 +183,7 @@ class ModelCommand extends Command $stms = $this->astParser->parse(file_get_contents($path)); $traverser = new NodeTraverser(); $traverser->addVisitor(make(ModelUpdateVisitor::class, [ + 'class' => $class, 'columns' => $columns, 'option' => $option, ])); diff --git a/src/devtool/src/Describe/RoutesCommand.php b/src/devtool/src/Describe/RoutesCommand.php index 04d928d65..baf134ee7 100644 --- a/src/devtool/src/Describe/RoutesCommand.php +++ b/src/devtool/src/Describe/RoutesCommand.php @@ -101,15 +101,16 @@ class RoutesCommand extends HyperfCommand } else { $action = $handler->callback; } - if (isset($data[$uri])) { - $data[$uri]['method'][] = $method; + $unique = "{$serverName}|{$action}"; + if (isset($data[$unique])) { + $data[$unique]['method'][] = $method; } else { // method,uri,name,action,middleware $registedMiddlewares = MiddlewareManager::get($serverName, $uri, $method); $middlewares = $this->config->get('middlewares.' . $serverName, []); $middlewares = array_merge($middlewares, $registedMiddlewares); - $data[$uri] = [ + $data[$unique] = [ 'server' => $serverName, 'method' => [$method], 'uri' => $uri,