Last active
June 16, 2023 09:27
-
-
Save Nex-Otaku/0150fecee833fc358f96773dfd3d71ea to your computer and use it in GitHub Desktop.
Model Maker for Laravel
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\Module\ModelMaker; | |
use Illuminate\Console\Command; | |
use Illuminate\Console\Concerns\InteractsWithIO; | |
use Illuminate\Support\Facades\DB; | |
use Illuminate\Support\Str; | |
use NexOtaku\MinimalFilesystem\Filesystem; | |
use NunoMaduro\LaravelConsoleMenu\Menu; | |
use Symfony\Component\Console\Input\InputInterface; | |
use Symfony\Component\Console\Output\OutputInterface; | |
class ModelMaker | |
{ | |
private bool $isSelectedModel = false; | |
private string $modelNameCamelCased = ''; | |
private string $modelNameSnakeCased = ''; | |
private string $tableName = ''; | |
private Command $command; | |
use InteractsWithIO; | |
private function __construct( | |
InputInterface $input, | |
OutputInterface $output, | |
Command $command | |
) { | |
$this->input = $input; | |
$this->output = $output; | |
$this->command = $command; | |
} | |
public static function instance( | |
InputInterface $input, | |
OutputInterface $output, | |
Command $command | |
): self { | |
return new self( | |
$input, | |
$output, | |
$command | |
); | |
} | |
public function getCommands(): array | |
{ | |
$migrationName = $this->getMigrationName(); | |
return [ | |
"php artisan make:migration {$migrationName} --create={$this->tableName}", | |
"php artisan migrate", | |
"php artisan make:model {$this->modelNameCamelCased}", | |
]; | |
} | |
private function getMigrationName(): string | |
{ | |
if (!$this->isSelectedModel()) { | |
throw new \LogicException('Нельзя работать с миграцией не выбрав модель'); | |
} | |
return "create_{$this->tableName}_table"; | |
} | |
private function parseSpacedName(string $modelNameWithSpaces): void | |
{ | |
$this->modelNameCamelCased = $this->getCamelCasedFromDelimitedBySpace($modelNameWithSpaces); | |
$this->modelNameSnakeCased = $this->getSnakeCased($modelNameWithSpaces); | |
$this->tableName = $this->pluralize($this->modelNameSnakeCased); | |
$this->isSelectedModel = true; | |
} | |
private function selectModelCamelized(string $modelNameCamelized): void | |
{ | |
$this->modelNameCamelCased = $modelNameCamelized; | |
$this->modelNameSnakeCased = $this->getSnakeCasedFromCamelized($modelNameCamelized); | |
$this->tableName = $this->pluralize($this->modelNameSnakeCased); | |
$this->isSelectedModel = true; | |
} | |
/** | |
* @param string $phrase | |
* @param string $delimiter | |
* @return string[] | |
*/ | |
private function getWords(string $phrase, string $delimiter): array | |
{ | |
$words = explode($delimiter, trim($phrase)); | |
$filtered = []; | |
foreach ($words as $word) { | |
if (trim($word) === '') { | |
continue; | |
} | |
$filtered [] = $word; | |
} | |
return $filtered; | |
} | |
/** | |
* @param string $name | |
* @return string[] | |
*/ | |
private function getWordsDelimitedBySpaces(string $name): array | |
{ | |
return $this->getWords($name, ' '); | |
} | |
/** | |
* @param string $name | |
* @return string[] | |
*/ | |
private function getWordsDelimitedByUnderscore(string $name): array | |
{ | |
return $this->getWords($name, '_'); | |
} | |
/** | |
* @param string $name | |
* @return string[] | |
*/ | |
private function getWordsDelimitedByCapitalLetters(string $name): array | |
{ | |
$parts = []; | |
$part = ''; | |
$letters = str_split($name); | |
foreach ($letters as $letter) { | |
if ($letter !== strtolower($letter)) { | |
if ($part !== '') { | |
$parts []= $part; | |
} | |
$part = $letter; | |
continue; | |
} | |
$part .= $letter; | |
} | |
if ($part !== '') { | |
$parts []= $part; | |
} | |
return $parts; | |
} | |
private function getCamelCasedFromDelimitedBySpace(string $name): string | |
{ | |
return implode( | |
'', | |
array_map(function (string $value) { | |
return $this->camelize($value); | |
}, $this->getWordsDelimitedBySpaces($name)) | |
); | |
} | |
private function getSnakeCased(string $name): string | |
{ | |
return implode('_', array_map('strtolower', $this->getWordsDelimitedBySpaces($name))); | |
} | |
private function isSelectedModel(): bool | |
{ | |
return $this->isSelectedModel; | |
} | |
public function make(): void | |
{ | |
$repeat = true; | |
while ($repeat) { | |
/** @var Menu $menu */ | |
$menu = $this->command->menu(); | |
$this->printMainMenuHeader($menu); | |
$menu->setTitle('Выберите действие'); | |
$this->addSelectModelOptions($menu); | |
$menu->addOptions( | |
[ | |
'addModel' => 'Добавить модель', | |
] | |
); | |
if ($this->isSelectedModel()) { | |
$menu->addOptions( | |
[ | |
'addFields' => 'Добавить поля', | |
'runMigrations' => 'Выполнить миграции', | |
'buildModel' => 'Создать модель', | |
] | |
); | |
} | |
$option = $menu->open(); | |
if (in_array($option, $this->getModels())) { | |
$this->selectModelCamelized($option); | |
continue; | |
} | |
switch ($option) { | |
case 'addModel': | |
$this->addModel(); | |
break; | |
case 'addFields': | |
$this->loopAddFields(); | |
break; | |
case 'runMigrations': | |
$this->runMigrations(); | |
break; | |
case 'buildModel': | |
$this->buildModel(); | |
break; | |
case null: | |
$repeat = false; | |
break; | |
default: | |
echo "Неизвестная опция - {$option}\n"; | |
die(); | |
} | |
} | |
} | |
private function getTables(): array | |
{ | |
$databaseName = env('DB_DATABASE'); | |
$tables = DB::select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$databaseName}'"); | |
$table_names = []; | |
foreach ($tables as $table) { | |
$tableName = $table->table_name; | |
if ($tableName === 'migrations') { | |
continue; | |
} | |
$table_names[] = $tableName; | |
} | |
return $table_names; | |
} | |
private function getModels(): array | |
{ | |
$tables = $this->getTables(); | |
$models = []; | |
foreach ($tables as $table) { | |
$camelizedPlural = $this->getCamelizedFromSnakeCased($table); | |
$models []= $this->depluralize($camelizedPlural); | |
} | |
return $models; | |
} | |
private function getCamelizedFromSnakeCased(string $name): string | |
{ | |
return implode( | |
'', | |
array_map(function (string $value) { | |
return $this->camelize($value); | |
}, $this->getWordsDelimitedByUnderscore($name)) | |
); | |
} | |
private function getSnakeCasedFromCamelized(string $camelizedName): string | |
{ | |
return implode( | |
'_', | |
array_map(function (string $value) { | |
return strtolower($value); | |
}, $this->getWordsDelimitedByCapitalLetters($camelizedName)) | |
); | |
} | |
private function printMainMenuHeader(Menu $menu): void | |
{ | |
$menu->addLineBreak(); | |
$menu->addStaticItem("Модель: " . ($this->isSelectedModel() ? $this->modelNameCamelCased : '(не выбрано)')); | |
$menu->addStaticItem("Таблица: " . ($this->isSelectedModel() ? $this->tableName : '(не выбрано)')); | |
$menu->addLineBreak(); | |
} | |
private function addSelectModelOptions(Menu $menu): void | |
{ | |
$models = $this->getModels(); | |
$modelsJoinedWithKeys = array_combine($models, $models); | |
$menu->addOptions($modelsJoinedWithKeys); | |
} | |
private function addModel(): void | |
{ | |
/** @var Menu $menu */ | |
$menu = $this->command->menu(); | |
$this->printMainMenuHeader($menu); | |
$modelNameWithSpaces = $menu->addQuestion('Добавить модель', 'Название модели') | |
->open(); | |
if ($modelNameWithSpaces === null) { | |
return; | |
} | |
$this->parseSpacedName($modelNameWithSpaces); | |
} | |
private function loopAddFields(): void | |
{ | |
$repeat = true; | |
while ($repeat) { | |
/** @var Menu $menu */ | |
$menu = $this->command->menu(); | |
$this->printFields($menu); | |
$fieldName = $menu->addQuestion('Добавить поле', 'Введите название поля') | |
->open(); | |
if ($fieldName === null) { | |
break; | |
} | |
$tableFieldName = $this->getSnakeCased($fieldName); | |
/** @var Menu $menu */ | |
$menu = $this->command->menu(); | |
$this->printFields($menu); | |
$option = $menu->setTitle("Выберите тип для поля \"{$tableFieldName}\"") | |
->addOptions( | |
[ | |
'id' => 'ID', | |
'idNull' => 'ID NULL', | |
'string' => 'string', | |
'stringNull' => 'string NULL', | |
'text' => 'text', | |
'int' => 'INT', | |
'intNull' => 'INT NULL', | |
'bigInt' => 'BIGINT', | |
'decimal' => 'DECIMAL (30,10)', | |
'decimalNull' => 'DECIMAL (30,10) NULL', | |
'boolean' => 'boolean', | |
'timestampNull' => 'TIMESTAMP NULL', | |
'jsonNull' => 'json NULL', | |
] | |
)->open(); | |
switch ($option) { | |
case 'id': | |
$this->addFieldToMigration("\$table->unsignedBigInteger('{$tableFieldName}');"); | |
break; | |
case 'idNull': | |
$this->addFieldToMigration("\$table->unsignedBigInteger('{$tableFieldName}')->nullable();"); | |
break; | |
case 'string': | |
$this->addFieldToMigration("\$table->string('{$tableFieldName}');"); | |
break; | |
case 'stringNull': | |
$this->addFieldToMigration("\$table->string('{$tableFieldName}')->nullable();"); | |
break; | |
case 'text': | |
$this->addFieldToMigration("\$table->text('{$tableFieldName}');"); | |
break; | |
case 'int': | |
$this->addFieldToMigration("\$table->integer('{$tableFieldName}');"); | |
break; | |
case 'intNull': | |
$this->addFieldToMigration("\$table->integer('{$tableFieldName}')->nullable();"); | |
break; | |
case 'bigInt': | |
$this->addFieldToMigration("\$table->bigInteger('{$tableFieldName}');"); | |
break; | |
case 'decimal': | |
$this->addFieldToMigration("\$table->decimal('{$tableFieldName}');"); | |
break; | |
case 'decimalNull': | |
$this->addFieldToMigration("\$table->decimal('{$tableFieldName}')->nullable();"); | |
break; | |
case 'boolean': | |
$this->addFieldToMigration("\$table->boolean('{$tableFieldName}');"); | |
break; | |
case 'timestampNull': | |
$this->addFieldToMigration("\$table->timestamp('{$tableFieldName}')->nullable();"); | |
break; | |
case 'jsonNull': | |
$this->addFieldToMigration("\$table->json('{$tableFieldName}')->nullable();"); | |
break; | |
case null: | |
$repeat = false; | |
break; | |
default: | |
echo "Неизвестная опция - {$option}\n"; | |
die(); | |
} | |
} | |
} | |
private function runMigrations(): void | |
{ | |
$this->artisanRun('migrate:fresh'); | |
} | |
private function buildModel(): void | |
{ | |
$this->deleteModel($this->modelNameCamelCased); | |
$this->artisanRun("make:model {$this->modelNameCamelCased}"); | |
$this->updateModelFields($this->modelNameCamelCased); | |
} | |
private function artisanRun(string $command): void | |
{ | |
$artisanCommand = "php artisan {$command}"; | |
$output = shell_exec($artisanCommand); | |
echo $output . "\n"; | |
} | |
private function findMigrationFile(): ?string | |
{ | |
$fs = new FileSystem(); | |
$migrations = $fs->searchFiles($this->getMigrationsPath(), '*' . $this->getMigrationName() . '.php'); | |
if (count($migrations) < 1) { | |
return null; | |
} | |
sort($migrations); | |
return $migrations[0]; | |
} | |
private function createMigration(): void | |
{ | |
if (!$this->isSelectedModel()) { | |
throw new \LogicException('Нельзя работать с миграцией не выбрав модель'); | |
} | |
$migrationName = $this->getMigrationName(); | |
$this->artisanRun("make:migration {$migrationName} --create={$this->tableName}"); | |
} | |
private function getMigrationRows(): array | |
{ | |
$migrationPath = $this->findMigrationFile(); | |
if ($migrationPath === null) { | |
$this->createMigration(); | |
$migrationPath = $this->findMigrationFile(); | |
} | |
if ($migrationPath === null) { | |
throw new \LogicException('Не удалось найти миграцию'); | |
} | |
$fs = new FileSystem(); | |
$content = $fs->readFile($migrationPath); | |
return explode("\n", $content); | |
} | |
private function findRowMatch(array $rows, string $match, int $offset): int | |
{ | |
$index = -1; | |
for ($i = $offset; $i < count($rows); $i++) { | |
$row = trim($rows[$i]); | |
if ($row === $match) { | |
$index = $i; | |
break; | |
} | |
} | |
return $index; | |
} | |
private function findRowStartsWith(array $rows, string $startsWith, int $offset): int | |
{ | |
$index = -1; | |
for ($i = $offset; $i < count($rows); $i++) { | |
$row = trim($rows[$i]); | |
if (str_starts_with($row, $startsWith)) { | |
$index = $i; | |
break; | |
} | |
} | |
return $index; | |
} | |
private function printFields(Menu $menu): void | |
{ | |
$rows = $this->getMigrationRows(); | |
$start = $this->findRowStartsWith($rows, 'Schema::create(', 0); | |
if ($start === -1) { | |
return; | |
} | |
$end = $this->findRowMatch($rows, '});', $start); | |
if ($end === -1) { | |
return; | |
} | |
$definitions = array_slice($rows, $start + 1, $end - $start - 1); | |
foreach ($definitions as $definition) { | |
$cleaned = trim($definition); | |
$menu->addStaticItem($cleaned); | |
} | |
$menu->addLineBreak(); | |
} | |
private function addFieldToMigration(string $fieldDefinition): void | |
{ | |
$rows = $this->getMigrationRows(); | |
// Определяем номер строки завершающей "Schema::create(", изначально 19 | |
$index = -1; | |
for ($i = 0; $i < count($rows); $i++) { | |
$row = trim($rows[$i]); | |
if ($row === '});') { | |
// Если перед этой строкой идёт "$table->timestamps();" то идём на строку выше | |
if (($i > 0) && (trim($rows[$i - 1]) === '$table->timestamps();')) { | |
$index = $i - 1; | |
} else { | |
$index = $i; | |
} | |
break; | |
} | |
} | |
if ($index === -1) { | |
throw new \LogicException('Не удалось найти место для вставки кода в миграции'); | |
} | |
// Вставляем новую строку | |
$firstPart = array_slice($rows, 0, $index); | |
$secondPart = array_slice($rows, $index); | |
$fieldRow = " {$fieldDefinition}"; | |
$newRows = array_merge($firstPart, [$fieldRow], $secondPart); | |
// Перезаписываем файл. | |
$migrationPath = $this->findMigrationFile(); | |
if ($migrationPath === null) { | |
throw new \LogicException('Не удалось найти миграцию'); | |
} | |
$fs = new FileSystem(); | |
$fs->writeFile($migrationPath, implode("\n", $newRows)); | |
} | |
private function getMigrationsPath(): string | |
{ | |
return base_path() | |
. DIRECTORY_SEPARATOR . 'database' | |
. DIRECTORY_SEPARATOR . 'migrations'; | |
} | |
private function camelize(string $value): string | |
{ | |
return strtoupper(substr($value, 0, 1)) | |
. strtolower(substr($value, 1)); | |
} | |
private function pluralize(string $value): string | |
{ | |
if (strlen($value) === 0) { | |
return ''; | |
} | |
return Str::plural($value); | |
} | |
private function depluralize(string $value): string | |
{ | |
if (strlen($value) === 0) { | |
return $value; | |
} | |
return Str::singular($value); | |
} | |
private function deleteModel(string $modelNameCamelCased): void | |
{ | |
$fs = new FileSystem(); | |
$fs->deleteFile($this->getModelPath($modelNameCamelCased)); | |
} | |
private function updateModelFields(string $modelNameCamelCased): void | |
{ | |
$rows = $this->getModelRows($modelNameCamelCased); | |
// Определяем номер строки с именем класса | |
$classNameRowIndex = $this->findRowStartsWith($rows, 'class ', 0); | |
if ($classNameRowIndex === -1) { | |
var_dump($rows); | |
throw new \LogicException('Не удалось найти место для вставки PhpDoc'); | |
} | |
$firstPart = array_slice($rows, 0, $classNameRowIndex); | |
$secondPart = array_slice($rows, $classNameRowIndex); | |
$rows2 = array_merge($firstPart, $this->getPropertiesPhpDocRows(), $secondPart); | |
// Определяем номер строки с завершением блока класса | |
$closingClassRowIndex = $this->findRowStartsWith($rows2, '}', 0); | |
if ($closingClassRowIndex === -1) { | |
throw new \LogicException('Не удалось найти место для вставки fillable'); | |
} | |
$firstPart = array_slice($rows2, 0, $closingClassRowIndex); | |
$secondPart = array_slice($rows2, $closingClassRowIndex); | |
$rows3 = array_merge($firstPart, $this->getPropertiesFillableRows(), $secondPart); | |
// Перезаписываем файл. | |
$fs = new FileSystem(); | |
$fs->writeFile($this->getModelPath($modelNameCamelCased), implode("\n", $rows3)); | |
} | |
private function getModelRows(string $modelNameCamelCased): array | |
{ | |
$fs = new FileSystem(); | |
$content = $fs->readFile($this->getModelPath($modelNameCamelCased)); | |
return explode("\n", $content); | |
} | |
private function getModelPath(string $modelNameCamelCased): string | |
{ | |
return __DIR__ . '/../../Models/' . $modelNameCamelCased . '.php'; | |
} | |
private function getPropertiesPhpDocRows(): array | |
{ | |
$phpDocRows = ['/**']; | |
$migrationRows = $this->getMigrationRows(); | |
foreach ($migrationRows as $row) { | |
if (!$this->isRowMigrationField($row)) { | |
continue; | |
} | |
$type = $this->extractMirationRowPropertyType($row); | |
$name = $this->extractMirationRowPropertyName($row); | |
$phpDocRows []= " * @property {$type} \${$name}"; | |
} | |
$phpDocRows []= '*/'; | |
return $phpDocRows; | |
} | |
private function getPropertiesFillableRows(): array | |
{ | |
$fillableRows = [ | |
'', | |
' protected $fillable = [', | |
]; | |
$migrationRows = $this->getMigrationRows(); | |
foreach ($migrationRows as $row) { | |
if (!$this->isRowMigrationField($row)) { | |
continue; | |
} | |
$name = $this->extractMirationRowPropertyName($row); | |
$fillableRows []= " '{$name}',"; | |
} | |
$fillableRows []= ' ];'; | |
return $fillableRows; | |
} | |
private function extractMirationRowPropertyType(string $row): string | |
{ | |
$types = [ | |
[ | |
'php_type' => 'int', | |
'migration_field_type' => 'integer', | |
], | |
[ | |
'php_type' => 'int', | |
'migration_field_type' => 'unsignedBigInteger', | |
], | |
[ | |
'php_type' => 'int', | |
'migration_field_type' => 'bigInteger', | |
], | |
[ | |
'php_type' => 'string', | |
'migration_field_type' => 'string', | |
], | |
[ | |
'php_type' => 'string', | |
'migration_field_type' => 'decimal', | |
], | |
[ | |
'php_type' => 'string', | |
'migration_field_type' => 'json', | |
], | |
[ | |
'php_type' => 'int', | |
'migration_field_type' => 'boolean', | |
], | |
[ | |
'php_type' => 'string', | |
'migration_field_type' => 'text', | |
], | |
]; | |
$detectedType = null; | |
$migrationFieldType = $this->getBetween($row, '$table->', '(\''); | |
foreach ($types as $typeInfo) { | |
if ($typeInfo['migration_field_type'] === $migrationFieldType) { | |
$detectedType = $typeInfo['php_type']; | |
break; | |
} | |
} | |
if ($detectedType === null) { | |
return 'mixed'; | |
} | |
$isNullable = str_contains($row, '->nullable()'); | |
return $detectedType . ($isNullable ? '|null' : ''); | |
} | |
private function extractMirationRowPropertyName(string $row): string | |
{ | |
$params = $this->getBetween($row, '(', ')'); | |
$paramsList = explode(',', $params); | |
$firstParam = $paramsList[0] ?? ''; | |
return $this->getBetween($firstParam, '\'', '\''); | |
} | |
private function getBetween(string $line, string $before, string $after): string | |
{ | |
$indexHead = strpos($line, $before); | |
if ($indexHead === false) { | |
return ''; | |
} | |
$line = substr($line, $indexHead + strlen($before)); | |
$indexTail = strpos($line, $after); | |
if ($indexTail === false) { | |
return ''; | |
} | |
return substr($line, 0, $indexTail); | |
} | |
private function isRowMigrationField(string $row): bool | |
{ | |
return str_starts_with(trim($row), '$table->') | |
&& str_contains($row, '(\'') | |
&& str_ends_with(trim($row), ';'); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment