Lines 76.25% 61 / 80
Methods 66.66% 6 / 9
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 ensureTableExists 0.00% 0 / 14 0.00% 0 / 1 42
 isApplied 100.00% 9 / 9 100.00% 1 / 1 2
 recordMigration 100.00% 11 / 11 100.00% 1 / 1 3
 removeMigration 100.00% 7 / 7 100.00% 1 / 1 1
 getAppliedMigrations 100.00% 19 / 19 100.00% 1 / 1 7
 getAppliedVersions 100.00% 1 / 1 100.00% 1 / 1 1
 getLastAppliedVersion 87.50% 7 / 8 0.00% 0 / 1 4.03
 tableExists 60.00% 6 / 10 0.00% 0 / 1 8.30
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}