From 8b6e028ae6165ee1cfda52cb3e7229dc416b48a2 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 9 May 2026 18:14:37 +0200 Subject: [PATCH] Add migration check command and cleanup orphaned migrations --- .../Commands/CheckMigrationsCommand.php | 352 ++++++++++++++++++ ...01_create_personal_access_tokens_table.php | 36 -- ...204009_create_user_sessions_logs_table.php | 36 -- ...11_140005_drop_user_session_logs_table.php | 32 -- ...52_add_columns_to_radio_contests_table.php | 33 -- ...1_add_columns_to_radio_giveaways_table.php | 32 -- ...0918_remove_is_active_from_radio_ranks.php | 22 -- ...14942_add_country_support_to_ip_tables.php | 51 --- 8 files changed, 352 insertions(+), 242 deletions(-) create mode 100755 app/Console/Commands/CheckMigrationsCommand.php delete mode 100755 database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php delete mode 100755 database/migrations/2022_10_01_204009_create_user_sessions_logs_table.php delete mode 100755 database/migrations/2022_10_11_140005_drop_user_session_logs_table.php delete mode 100755 database/migrations/2026_02_11_180752_add_columns_to_radio_contests_table.php delete mode 100755 database/migrations/2026_02_11_180801_add_columns_to_radio_giveaways_table.php delete mode 100755 database/migrations/2026_02_16_210918_remove_is_active_from_radio_ranks.php delete mode 100755 database/migrations/2026_02_16_214942_add_country_support_to_ip_tables.php diff --git a/app/Console/Commands/CheckMigrationsCommand.php b/app/Console/Commands/CheckMigrationsCommand.php new file mode 100755 index 0000000..fca8ed6 --- /dev/null +++ b/app/Console/Commands/CheckMigrationsCommand.php @@ -0,0 +1,352 @@ + */ + 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."); + } +} diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php deleted file mode 100755 index 0f4f04c..0000000 --- a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->morphs('tokenable'); - $table->string('name'); - $table->string('token', 64)->unique(); - $table->text('abilities')->nullable(); - $table->timestamp('last_used_at')->nullable(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('personal_access_tokens'); - } -}; diff --git a/database/migrations/2022_10_01_204009_create_user_sessions_logs_table.php b/database/migrations/2022_10_01_204009_create_user_sessions_logs_table.php deleted file mode 100755 index f961943..0000000 --- a/database/migrations/2022_10_01_204009_create_user_sessions_logs_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - - $table->unsignedInteger('user_id'); - $table->string('ip', 100)->default('0'); - $table->text('browser')->nullable(); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('users_session_logs'); - } -}; diff --git a/database/migrations/2022_10_11_140005_drop_user_session_logs_table.php b/database/migrations/2022_10_11_140005_drop_user_session_logs_table.php deleted file mode 100755 index 16a592a..0000000 --- a/database/migrations/2022_10_11_140005_drop_user_session_logs_table.php +++ /dev/null @@ -1,32 +0,0 @@ -id(); - - $table->unsignedInteger('user_id'); - $table->string('ip', 100)->default('0'); - $table->text('browser')->nullable(); - - $table->timestamps(); - }); - } -}; diff --git a/database/migrations/2026_02_11_180752_add_columns_to_radio_contests_table.php b/database/migrations/2026_02_11_180752_add_columns_to_radio_contests_table.php deleted file mode 100755 index d79b417..0000000 --- a/database/migrations/2026_02_11_180752_add_columns_to_radio_contests_table.php +++ /dev/null @@ -1,33 +0,0 @@ -string('title'); - $table->text('description')->nullable(); - $table->boolean('is_active')->default(false); - $table->boolean('is_ended')->default(false); - $table->timestamp('start_date')->nullable(); - $table->timestamp('end_date')->nullable(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('radio_contests', function (Blueprint $table) { - $table->dropColumn(['title', 'description', 'is_active', 'is_ended', 'start_date', 'end_date']); - }); - } -}; diff --git a/database/migrations/2026_02_11_180801_add_columns_to_radio_giveaways_table.php b/database/migrations/2026_02_11_180801_add_columns_to_radio_giveaways_table.php deleted file mode 100755 index f7fd885..0000000 --- a/database/migrations/2026_02_11_180801_add_columns_to_radio_giveaways_table.php +++ /dev/null @@ -1,32 +0,0 @@ -string('title'); - $table->text('description')->nullable(); - $table->boolean('is_active')->default(false); - $table->boolean('is_ended')->default(false); - $table->timestamp('end_date')->nullable(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('radio_giveaways', function (Blueprint $table) { - $table->dropColumn(['title', 'description', 'is_active', 'is_ended', 'end_date']); - }); - } -}; diff --git a/database/migrations/2026_02_16_210918_remove_is_active_from_radio_ranks.php b/database/migrations/2026_02_16_210918_remove_is_active_from_radio_ranks.php deleted file mode 100755 index 3d39814..0000000 --- a/database/migrations/2026_02_16_210918_remove_is_active_from_radio_ranks.php +++ /dev/null @@ -1,22 +0,0 @@ -dropColumn('is_active'); - }); - } - - public function down(): void - { - Schema::table('radio_ranks', function (Blueprint $table) { - $table->boolean('is_active')->default(true)->after('badge_code'); - }); - } -}; diff --git a/database/migrations/2026_02_16_214942_add_country_support_to_ip_tables.php b/database/migrations/2026_02_16_214942_add_country_support_to_ip_tables.php deleted file mode 100755 index e20e165..0000000 --- a/database/migrations/2026_02_16_214942_add_country_support_to_ip_tables.php +++ /dev/null @@ -1,51 +0,0 @@ -string('country_code')->nullable()->after('asn'); - $table->string('country_name')->nullable()->after('country_code'); - $table->boolean('whitelist_country')->default(false)->after('country_name'); - }); - - Schema::table('website_ip_blacklist', function (Blueprint $table) { - $table->string('country_code')->nullable()->after('asn'); - $table->string('country_name')->nullable()->after('country_code'); - $table->boolean('blacklist_country')->default(false)->after('country_name'); - }); - - Schema::create('website_blocked_countries', function (Blueprint $table) { - $table->id(); - $table->string('country_code', 2); - $table->string('country_name'); - $table->timestamps(); - }); - - Schema::create('website_api_settings', function (Blueprint $table) { - $table->id(); - $table->string('key')->unique(); - $table->string('value')->nullable(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('website_blocked_countries'); - Schema::dropIfExists('website_api_settings'); - - Schema::table('website_ip_whitelist', function (Blueprint $table) { - $table->dropColumn(['country_code', 'country_name', 'whitelist_country']); - }); - - Schema::table('website_ip_blacklist', function (Blueprint $table) { - $table->dropColumn(['country_code', 'country_name', 'blacklist_country']); - }); - } -};