db->DBDriver !== 'MySQLi') { throw new \RuntimeException( 'This migration targets MySQL 8 because it uses enforced CHECK constraints.' ); } // These annual entities lacked a school_year column in the audited schema. foreach (['class_progress_reports', 'exams', 'print_requests'] as $table) { if ($this->db->tableExists($table) && ! $this->db->fieldExists('school_year', $table)) { // Nullable avoids inventing a year for existing rows. // Backfill it, then make it NOT NULL in a later migration. $this->forge->addColumn($table, [ 'school_year' => [ 'type' => 'VARCHAR', 'constraint' => 9, 'null' => true, ], ]); } } // Validate and normalize every direct owner to VARCHAR(9). foreach ($this->yearOwnerTables as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { continue; } $this->assertValidSchoolYears($table, 'school_year'); $this->normalizeYearColumn($table, 'school_year'); $this->ensureYearCheckConstraint($table, 'school_year'); $this->ensureYearIndex($table, 'school_year'); } // Promotion is a transition and legitimately owns both source and target years. if ($this->db->tableExists('promotion_queue')) { foreach (['school_year_from', 'school_year_to'] as $column) { if (! $this->db->fieldExists($column, 'promotion_queue')) { continue; } $this->assertValidSchoolYears('promotion_queue', $column); $this->normalizeYearColumn('promotion_queue', $column); $this->ensureYearCheckConstraint('promotion_queue', $column); $this->ensureYearIndex('promotion_queue', $column); } } // Remove redundant columns. Dependent non-primary indexes are removed first. foreach ($this->tablesWithoutDirectYear as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { continue; } $this->dropIndexesContainingColumn($table, 'school_year'); $this->dropChecksContainingColumn($table, 'school_year'); $this->forge->dropColumn($table, 'school_year'); } foreach ($this->tablesWithoutDirectSemester as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('semester', $table)) { continue; } $this->dropIndexesContainingColumn($table, 'semester'); $this->dropChecksContainingColumn($table, 'semester'); $this->forge->dropColumn($table, 'semester'); } foreach ($this->yearOwnerTables as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { continue; } $this->ensureYearIndex($table, 'school_year'); } } public function down(): void { throw new \RuntimeException( 'This migration is intentionally irreversible because dropping school_year columns destroys data. Restore from backup instead of pretending rollback can resurrect it.' ); } private function assertValidSchoolYears(string $table, string $column): void { $tableName = $this->quoteIdentifier($table); $columnName = $this->quoteIdentifier($column); $invalid = $this->db->query( "SELECT COUNT(*) AS aggregate FROM {$tableName} WHERE {$columnName} IS NOT NULL AND ( {$columnName} NOT REGEXP '^[0-9]{4}-[0-9]{4}$' OR CAST(RIGHT({$columnName}, 4) AS UNSIGNED) <> CAST(LEFT({$columnName}, 4) AS UNSIGNED) + 1 )" )->getRow(); if ((int) ($invalid->aggregate ?? 0) > 0) { throw new \RuntimeException( "Cannot migrate {$table}.{$column}: found {$invalid->aggregate} invalid school-year value(s). Expected YYYY-YYYY with consecutive years, for example 2025-2026." ); } } private function normalizeYearColumn(string $table, string $column): void { $metadata = $this->db->query( 'SELECT IS_NULLABLE, COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', [$table, $column] )->getRow(); if ($metadata === null) { return; } $nullable = $metadata->IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL'; $collation = $metadata->COLLATION_NAME && preg_match('/^[A-Za-z0-9_]+$/', $metadata->COLLATION_NAME) ? ' COLLATE ' . $metadata->COLLATION_NAME : ''; $this->db->query(sprintf( 'ALTER TABLE %s MODIFY COLUMN %s VARCHAR(9)%s %s', $this->quoteIdentifier($table), $this->quoteIdentifier($column), $collation, $nullable )); } private function ensureYearCheckConstraint(string $table, string $column): void { $constraint = $this->objectName('chk_sy', $table, $column); $exists = $this->db->query( 'SELECT COUNT(*) AS aggregate FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ? AND CONSTRAINT_TYPE = \'CHECK\'', [$table, $constraint] )->getRow(); if ((int) ($exists->aggregate ?? 0) > 0) { return; } $columnName = $this->quoteIdentifier($column); $this->db->query(sprintf( "ALTER TABLE %s ADD CONSTRAINT %s CHECK ( %s IS NULL OR ( %s REGEXP '^[0-9]{4}-[0-9]{4}$' AND CAST(RIGHT(%s, 4) AS UNSIGNED) = CAST(LEFT(%s, 4) AS UNSIGNED) + 1 ) )", $this->quoteIdentifier($table), $this->quoteIdentifier($constraint), $columnName, $columnName, $columnName, $columnName )); } private function ensureYearIndex(string $table, string $column): void { $indexed = $this->db->query( 'SELECT COUNT(*) AS aggregate FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', [$table, $column] )->getRow(); if ((int) ($indexed->aggregate ?? 0) > 0) { return; } $index = $this->objectName('idx_sy', $table, $column); $this->db->query(sprintf( 'ALTER TABLE %s ADD INDEX %s (%s)', $this->quoteIdentifier($table), $this->quoteIdentifier($index), $this->quoteIdentifier($column) )); } private function dropIndexesContainingColumn(string $table, string $column): void { $indexes = $this->db->query( 'SELECT DISTINCT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? AND INDEX_NAME <> \'PRIMARY\'', [$table, $column] )->getResult(); foreach ($indexes as $index) { $this->db->query(sprintf( 'ALTER TABLE %s DROP INDEX %s', $this->quoteIdentifier($table), $this->quoteIdentifier($index->INDEX_NAME) )); } } private function dropChecksContainingColumn(string $table, string $column): void { $checks = $this->db->query( 'SELECT tc.CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS tc INNER JOIN information_schema.CHECK_CONSTRAINTS cc ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME WHERE tc.CONSTRAINT_SCHEMA = DATABASE() AND tc.TABLE_NAME = ? AND tc.CONSTRAINT_TYPE = \'CHECK\' AND cc.CHECK_CLAUSE LIKE ?', [$table, '%' . $column . '%'] )->getResult(); foreach ($checks as $check) { $this->db->query(sprintf( 'ALTER TABLE %s DROP CHECK %s', $this->quoteIdentifier($table), $this->quoteIdentifier($check->CONSTRAINT_NAME) )); } } private function objectName(string $prefix, string $table, string $column): string { // MySQL identifiers are limited to 64 characters. return substr($prefix . '_' . $table . '_' . $column . '_' . substr(sha1($table . '.' . $column), 0, 8), 0, 64); } private function quoteIdentifier(string $identifier): string { return '`' . str_replace('`', '``', $identifier) . '`'; } }