Files
rspade_system/app/RSpade/Commands/Migrate/Maint_Migrate.php
2025-10-21 06:45:05 +00:00

616 lines
24 KiB
PHP
Executable File

<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Migrate;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Console\Output\BufferedOutput;
use Illuminate\Database\Console\Migrations\MigrateCommand;
use App\Providers\AppServiceProvider;
use App\RSpade\Core\Database\MigrationValidator;
use App\RSpade\Core\Database\SqlQueryTransformer;
/**
* Custom migration command that runs standard migrations plus maintenance commands
*
* This command extends Laravel's built-in migrate command to also run additional
* maintenance commands after migrations complete:
*
* 1. migrate:normalize_schema - Normalizes database schema (data types, encodings, required columns)
* 2. migrate:regenerate_constants - Updates model constants and docblocks, exports to JavaScript
*
* The command preserves and displays all output from the standard migration command,
* then runs the maintenance commands.
*
* This command is automatically used when running 'php artisan migrate' due to
* a modification in the artisan script.
*/
class Maint_Migrate extends Command
{
protected $signature = 'migrate {--force} {--seed} {--step} {--path=*} {--production : Run in production mode, skipping snapshot requirements} {--framework-only : Run only framework migrations (system/database/migrations), skip snapshot, but run normalization}';
protected $description = 'Run migrations and maintenance commands';
protected $flag_file = '/var/www/html/.migrating';
public function handle()
{
// Enable SQL query transformation for migrations
SqlQueryTransformer::enable();
$this->register_query_transformer();
// Enable full query logging to stdout for migrations
AppServiceProvider::set_query_log_mode(AppServiceProvider::QUERY_LOG_ALL_STDOUT);
// Ensure migrations table exists (create it if needed)
$this->ensure_migrations_table_exists();
// Check if we're in production mode (either via flag or environment)
$is_production = $this->option('production') || app()->environment('production');
// Check if we're in framework-only mode
$is_framework_only = $this->option('framework-only');
// Only enforce snapshot protection in development mode without --production or --framework-only flag
$require_snapshot = !$is_production && !$is_framework_only;
// Check for migration mode if we require snapshot
if ($require_snapshot) {
if (!file_exists($this->flag_file)) {
$this->error('[ERROR] Migration mode not active!');
$this->error('');
$this->line('In development mode, you must create a database snapshot before running migrations.');
$this->line('This prevents partial migrations from corrupting your database.');
$this->info('');
$this->line('To begin a migration session:');
$this->line(' php artisan migrate:begin');
return 1;
}
$this->info('[OK] Migration mode active - snapshot available for rollback');
$this->info('');
} elseif ($is_production) {
$this->info(' Running in production mode (no snapshot protection)');
} elseif ($is_framework_only) {
$this->info(' Running framework-only migrations (no snapshot protection)');
}
$mode_desc = $is_production ? ' (production mode)' : ($is_framework_only ? ' (framework-only)' : ' with maintenance commands');
$this->info('Running migrations' . $mode_desc . '...');
// Get all the options
$force = $this->option('force');
$seed = $this->option('seed');
$step = $this->option('step');
$paths = $this->option('path');
// Check if path option is used and throw exception
if (!empty($paths)) {
$this->error('[ERROR] Migration by path is disabled!');
$this->error('');
$this->line('This command enforces running all pending migrations in order.');
$this->line('Please run migrations without the --path option:');
$this->info(' php artisan migrate');
$this->error('');
$this->line('If you need to run specific migrations:');
$this->line(' 1. Place them in the standard migrations directory');
$this->line(' 2. Use migration timestamps to control execution order');
return 1;
}
// Determine which migration paths to use for whitelist check
$paths_to_check = $is_framework_only
? [database_path('migrations')]
: MigrationPaths::get_all_paths();
// Check migration whitelist
if (!$this->checkMigrationWhitelist($paths_to_check)) {
return 1;
}
// Validate migration files for Schema builder usage (only in non-production)
if (!$is_production && !$this->_validate_schema_rules()) {
return 1;
}
// Build command arguments
$migrateArgs = [];
if ($force) {
$migrateArgs['--force'] = true;
}
if ($seed) {
$migrateArgs['--seed'] = true;
}
if ($step) {
$migrateArgs['--step'] = true;
}
// Run normalize_schema BEFORE migrations to fix existing tables
// Pass --production flag to skip snapshot in both production and framework-only modes
$requiredColumnsArgs = ($is_production || $is_framework_only) ? ['--production' => true] : [];
$this->info("\n Pre-migration normalization (fixing existing tables)...\n");
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);
if ($normalizeExitCode !== 0) {
$this->error('Pre-migration normalization failed');
SqlQueryTransformer::disable();
return $normalizeExitCode;
}
// Use a buffered output to capture migration output
$bufferedOutput = new BufferedOutput();
try {
// Run the standard migrations and capture the output
$originalSqlMode = DB::selectOne('SELECT @@sql_mode as sql_mode')->sql_mode;
DB::statement("SET sql_mode = REPLACE(@@sql_mode, 'NO_ZERO_DATE', '');");
// Run migrations directly via migrator to avoid recursion
$migrator = app('migrator');
$migrator->setOutput($bufferedOutput);
// Use all migration paths (framework + user), or just framework if --framework-only
$migrationPaths = $is_framework_only
? [database_path('migrations')]
: MigrationPaths::get_all_paths();
// Run migrations one-by-one with normalization after each
$this->run_migrations_with_normalization($migrator, $migrationPaths, $step, $requiredColumnsArgs);
$exitCode = 0;
// Handle seeding if requested
if ($seed) {
$this->call('db:seed', ['--force' => $force]);
}
$migrationOutput = $bufferedOutput->fetch();
// Show the output from the migration command
$this->output->write($migrationOutput);
DB::statement('SET sql_mode = ?', [$originalSqlMode]);
// Check if migration failed
if ($exitCode !== 0) {
throw new \Exception("Migration command failed with exit code: $exitCode");
}
} catch (\Exception $e) {
// Restore SQL mode
try {
DB::statement('SET sql_mode = ?', [$originalSqlMode]);
} catch (\Exception $sqlEx) {
// Ignore SQL mode restore errors
}
$this->error('');
$this->error('[ERROR] Migration failed!');
$this->error('Error: ' . $e->getMessage());
// If in development mode with snapshot, automatically rollback
if ($require_snapshot && file_exists($this->flag_file)) {
$this->error('');
$this->warn(' Automatically rolling back to snapshot due to migration failure...');
$this->info('');
// Call rollback command with output
$rollback_result = Artisan::call('migrate:rollback', [], $this->output);
if ($rollback_result === 0) {
$this->info('');
$this->info('[OK] Database rolled back successfully!');
$this->info('');
$this->warn('[WARNING] You are still in migration mode.');
$this->line('Your options:');
$this->line(' 1. Fix your migration files');
$this->line(' 2. Run "php artisan migrate" to try again');
$this->line(' 3. Run "php artisan migrate:commit" to exit migration mode');
} else {
$this->error('[ERROR] Automatic rollback failed!');
$this->error('Run "php artisan migrate:rollback" manually.');
$this->warn('[WARNING] The database may be in an inconsistent state!');
}
}
// Disable query logging before returning
AppServiceProvider::disable_query_echo();
// Disable SQL query transformation
SqlQueryTransformer::disable();
return 1;
}
// Run normalize_schema AFTER migrations to add framework columns to new tables
$this->info("\n Post-migration normalization (adding framework columns to new tables)...\n");
// Switch to destructive-only query logging for normalize_schema
AppServiceProvider::set_query_log_mode(AppServiceProvider::QUERY_LOG_DESTRUCTIVE_STDOUT);
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);
if ($normalizeExitCode !== 0) {
$this->error('Post-migration normalization failed');
return $normalizeExitCode;
}
// Run regenerate constants if not in production mode (framework-only still runs it)
if (!$is_production) {
// Disable query logging for regenerate_constants
AppServiceProvider::disable_query_echo();
$maintenanceExitCode = $this->runMaintenanceCommand('rsx:constants:regenerate');
if ($maintenanceExitCode !== 0) {
$this->error('Regenerate constants maintenance failed');
return $maintenanceExitCode;
}
} else {
// In production mode, check manifest consistency with database
// Disable query logging for consistency check
AppServiceProvider::disable_query_echo();
$this->info("\n");
$consistency_check_exit = $this->call('rsx:migrate:check_consistency');
if ($consistency_check_exit !== 0) {
$exitCode = 1;
}
}
$this->info("\nAll tasks completed!");
// Unlink manifest cache if not in production (triggers fast rebuild)
if (app()->environment() !== 'production') {
\App\RSpade\Core\Manifest\Manifest::_unlink_cache();
}
// Disable query logging
AppServiceProvider::disable_query_echo();
// Disable SQL query transformation
SqlQueryTransformer::disable();
return $exitCode;
}
/**
* Register query transformation listener
*
* Intercepts all DB::statement() calls and transforms CREATE/ALTER statements
* to enforce framework column type conventions.
*/
protected function register_query_transformer(): void
{
// Store the original statement method
$original_statement = \Closure::bind(function () {
return $this->connection->statement(...func_get_args());
}, DB::getFacadeRoot(), DB::getFacadeRoot());
// Override DB::statement to transform queries
DB::macro('statement', function ($query, $bindings = []) use ($original_statement) {
// Transform the query before execution
$transformed = SqlQueryTransformer::transform($query);
// Call the original statement method with transformed query
return DB::connection()->statement($transformed, $bindings);
});
}
/**
* Check if all pending migrations are whitelisted
*
* @param array $paths Array of migration paths to check
*/
protected function checkMigrationWhitelist(array $paths): bool
{
// Load whitelists from both locations and merge them
$whitelistPaths = [
database_path('migrations/.migration_whitelist'), // Framework migrations
base_path('rsx/resource/migrations/.migration_whitelist'), // Application migrations
];
$whitelistedMigrations = [];
$foundAtLeastOne = false;
foreach ($whitelistPaths as $whitelistPath) {
if (file_exists($whitelistPath)) {
$foundAtLeastOne = true;
$whitelist = json_decode(file_get_contents($whitelistPath), true);
$migrations = array_keys($whitelist['migrations'] ?? []);
$whitelistedMigrations = array_merge($whitelistedMigrations, $migrations);
}
}
// If no whitelist exists yet, create one with existing migrations
if (!$foundAtLeastOne) {
$this->warn('[WARNING] No migration whitelist found. Creating one with existing migrations...');
$this->createInitialWhitelist();
return true;
}
// Get all migration files from specified paths
$migrationFiles = [];
foreach ($paths as $path) {
if (is_dir($path)) {
foreach (glob($path . '/*.php') as $file) {
$migrationFiles[] = basename($file);
}
}
}
// Check for unauthorized migrations
$unauthorizedMigrations = array_diff($migrationFiles, $whitelistedMigrations);
if (!empty($unauthorizedMigrations)) {
$this->error('[ERROR] Unauthorized migrations detected!');
$this->error('');
$this->line('The following migrations were not created via php artisan make:migration:');
foreach ($unauthorizedMigrations as $migration) {
$this->line(' • ' . $migration);
}
$this->error('');
$this->warn('[WARNING] Manually created migrations can cause timestamp conflicts and ordering issues.');
$this->error('');
$this->line('To fix this:');
$this->line('1. Create a new migration using: php artisan make:migration [name]');
$this->line('2. Copy the migration code from the unauthorized file to the new file');
$this->line('3. Delete the unauthorized migration file');
$this->line('4. Run migrations again');
$this->error('');
$this->line('This ensures proper timestamp generation and prevents LLM timestamp confusion.');
return false;
}
return true;
}
/**
* Create initial whitelist with existing migrations
* Creates separate whitelist files for framework and application migrations
*/
protected function createInitialWhitelist(): void
{
$whitelistLocations = [
database_path('migrations/.migration_whitelist') => database_path('migrations'),
base_path('rsx/resource/migrations/.migration_whitelist') => base_path('rsx/resource/migrations'),
];
$totalMigrations = 0;
foreach ($whitelistLocations as $whitelistPath => $migrationPath) {
// Skip if migration directory doesn't exist
if (!is_dir($migrationPath)) {
continue;
}
$whitelist = [
'description' => 'This file tracks migrations created via php artisan make:migration',
'purpose' => 'Prevents manually created migrations from running to avoid timestamp conflicts',
'migrations' => [],
];
// Add all existing migrations from this specific path
foreach (glob($migrationPath . '/*.php') as $file) {
$filename = basename($file);
$whitelist['migrations'][$filename] = [
'created_at' => 'pre-whitelist',
'created_by' => 'system',
'command' => 'existing migration',
];
}
// Only create whitelist if there are migrations in this location
if (!empty($whitelist['migrations'])) {
file_put_contents($whitelistPath, json_encode($whitelist, JSON_PRETTY_PRINT));
$count = count($whitelist['migrations']);
$totalMigrations += $count;
$location = str_replace(base_path() . '/', '', dirname($whitelistPath));
$this->info("[OK] Created whitelist in {$location} with {$count} migration(s).");
}
}
if ($totalMigrations === 0) {
$this->info('[OK] No existing migrations found. Empty whitelists created.');
}
}
protected function runMaintenanceCommand($command, $arguments = [])
{
$this->info("\nRunning $command...");
$output = new BufferedOutput();
$exitCode = Artisan::call($command, $arguments, $output);
$commandOutput = $output->fetch();
// Only show concise output if successful
if ($exitCode === 0) {
$this->info("[OK] Command $command completed successfully.");
} else {
$this->error("[FAIL] Command $command failed with exit code $exitCode");
$this->output->write($commandOutput);
}
return $exitCode;
}
/**
* Validate migration files for Schema builder usage
* Only enforced in non-production mode
*/
protected function _validate_schema_rules(): bool
{
$repository = app('migration.repository');
$pending_migrations = MigrationValidator::get_pending_migrations($repository);
if (empty($pending_migrations)) {
// No pending migrations to validate
return true;
}
$this->info('Validating migration files for Schema builder usage...');
$has_violations = false;
foreach ($pending_migrations as $migration_path) {
try {
// Validate the migration file for Schema builder usage
MigrationValidator::validate_migration_file($migration_path);
$this->info(" [OK] " . basename($migration_path));
} catch (\RuntimeException $e) {
// MigrationValidator already printed colored error output
$has_violations = true;
break; // Stop at first violation
}
}
if ($has_violations) {
$this->error('');
$this->error('Migration validation failed!');
$this->info('');
$this->line('Please update your migration to use raw SQL instead of Schema builder.');
$this->line('See: php artisan rsx:man migrations');
return false;
}
// All validations passed - now remove down() methods
$processed_migrations = [];
foreach ($pending_migrations as $migration_path) {
if (MigrationValidator::remove_down_method($migration_path)) {
$processed_migrations[] = $migration_path;
}
}
// If we processed any migrations (removed down methods), notify user
if (!empty($processed_migrations)) {
$this->info('');
$this->warn('[WARNING] Modified migration files:');
foreach ($processed_migrations as $path) {
$this->line(' • ' . basename($path) . ' - down() method removed');
}
$this->info('');
$this->line('These migrations have been updated to follow framework standards.');
}
return true;
}
/**
* Ensure the migrations table exists in the database
* Creates it using raw SQL if it doesn't exist
*/
protected function ensure_migrations_table_exists(): void
{
try {
// Try to query the migrations table
DB::select('SELECT 1 FROM migrations LIMIT 1');
} catch (\Exception $e) {
// Table doesn't exist, create it
$this->info('Creating migrations table...');
DB::statement("
CREATE TABLE IF NOT EXISTS migrations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL,
batch INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$this->info('[OK] Migrations table created');
}
}
/**
* Run migrations one-by-one with normalization after each
*
* Runs pending migrations individually, normalizing schema after each successful
* migration. This ensures type consistency before subsequent migrations reference
* newly-created tables.
*
* @param \Illuminate\Database\Migrations\Migrator $migrator
* @param array $migrationPaths
* @param bool $step
* @param array $requiredColumnsArgs
* @return void
*/
protected function run_migrations_with_normalization($migrator, array $migrationPaths, bool $step, array $requiredColumnsArgs): void
{
// Get all migration files
$files = [];
foreach ($migrationPaths as $path) {
if (is_dir($path)) {
$path_files = glob($path . '/*.php');
if ($path_files) {
foreach ($path_files as $file) {
$files[] = $file;
}
}
}
}
// Sort files chronologically by filename
sort($files);
// Get already-run migrations
$repository = $migrator->getRepository();
$ran = $repository->getRan();
// Filter to only pending migrations
$pending = [];
foreach ($files as $file) {
$migrationName = $migrator->getMigrationName($file);
if (!in_array($migrationName, $ran)) {
$pending[] = $file;
}
}
if (empty($pending)) {
$this->info(' INFO Nothing to migrate.');
return;
}
$totalMigrations = count($pending);
$currentMigration = 1;
// Run each migration individually with normalization after
foreach ($pending as $file) {
$migrationName = $migrator->getMigrationName($file);
$this->info("\n[$currentMigration/$totalMigrations] Running: $migrationName");
$this->newLine();
// Run this single migration
$migrator->runPending([$file], [
'pretend' => false,
'step' => $step
]);
// Run normalization after this migration (if not the last)
if ($currentMigration < $totalMigrations) {
$this->info("\n Normalizing schema after migration...\n");
// Switch to destructive-only query logging
AppServiceProvider::set_query_log_mode(AppServiceProvider::QUERY_LOG_DESTRUCTIVE_STDOUT);
$normalizeExitCode = $this->call('migrate:normalize_schema', $requiredColumnsArgs);
// Restore full query logging
AppServiceProvider::set_query_log_mode(AppServiceProvider::QUERY_LOG_ALL_STDOUT);
if ($normalizeExitCode !== 0) {
throw new \Exception("Normalization failed after migration: $migrationName");
}
}
$currentMigration++;
}
$this->newLine();
$this->info("[OK] All $totalMigrations migration" . ($totalMigrations > 1 ? 's' : '') . " completed successfully");
}
}