'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) "); } }