table => nullable */ private array $tables = [ 'discount_usages' => false, 'event_charges' => true, 'invoice_event' => true, 'payment_error' => true, 'payment_notification_logs' => true, 'payment_transactions' => true, 'reimbursement_batch_items' => true, ]; public function up(): void { if ($this->db->DBDriver !== 'MySQLi') { throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.'); } foreach ($this->tables as $table => $nullable) { if (! $this->db->tableExists($table)) { continue; } if (! $this->db->fieldExists('school_year', $table)) { $this->forge->addColumn($table, [ 'school_year' => [ 'type' => 'VARCHAR', 'constraint' => 9, 'null' => $nullable, 'after' => $this->afterColumn($table), ], ]); } $this->backfillSchoolYear($table); $this->assertValidYears($table); $nullSql = $nullable ? 'NULL' : 'NOT NULL'; $this->db->query( sprintf( 'ALTER TABLE `%s` MODIFY `school_year` VARCHAR(9) %s', str_replace('`', '``', $table), $nullSql ) ); $this->ensureIndex($table); } } public function down(): void { // These columns are part of the business model and must not be dropped on rollback. } private function assertValidYears(string $table): void { $sql = sprintf( "SELECT COUNT(*) AS invalid_count FROM `%s` WHERE `school_year` IS NOT NULL AND ( `school_year` NOT REGEXP '^[0-9]{4}-[0-9]{4}$' OR CAST(RIGHT(`school_year`, 4) AS UNSIGNED) <> CAST(LEFT(`school_year`, 4) AS UNSIGNED) + 1 )", str_replace('`', '``', $table) ); $row = $this->db->query($sql)->getRowArray(); $invalid = (int) ($row['invalid_count'] ?? 0); if ($invalid > 0) { throw new RuntimeException( "Cannot normalize {$table}.school_year: {$invalid} invalid value(s). Expected YYYY-YYYY, for example 2025-2026." ); } } private function backfillSchoolYear(string $table): void { if ($table === 'discount_usages' && $this->db->tableExists('invoices')) { $this->db->query( "UPDATE `discount_usages` du INNER JOIN `invoices` i ON i.`id` = du.`invoice_id` SET du.`school_year` = i.`school_year` WHERE (du.`school_year` IS NULL OR TRIM(du.`school_year`) = '') AND i.`school_year` REGEXP '^[0-9]{4}-[0-9]{4}$' AND CAST(RIGHT(i.`school_year`, 4) AS UNSIGNED) = CAST(LEFT(i.`school_year`, 4) AS UNSIGNED) + 1" ); } } private function ensureIndex(string $table): void { $indexName = 'idx_' . $table . '_school_year'; if (strlen($indexName) > 64) { $indexName = 'idx_' . substr(hash('sha256', $table . '_school_year'), 0, 24); } $row = $this->db->query( 'SELECT COUNT(*) AS index_count FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', [$table, 'school_year'] )->getRowArray(); if ((int) ($row['index_count'] ?? 0) === 0) { $this->db->query(sprintf( 'CREATE INDEX `%s` ON `%s` (`school_year`)', str_replace('`', '``', $indexName), str_replace('`', '``', $table) )); } } private function afterColumn(string $table): string { $preferred = ['semester', 'invoice_id', 'payment_id', 'batch_id']; foreach ($preferred as $column) { if ($this->db->fieldExists($column, $table)) { return $column; } } return 'id'; } }