/shared/platform-tools/functions_migrations.php // → resolves to /database. function migrations_dir(): string { $dir = $GLOBALS['MIGRATIONS_DIR'] ?? (dirname(__DIR__, 2) . '/database'); return rtrim($dir, '/'); } function db_ensure_migrations_table(PDO $pdo): void { // FK on applied_by is intentionally omitted: tenants vary in whether // they have a `users` table by this name. The application enforces // referential semantics already (we only ever insert the caller's // user_id; deletion of that user is rare and orphan rows are harmless). $pdo->exec( "CREATE TABLE IF NOT EXISTS `applied_migrations` ( `filename` VARCHAR(150) NOT NULL, `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `applied_by` INT DEFAULT NULL, PRIMARY KEY (`filename`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); } function migrations_list_files(): array { $files = glob(migrations_dir() . '/migration_*.sql') ?: []; sort($files); return array_map('basename', $files); } function migrations_status(PDO $pdo): array { db_ensure_migrations_table($pdo); $applied = $pdo->query("SELECT filename, applied_at FROM applied_migrations") ->fetchAll(PDO::FETCH_KEY_PAIR); $applied = $applied ?: []; $rows = []; foreach (migrations_list_files() as $file) { $rows[] = [ 'filename' => $file, 'applied_at' => $applied[$file] ?? null, ]; } return $rows; } // Split a SQL blob into individual statements, respecting strings, escapes, // line comments (-- … and # …) and block comments (/* … */). Comments are // recognised inside the same state machine that tracks string literals, so a // value like 'use -- with care' or 'a /* b */ c' passes through untouched. // DELIMITER changes are not supported (project migrations don't use stored // procedures). function migrations_split_sql(string $sql): array { $statements = []; $current = ''; $in_string = false; $quote = null; $len = strlen($sql); for ($i = 0; $i < $len; $i++) { $ch = $sql[$i]; if ($in_string) { // Backslash escapes apply inside '…' and "…", never inside `…`. if ($ch === '\\' && $quote !== '`' && $i + 1 < $len) { $current .= $ch . $sql[$i + 1]; $i++; continue; } if ($ch === $quote) { if ($i + 1 < $len && $sql[$i + 1] === $quote) { $current .= $ch . $sql[$i + 1]; $i++; continue; } $in_string = false; $quote = null; } $current .= $ch; continue; } // Block comment /* … */ — dropped (including /*! … */ version hints). if ($ch === '/' && $i + 1 < $len && $sql[$i + 1] === '*') { $end = strpos($sql, '*/', $i + 2); $i = ($end === false) ? $len : $end + 1; continue; } // Line comment: `#`, or `--` followed by whitespace/EOL (MySQL rule — // `a--b` is arithmetic, not a comment). if ($ch === '#' || ($ch === '-' && $i + 1 < $len && $sql[$i + 1] === '-' && ($i + 2 >= $len || strpbrk($sql[$i + 2], " \t\r\n") !== false))) { $nl = strpos($sql, "\n", $i); if ($nl === false) break; // comment runs to end of input $i = $nl - 1; // keep the \n as statement whitespace continue; } if ($ch === "'" || $ch === '"' || $ch === '`') { $in_string = true; $quote = $ch; $current .= $ch; continue; } if ($ch === ';') { $stmt = trim($current); if ($stmt !== '') $statements[] = $stmt; $current = ''; continue; } $current .= $ch; } $stmt = trim($current); if ($stmt !== '') $statements[] = $stmt; return $statements; } function migrations_apply_file(PDO $pdo, string $filename, ?int $userId = null): array { if (!preg_match('/^migration_[a-z0-9_]+\.sql$/i', $filename)) { return ['ok' => false, 'error' => 'Nome de migration inválido.']; } $path = migrations_dir() . '/' . $filename; if (!is_file($path)) { return ['ok' => false, 'error' => "Ficheiro não encontrado: {$filename}"]; } db_ensure_migrations_table($pdo); $stmt = $pdo->prepare("SELECT 1 FROM applied_migrations WHERE filename = ?"); $stmt->execute([$filename]); if ($stmt->fetchColumn()) { return ['ok' => false, 'error' => 'Já aplicada anteriormente.']; } $sql = file_get_contents($path); if ($sql === false) { return ['ok' => false, 'error' => 'Não foi possível ler o ficheiro.']; } $statements = migrations_split_sql($sql); if (!$statements) { return ['ok' => false, 'error' => 'O ficheiro não contém statements SQL.']; } $applied = 0; foreach ($statements as $statement) { try { $pdo->exec($statement); $applied++; } catch (PDOException $e) { return [ 'ok' => false, 'error' => 'Erro no statement #' . ($applied + 1) . ': ' . $e->getMessage(), 'applied' => $applied, 'total' => count($statements), ]; } } $stmt = $pdo->prepare( "INSERT INTO applied_migrations (filename, applied_by) VALUES (?, ?)" ); $stmt->execute([$filename, $userId]); return ['ok' => true, 'applied' => $applied, 'total' => count($statements)]; } function migrations_mark_all_applied(PDO $pdo, ?int $userId = null): int { db_ensure_migrations_table($pdo); $existing = $pdo->query("SELECT filename FROM applied_migrations") ->fetchAll(PDO::FETCH_COLUMN); $existing = array_flip($existing ?: []); $stmt = $pdo->prepare( "INSERT INTO applied_migrations (filename, applied_by) VALUES (?, ?)" ); $count = 0; foreach (migrations_list_files() as $file) { if (!isset($existing[$file])) { $stmt->execute([$file, $userId]); $count++; } } return $count; }