Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
52.41% |
76 / 145 |
|
59.26% |
16 / 27 |
CRAP | |
0.00% |
0 / 1 |
| AbstractMigration | |
52.41% |
76 / 145 |
|
59.26% |
16 / 27 |
447.92 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getDependencies | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isReversible | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| down | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| table | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isDbType | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| isMySql | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isPostgreSql | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isSqlite | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isSqlServer | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addColumn | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
4.84 | |||
| dropColumn | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| dropColumns | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| createIndex | |
33.33% |
5 / 15 |
|
0.00% |
0 / 1 |
8.74 | |||
| createUniqueIndex | |
28.57% |
6 / 21 |
|
0.00% |
0 / 1 |
6.28 | |||
| indexExists | |
40.62% |
13 / 32 |
|
0.00% |
0 / 1 |
10.23 | |||
| dropTable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| dropTableIfExists | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| updateLanguageCode | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| integerType | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
| textType | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
| varcharType | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
| timestampType | |
40.00% |
4 / 10 |
|
0.00% |
0 / 1 |
21.82 | |||
| booleanType | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
| autoIncrementColumn | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
42 | |||
| getConfig | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getChecksum | |
100.00% |
6 / 6 |
|
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 | |
| 18 | declare(strict_types=1); |
| 19 | |
| 20 | namespace phpMyFAQ\Setup\Migration; |
| 21 | |
| 22 | use phpMyFAQ\Configuration; |
| 23 | use phpMyFAQ\Database; |
| 24 | use phpMyFAQ\Setup\Migration\Operations\OperationRecorder; |
| 25 | |
| 26 | /* @mago-expect lint:too-many-methods - provides the full migration-recording toolkit to subclasses */ |
| 27 | abstract 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 | } |