connection instead of addSql() because the * backfill has to happen between adding the nullable columns and tightening * them to NOT NULL — addSql() defers everything to the end of up(), which would * run the guard before the data exists. * * MariaDB commits implicitly on DDL, so this migration cannot roll back as a * unit. The order below is what keeps it safe: the replacement unique index is * created before the old one is dropped, so the tables are never left without * uniqueness protection. */ final class Version20260728074119 extends AbstractMigration { /** clinic wins when set; NULL means the doctor's own practice. */ private const BACKFILL = "SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'), entity_id = IFNULL(clinic_id, doctor_id)"; public function getDescription(): string { return 'Mark appointments, weekly_schedules and date_overrides with their owning tenant'; } public function up(Schema $schema): void { foreach (['appointments', 'weekly_schedules', 'date_overrides'] as $table) { $this->connection->executeStatement( "ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL" ); $this->connection->executeStatement("UPDATE {$table} " . self::BACKFILL); // Defensive: a clinic_id pointing at a deleted clinic falls back to // the doctor's own practice rather than aborting the migration. $this->connection->executeStatement( "UPDATE {$table} t LEFT JOIN clinics c ON c.id = t.clinic_id SET t.entity_type = 'doctor', t.entity_id = t.doctor_id WHERE t.clinic_id IS NOT NULL AND c.id IS NULL" ); $remaining = (int) $this->connection->fetchOne( "SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL OR entity_id IS NULL" ); $this->abortIf($remaining > 0, "Backfill left {$remaining} rows in {$table} without a tenant."); $this->connection->executeStatement( "ALTER TABLE {$table} MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL" ); } $this->connection->executeStatement( 'CREATE INDEX idx_appointments_tenant_slot ON appointments (entity_type, entity_id, slot_start)' ); $this->connection->executeStatement( 'CREATE INDEX idx_appointments_tenant_status ON appointments (entity_type, entity_id, status)' ); // New unique key first, old one and its generated column afterwards. $this->connection->executeStatement( 'CREATE UNIQUE INDEX uniq_weekly_schedule_doctor_tenant ON weekly_schedules (doctor_id, entity_type, entity_id)' ); $this->connection->executeStatement('DROP INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules'); $this->connection->executeStatement('ALTER TABLE weekly_schedules DROP clinic_key'); $this->connection->executeStatement( 'CREATE UNIQUE INDEX uniq_date_override_doctor_tenant_date ON date_overrides (doctor_id, entity_type, entity_id, date)' ); $this->connection->executeStatement('DROP INDEX uniq_date_override_doctor_clinic_date ON date_overrides'); $this->connection->executeStatement('ALTER TABLE date_overrides DROP clinic_key'); } public function down(Schema $schema): void { $this->addSql("ALTER TABLE weekly_schedules ADD clinic_key INT AS (IFNULL(clinic_id, 0)) STORED"); $this->addSql('CREATE UNIQUE INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules (doctor_id, clinic_key)'); $this->addSql('DROP INDEX uniq_weekly_schedule_doctor_tenant ON weekly_schedules'); $this->addSql("ALTER TABLE date_overrides ADD clinic_key INT AS (IFNULL(clinic_id, 0)) STORED"); $this->addSql('CREATE UNIQUE INDEX uniq_date_override_doctor_clinic_date ON date_overrides (doctor_id, clinic_key, date)'); $this->addSql('DROP INDEX uniq_date_override_doctor_tenant_date ON date_overrides'); $this->addSql('DROP INDEX idx_appointments_tenant_slot ON appointments'); $this->addSql('DROP INDEX idx_appointments_tenant_status ON appointments'); foreach (['appointments', 'weekly_schedules', 'date_overrides'] as $table) { $this->addSql("ALTER TABLE {$table} DROP entity_type, DROP entity_id"); } } /** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */ public function isTransactional(): bool { return false; } }