serviceItemRepo->findByUuid($request['service_item_uuid']); * * A lookup by uuid *is* an unanchored query, and TenantFilter cannot help when * the table has no column to filter on. All three leaks found in phase 7 had * exactly this shape. With these columns the filter covers them, so a forgotten * guard returns nothing instead of another environment's row. * * Only the eight children actually reachable that way are changed — measured, * not guessed. The rest stay aggregate children and are still reached from their * root. * * The pair is derived from the root, and a root never changes environment, so * the copy is written once and can never drift. */ final class Version20260728170000 extends AbstractMigration { /** table => [join clause, root alias] */ private const BACKFILL = [ 'patient_attachments' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], 'patient_calls' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], 'patient_medical_records' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], 'patient_messages' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], 'patient_notes' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], 'patient_sessions' => ['JOIN patient_records r ON r.id = t.record_id', 'r'], 'session_payments' => ['JOIN patient_sessions s ON s.id = t.session_id JOIN patient_records r ON r.id = s.record_id', 'r'], 'service_items' => ['JOIN service_sections sec ON sec.id = t.section_id', 'sec'], ]; public function getDescription(): string { return 'Give the request-reachable aggregate children their own tenant pair'; } public function up(Schema $schema): void { foreach (self::BACKFILL as $table => [$join, $alias]) { $this->connection->executeStatement( "ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL" ); $this->connection->executeStatement( "UPDATE {$table} t {$join} SET t.entity_type = {$alias}.entity_type, t.entity_id = {$alias}.entity_id" ); // A row whose root cannot be reached is already broken data; inventing // an environment for it would hide the breakage behind a wrong owner. $remaining = (int) $this->connection->fetchOne( "SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL OR entity_id IS NULL" ); $this->abortIf( $remaining > 0, "{$remaining} row(s) in {$table} have no reachable root; fix them before rerunning." ); $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_{$table}_entity ON {$table} (entity_type, entity_id)" ); } } public function down(Schema $schema): void { foreach (array_keys(self::BACKFILL) as $table) { $this->addSql("DROP INDEX idx_{$table}_entity ON {$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; } }