Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.25% covered (warning)
76.25%
61 / 80
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
MigrationTracker
76.25% covered (warning)
76.25%
61 / 80
66.67% covered (warning)
66.67%
6 / 9
43.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureTableExists
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 isApplied
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 recordMigration
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 removeMigration
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getAppliedMigrations
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 getAppliedVersions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastAppliedVersion
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 tableExists
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
8.30
1<?php
2
3/**
4 * Tracks applied migrations in the database.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2023-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-01-25
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Setup\Migration;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Database;
24
25class MigrationTracker
26{
27    private const TABLE_NAME = 'faqmigrations';
28
29    public function __construct(
30        private readonly Configuration $configuration,
31    ) {
32    }
33
34    /**
35     * Creates the migrations tracking table if it doesn't exist.
36     */
37    public function ensureTableExists(): void
38    {
39        $tableName = Database::getTablePrefix() . self::TABLE_NAME;
40        $dbType = Database::getType();
41
42        $createTableSql = match ($dbType) {
43            'mysqli', 'pdo_mysql' => "CREATE TABLE IF NOT EXISTS {$tableName} (
44                id INT NOT NULL AUTO_INCREMENT,
45                version VARCHAR(50) NOT NULL,
46                applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
47                execution_time_ms INT DEFAULT NULL,
48                checksum VARCHAR(64) DEFAULT NULL,
49                description TEXT DEFAULT NULL,
50                PRIMARY KEY (id),
51                UNIQUE KEY idx_version (version)
52            ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB",
53            'pgsql', 'pdo_pgsql' => "CREATE TABLE IF NOT EXISTS {$tableName} (
54                id SERIAL NOT NULL,
55                version VARCHAR(50) NOT NULL,
56                applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
57                execution_time_ms INTEGER DEFAULT NULL,
58                checksum VARCHAR(64) DEFAULT NULL,
59                description TEXT DEFAULT NULL,
60                PRIMARY KEY (id),
61                UNIQUE (version)
62            )",
63            'sqlite3', 'pdo_sqlite' => "CREATE TABLE IF NOT EXISTS {$tableName} (
64                id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
65                version VARCHAR(50) NOT NULL UNIQUE,
66                applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
67                execution_time_ms INTEGER DEFAULT NULL,
68                checksum VARCHAR(64) DEFAULT NULL,
69                description TEXT DEFAULT NULL
70            )",
71            'sqlsrv', 'pdo_sqlsrv' => "IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='{$tableName}' AND xtype='U')
72                CREATE TABLE {$tableName} (
73                    id INT IDENTITY(1,1) NOT NULL,
74                    version VARCHAR(50) NOT NULL UNIQUE,
75                    applied_at DATETIME NOT NULL DEFAULT GETDATE(),
76                    execution_time_ms INT DEFAULT NULL,
77                    checksum VARCHAR(64) DEFAULT NULL,
78                    description NVARCHAR(MAX) DEFAULT NULL,
79                    PRIMARY KEY (id)
80                )",
81            default => throw new \RuntimeException("Unsupported database type: {$dbType}"),
82        };
83
84        $this->configuration->getDb()->query($createTableSql);
85    }
86
87    /**
88     * Checks if a migration has been applied.
89     */
90    public function isApplied(string $version): bool
91    {
92        $tableName = Database::getTablePrefix() . self::TABLE_NAME;
93        $query = sprintf(
94            "SELECT COUNT(*) as cnt FROM %s WHERE version = '%s'",
95            $tableName,
96            $this->configuration->getDb()->escape($version),
97        );
98
99        $result = $this->configuration->getDb()->query($query);
100        $row = $this->configuration->getDb()->fetchObject($result);
101
102        return $row instanceof \stdClass && (int) $row->cnt > 0;
103    }
104
105    /**
106     * Records a migration as applied.
107     */
108    public function recordMigration(
109        string $version,
110        int $executionTimeMs = 0,
111        ?string $checksum = null,
112        ?string $description = null,
113    ): void {
114        $tableName = Database::getTablePrefix() . self::TABLE_NAME;
115        $db = $this->configuration->getDb();
116
117        $query = sprintf(
118            "INSERT INTO %s (version, execution_time_ms, checksum, description) VALUES ('%s', %d, %s, %s)",
119            $tableName,
120            $db->escape($version),
121            $executionTimeMs,
122            $checksum !== null ? "'" . $db->escape($checksum) . "'" : 'NULL',
123            $description !== null ? "'" . $db->escape($description) . "'" : 'NULL',
124        );
125
126        $db->query($query);
127    }
128
129    /**
130     * Removes a migration record (for rollback).
131     */
132    public function removeMigration(string $version): void
133    {
134        $tableName = Database::getTablePrefix() . self::TABLE_NAME;
135        $query = sprintf(
136            "DELETE FROM %s WHERE version = '%s'",
137            $tableName,
138            $this->configuration->getDb()->escape($version),
139        );
140
141        $this->configuration->getDb()->query($query);
142    }
143
144    /**
145     * Returns all applied migrations.
146     *
147     * @return array<int, array{version: string, applied_at: string, execution_time_ms: int, checksum: string|null, description: string|null}>
148     */
149    public function getAppliedMigrations(): array
150    {
151        $tableName = Database::getTablePrefix() . self::TABLE_NAME;
152        $query = sprintf(
153            'SELECT version, applied_at, execution_time_ms, checksum, description FROM %s ORDER BY id ASC',
154            $tableName,
155        );
156
157        $result = $this->configuration->getDb()->query($query);
158        $migrations = [];
159
160        while (true) {
161            $row = $this->configuration->getDb()->fetchObject($result);
162            if ($row === false || $row === null || $row === []) {
163                break;
164            }
165
166            $migrations[] = [
167                'version' => (string) $row->version,
168                'applied_at' => (string) $row->applied_at,
169                'execution_time_ms' => (int) $row->execution_time_ms,
170                'checksum' => $row->checksum === null ? null : (string) $row->checksum,
171                'description' => $row->description === null ? null : (string) $row->description,
172            ];
173        }
174
175        return $migrations;
176    }
177
178    /**
179     * Returns the list of applied versions.
180     *
181     * @return string[]
182     */
183    public function getAppliedVersions(): array
184    {
185        return array_column($this->getAppliedMigrations(), 'version');
186    }
187
188    /**
189     * Returns the last applied migration version.
190     */
191    public function getLastAppliedVersion(): ?string
192    {
193        $tableName = Database::getTablePrefix() . self::TABLE_NAME;
194        $dbType = Database::getType();
195
196        // Build database-specific query
197        $query = match ($dbType) {
198            'sqlsrv', 'pdo_sqlsrv' => sprintf('SELECT TOP 1 version FROM %s ORDER BY id DESC', $tableName),
199            default => sprintf('SELECT version FROM %s ORDER BY id DESC LIMIT 1', $tableName),
200        };
201
202        $result = $this->configuration->getDb()->query($query);
203        $row = $this->configuration->getDb()->fetchObject($result);
204
205        return $row instanceof \stdClass ? (string) $row->version : null;
206    }
207
208    /**
209     * Checks if the tracking table exists.
210     */
211    public function tableExists(): bool
212    {
213        $tableName = Database::getTablePrefix() . self::TABLE_NAME;
214        $dbType = Database::getType();
215
216        $query = match ($dbType) {
217            'mysqli', 'pdo_mysql' => "SHOW TABLES LIKE '{$tableName}'",
218            'pgsql', 'pdo_pgsql' => "SELECT tablename FROM pg_catalog.pg_tables WHERE tablename = '{$tableName}'",
219            'sqlite3', 'pdo_sqlite' => "SELECT name FROM sqlite_master WHERE type='table' AND name='{$tableName}'",
220            'sqlsrv', 'pdo_sqlsrv' => "SELECT * FROM sysobjects WHERE name='{$tableName}' AND xtype='U'",
221            default => throw new \RuntimeException("Unsupported database type: {$dbType}"),
222        };
223
224        $result = $this->configuration->getDb()->query($query);
225        return $this->configuration->getDb()->numRows($result) > 0;
226    }
227}