Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.16% covered (success)
90.16%
110 / 122
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
DatabaseMigrationRunner
90.08% covered (success)
90.08%
109 / 121
50.00% covered (danger)
50.00%
5 / 10
38.34
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
 getCurrentVersion
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 runUpScript
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 runDownScript
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 recordSystemVersion
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 executeSqlScript
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 splitStatements
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 updateQuoteState
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
31.32
 logMigration
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
3.01
 tableExists
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
2.21
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Updater\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Updater\Exception\UpdaterException;
12use PDO;
13use Throwable;
14
15/**
16 * Executes update SQL migrations (DDL/DML) and records version history.
17 *
18 * @package App\Core\Updater\Service
19 */
20final class DatabaseMigrationRunner
21{
22    /**
23     * DatabaseMigrationRunner constructor.
24     *
25     * @param PDO $pdo Active PDO database connection.
26     * @param string $tablePrefix Database table prefix.
27     */
28    public function __construct(
29        private readonly PDO $pdo,
30        private readonly string $tablePrefix = 'a_'
31    ) {
32    }
33
34    /**
35     * Retrieves currently installed version from a_core_system_version.
36     *
37     * @return string|null Version string or null if system not yet initialized.
38     */
39    public function getCurrentVersion(): ?string
40    {
41        $tableName = $this->tablePrefix . 'core_system_version';
42        $sql = "SELECT version FROM {$tableName} ORDER BY id DESC LIMIT 1";
43
44        try {
45            $stmt = $this->pdo->query($sql);
46            if ($stmt === false) {
47                return null;
48            }
49        } catch (Throwable) {
50            // Table may not exist prior to baseline installation
51            return null;
52        }
53
54        $version = $stmt->fetchColumn();
55        return is_string($version) && trim($version) !== '' ? trim($version) : null;
56    }
57
58    /**
59     * Executes upgrade SQL script and logs migration details.
60     *
61     * @param string $sqlFilePath Absolute path to the SQL upgrade file.
62     * @param string $version Target release version.
63     * @param string $migrationName Identifier for the migration script.
64     * @throws UpdaterException If SQL execution fails.
65     */
66    public function runUpScript(string $sqlFilePath, string $version, string $migrationName): void
67    {
68        if (!file_exists($sqlFilePath) || !is_readable($sqlFilePath)) {
69            throw new UpdaterException(sprintf('Upgrade SQL script not found or unreadable: %s', $sqlFilePath));
70        }
71
72        $rawSql = (string) file_get_contents($sqlFilePath);
73        $checksum = hash('sha256', $rawSql);
74        $startTime = microtime(true);
75
76        try {
77            $this->executeSqlScript($rawSql);
78            $executionMs = (int) round((microtime(true) - $startTime) * 1000);
79
80            $this->logMigration($version, $migrationName, 'success', $executionMs, $checksum, null);
81        } catch (Throwable $e) {
82            $executionMs = (int) round((microtime(true) - $startTime) * 1000);
83            $this->logMigration($version, $migrationName, 'failed', $executionMs, $checksum, $e->getMessage());
84
85            throw new UpdaterException(
86                sprintf('SQL migration failed for version %s (%s): %s', $version, $migrationName, $e->getMessage()),
87                0,
88                $e
89            );
90        }
91    }
92
93    /**
94     * Executes rollback SQL script.
95     *
96     * @param string $sqlFilePath Absolute path to the SQL rollback script.
97     * @param string $version Target release version.
98     * @param string $migrationName Identifier for the migration script.
99     * @throws UpdaterException If SQL rollback fails.
100     */
101    public function runDownScript(string $sqlFilePath, string $version, string $migrationName): void
102    {
103        if (!file_exists($sqlFilePath) || !is_readable($sqlFilePath)) {
104            return;
105        }
106
107        $rawSql = (string) file_get_contents($sqlFilePath);
108        $checksum = hash('sha256', $rawSql);
109        $startTime = microtime(true);
110
111        try {
112            $this->executeSqlScript($rawSql);
113            $executionMs = (int) round((microtime(true) - $startTime) * 1000);
114
115            $this->logMigration($version, $migrationName . '_rollback', 'rollback', $executionMs, $checksum, null);
116        } catch (Throwable $e) {
117            $executionMs = (int) round((microtime(true) - $startTime) * 1000);
118            $this->logMigration(
119                $version,
120                $migrationName . '_rollback',
121                'rollback_failed',
122                $executionMs,
123                $checksum,
124                $e->getMessage()
125            );
126            throw new UpdaterException(
127                sprintf('SQL rollback failed for version %s: %s', $version, $e->getMessage()),
128                0,
129                $e
130            );
131        }
132    }
133
134    /**
135     * Records new version entry in a_core_system_version.
136     */
137    public function recordSystemVersion(
138        string $version,
139        string $profile,
140        string $checksum,
141        string $appliedBy = 'system-updater'
142    ): void {
143        $tableName = $this->tablePrefix . 'core_system_version';
144        $nowExpr = ((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite')
145            ? "datetime('now')"
146            : 'NOW()';
147
148        $stmt = $this->pdo->prepare(
149            "INSERT INTO `{$tableName}` (`version`, `profile`, `installed_at`, `package_checksum`, `applied_by`) "
150            . "VALUES (:version, :profile, {$nowExpr}, :checksum, :applied_by)"
151        );
152        $stmt->execute([
153            ':version' => $version,
154            ':profile' => $profile,
155            ':checksum' => $checksum,
156            ':applied_by' => $appliedBy,
157        ]);
158    }
159
160    /**
161     * Splits and executes SQL script statement by statement.
162     */
163    private function executeSqlScript(string $sql): void
164    {
165        $cleanSql = (string) preg_replace('/^\xEF\xBB\xBF/', '', $sql);
166        $cleanSql = (string) preg_replace('/\/\*.*?\*\//s', '', $cleanSql);
167
168        $statements = $this->splitStatements($cleanSql);
169        foreach ($statements as $stmt) {
170            $this->pdo->exec($stmt);
171        }
172    }
173
174    /**
175     * Splits SQL text into individual executable queries respecting quotes.
176     *
177     * @return list<string>
178     */
179    private function splitStatements(string $sql): array
180    {
181        $stmts = [];
182        $current = '';
183        $inQuote = false;
184        $quoteChar = '';
185        $len = strlen($sql);
186
187        for ($i = 0; $i < $len; $i++) {
188            $char = $sql[$i];
189            $this->updateQuoteState($sql, $i, $char, $inQuote, $quoteChar);
190
191            if ($char === ';' && !$inQuote) {
192                $trimmed = trim($current);
193                if ($trimmed !== '') {
194                    $stmts[] = $trimmed;
195                }
196                $current = '';
197            } else {
198                $current .= $char;
199            }
200        }
201
202        $trailing = trim($current);
203        if ($trailing !== '') {
204            $stmts[] = $trailing;
205        }
206
207        return $stmts;
208    }
209
210    private function updateQuoteState(
211        string $sql,
212        int $i,
213        string $char,
214        bool &$inQuote,
215        string &$quoteChar
216    ): void {
217        if (($char !== "'" && $char !== '"' && $char !== '`') || ($i > 0 && $sql[$i - 1] === '\\')) {
218            return;
219        }
220
221        if (!$inQuote) {
222            $inQuote = true;
223            $quoteChar = $char;
224        } elseif ($quoteChar === $char) {
225            $inQuote = false;
226        }
227    }
228
229    /**
230     * Inserts log entry into a_core_system_migration.
231     */
232    private function logMigration(
233        string $version,
234        string $name,
235        string $status,
236        int $executionMs,
237        string $checksum,
238        ?string $error
239    ): void {
240        $tableName = $this->tablePrefix . 'core_system_migration';
241        if (!$this->tableExists($tableName)) {
242            return;
243        }
244
245        $nowExpr = ((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite')
246            ? "datetime('now')"
247            : 'NOW()';
248
249        $stmt = $this->pdo->prepare(
250            "INSERT INTO `{$tableName}"
251            . "(`version`, `migration_name`, `executed_at`, `status`, "
252            . "`execution_time_ms`, `sql_checksum`, `error_message`) "
253            . "VALUES (:version, :name, {$nowExpr}, :status, :exec_time, :checksum, :error)"
254        );
255
256        $stmt->execute([
257            ':version' => $version,
258            ':name' => $name,
259            ':status' => $status,
260            ':exec_time' => $executionMs,
261            ':checksum' => $checksum,
262            ':error' => $error,
263        ]);
264    }
265
266    /**
267     * Checks if given table exists in active database connection.
268     */
269    private function tableExists(string $tableName): bool
270    {
271        $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
272        if ($driver === 'sqlite') {
273            $stmt = $this->pdo->prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :t");
274            $stmt->execute([':t' => $tableName]);
275            return $stmt->fetchColumn() !== false;
276        }
277
278        $stmt = $this->pdo->prepare('SHOW TABLES LIKE :t');
279        $stmt->execute([':t' => $tableName]);
280        return $stmt->fetchColumn() !== false;
281    }
282}