507 lines
16 KiB
PHP
507 lines
16 KiB
PHP
|
<?php
|
||
|
|
||
|
namespace Illuminate\Database\Console;
|
||
|
|
||
|
use BackedEnum;
|
||
|
use Illuminate\Contracts\Container\BindingResolutionException;
|
||
|
use Illuminate\Database\Eloquent\Model;
|
||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||
|
use Illuminate\Support\Facades\Gate;
|
||
|
use Illuminate\Support\Str;
|
||
|
use ReflectionClass;
|
||
|
use ReflectionMethod;
|
||
|
use SplFileObject;
|
||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||
|
use UnitEnum;
|
||
|
|
||
|
#[AsCommand(name: 'model:show')]
|
||
|
class ShowModelCommand extends DatabaseInspectionCommand
|
||
|
{
|
||
|
/**
|
||
|
* The console command name.
|
||
|
*
|
||
|
* @var string
|
||
|
*/
|
||
|
protected $name = 'model:show {model}';
|
||
|
|
||
|
/**
|
||
|
* The console command description.
|
||
|
*
|
||
|
* @var string
|
||
|
*/
|
||
|
protected $description = 'Show information about an Eloquent model';
|
||
|
|
||
|
/**
|
||
|
* The console command signature.
|
||
|
*
|
||
|
* @var string
|
||
|
*/
|
||
|
protected $signature = 'model:show {model : The model to show}
|
||
|
{--database= : The database connection to use}
|
||
|
{--json : Output the model as JSON}';
|
||
|
|
||
|
/**
|
||
|
* The methods that can be called in a model to indicate a relation.
|
||
|
*
|
||
|
* @var array
|
||
|
*/
|
||
|
protected $relationMethods = [
|
||
|
'hasMany',
|
||
|
'hasManyThrough',
|
||
|
'hasOneThrough',
|
||
|
'belongsToMany',
|
||
|
'hasOne',
|
||
|
'belongsTo',
|
||
|
'morphOne',
|
||
|
'morphTo',
|
||
|
'morphMany',
|
||
|
'morphToMany',
|
||
|
'morphedByMany',
|
||
|
];
|
||
|
|
||
|
/**
|
||
|
* Execute the console command.
|
||
|
*
|
||
|
* @return int
|
||
|
*/
|
||
|
public function handle()
|
||
|
{
|
||
|
$class = $this->qualifyModel($this->argument('model'));
|
||
|
|
||
|
try {
|
||
|
$model = $this->laravel->make($class);
|
||
|
|
||
|
$class = get_class($model);
|
||
|
} catch (BindingResolutionException $e) {
|
||
|
$this->components->error($e->getMessage());
|
||
|
|
||
|
return 1;
|
||
|
}
|
||
|
|
||
|
if ($this->option('database')) {
|
||
|
$model->setConnection($this->option('database'));
|
||
|
}
|
||
|
|
||
|
$this->display(
|
||
|
$class,
|
||
|
$model->getConnection()->getName(),
|
||
|
$model->getConnection()->getTablePrefix().$model->getTable(),
|
||
|
$this->getPolicy($model),
|
||
|
$this->getAttributes($model),
|
||
|
$this->getRelations($model),
|
||
|
$this->getObservers($model),
|
||
|
);
|
||
|
|
||
|
return 0;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the first policy associated with this model.
|
||
|
*
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @return string
|
||
|
*/
|
||
|
protected function getPolicy($model)
|
||
|
{
|
||
|
$policy = Gate::getPolicyFor($model::class);
|
||
|
|
||
|
return $policy ? $policy::class : null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the column attributes for the given model.
|
||
|
*
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @return \Illuminate\Support\Collection
|
||
|
*/
|
||
|
protected function getAttributes($model)
|
||
|
{
|
||
|
$connection = $model->getConnection();
|
||
|
$schema = $connection->getSchemaBuilder();
|
||
|
$table = $model->getTable();
|
||
|
$columns = $schema->getColumns($table);
|
||
|
$indexes = $schema->getIndexes($table);
|
||
|
|
||
|
return collect($columns)
|
||
|
->map(fn ($column) => [
|
||
|
'name' => $column['name'],
|
||
|
'type' => $column['type'],
|
||
|
'increments' => $column['auto_increment'],
|
||
|
'nullable' => $column['nullable'],
|
||
|
'default' => $this->getColumnDefault($column, $model),
|
||
|
'unique' => $this->columnIsUnique($column['name'], $indexes),
|
||
|
'fillable' => $model->isFillable($column['name']),
|
||
|
'hidden' => $this->attributeIsHidden($column['name'], $model),
|
||
|
'appended' => null,
|
||
|
'cast' => $this->getCastType($column['name'], $model),
|
||
|
])
|
||
|
->merge($this->getVirtualAttributes($model, $columns));
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the virtual (non-column) attributes for the given model.
|
||
|
*
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @param array $columns
|
||
|
* @return \Illuminate\Support\Collection
|
||
|
*/
|
||
|
protected function getVirtualAttributes($model, $columns)
|
||
|
{
|
||
|
$class = new ReflectionClass($model);
|
||
|
|
||
|
return collect($class->getMethods())
|
||
|
->reject(
|
||
|
fn (ReflectionMethod $method) => $method->isStatic()
|
||
|
|| $method->isAbstract()
|
||
|
|| $method->getDeclaringClass()->getName() === Model::class
|
||
|
)
|
||
|
->mapWithKeys(function (ReflectionMethod $method) use ($model) {
|
||
|
if (preg_match('/^get(.+)Attribute$/', $method->getName(), $matches) === 1) {
|
||
|
return [Str::snake($matches[1]) => 'accessor'];
|
||
|
} elseif ($model->hasAttributeMutator($method->getName())) {
|
||
|
return [Str::snake($method->getName()) => 'attribute'];
|
||
|
} else {
|
||
|
return [];
|
||
|
}
|
||
|
})
|
||
|
->reject(fn ($cast, $name) => collect($columns)->contains('name', $name))
|
||
|
->map(fn ($cast, $name) => [
|
||
|
'name' => $name,
|
||
|
'type' => null,
|
||
|
'increments' => false,
|
||
|
'nullable' => null,
|
||
|
'default' => null,
|
||
|
'unique' => null,
|
||
|
'fillable' => $model->isFillable($name),
|
||
|
'hidden' => $this->attributeIsHidden($name, $model),
|
||
|
'appended' => $model->hasAppended($name),
|
||
|
'cast' => $cast,
|
||
|
])
|
||
|
->values();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the relations from the given model.
|
||
|
*
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @return \Illuminate\Support\Collection
|
||
|
*/
|
||
|
protected function getRelations($model)
|
||
|
{
|
||
|
return collect(get_class_methods($model))
|
||
|
->map(fn ($method) => new ReflectionMethod($model, $method))
|
||
|
->reject(
|
||
|
fn (ReflectionMethod $method) => $method->isStatic()
|
||
|
|| $method->isAbstract()
|
||
|
|| $method->getDeclaringClass()->getName() === Model::class
|
||
|
)
|
||
|
->filter(function (ReflectionMethod $method) {
|
||
|
$file = new SplFileObject($method->getFileName());
|
||
|
$file->seek($method->getStartLine() - 1);
|
||
|
$code = '';
|
||
|
while ($file->key() < $method->getEndLine()) {
|
||
|
$code .= trim($file->current());
|
||
|
$file->next();
|
||
|
}
|
||
|
|
||
|
return collect($this->relationMethods)
|
||
|
->contains(fn ($relationMethod) => str_contains($code, '$this->'.$relationMethod.'('));
|
||
|
})
|
||
|
->map(function (ReflectionMethod $method) use ($model) {
|
||
|
$relation = $method->invoke($model);
|
||
|
|
||
|
if (! $relation instanceof Relation) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
return [
|
||
|
'name' => $method->getName(),
|
||
|
'type' => Str::afterLast(get_class($relation), '\\'),
|
||
|
'related' => get_class($relation->getRelated()),
|
||
|
];
|
||
|
})
|
||
|
->filter()
|
||
|
->values();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the Observers watching this model.
|
||
|
*
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @return \Illuminate\Support\Collection
|
||
|
*/
|
||
|
protected function getObservers($model)
|
||
|
{
|
||
|
$listeners = $this->getLaravel()->make('events')->getRawListeners();
|
||
|
|
||
|
// Get the Eloquent observers for this model...
|
||
|
$listeners = array_filter($listeners, function ($v, $key) use ($model) {
|
||
|
return Str::startsWith($key, 'eloquent.') && Str::endsWith($key, $model::class);
|
||
|
}, ARRAY_FILTER_USE_BOTH);
|
||
|
|
||
|
// Format listeners Eloquent verb => Observer methods...
|
||
|
$extractVerb = function ($key) {
|
||
|
preg_match('/eloquent.([a-zA-Z]+)\: /', $key, $matches);
|
||
|
|
||
|
return $matches[1] ?? '?';
|
||
|
};
|
||
|
|
||
|
$formatted = [];
|
||
|
|
||
|
foreach ($listeners as $key => $observerMethods) {
|
||
|
$formatted[] = [
|
||
|
'event' => $extractVerb($key),
|
||
|
'observer' => array_map(fn ($obs) => is_string($obs) ? $obs : 'Closure', $observerMethods),
|
||
|
];
|
||
|
}
|
||
|
|
||
|
return collect($formatted);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Render the model information.
|
||
|
*
|
||
|
* @param string $class
|
||
|
* @param string $database
|
||
|
* @param string $table
|
||
|
* @param string $policy
|
||
|
* @param \Illuminate\Support\Collection $attributes
|
||
|
* @param \Illuminate\Support\Collection $relations
|
||
|
* @param \Illuminate\Support\Collection $observers
|
||
|
* @return void
|
||
|
*/
|
||
|
protected function display($class, $database, $table, $policy, $attributes, $relations, $observers)
|
||
|
{
|
||
|
$this->option('json')
|
||
|
? $this->displayJson($class, $database, $table, $policy, $attributes, $relations, $observers)
|
||
|
: $this->displayCli($class, $database, $table, $policy, $attributes, $relations, $observers);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Render the model information as JSON.
|
||
|
*
|
||
|
* @param string $class
|
||
|
* @param string $database
|
||
|
* @param string $table
|
||
|
* @param string $policy
|
||
|
* @param \Illuminate\Support\Collection $attributes
|
||
|
* @param \Illuminate\Support\Collection $relations
|
||
|
* @param \Illuminate\Support\Collection $observers
|
||
|
* @return void
|
||
|
*/
|
||
|
protected function displayJson($class, $database, $table, $policy, $attributes, $relations, $observers)
|
||
|
{
|
||
|
$this->output->writeln(
|
||
|
collect([
|
||
|
'class' => $class,
|
||
|
'database' => $database,
|
||
|
'table' => $table,
|
||
|
'policy' => $policy,
|
||
|
'attributes' => $attributes,
|
||
|
'relations' => $relations,
|
||
|
'observers' => $observers,
|
||
|
])->toJson()
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Render the model information for the CLI.
|
||
|
*
|
||
|
* @param string $class
|
||
|
* @param string $database
|
||
|
* @param string $table
|
||
|
* @param string $policy
|
||
|
* @param \Illuminate\Support\Collection $attributes
|
||
|
* @param \Illuminate\Support\Collection $relations
|
||
|
* @param \Illuminate\Support\Collection $observers
|
||
|
* @return void
|
||
|
*/
|
||
|
protected function displayCli($class, $database, $table, $policy, $attributes, $relations, $observers)
|
||
|
{
|
||
|
$this->newLine();
|
||
|
|
||
|
$this->components->twoColumnDetail('<fg=green;options=bold>'.$class.'</>');
|
||
|
$this->components->twoColumnDetail('Database', $database);
|
||
|
$this->components->twoColumnDetail('Table', $table);
|
||
|
|
||
|
if ($policy) {
|
||
|
$this->components->twoColumnDetail('Policy', $policy);
|
||
|
}
|
||
|
|
||
|
$this->newLine();
|
||
|
|
||
|
$this->components->twoColumnDetail(
|
||
|
'<fg=green;options=bold>Attributes</>',
|
||
|
'type <fg=gray>/</> <fg=yellow;options=bold>cast</>',
|
||
|
);
|
||
|
|
||
|
foreach ($attributes as $attribute) {
|
||
|
$first = trim(sprintf(
|
||
|
'%s %s',
|
||
|
$attribute['name'],
|
||
|
collect(['increments', 'unique', 'nullable', 'fillable', 'hidden', 'appended'])
|
||
|
->filter(fn ($property) => $attribute[$property])
|
||
|
->map(fn ($property) => sprintf('<fg=gray>%s</>', $property))
|
||
|
->implode('<fg=gray>,</> ')
|
||
|
));
|
||
|
|
||
|
$second = collect([
|
||
|
$attribute['type'],
|
||
|
$attribute['cast'] ? '<fg=yellow;options=bold>'.$attribute['cast'].'</>' : null,
|
||
|
])->filter()->implode(' <fg=gray>/</> ');
|
||
|
|
||
|
$this->components->twoColumnDetail($first, $second);
|
||
|
|
||
|
if ($attribute['default'] !== null) {
|
||
|
$this->components->bulletList(
|
||
|
[sprintf('default: %s', $attribute['default'])],
|
||
|
OutputInterface::VERBOSITY_VERBOSE
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$this->newLine();
|
||
|
|
||
|
$this->components->twoColumnDetail('<fg=green;options=bold>Relations</>');
|
||
|
|
||
|
foreach ($relations as $relation) {
|
||
|
$this->components->twoColumnDetail(
|
||
|
sprintf('%s <fg=gray>%s</>', $relation['name'], $relation['type']),
|
||
|
$relation['related']
|
||
|
);
|
||
|
}
|
||
|
|
||
|
$this->newLine();
|
||
|
|
||
|
$this->components->twoColumnDetail('<fg=green;options=bold>Observers</>');
|
||
|
|
||
|
if ($observers->count()) {
|
||
|
foreach ($observers as $observer) {
|
||
|
$this->components->twoColumnDetail(
|
||
|
sprintf('%s', $observer['event']),
|
||
|
implode(', ', $observer['observer'])
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$this->newLine();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the cast type for the given column.
|
||
|
*
|
||
|
* @param string $column
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @return string|null
|
||
|
*/
|
||
|
protected function getCastType($column, $model)
|
||
|
{
|
||
|
if ($model->hasGetMutator($column) || $model->hasSetMutator($column)) {
|
||
|
return 'accessor';
|
||
|
}
|
||
|
|
||
|
if ($model->hasAttributeMutator($column)) {
|
||
|
return 'attribute';
|
||
|
}
|
||
|
|
||
|
return $this->getCastsWithDates($model)->get($column) ?? null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the model casts, including any date casts.
|
||
|
*
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @return \Illuminate\Support\Collection
|
||
|
*/
|
||
|
protected function getCastsWithDates($model)
|
||
|
{
|
||
|
return collect($model->getDates())
|
||
|
->filter()
|
||
|
->flip()
|
||
|
->map(fn () => 'datetime')
|
||
|
->merge($model->getCasts());
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the default value for the given column.
|
||
|
*
|
||
|
* @param array $column
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @return mixed|null
|
||
|
*/
|
||
|
protected function getColumnDefault($column, $model)
|
||
|
{
|
||
|
$attributeDefault = $model->getAttributes()[$column['name']] ?? null;
|
||
|
|
||
|
return match (true) {
|
||
|
$attributeDefault instanceof BackedEnum => $attributeDefault->value,
|
||
|
$attributeDefault instanceof UnitEnum => $attributeDefault->name,
|
||
|
default => $attributeDefault ?? $column['default'],
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determine if the given attribute is hidden.
|
||
|
*
|
||
|
* @param string $attribute
|
||
|
* @param \Illuminate\Database\Eloquent\Model $model
|
||
|
* @return bool
|
||
|
*/
|
||
|
protected function attributeIsHidden($attribute, $model)
|
||
|
{
|
||
|
if (count($model->getHidden()) > 0) {
|
||
|
return in_array($attribute, $model->getHidden());
|
||
|
}
|
||
|
|
||
|
if (count($model->getVisible()) > 0) {
|
||
|
return ! in_array($attribute, $model->getVisible());
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determine if the given attribute is unique.
|
||
|
*
|
||
|
* @param string $column
|
||
|
* @param array $indexes
|
||
|
* @return bool
|
||
|
*/
|
||
|
protected function columnIsUnique($column, $indexes)
|
||
|
{
|
||
|
return collect($indexes)->contains(
|
||
|
fn ($index) => count($index['columns']) === 1 && $index['columns'][0] === $column && $index['unique']
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Qualify the given model class base name.
|
||
|
*
|
||
|
* @param string $model
|
||
|
* @return string
|
||
|
*
|
||
|
* @see \Illuminate\Console\GeneratorCommand
|
||
|
*/
|
||
|
protected function qualifyModel(string $model)
|
||
|
{
|
||
|
if (str_contains($model, '\\') && class_exists($model)) {
|
||
|
return $model;
|
||
|
}
|
||
|
|
||
|
$model = ltrim($model, '\\/');
|
||
|
|
||
|
$model = str_replace('/', '\\', $model);
|
||
|
|
||
|
$rootNamespace = $this->laravel->getNamespace();
|
||
|
|
||
|
if (Str::startsWith($model, $rootNamespace)) {
|
||
|
return $model;
|
||
|
}
|
||
|
|
||
|
return is_dir(app_path('Models'))
|
||
|
? $rootNamespace.'Models\\'.$model
|
||
|
: $rootNamespace.$model;
|
||
|
}
|
||
|
}
|