Files
slim-blog/src/Shared/Database/Migrator.php
2026-03-16 16:02:01 +01:00

138 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Shared\Database;
use PDO;
/**
* Gestionnaire de migrations de base de données.
*
* Responsabilité unique : exécuter les migrations DDL et synchroniser
* l'index FTS5. Le provisionnement des données initiales est délégué
* à {@see Seeder}.
*
* Convention des fichiers de migration :
* - Placés dans database/migrations/
* - Nommés NNN_description.php (ex: 001_create_schema.php)
* - Retournent un tableau ['up' => 'SQL...', 'down' => 'SQL...']
* - Triés et exécutés par ordre alphanumérique croissant
*
* run() est idempotent et sûr à appeler à chaque démarrage applicatif :
* les migrations déjà appliquées ne sont jamais rejouées.
*/
final class Migrator
{
/**
* Répertoire contenant les fichiers de migration.
*/
private const MIGRATIONS_DIR = __DIR__ . '/../../../database/migrations';
/**
* Exécute les migrations en attente puis synchronise l'index FTS5.
*
* Opération idempotente et sans effets de bord sur les données :
* sûre à appeler à chaque démarrage applicatif.
*
* Séquence :
* 1. Crée la table de suivi si absente
* 2. Joue les migrations en attente
* 3. Indexe dans posts_fts les articles absents de l'index (syncFtsIndex)
*
* @param PDO $db L'instance de connexion à la base de données
*/
public static function run(PDO $db): void
{
self::createMigrationTable($db);
self::runPendingMigrations($db);
self::syncFtsIndex($db);
}
/**
* Crée la table de suivi des migrations si elle n'existe pas.
*
* Cette table doit exister avant de pouvoir lire les migrations appliquées.
*
* @param PDO $db L'instance de connexion à la base de données
*/
private static function createMigrationTable(PDO $db): void
{
$db->exec('
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
run_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
');
}
/**
* Charge les fichiers de migration, filtre ceux déjà appliqués
* et exécute les migrations en attente dans l'ordre.
*
* @param PDO $db L'instance de connexion à la base de données
*/
private static function runPendingMigrations(PDO $db): void
{
// Versions déjà appliquées (indexées pour un accès O(1))
$stmt = $db->query('SELECT version FROM migrations');
$rows = $stmt ? $stmt->fetchAll(PDO::FETCH_COLUMN) : [];
$applied = array_flip($rows);
// Fichiers de migration triés par nom (ordre alphanumérique = ordre numérique)
$files = glob(self::MIGRATIONS_DIR . '/*.php') ?: [];
sort($files);
$insert = $db->prepare('INSERT INTO migrations (version, run_at) VALUES (:version, :run_at)');
foreach ($files as $file) {
$version = basename($file, '.php');
if (isset($applied[$version])) {
continue;
}
// require évalue le fichier à chaque appel dans la boucle.
// require_once aurait mis en cache le résultat du premier fichier
// et l'aurait réutilisé pour tous les suivants — à ne pas utiliser ici.
$migration = require $file;
$db->exec($migration['up']);
$insert->execute([
':version' => $version,
':run_at' => date('Y-m-d H:i:s'),
]);
}
}
/**
* Synchronise l'index FTS5 avec les articles présents en base.
*
* Insère dans posts_fts les articles dont le rowid est absent de l'index.
* Idempotent et sans effet si l'index est déjà à jour.
*
* Nécessaire car les triggers FTS5 ne couvrent que les INSERT/UPDATE/DELETE
* effectués APRÈS leur création — les articles existants au moment de la
* migration 002 ne sont pas indexés rétroactivement.
*
* strip_tags() est enregistrée comme fonction SQLite dans container.php via
* sqliteCreateFunction() avant l'appel à Migrator::run() — elle est donc
* disponible ici.
*
* @param PDO $db L'instance de connexion à la base de données
*/
private static function syncFtsIndex(PDO $db): void
{
$db->exec("
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
COALESCE((SELECT username FROM users WHERE id = p.author_id), '')
FROM posts p
WHERE p.id NOT IN (SELECT rowid FROM posts_fts)
");
}
}