Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.41% covered (warning)
52.41%
76 / 145
59.26% covered (warning)
59.26%
16 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractMigration
52.41% covered (warning)
52.41%
76 / 145
59.26% covered (warning)
59.26%
16 / 27
447.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDependencies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isReversible
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 down
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 table
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDbType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isMySql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPostgreSql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSqlite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSqlServer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addColumn
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 dropColumn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 dropColumns
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createIndex
33.33% covered (danger)
33.33%
5 / 15
0.00% covered (danger)
0.00%
0 / 1
8.74
 createUniqueIndex
28.57% covered (danger)
28.57%
6 / 21
0.00% covered (danger)
0.00%
0 / 1
6.28
 indexExists
40.62% covered (danger)
40.62%
13 / 32
0.00% covered (danger)
0.00%
0 / 1
10.23
 dropTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dropTableIfExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateLanguageCode
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 integerType
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 textType
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 varcharType
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 timestampType
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
21.82
 booleanType
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 autoIncrementColumn
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
42
 getConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getChecksum
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Base class for all migrations with helper methods.
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;
24use phpMyFAQ\Setup\Migration\Operations\OperationRecorder;
25
26/* @mago-expect lint:too-many-methods - provides the full migration-recording toolkit to subclasses */
27abstract readonly class AbstractMigration implements MigrationInterface
28{
29    protected string $tablePrefix;
30    protected string $dbType;
31
32    public function __construct(
33        protected Configuration $configuration,
34    ) {
35        $this->tablePrefix = Database::getTablePrefix();
36        $this->dbType = Database::getType();
37    }
38
39    /**
40     * Default implementation returns an empty array (no dependencies).
41     *
42     * @return string[]
43     */
44    public function getDependencies(): array
45    {
46        return [];
47    }
48
49    /**
50     * Default implementation - migrations are not reversible unless overridden.
51     */
52    public function isReversible(): bool
53    {
54        return false;
55    }
56
57    /**
58     * Default implementation - does nothing. Override in subclass if migration is reversible.
59     */
60    public function down(OperationRecorder $recorder): void
61    {
62        // Override in subclass if migration is reversible
63    }
64
65    /**
66     * Returns the table name with a prefix.
67     */
68    protected function table(string $name): string
69    {
70        return $this->tablePrefix . $name;
71    }
72
73    /**
74     * Checks if the current database type matches any of the given types.
75     *
76     * @param string|string[] $types Database type(s) to check
77     */
78    protected function isDbType(string|array $types): bool
79    {
80        $types = (array) $types;
81        return in_array($this->dbType, $types, strict: true);
82    }
83
84    /**
85     * Returns true if running MySQL/MariaDB.
86     */
87    protected function isMySql(): bool
88    {
89        return $this->isDbType(['mysqli', 'pdo_mysql']);
90    }
91
92    /**
93     * Returns true if running PostgreSQL.
94     */
95    protected function isPostgreSql(): bool
96    {
97        return $this->isDbType(['pgsql', 'pdo_pgsql']);
98    }
99
100    /**
101     * Returns true if running SQLite.
102     */
103    protected function isSqlite(): bool
104    {
105        return $this->isDbType(['sqlite3', 'pdo_sqlite']);
106    }
107
108    /**
109     * Returns true if running SQL Server.
110     */
111    protected function isSqlServer(): bool
112    {
113        return $this->isDbType(['sqlsrv', 'pdo_sqlsrv']);
114    }
115
116    /**
117     * Helper to add a column to a table.
118     * Returns the appropriate SQL for the current database type.
119     */
120    protected function addColumn(string $table, string $column, string $type, ?string $default = null): string
121    {
122        $tableName = $this->table($table);
123        $defaultClause = '';
124        if ($default !== null) {
125            $defaultClause = " DEFAULT {$default}";
126        }
127
128        if ($this->isSqlite()) {
129            return sprintf('ALTER TABLE %s ADD COLUMN %s %s%s', $tableName, $column, $type, $defaultClause);
130        }
131
132        // MySQL, PostgreSQL, SQL Server
133        $columnKeyword = $this->isSqlServer() ? '' : 'COLUMN ';
134        return sprintf('ALTER TABLE %s ADD %s%s %s%s', $tableName, $columnKeyword, $column, $type, $defaultClause);
135    }
136
137    /**
138     * Helper to drop a column from a table.
139     */
140    protected function dropColumn(string $table, string $column): string
141    {
142        $tableName = $this->table($table);
143        return sprintf('ALTER TABLE %s DROP COLUMN %s', $tableName, $column);
144    }
145
146    /**
147     * Helper to drop multiple columns from a table.
148     * Returns one ALTER TABLE ... DROP COLUMN statement per column for cross-database compatibility.
149     *
150     * @param string[] $columns
151     * @return string[]
152     */
153    protected function dropColumns(string $table, array $columns): array
154    {
155        $tableName = $this->table($table);
156        return array_map(static fn(string $col): string => sprintf(
157            'ALTER TABLE %s DROP COLUMN %s',
158            $tableName,
159            $col,
160        ), $columns);
161    }
162
163    /**
164     * Helper to create an index.
165     *
166     * @param string|string[] $columns
167     */
168    protected function createIndex(string $table, string $indexName, string|array $columns): string
169    {
170        $tableName = $this->table($table);
171        $columnList = is_array($columns) ? implode(', ', $columns) : $columns;
172
173        if ($this->isSqlServer()) {
174            return sprintf(
175                "IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = '%s' AND object_id = OBJECT_ID('%s')) "
176                . 'CREATE INDEX %s ON %s (%s)',
177                $indexName,
178                $tableName,
179                $indexName,
180                $tableName,
181                $columnList,
182            );
183        }
184
185        // MySQL/MariaDB don't support IF NOT EXISTS in older versions
186        // Use conditional execution or manual check instead
187        if ($this->isMySql()) {
188            return sprintf('CREATE INDEX %s ON %s (%s)', $indexName, $tableName, $columnList);
189        }
190
191        // PostgreSQL and SQLite support IF NOT EXISTS
192        return sprintf('CREATE INDEX IF NOT EXISTS %s ON %s (%s)', $indexName, $tableName, $columnList);
193    }
194
195    /**
196     * Helper to create a UNIQUE index.
197     *
198     * On SQL Server, NULLs are treated as equal in unique indexes, so a filtered
199     * index is emitted to allow multiple NULL values. MySQL/MariaDB, PostgreSQL
200     * and SQLite already treat NULLs as distinct in unique indexes.
201     *
202     * @param string|string[] $columns
203     */
204    protected function createUniqueIndex(string $table, string $indexName, string|array $columns): string
205    {
206        $tableName = $this->table($table);
207        $columns = (array) $columns;
208        $columnList = implode(', ', $columns);
209
210        if ($this->isSqlServer()) {
211            $whereClause = implode(' AND ', array_map(static fn(string $col): string => sprintf(
212                '%s IS NOT NULL',
213                $col,
214            ), $columns));
215            return sprintf(
216                "IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = '%s' AND object_id = OBJECT_ID('%s')) "
217                . 'CREATE UNIQUE INDEX %s ON %s (%s) WHERE %s',
218                $indexName,
219                $tableName,
220                $indexName,
221                $tableName,
222                $columnList,
223                $whereClause,
224            );
225        }
226
227        if ($this->isMySql()) {
228            return sprintf('CREATE UNIQUE INDEX %s ON %s (%s)', $indexName, $tableName, $columnList);
229        }
230
231        return sprintf('CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (%s)', $indexName, $tableName, $columnList);
232    }
233
234    /**
235     * Helper to check if an index exists.
236     * Returns a SQL query that checks for index existence.
237     */
238    protected function indexExists(string $table, string $indexName): string
239    {
240        $db = $this->configuration->getDb();
241        $tableName = $this->table($table);
242        $escapedTableName = $db->escape($tableName);
243        $escapedIndexName = $db->escape($indexName);
244
245        if ($this->isMySql()) {
246            return sprintf(
247                'SELECT COUNT(*) as idx_count FROM information_schema.STATISTICS '
248                . "WHERE table_schema = DATABASE() AND table_name = '%s' AND index_name = '%s'",
249                $escapedTableName,
250                $escapedIndexName,
251            );
252        }
253
254        if ($this->isPostgreSql()) {
255            return sprintf(
256                'SELECT COUNT(*) as idx_count FROM pg_indexes '
257                . "WHERE schemaname = 'public' AND tablename = '%s' AND indexname = '%s'",
258                $escapedTableName,
259                $escapedIndexName,
260            );
261        }
262
263        if ($this->isSqlite()) {
264            return sprintf(
265                'SELECT COUNT(*) as idx_count FROM sqlite_master '
266                . "WHERE type = 'index' AND name = '%s' AND tbl_name = '%s'",
267                $escapedIndexName,
268                $escapedTableName,
269            );
270        }
271
272        if ($this->isSqlServer()) {
273            return sprintf(
274                'SELECT COUNT(*) as idx_count FROM sys.indexes ' . "WHERE name = '%s' AND object_id = OBJECT_ID('%s')",
275                $escapedIndexName,
276                $escapedTableName,
277            );
278        }
279
280        throw new \RuntimeException("Unsupported database type for index existence check: {$this->dbType}");
281    }
282
283    /**
284     * Helper to drop a table.
285     */
286    protected function dropTable(string $table): string
287    {
288        return sprintf('DROP TABLE %s', $this->table($table));
289    }
290
291    /**
292     * Helper to drop a table if it exists.
293     */
294    protected function dropTableIfExists(string $table): string
295    {
296        return sprintf('DROP TABLE IF EXISTS %s', $this->table($table));
297    }
298
299    /**
300     * Helper for UPDATE queries with a language code fix pattern.
301     */
302    protected function updateLanguageCode(string $table, string $column, string $oldCode, string $newCode): string
303    {
304        $db = $this->configuration->getDb();
305        $escapedOldCode = $db->escape($oldCode);
306        $escapedNewCode = $db->escape($newCode);
307
308        return sprintf(
309            "UPDATE %s SET %s='%s' WHERE %s='%s'",
310            $this->table($table),
311            $column,
312            $escapedNewCode,
313            $column,
314            $escapedOldCode,
315        );
316    }
317
318    /**
319     * Returns the INTEGER type appropriate for the database.
320     */
321    protected function integerType(): string
322    {
323        return match ($this->dbType) {
324            'mysqli', 'pdo_mysql' => 'INT',
325            default => 'INTEGER',
326        };
327    }
328
329    /**
330     * Returns the TEXT type appropriate for the database.
331     */
332    protected function textType(): string
333    {
334        return match ($this->dbType) {
335            'sqlsrv', 'pdo_sqlsrv' => 'NVARCHAR(MAX)',
336            default => 'TEXT',
337        };
338    }
339
340    /**
341     * Returns the VARCHAR type appropriate for the database.
342     */
343    protected function varcharType(int $length): string
344    {
345        return match ($this->dbType) {
346            'sqlsrv', 'pdo_sqlsrv' => "NVARCHAR({$length})",
347            default => "VARCHAR({$length})",
348        };
349    }
350
351    /**
352     * Returns the TIMESTAMP/DATETIME type with default appropriate for the database.
353     */
354    protected function timestampType(bool $withDefault = true): string
355    {
356        $type = match ($this->dbType) {
357            'mysqli', 'pdo_mysql' => 'TIMESTAMP',
358            'sqlsrv', 'pdo_sqlsrv' => 'DATETIME',
359            default => 'TIMESTAMP',
360        };
361
362        if (!$withDefault) {
363            return $type;
364        }
365
366        return match ($this->dbType) {
367            'sqlsrv', 'pdo_sqlsrv' => $type . ' NOT NULL DEFAULT GETDATE()',
368            'sqlite3', 'pdo_sqlite' => 'DATETIME DEFAULT CURRENT_TIMESTAMP',
369            default => $type . ' NOT NULL DEFAULT CURRENT_TIMESTAMP',
370        };
371    }
372
373    /**
374     * Returns the BOOLEAN/TINYINT type appropriate for the database.
375     */
376    protected function booleanType(): string
377    {
378        return match ($this->dbType) {
379            'mysqli', 'pdo_mysql' => 'TINYINT(1)',
380            'sqlsrv', 'pdo_sqlsrv' => 'TINYINT',
381            default => 'INTEGER',
382        };
383    }
384
385    /**
386     * Returns the auto-increment column definition appropriate for the database.
387     */
388    protected function autoIncrementColumn(string $columnName = 'id'): string
389    {
390        return match ($this->dbType) {
391            'mysqli', 'pdo_mysql' => "{$columnName} INT NOT NULL AUTO_INCREMENT",
392            'pgsql', 'pdo_pgsql' => "{$columnName} SERIAL NOT NULL",
393            'sqlite3', 'pdo_sqlite' => "{$columnName} INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT",
394            'sqlsrv', 'pdo_sqlsrv' => "{$columnName} INT IDENTITY(1,1) NOT NULL",
395            default => "{$columnName} INTEGER NOT NULL",
396        };
397    }
398
399    /**
400     * Gets a configuration value safely.
401     */
402    protected function getConfig(string $key): mixed
403    {
404        return $this->configuration->get(item: $key);
405    }
406
407    /**
408     * Calculates a checksum for migration integrity.
409     */
410    public function getChecksum(): string
411    {
412        $data = [
413            'version' => $this->getVersion(),
414            'description' => $this->getDescription(),
415            'class' => static::class,
416        ];
417        return hash('sha256', (string) json_encode($data));
418    }
419}