373 lines
12 KiB
PHP
373 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Database\Migrations;
|
|
|
|
use CodeIgniter\Database\Migration;
|
|
|
|
class FixSchoolYearColumns extends Migration
|
|
{
|
|
/**
|
|
* Tables that directly own an academic year.
|
|
*
|
|
* school_year stays as text by design. Valid values use YYYY-YYYY and
|
|
* the second year must be exactly the first year + 1.
|
|
*/
|
|
private array $yearOwnerTables = [
|
|
'additional_charges',
|
|
'archived_paypal_transactions',
|
|
'attendance_data',
|
|
'attendance_day',
|
|
'attendance_record',
|
|
'attendance_tracking',
|
|
'badge_print_logs',
|
|
'below_sixty_decisions',
|
|
'calendar_events',
|
|
'certificate_records',
|
|
'classSection',
|
|
'class_progress_reports',
|
|
'competitions',
|
|
'current_flag',
|
|
'discount_vouchers',
|
|
'early_dismissal_signatures',
|
|
'enrollments',
|
|
'events',
|
|
'exams',
|
|
'exam_drafts',
|
|
'expenses',
|
|
'final_exam',
|
|
'final_score',
|
|
'flag',
|
|
'grading_locks',
|
|
'homework',
|
|
'inventory_movements',
|
|
'invoices',
|
|
'late_slip_logs',
|
|
'manual_payments',
|
|
'midterm_exam',
|
|
'missing_score_overrides',
|
|
'parent_attendance_reports',
|
|
'parent_meeting_schedules',
|
|
'parent_notifications',
|
|
'participation',
|
|
'payments',
|
|
'placement_batches',
|
|
'print_requests',
|
|
'project',
|
|
'quiz',
|
|
'refunds',
|
|
'reimbursements',
|
|
'reimbursement_batches',
|
|
'report_card_acknowledgements',
|
|
'scan_log',
|
|
'score_comments',
|
|
'semester_scores',
|
|
'staff_attendance',
|
|
'student_class',
|
|
'student_decisions',
|
|
'teacher_attendance_data',
|
|
'teacher_class',
|
|
'teacher_submission_notification_history',
|
|
'whatsapp_group_links',
|
|
'whatsapp_group_memberships',
|
|
];
|
|
|
|
/** Tables where school_year is redundant or conceptually incorrect. */
|
|
private array $tablesWithoutDirectYear = [
|
|
'chapters',
|
|
'classes',
|
|
'class_preparation_log',
|
|
'class_prep_adjustments',
|
|
'contactus',
|
|
'emergency_contacts',
|
|
'inventory_items',
|
|
'invoice_students_list',
|
|
'ip_attempts',
|
|
'login_activity',
|
|
'messages',
|
|
'notifications',
|
|
'notification_recipients',
|
|
'parents',
|
|
'paypal_transactions',
|
|
'placement_levels',
|
|
'preferences',
|
|
'staff',
|
|
'students',
|
|
'support_requests',
|
|
'users',
|
|
'user_notifications',
|
|
];
|
|
|
|
/** Tables where semester is redundant or conceptually incorrect. */
|
|
private array $tablesWithoutDirectSemester = [
|
|
'badge_print_logs',
|
|
'classes',
|
|
'contactus',
|
|
'emergency_contacts',
|
|
'inventory_items',
|
|
'invoice_students_list',
|
|
'ip_attempts',
|
|
'notification_recipients',
|
|
'notifications',
|
|
'parents',
|
|
'support_requests',
|
|
'user_notifications',
|
|
'whatsapp_group_links',
|
|
];
|
|
|
|
public function up(): void
|
|
{
|
|
if ($this->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) . '`';
|
|
}
|
|
}
|