Files
Atomcms-edit/app/Console/Commands/CheckMigrationsCommand.php
T

353 lines
12 KiB
PHP
Executable File

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
class CheckMigrationsCommand extends Command
{
#[\Override]
protected $signature = 'atom:check-migrations
{--orphaned : Check for orphaned migrations only}
{--superseded : Check for superseded migrations only}
{--duplicates : Check for duplicate table creations only}
{--pending : Check pending migrations only}
{--delete : Delete problematic migration files}
{--force : Skip confirmation prompt when deleting}';
#[\Override]
protected $description = 'Analyze and clean up orphaned, superseded, duplicate, or pending migrations';
private int $errors = 0;
private int $warnings = 0;
/** @var list<string> */
private array $filesToDelete = [];
public function handle(): int
{
$mode = $this->option('orphaned') ? 'orphaned'
: ($this->option('superseded') ? 'superseded'
: ($this->option('duplicates') ? 'duplicates'
: ($this->option('pending') ? 'pending'
: 'all')));
if (! Schema::hasTable('migrations')) {
$this->error('The migrations table does not exist. Run `php artisan migrate` first.');
return self::FAILURE;
}
$ranMigrations = DB::table('migrations')->pluck('migration')->toArray();
if ($mode === 'pending' || $mode === 'all') {
$this->checkPending($ranMigrations);
}
if ($mode === 'orphaned' || $mode === 'all') {
$this->checkOrphaned();
}
if ($mode === 'superseded' || $mode === 'all') {
$this->checkSuperseded();
}
if ($mode === 'duplicates' || $mode === 'all') {
$this->checkDuplicates();
}
$this->newLine();
if ($this->option('delete') && $this->filesToDelete !== []) {
$this->deleteFiles();
}
if ($this->errors > 0) {
$this->error("Found {$this->errors} error(s) and {$this->warnings} warning(s).");
return self::FAILURE;
}
if ($this->warnings > 0) {
$this->warn("Found {$this->warnings} warning(s), no errors.");
return self::SUCCESS;
}
$this->info('No issues found.');
return self::SUCCESS;
}
private function checkPending(array $ran): void
{
$files = File::files(database_path('migrations'));
$pending = 0;
foreach ($files as $f) {
$name = str_replace('.php', '', $f->getFilename());
if (! in_array($name, $ran)) {
$pending++;
}
}
if ($pending > 0) {
$this->warn("{$pending} pending migration(s) found. Run `php artisan migrate`.");
$this->errors++;
} else {
$this->info('All migrations have been run.');
}
}
private function checkOrphaned(): void
{
$migrationsPath = database_path('migrations');
$files = File::files($migrationsPath);
foreach ($files as $file) {
$content = File::get($file->getPathname());
$createdTables = $this->extractCreatedTables($content);
foreach ($createdTables as $table) {
if (! Schema::hasTable($table)) {
$this->warn("[Orphaned] {$file->getFilename()} creates table `{$table}` which no longer exists.");
$this->warnings++;
$this->addFileToDelete($file->getFilename());
}
}
$modifiedTables = $this->extractModifiedTables($content);
foreach ($modifiedTables as $table => $columns) {
if (! Schema::hasTable($table)) {
$this->warn("[Orphaned] {$file->getFilename()} modifies table `{$table}` which no longer exists.");
$this->warnings++;
$this->addFileToDelete($file->getFilename());
continue;
}
foreach ($columns as $column) {
if (! Schema::hasColumn($table, $column)) {
$this->warn("[Orphaned] {$file->getFilename()} adds column `{$column}` to `{$table}` but column no longer exists.");
$this->warnings++;
$this->addFileToDelete($file->getFilename());
}
}
}
}
}
private function checkSuperseded(): void
{
$migrationsPath = database_path('migrations');
$files = File::files($migrationsPath);
$createdIn = [];
$droppedIn = [];
foreach ($files as $file) {
$content = File::get($file->getPathname());
$name = $file->getFilename();
$upContent = $this->extractUpMethod($content);
$createdTables = $this->extractCreatedTables($upContent);
$droppedTablesInUp = $this->extractDroppedTables($upContent);
foreach ($createdTables as $table) {
$createdIn[$table][] = $name;
}
foreach ($droppedTablesInUp as $table) {
$droppedIn[$table][] = $name;
}
}
foreach ($createdIn as $table => $creators) {
if (isset($droppedIn[$table]) && $droppedIn[$table] !== []) {
$droppers = $droppedIn[$table];
$stillExists = Schema::hasTable($table);
if (! $stillExists) {
$this->warn('[Superseded] Table `' . $table . '` created in ' . implode(', ', $creators) . ' and dropped in ' . implode(', ', $droppers) . '.');
$this->warnings++;
foreach (array_merge($creators, $droppers) as $f) {
$this->addFileToDelete($f);
}
}
if (count($creators) > 1) {
$this->warn('[Duplicate] Table `' . $table . '` was created multiple times across migrations.');
$this->warnings++;
}
}
}
}
private function extractUpMethod(string $content): string
{
if (preg_match('/function\s+up\s*\(\s*\)\s*(?::\s*\w+\s*)?\s*\{.*?\n\s*\}/s', $content, $m)) {
return $m[0];
}
return $content;
}
private function extractDownMethod(string $content): string
{
if (preg_match('/function\s+down\s*\(\s*\)\s*(?::\s*\w+\s*)?\s*\{.*?\n\s*\}/s', $content, $m)) {
return $m[0];
}
return '';
}
private function checkDuplicates(): void
{
$migrationsPath = database_path('migrations');
$files = File::files($migrationsPath);
$tableCreations = [];
foreach ($files as $file) {
$content = File::get($file->getPathname());
$createdTables = $this->extractCreatedTables($content);
foreach ($createdTables as $table) {
if (! isset($tableCreations[$table])) {
$tableCreations[$table] = [];
}
$tableCreations[$table][] = $file->getFilename();
}
}
foreach ($tableCreations as $table => $creators) {
if (count($creators) > 1) {
$this->warn('[Duplicate] Table `' . $table . '` created in: ' . implode(', ', $creators));
$this->warnings++;
$existing = Schema::hasTable($table);
if ($existing) {
$toDelete = array_slice($creators, 0, -1);
foreach ($toDelete as $f) {
$this->addFileToDelete($f);
}
} else {
foreach ($creators as $f) {
$this->addFileToDelete($f);
}
}
}
}
}
private function extractCreatedTables(string $content): array
{
preg_match_all('/Schema::create\([\'"]([^\'"]+)[\'"]/', $content, $matches);
return array_unique($matches[1]);
}
private function extractModifiedTables(string $content): array
{
$tables = [];
preg_match_all('/Schema::table\([\'"]([^\'"]+)[\'"]/', $content, $tableMatches);
if (empty($tableMatches[1])) {
return $tables;
}
preg_match_all('/\$table->(' . implode('|', $this->columnMethods()) . ')\([\'"]([^\'"]+)[\'"]\)/', $content, $colMatches);
foreach ($colMatches[1] as $i => $method) {
$table = $tableMatches[1][0];
$column = $colMatches[2][$i];
if (! isset($tables[$table]) || ! in_array($column, $tables[$table])) {
$tables[$table][] = $column;
}
}
return $tables;
}
private function extractDroppedTables(string $content): array
{
$tables = [];
if (preg_match('/Schema::dropIfExists\([\'"]([^\'"]+)[\'"]/', $content, $m)) {
$tables[] = $m[1];
}
if (preg_match('/Schema::drop\([\'"]([^\'"]+)[\'"]/', $content, $m)) {
$tables[] = $m[1];
}
return $tables;
}
private function columnMethods(): array
{
return [
'bigInteger', 'binary', 'boolean', 'char', 'date', 'dateTime', 'datetime',
'decimal', 'double', 'enum', 'float', 'foreignId', 'foreignIdFor',
'foreignUlid', 'foreignUuid', 'geometry', 'geometryCollection',
'id', 'integer', 'json', 'jsonb', 'lineString', 'longText',
'macAddress', 'mediumInteger', 'mediumText', 'morphs',
'nullableMorphs', 'nullableUuidMorphs', 'nullableTimestamps',
'point', 'polygon', 'rememberToken', 'set', 'smallInteger',
'softDeletes', 'softDeletesTz', 'string', 'text', 'time',
'timestamp', 'timestamps', 'timestampsTz', 'tinyInteger',
'tinyText', 'unsignedBigInteger', 'unsignedInteger',
'unsignedMediumInteger', 'unsignedSmallInteger',
'unsignedTinyInteger', 'uuid', 'year',
];
}
private function addFileToDelete(string $filename): void
{
if (! in_array($filename, $this->filesToDelete)) {
$this->filesToDelete[] = $filename;
}
}
private function deleteFiles(): void
{
$this->newLine();
$this->warn('The following migration files will be deleted:');
foreach ($this->filesToDelete as $f) {
$this->line(" - {$f}");
}
$this->newLine();
if (! $this->option('force')) {
$this->line('Records will also be removed from the `migrations` database table.');
if (! $this->confirm('Are you sure you want to delete these migration files?', false)) {
$this->info('Deletion cancelled.');
return;
}
}
$deleted = 0;
$migrationsPath = database_path('migrations');
$names = array_map(fn ($f) => str_replace('.php', '', $f), $this->filesToDelete);
foreach ($this->filesToDelete as $filename) {
$path = $migrationsPath . '/' . $filename;
if (File::exists($path)) {
File::delete($path);
$this->line("[Deleted] {$filename}");
$deleted++;
}
}
DB::table('migrations')->whereIn('migration', $names)->delete();
$this->line('[Cleanup] Removed ' . count($names) . ' record(s) from migrations table.');
$this->newLine();
$this->info("Deleted {$deleted} migration file(s) and cleaned up the migrations table.");
}
}