<?php
namespace Illuminate\Foundation\Console;
use BackedEnum;
use Doctrine\DBAL\Schema\Column;
use Doctrine\DBAL\Schema\Index;
use Doctrine\DBAL\Types\DecimalType;
use Illuminate\Console\Command;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Console\DatabaseInspectionCommand;
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 name of the console command.
*
* This name is used to identify the command during lazy loading.
*
* @var string|null
*
* @deprecated
*/
protected static $defaultName = 'model:show';
/**
* 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 void
*/
public function handle()
{
if (! $this->ensureDependenciesExist()) {
return 1;
}
$class = $this->qualifyModel($this->argument('model'));
try {
$model = $this->laravel->make($class);
$class = get_class($model);
} catch (BindingResolutionException $e) {
return $this->components->error($e->getMessage());
}
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),
);
}
/**
* Get the first policy associated with this model.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @return Illuminate\Support\Collection
*/
protected function getPolicy($model)
{
return collect(Gate::policies())
->filter(fn ($policy, $modelClass) => $modelClass === get_class($model))
->values()
->first();
}
/**
* Get the column attributes for the given model.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @return \Illuminate\Support\Collection
*/
protected function getAttributes($model)
{
$schema = $model->getConnection()->getDoctrineSchemaManager();
$this->registerTypeMappings($schema->getDatabasePlatform());
$table = $model->getConnection()->getTablePrefix().$model->getTable();
$columns = $schema->listTableColumns($table);
$indexes = $schema->listTableIndexes($table);
return collect($columns)
->values()
->map(fn (Column $column) => [
'name' => $column->getName(),
'type' => $this->getColumnType($column),
'increments' => $column->getAutoincrement(),
'nullable' => ! $column->getNotnull(),
'default' => $this->getColumnDefault($column, $model),
'unique' => $this->columnIsUnique($column->getName(), $indexes),
'fillable' => $model->isFillable($column->getName()),
'hidden' => $this->attributeIsHidden($column->getName(), $model),
'appended' => null,
'cast' => $this->getCastType($column->getName(), $model),
])
->merge($this->getVirtualAttributes($model, $columns));
}
/**
* Get the virtual (non-column) attributes for the given model.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param \Doctrine\DBAL\Schema\Column[] $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() !== get_class($model)
)
->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)->has($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() !== get_class($model)
)
->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 type of the given column.
*
* @param \Doctrine\DBAL\Schema\Column $column
* @return string
*/
protected function getColumnType($column)
{
$name = $column->getType()->getName();
$unsigned = $column->getUnsigned() ? ' unsigned' : '';
$details = match (get_class($column->getType())) {
DecimalType::class => $column->getPrecision().','.$column->getScale(),
default => $column->getLength(),
};
if ($details) {
return sprintf('%s(%s)%s', $name, $details, $unsigned);
}
return sprintf('%s%s', $name, $unsigned);
}
/**
* Get the default value for the given column.
*
* @param \Doctrine\DBAL\Schema\Column $column
* @param \Illuminate\Database\Eloquent\Model $model
* @return mixed|null
*/
protected function getColumnDefault($column, $model)
{
$attributeDefault = $model->getAttributes()[$column->getName()] ?? null;
return match (true) {
$attributeDefault instanceof BackedEnum => $attributeDefault->value,
$attributeDefault instanceof UnitEnum => $attributeDefault->name,
default => $attributeDefault ?? $column->getDefault(),
};
}
/**
* 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 \Doctrine\DBAL\Schema\Index[] $indexes
* @return bool
*/
protected function columnIsUnique($column, $indexes)
{
return collect($indexes)
->filter(fn (Index $index) => count($index->getColumns()) === 1 && $index->getColumns()[0] === $column)
->contains(fn (Index $index) => $index->isUnique());
}
/**
* 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;
}
}