diff --git a/src/Tempest/Core/src/PublishesFiles.php b/src/Tempest/Core/src/PublishesFiles.php index 5b6a7d203..0ec5f635d 100644 --- a/src/Tempest/Core/src/PublishesFiles.php +++ b/src/Tempest/Core/src/PublishesFiles.php @@ -15,6 +15,7 @@ use Tempest\Generation\Exceptions\FileGenerationFailedException; use Tempest\Generation\StubFileGenerator; use Tempest\Support\NamespaceHelper; +use Tempest\Validation\Rule; use Tempest\Validation\Rules\EndsWith; use Tempest\Validation\Rules\NotEmpty; use Throwable; @@ -160,16 +161,19 @@ public function getSuggestedPath(string $className, ?string $pathPrefix = null, /** * Prompt the user for the target path to save the generated file. * @param string $suggestedPath The suggested path to show to the user. + * @param ?array Rules to use instead of the default ones. + * * @return string The target path that the user has chosen. */ - public function promptTargetPath(string $suggestedPath): string + public function promptTargetPath(string $suggestedPath, ?array $rules = null): string { $className = NamespaceHelper::toClassName($suggestedPath); + $rules ??= [new NotEmpty(), new EndsWith('.php')]; return $this->console->ask( question: sprintf('Where do you want to save the file "%s"?', $className), default: $suggestedPath, - validation: [new NotEmpty(), new EndsWith('.php')], + validation: $rules, ); } diff --git a/src/Tempest/Database/src/Commands/MakeMigrationCommand.php b/src/Tempest/Database/src/Commands/MakeMigrationCommand.php new file mode 100644 index 000000000..061013ffe --- /dev/null +++ b/src/Tempest/Database/src/Commands/MakeMigrationCommand.php @@ -0,0 +1,223 @@ +getStubFileFromMigrationType($migrationType); + $targetPath = match ($migrationType) { + MigrationType::RAW => $this->generateRawFile($fileName, $stubFile), + default => $this->generateClassFile($fileName, $stubFile, $migrationType), + }; + + $this->success(sprintf('Migration file successfully created at "%s".', $targetPath)); + } catch (FileGenerationAbortedException|FileGenerationFailedException|InvalidArgumentException $e) { + $this->error($e->getMessage()); + } + } + + /** + * Generates a raw migration file. + * @param string $fileName The name of the file. + * @param StubFile $stubFile The stub file to use. + * + * @return string The path to the generated file. + */ + private function generateRawFile( + string $fileName, + StubFile $stubFile, + ): string { + $now = date('Y-m-d'); + $tableName = str($fileName)->snake()->toString(); + $suggestedPath = str($this->getSuggestedPath('Dummy')) + ->replace( + [ 'Dummy', '.php' ], + [ $now . '_' . $tableName, '.sql' ], + ) + ->toString(); + + $targetPath = $this->promptTargetPath($suggestedPath, rules: [ + new NotEmpty(), + new EndsWith('.sql'), + ]); + $shouldOverride = $this->askForOverride($targetPath); + + $this->stubFileGenerator->generateRawFile( + stubFile: $stubFile, + targetPath: $targetPath, + shouldOverride: $shouldOverride, + replacements: [ + 'DummyTableName' => $tableName, + ], + ); + + return $targetPath; + } + + /** + * Generates a class migration file. + * + * @param string $fileName The name of the file. + * @param StubFile $stubFile The stub file to use. + * @param MigrationType $migrationType The type of the migration. + * + * @return string The path to the generated file. + */ + private function generateClassFile( + string $fileName, + StubFile $stubFile, + MigrationType $migrationType, + ): string { + $suggestedPath = $this->getSuggestedPath($fileName); + $targetPath = $this->promptTargetPath($suggestedPath); + $shouldOverride = $this->askForOverride($targetPath); + $tableName = str($fileName)->snake()->toString(); + $replacements = [ + 'dummy-date' => date('Y-m-d'), + 'dummy-table-name' => $tableName, + ]; + + if ($migrationType === MigrationType::MODEL) { + $appModels = $this->getAppDatabaseModels(); + $migrationModel = $this->ask('Model related to the migration', array_keys($appModels)); + $migrationModel = $appModels[$migrationModel] ?? null; + $migrationModelName = str($migrationModel?->getName() ?? '')->start('\\')->toString(); + + $replacements["'DummyModel'"] = sprintf('%s::class', $migrationModelName); + } + + $this->stubFileGenerator->generateClassFile( + stubFile: $stubFile, + targetPath: $targetPath, + shouldOverride: $shouldOverride, + replacements: $replacements, + ); + + return $targetPath; + } + + private function getStubFileFromMigrationType(MigrationType $migrationType): StubFile + { + try { + return match ($migrationType) { + MigrationType::RAW => StubFile::from(dirname(__DIR__) . '/Stubs/migration.stub.sql'), + MigrationType::MODEL => StubFile::from(MigrationModelStub::class), + MigrationType::OBJECT => StubFile::from(MigrationStub::class), // @phpstan-ignore match.alwaysTrue (Because this is a guardrail for the future implementations) + default => throw new InvalidArgumentException(sprintf('The "%s" migration type has no supported stub file.', $migrationType->value)), + }; + } catch (InvalidArgumentException $invalidArgumentException) { + throw new FileGenerationFailedException(sprintf('Cannot retrieve stub file: %s', $invalidArgumentException->getMessage())); + } + } + + /** + * Get database models defined in the application. + * + * @return array The list of models. + */ + private function getAppDatabaseModels(): array + { + $composer = get(Composer::class); + $directories = new RecursiveDirectoryIterator($composer->mainNamespace->path, flags: FilesystemIterator::UNIX_PATHS | FilesystemIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($directories); + $databaseModels = []; + + foreach ($files as $file) { + // We assume that any PHP file that starts with an uppercase letter will be a class + if ($file->getExtension() !== 'php') { + continue; + } + if (ucfirst($file->getFilename()) !== $file->getFilename()) { + continue; + } + // Try to create a PSR-compliant class name from the path + $fqcn = str_replace( + [ + rtrim($composer->mainNamespace->path, '\\/'), + '/', + '\\\\', + '.php', + ], + [ + $composer->mainNamespace->namespace, + '\\', + '\\', + '', + ], + $file->getPathname(), + ); + + // Bail if not a class + if (! class_exists($fqcn)) { + continue; + } + + try { + $class = new ClassReflector($fqcn); + } catch (Throwable) { + continue; + } + + // Bail if not a database model + if (! $class->implements(DatabaseModel::class)) { + continue; + } + + // Bail if the class should not be discovered + if ($class->hasAttribute(DoNotDiscover::class)) { + continue; + } + + $databaseModels[] = $class; + } + + return arr($databaseModels) + ->mapWithKeys(fn (ClassReflector $model) => yield $model->getName() => $model) + ->toArray(); + } +} diff --git a/src/Tempest/Database/src/Enums/MigrationType.php b/src/Tempest/Database/src/Enums/MigrationType.php new file mode 100644 index 000000000..3c3852410 --- /dev/null +++ b/src/Tempest/Database/src/Enums/MigrationType.php @@ -0,0 +1,16 @@ + 'dummy-date_dummy-table-name'; + } + + public function up(): QueryStatement { + return CreateTableStatement::forModel('DummyModel') // @phpstan-ignore-line argument.type (Because this is stub file and this param will be replaced by actual model name) + ->primary() + ->text('name') + ->datetime('created_at') + ->datetime('updated_at'); + } + + public function down(): QueryStatement { + return DropTableStatement::forModel('DummyModel'); // @phpstan-ignore-line argument.type (Because this is stub file and this param will be replaced by actual model name) + } +} diff --git a/src/Tempest/Database/src/Stubs/MigrationStub.php b/src/Tempest/Database/src/Stubs/MigrationStub.php new file mode 100644 index 000000000..2f1488dc9 --- /dev/null +++ b/src/Tempest/Database/src/Stubs/MigrationStub.php @@ -0,0 +1,34 @@ + 'dummy-date_dummy-table-name'; + } + + public function up(): QueryStatement { + return new CreateTableStatement( + tableName: 'dummy-table-name' + ) + ->primary() + ->text('name') + ->datetime('created_at') + ->datetime('updated_at'); + } + + public function down(): QueryStatement { + return new DropTableStatement( + tableName: 'dummy-table-name' + ); + } +} diff --git a/src/Tempest/Database/src/Stubs/migration.stub.sql b/src/Tempest/Database/src/Stubs/migration.stub.sql new file mode 100644 index 000000000..68bd31d2b --- /dev/null +++ b/src/Tempest/Database/src/Stubs/migration.stub.sql @@ -0,0 +1,5 @@ +CREATE TABLE DummyTableName +( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `name` TEXT NOT NULL +); \ No newline at end of file