Lines 61.56% 173 / 281
Methods 60.00% 18 / 30
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 getVersion 100.00% 1 / 1 100.00% 1 / 1 1
 getDependencies 100.00% 1 / 1 100.00% 1 / 1 1
 getDescription 100.00% 1 / 1 100.00% 1 / 1 1
 up 70.89% 95 / 134 0.00% 0 / 1 24.12
 [phpMyFAQ\Setup\Migration\AbstractMigration] __construct 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] isReversible 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] down 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] table 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] isDbType 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] isMySql 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] isPostgreSql 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] isSqlite 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] isSqlServer 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] addColumn 62.50% 5 / 8 0.00% 0 / 1 4.84
 [phpMyFAQ\Setup\Migration\AbstractMigration] dropColumn 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] dropColumns 100.00% 6 / 6 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] createIndex 33.33% 5 / 15 0.00% 0 / 1 8.74
 [phpMyFAQ\Setup\Migration\AbstractMigration] createUniqueIndex 28.57% 6 / 21 0.00% 0 / 1 6.28
 [phpMyFAQ\Setup\Migration\AbstractMigration] indexExists 40.62% 13 / 32 0.00% 0 / 1 10.23
 [phpMyFAQ\Setup\Migration\AbstractMigration] dropTable 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] dropTableIfExists 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] updateLanguageCode 100.00% 11 / 11 100.00% 1 / 1 1
 [phpMyFAQ\Setup\Migration\AbstractMigration] integerType 66.66% 2 / 3 0.00% 0 / 1 3.33
 [phpMyFAQ\Setup\Migration\AbstractMigration] textType 66.66% 2 / 3 0.00% 0 / 1 3.33
 [phpMyFAQ\Setup\Migration\AbstractMigration] varcharType 0.00% 0 / 3 0.00% 0 / 1 12
 [phpMyFAQ\Setup\Migration\AbstractMigration] timestampType 40.00% 4 / 10 0.00% 0 / 1 21.82
 [phpMyFAQ\Setup\Migration\AbstractMigration] booleanType 0.00% 0 / 4 0.00% 0 / 1 20
 [phpMyFAQ\Setup\Migration\AbstractMigration] autoIncrementColumn 0.00% 0 / 6 0.00% 0 / 1 42
 [phpMyFAQ\Setup\Migration\AbstractMigration] getConfig 0.00% 0 / 1 0.00% 0 / 1 2
 [phpMyFAQ\Setup\Migration\AbstractMigration] getChecksum 100.00% 6 / 6 100.00% 1 / 1 1
26readonly class Migration400Alpha extends AbstractMigration
27{
28    public function getVersion(): string
29    {
30        return '4.0.0-alpha';
31    }
32
33    public function getDependencies(): array
34    {
35        return ['3.2.3'];
36    }
37
38    public function getDescription(): string
39    {
40        return 'New file layout, bookmarks, sticky order, online update config, remove social networks';
41    }
42
43    public function up(OperationRecorder $recorder): void
44    {
45        // Copy database configuration
46        if (defined('PMF_LEGACY_CONFIG_DIR') && defined('PMF_CONFIG_DIR')) {
47            $legacyConfigDir = (string) PMF_LEGACY_CONFIG_DIR;
48            $configDir = (string) PMF_CONFIG_DIR;
49            $recorder->copyFile($legacyConfigDir . '/database.php', $configDir . '/database.php');
50
51            // Copy Azure configuration, if available
52            $recorder->copyFile($legacyConfigDir . '/azure.php', $configDir . '/azure.php', true);
53
54            // Copy Elasticsearch configuration, if available
55            $recorder->copyFile($legacyConfigDir . '/elasticsearch.php', $configDir . '/elasticsearch.php', true);
56
57            // Copy LDAP configuration, if available
58            $recorder->copyFile($legacyConfigDir . '/ldap.php', $configDir . '/ldap.php', true);
59        }
60
61        if (defined('PMF_ROOT_DIR')) {
62            $rootDir = (string) PMF_ROOT_DIR;
63
64            // Copy data directory
65            $recorder->copyDirectory($rootDir . '/data', $rootDir . '/content/core');
66
67            // Copy logs directory
68            $recorder->copyDirectory($rootDir . '/logs', $rootDir . '/content/core');
69
70            // Copy attachments directory
71            $recorder->copyDirectory($rootDir . '/attachments', $rootDir . '/content/user');
72
73            // Copy images directory
74            $recorder->copyDirectory($rootDir . '/images', $rootDir . '/content/user');
75        }
76
77        // Online Update configuration
78        $recorder->addConfig('upgrade.onlineUpdateEnabled', true);
79        $recorder->addConfig('upgrade.releaseEnvironment', ReleaseType::DEVELOPMENT->value);
80        $recorder->addConfig('upgrade.dateLastChecked', '');
81        $recorder->addConfig('upgrade.lastDownloadedPackage', '');
82
83        // Rewrite rules are now mandatory, social network support removed
84        $recorder->deleteConfig('main.enableRewriteRules');
85        $recorder->deleteConfig('socialnetworks.enableTwitterSupport');
86        $recorder->deleteConfig('socialnetworks.twitterConsumerKey');
87        $recorder->deleteConfig('socialnetworks.twitterConsumerSecret');
88        $recorder->deleteConfig('socialnetworks.twitterAccessTokenKey');
89        $recorder->deleteConfig('socialnetworks.twitterAccessTokenSecret');
90        $recorder->deleteConfig('socialnetworks.disableAll');
91        $recorder->deleteConfig('mail.remoteSMTPEncryption');
92
93        // Bookmarks support
94        // "CREATE TABLE IF NOT EXISTS" keeps a re-run alive after a previously failed
95        // update - SQL Server has no support for it
96        if ($this->isMySql()) {
97            $recorder->addSql(
98                sprintf(
99                    'CREATE TABLE IF NOT EXISTS %sfaqbookmarks (userid int(11) DEFAULT NULL, faqid int(11) DEFAULT NULL)',
100                    $this->tablePrefix,
101                ),
102                'Create bookmarks table (MySQL)',
103            );
104        }
105
106        if (!$this->isMySql() && !$this->isSqlServer()) {
107            $recorder->addSql(
108                sprintf(
109                    'CREATE TABLE IF NOT EXISTS %sfaqbookmarks (userid INTEGER DEFAULT NULL, faqid INTEGER DEFAULT NULL)',
110                    $this->tablePrefix,
111                ),
112                'Create bookmarks table',
113            );
114        }
115
116        if ($this->isSqlServer()) {
117            $recorder->addSql(
118                sprintf(
119                    'CREATE TABLE %sfaqbookmarks (userid INTEGER DEFAULT NULL, faqid INTEGER DEFAULT NULL)',
120                    $this->tablePrefix,
121                ),
122                'Create bookmarks table',
123            );
124        }
125
126        // Custom order of sticky records
127        if ($this->isMySql()) {
128            $recorder->addSql(
129                sprintf('ALTER TABLE %sfaqdata ADD COLUMN sticky_order int(10) DEFAULT NULL', $this->tablePrefix),
130                'Add sticky_order column to faqdata (MySQL)',
131            );
132
133            $recorder->addSql(
134                sprintf(
135                    'ALTER TABLE %sfaqdata_revisions ADD COLUMN sticky_order int(10) DEFAULT NULL',
136                    $this->tablePrefix,
137                ),
138                'Add sticky_order column to faqdata_revisions (MySQL)',
139            );
140        }
141
142        if ($this->isPostgreSql()) {
143            // "IF NOT EXISTS" keeps a re-run alive after a previously failed update
144            $recorder->addSql(
145                sprintf(
146                    'ALTER TABLE %sfaqdata ADD COLUMN IF NOT EXISTS sticky_order integer DEFAULT NULL',
147                    $this->tablePrefix,
148                ),
149                'Add sticky_order column to faqdata',
150            );
151
152            $recorder->addSql(
153                sprintf(
154                    'ALTER TABLE %sfaqdata_revisions ADD COLUMN IF NOT EXISTS sticky_order integer DEFAULT NULL',
155                    $this->tablePrefix,
156                ),
157                'Add sticky_order column to faqdata_revisions',
158            );
159        }
160
161        if (!$this->isMySql() && !$this->isPostgreSql()) {
162            $recorder->addSql(
163                sprintf('ALTER TABLE %sfaqdata ADD COLUMN sticky_order integer DEFAULT NULL', $this->tablePrefix),
164                'Add sticky_order column to faqdata',
165            );
166
167            $recorder->addSql(
168                sprintf(
169                    'ALTER TABLE %sfaqdata_revisions ADD COLUMN sticky_order integer DEFAULT NULL',
170                    $this->tablePrefix,
171                ),
172                'Add sticky_order column to faqdata_revisions',
173            );
174        }
175
176        $recorder->addConfig('records.orderStickyFaqsCustom', 'false');
177
178        // Remove template metadata tables ("IF EXISTS" keeps a re-run alive after
179        // a previously failed update - supported by all databases, SQL Server since 2016)
180        $recorder->addSql($this->dropTableIfExists('faqmeta'), 'Drop faqmeta table');
181
182        // Blocked statistics browsers
183        $recorder->addConfig('main.botIgnoreList', 'nustcrape,webpost,GoogleBot,msnbot,crawler,scooter,
184            bravobrian,archiver,w3c,controler,wget,bot,spider,Yahoo! Slurp,htdig,gsa-crawler,AirControler,Uptime-Kuma');
185
186        // Enable/Disable cookie consent
187        $recorder->addConfig('main.enableCookieConsent', true);
188
189        // Add parent category ID to faqcategory_order
190        if ($this->isMySql()) {
191            $recorder->addSql(
192                sprintf(
193                    'ALTER TABLE %sfaqcategory_order ADD COLUMN parent_id int(11) DEFAULT NULL AFTER category_id',
194                    $this->tablePrefix,
195                ),
196                'Add parent_id column to faqcategory_order (MySQL)',
197            );
198        }
199
200        if (!$this->isMySql() && $this->isSqlServer()) {
201            $recorder->addSql(
202                sprintf(
203                    'ALTER TABLE %sfaqcategory_order ADD COLUMN parent_id INTEGER DEFAULT NULL',
204                    $this->tablePrefix,
205                ),
206                'Add parent_id column to faqcategory_order (SQL Server)',
207            );
208        }
209
210        if (!$this->isMySql() && !$this->isSqlServer()) {
211            // SQLite and PostgreSQL - table rebuild approach
212            $recorder->addSql(sprintf(
213                'CREATE TABLE IF NOT EXISTS %sfaqcategory_order_new (
214                    category_id INTEGER NOT NULL,
215                    parent_id INTEGER DEFAULT NULL,
216                    position INTEGER NOT NULL,
217                    PRIMARY KEY (category_id))',
218                $this->tablePrefix,
219            ), 'Create new faqcategory_order table with parent_id');
220
221            // The NOT IN guard keeps a re-run from violating the primary key if
222            // a previous update attempt already copied some rows.
223            $recorder->addSql(
224                sprintf(
225                    'INSERT INTO %sfaqcategory_order_new (category_id, parent_id, position)
226                        SELECT category_id, NULL AS parent_id, position FROM %sfaqcategory_order
227                        WHERE category_id NOT IN (SELECT category_id FROM %sfaqcategory_order_new)',
228                    $this->tablePrefix,
229                    $this->tablePrefix,
230                    $this->tablePrefix,
231                ),
232                'Copy data to new faqcategory_order table',
233            );
234
235            $recorder->addSql($this->dropTableIfExists('faqcategory_order'), 'Drop old faqcategory_order table');
236
237            $recorder->addSql(
238                sprintf(
239                    'ALTER TABLE %sfaqcategory_order_new RENAME TO %sfaqcategory_order',
240                    $this->tablePrefix,
241                    $this->tablePrefix,
242                ),
243                'Rename new faqcategory_order table',
244            );
245        }
246    }
247}

Inherited from phpMyFAQ\Setup\Migration\AbstractMigration

32    public function __construct(
33        protected Configuration $configuration,
34    ) {
35        $this->tablePrefix = Database::getTablePrefix();
36        $this->dbType = Database::getType();
37    }
52    public function isReversible(): bool
53    {
54        return false;
55    }
60    public function down(OperationRecorder $recorder): void
61    {
62        // Override in subclass if migration is reversible
63    }
68    protected function table(string $name): string
69    {
70        return $this->tablePrefix . $name;
71    }
78    protected function isDbType(string|array $types): bool
79    {
80        $types = (array) $types;
81        return in_array($this->dbType, $types, strict: true);
82    }
87    protected function isMySql(): bool
88    {
89        return $this->isDbType(['mysqli', 'pdo_mysql']);
90    }
95    protected function isPostgreSql(): bool
96    {
97        return $this->isDbType(['pgsql', 'pdo_pgsql']);
98    }
103    protected function isSqlite(): bool
104    {
105        return $this->isDbType(['sqlite3', 'pdo_sqlite']);
106    }
111    protected function isSqlServer(): bool
112    {
113        return $this->isDbType(['sqlsrv', 'pdo_sqlsrv']);
114    }
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    }
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    }
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    }
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    }
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    }
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    }
286    protected function dropTable(string $table): string
287    {
288        return sprintf('DROP TABLE %s', $this->table($table));
289    }
294    protected function dropTableIfExists(string $table): string
295    {
296        return sprintf('DROP TABLE IF EXISTS %s', $this->table($table));
297    }
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    }
321    protected function integerType(): string
322    {
323        return match ($this->dbType) {
324            'mysqli', 'pdo_mysql' => 'INT',
325            default => 'INTEGER',
326        };
327    }
332    protected function textType(): string
333    {
334        return match ($this->dbType) {
335            'sqlsrv', 'pdo_sqlsrv' => 'NVARCHAR(MAX)',
336            default => 'TEXT',
337        };
338    }
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    }
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    }
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    }
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    }
402    protected function getConfig(string $key): mixed
403    {
404        return $this->configuration->get(item: $key);
405    }
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    }