Files
fitme3070.pt/shared/platform-tools/functions_migrations.php
T
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

209 lines
7.5 KiB
PHP

<?php
// ─── DB migrations runner ────────────────────────────────────────────────────
//
// Lightweight, dependency-free runner over the `database/migration_*.sql`
// files in the host repo. State is tracked in `applied_migrations` (auto-created
// on first call). Files are matched by basename only — path traversal is
// blocked by an explicit regex.
//
// Public API:
// db_ensure_migrations_table(PDO $pdo): void
// migrations_dir(): string — host repo migrations dir
// migrations_list_files(): array — basenames, sorted
// migrations_status(PDO $pdo): array — [{filename, applied_at|null}]
// migrations_apply_file(PDO, string $filename, ?int $userId): array
// migrations_mark_all_applied(PDO, ?int $userId): int
// migrations_split_sql(string $sql): array — string/comment-aware splitter
//
// Migrations directory resolution:
// 1. $GLOBALS['MIGRATIONS_DIR'] if set (set this in the stub for non-standard layouts)
// 2. Otherwise: dirname(__DIR__, 2) . '/database'
// Assumes this file lives at <host_repo>/shared/platform-tools/functions_migrations.php
// → resolves to <host_repo>/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;
}