Phase 2 of the tenant-marking series. appointments, weekly_schedules and date_overrides kept their environment implicit in a nullable clinic_id, so every query that wanted "this environment's rows" had to rebuild clinic_id IS NULL ? doctor : clinic itself. The two calendar tables also depended on a MariaDB-only generated column, clinic_key = IFNULL(clinic_id, 0), purely to make a unique key work across NULLs. All three now carry the (entity_type, entity_id) pair that service_sections, patient_records and clinic_staff already use, via a shared TenantOwnedTrait. The pair is a deliberate denormalisation of clinic_id/doctor_id: the automatic tenant filter and the tenant-leading indexes both need a real column, and neither can be built on an IF() expression. - Unique keys keep doctor_id alongside the pair. A clinic has several doctors and each has their own schedule, so (entity_type, entity_id) alone would reject the second doctor. - clinic_key is gone from both calendar tables. - appointments gained tenant-leading indexes; EXPLAIN on the panel's list query now picks idx_appointments_tenant_slot. Deliberately unchanged, both with the reason already recorded in the code: active_slot_key stays keyed on doctor + slot, since adding the environment would let one doctor be booked in their own practice and a clinic at the same moment. holidays keeps its nullable clinic_id, where NULL means "every environment" rather than "personal practice" — a meaning the pair cannot carry. The migration adds the columns nullable, backfills, aborts if any row is left without an owner, and only then tightens to NOT NULL. It creates each replacement unique index before dropping the old one, so the tables are never left unprotected — MariaDB commits implicitly on DDL, so ordering is the only safety net. It runs its statements through the connection rather than addSql() because the guard has to sit between the backfill and the NOT NULL change. Columns are NOT NULL with no default on purpose: a construction site that forgets assignTenant() fails at flush instead of silently writing entity_id 0, which the phase 4 filter would then hide from everyone. Verified on the dev database: 0 rows without a tenant, 0 personal bookings mismatched against their doctor, 0 clinic bookings mismatched against their clinic. Tests: 819 passing (813 + 6 new in BookingTenantTest). PHPStan clean on every changed file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
107 lines
5.0 KiB
PHP
107 lines
5.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DoctrineMigrations;
|
|
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use Doctrine\Migrations\AbstractMigration;
|
|
|
|
/**
|
|
* Phase 2 of the tenant-marking series: store the owning environment explicitly
|
|
* on the booking tables as the (entity_type, entity_id) pair already used by
|
|
* service_sections, patient_records and clinic_staff.
|
|
*
|
|
* Statements run through $this->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;
|
|
}
|
|
}
|