185 lines
8.5 KiB
PHP
185 lines
8.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
final class Migrations
|
||
{
|
||
public static function run(string $migrationsDir): void
|
||
{
|
||
$db = DB::pdo();
|
||
$db->exec('CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL);');
|
||
|
||
$files = glob(rtrim($migrationsDir, '/') . '/*.sql') ?: [];
|
||
sort($files);
|
||
|
||
foreach ($files as $file) {
|
||
$name = basename($file);
|
||
|
||
$stmt = $db->prepare('SELECT 1 FROM _migrations WHERE name = :n');
|
||
$stmt->execute([':n' => $name]);
|
||
if ($stmt->fetchColumn()) {
|
||
continue;
|
||
}
|
||
|
||
$sql = file_get_contents($file);
|
||
if ($sql === false) {
|
||
throw new RuntimeException("Cannot read migration: $name");
|
||
}
|
||
|
||
// Les toutes premières installations utilisaient déjà deleted_at avant
|
||
// que cette colonne ne soit correctement inscrite dans une migration.
|
||
if ($name === '025_animal_archive_and_trash.sql') {
|
||
$columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name');
|
||
if (!in_array('deleted_at', $columns, true)) {
|
||
$db->exec('ALTER TABLE animals ADD COLUMN deleted_at TEXT');
|
||
}
|
||
}
|
||
|
||
// Répare les bases créées par une candidate rc.3 incomplète, dans
|
||
// laquelle les médias privés étaient utilisés avant d'être migrés.
|
||
if ($name === '089_media_schema_repair.sql') {
|
||
$columns = array_column(
|
||
$db->query('PRAGMA table_info(animal_photos)')->fetchAll(PDO::FETCH_ASSOC),
|
||
'name',
|
||
);
|
||
if (!in_array('is_public', $columns, true)) {
|
||
$db->exec('ALTER TABLE animal_photos ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1');
|
||
}
|
||
}
|
||
|
||
// Les anciennes bases possédaient déjà le nouvel historique alors
|
||
// que les installations neuves recevaient encore son premier schéma.
|
||
if ($name === '090_medication_administration_routes.sql') {
|
||
$columns = array_column(
|
||
$db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC),
|
||
'name',
|
||
);
|
||
if (!in_array('type', $columns, true)) {
|
||
$db->exec("ALTER TABLE animal_history ADD COLUMN type TEXT NOT NULL DEFAULT 'history'");
|
||
}
|
||
if (!in_array('label', $columns, true)) {
|
||
$db->exec("ALTER TABLE animal_history ADD COLUMN label TEXT NOT NULL DEFAULT ''");
|
||
}
|
||
if (!in_array('details', $columns, true)) {
|
||
$db->exec('ALTER TABLE animal_history ADD COLUMN details TEXT');
|
||
}
|
||
}
|
||
|
||
// Deux anciens schémas de l’historique ont circulé : l’un avec
|
||
// action/field/values, l’autre avec type/label/details. Le code
|
||
// courant accepte les données des deux et a besoin de leur union.
|
||
if ($name === '096_animal_history_compatibility.sql') {
|
||
$columns = array_column(
|
||
$db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC),
|
||
'name',
|
||
);
|
||
$definitions = [
|
||
'action' => "TEXT NOT NULL DEFAULT 'history'",
|
||
'field' => 'TEXT',
|
||
'old_value' => 'TEXT',
|
||
'new_value' => 'TEXT',
|
||
'meta' => 'TEXT',
|
||
'type' => "TEXT NOT NULL DEFAULT 'history'",
|
||
'label' => "TEXT NOT NULL DEFAULT ''",
|
||
'details' => 'TEXT',
|
||
'user_id' => 'INTEGER',
|
||
];
|
||
foreach ($definitions as $column => $definition) {
|
||
if (!in_array($column, $columns, true)) {
|
||
$db->exec("ALTER TABLE animal_history ADD COLUMN $column $definition");
|
||
}
|
||
}
|
||
}
|
||
|
||
$relaxesForeignKeys = in_array(
|
||
$name,
|
||
['093_room_rules_and_care_periods.sql', '097_remove_legacy_room_constraint.sql'],
|
||
true,
|
||
);
|
||
if ($relaxesForeignKeys) {
|
||
$db->exec('PRAGMA foreign_keys = OFF');
|
||
}
|
||
$db->beginTransaction();
|
||
try {
|
||
if ($name === '097_remove_legacy_room_constraint.sql') {
|
||
self::modernizeAnimalConstraints($db);
|
||
} else {
|
||
$db->exec($sql);
|
||
}
|
||
$ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))");
|
||
$ins->execute([':n' => $name]);
|
||
if ($relaxesForeignKeys && $db->query('PRAGMA foreign_key_check')->fetchColumn() !== false) {
|
||
throw new RuntimeException('Foreign key check failed after room migration');
|
||
}
|
||
$db->commit();
|
||
if ($relaxesForeignKeys) {
|
||
$db->exec('PRAGMA foreign_keys = ON');
|
||
}
|
||
} catch (Throwable $e) {
|
||
$db->rollBack();
|
||
if ($relaxesForeignKeys) {
|
||
$db->exec('PRAGMA foreign_keys = ON');
|
||
}
|
||
throw new RuntimeException('Migration ' . $name . ' : ' . $e->getMessage(), 0, $e);
|
||
}
|
||
}
|
||
}
|
||
|
||
private static function modernizeAnimalConstraints(PDO $db): void
|
||
{
|
||
$createSql = (string) $db
|
||
->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='animals'")
|
||
->fetchColumn();
|
||
$constraint = "/refuge_room\\s+TEXT\\s+CHECK\\s*\\(\\s*refuge_room\\s+IN\\s*\\(\\s*'silver'\\s*,\\s*'twix'\\s*\\)\\s+OR\\s+refuge_room\\s+IS\\s+NULL\\s*\\)/i";
|
||
$registrationConstraint = "/identification_registration_status\\s+TEXT\\s+NOT\\s+NULL\\s+DEFAULT\\s+'unknown'\\s+CHECK\\s*\\(\\s*identification_registration_status\\s+IN\\s*\\(\\s*'unknown'\\s*,\\s*'pending'\\s*,\\s*'registered'\\s*\\)\\s*\\)/i";
|
||
if (
|
||
$createSql === ''
|
||
|| (!preg_match($constraint, $createSql) && !preg_match($registrationConstraint, $createSql))
|
||
) {
|
||
return;
|
||
}
|
||
|
||
$newSql = preg_replace(
|
||
'/^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["`\[]?animals["`\]]?/i',
|
||
'CREATE TABLE animals_room_unrestricted',
|
||
$createSql,
|
||
1,
|
||
);
|
||
$newSql = preg_replace($constraint, 'refuge_room TEXT', (string) $newSql, 1);
|
||
$newSql = preg_replace(
|
||
$registrationConstraint,
|
||
"identification_registration_status TEXT NOT NULL DEFAULT 'unknown' " .
|
||
"CHECK(identification_registration_status IN ('unknown','registered','change_pending','owner_changed','pending'))",
|
||
(string) $newSql,
|
||
1,
|
||
);
|
||
if (!$newSql || preg_match($constraint, $newSql)) {
|
||
throw new RuntimeException('Unable to remove the legacy shelter-room constraint.');
|
||
}
|
||
|
||
$objects = $db
|
||
->query("SELECT sql FROM sqlite_master WHERE tbl_name='animals' AND type IN ('index','trigger') AND sql IS NOT NULL")
|
||
->fetchAll(PDO::FETCH_COLUMN);
|
||
$views = $db
|
||
->query("SELECT name,sql FROM sqlite_master WHERE type='view' AND sql IS NOT NULL ORDER BY rowid")
|
||
->fetchAll(PDO::FETCH_ASSOC);
|
||
$columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name');
|
||
$quotedColumns = implode(',', array_map(static fn(string $column): string => '"' . str_replace('"', '""', $column) . '"', $columns));
|
||
|
||
foreach ($views as $view) {
|
||
$viewName = '"' . str_replace('"', '""', (string) $view['name']) . '"';
|
||
$db->exec('DROP VIEW ' . $viewName);
|
||
}
|
||
$db->exec($newSql);
|
||
$db->exec("INSERT INTO animals_room_unrestricted ($quotedColumns) SELECT $quotedColumns FROM animals");
|
||
$db->exec("UPDATE animals_room_unrestricted SET identification_registration_status='change_pending' WHERE identification_registration_status='pending'");
|
||
$db->exec('DROP TABLE animals');
|
||
$db->exec('ALTER TABLE animals_room_unrestricted RENAME TO animals');
|
||
foreach ($objects as $objectSql) {
|
||
$db->exec((string) $objectSql);
|
||
}
|
||
foreach ($views as $view) {
|
||
$db->exec((string) $view['sql']);
|
||
}
|
||
}
|
||
}
|