Lines 60.62% 154 / 254
Methods 58.06% 18 / 31
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 63.88% 46 / 72 0.00% 0 / 1 12.81
 rebuildTableWithoutColumns 85.71% 30 / 35 0.00% 0 / 1 4.05
 [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
25readonly class Migration320Beta extends AbstractMigration
26{
27    public function getVersion(): string
28    {
29        return '3.2.0-beta';
30    }
31
32    public function getDependencies(): array
33    {
34        return ['3.2.0-alpha'];
35    }
36
37    public function getDescription(): string
38    {
39        return 'SMTP TLS config, remove link verification, config value as TEXT column';
40    }
41
42    public function up(OperationRecorder $recorder): void
43    {
44        $recorder->addConfig('mail.remoteSMTPDisableTLSPeerVerification', false);
45        $recorder->deleteConfig('main.enableLinkVerification');
46
47        // Delete link verification columns - use portable syntax
48        if ($this->isSqlite()) {
49            // SQLite requires table rebuild for dropping columns
50            $this->rebuildTableWithoutColumns($recorder, 'faqdata');
51            $this->rebuildTableWithoutColumns($recorder, 'faqdata_revisions');
52        }
53        if ($this->isPostgreSql()) {
54            // "IF EXISTS" keeps a re-run alive after a previously failed update
55            // already applied this statement - the version number is only updated
56            // at the very end, so a failed run starts over from the old version.
57            $recorder->addSql(
58                sprintf(
59                    'ALTER TABLE %sfaqdata DROP COLUMN IF EXISTS links_state, DROP COLUMN IF EXISTS links_check_date',
60                    $this->tablePrefix,
61                ),
62                'Remove link verification columns from faqdata',
63            );
64            $recorder->addSql(sprintf(
65                'ALTER TABLE %sfaqdata_revisions
66                        DROP COLUMN IF EXISTS links_state, DROP COLUMN IF EXISTS links_check_date',
67                $this->tablePrefix,
68            ), 'Remove link verification columns from faqdata_revisions');
69        }
70
71        if (!$this->isSqlite() && !$this->isPostgreSql()) {
72            // MySQL, SQL Server - use separate DROP COLUMN statements
73            $recorder->addSql(
74                sprintf('ALTER TABLE %sfaqdata DROP COLUMN links_state', $this->tablePrefix),
75                'Remove links_state column from faqdata',
76            );
77            $recorder->addSql(
78                sprintf('ALTER TABLE %sfaqdata DROP COLUMN links_check_date', $this->tablePrefix),
79                'Remove links_check_date column from faqdata',
80            );
81
82            $recorder->addSql(
83                sprintf('ALTER TABLE %sfaqdata_revisions DROP COLUMN links_state', $this->tablePrefix),
84                'Remove links_state column from faqdata_revisions',
85            );
86            $recorder->addSql(
87                sprintf('ALTER TABLE %sfaqdata_revisions DROP COLUMN links_check_date', $this->tablePrefix),
88                'Remove links_check_date column from faqdata_revisions',
89            );
90        }
91
92        // Configuration values in a TEXT column
93        if ($this->isMySql()) {
94            $recorder->addSql(
95                sprintf('ALTER TABLE %sfaqconfig MODIFY config_value TEXT DEFAULT NULL', $this->tablePrefix),
96                'Change faqconfig.config_value to TEXT (MySQL)',
97            );
98            return;
99        }
100
101        if ($this->isPostgreSql()) {
102            $recorder->addSql(
103                sprintf('ALTER TABLE %sfaqconfig ALTER COLUMN config_value TYPE TEXT', $this->tablePrefix),
104                'Change faqconfig.config_value to TEXT (PostgreSQL)',
105            );
106            return;
107        }
108
109        if ($this->isSqlite()) {
110            // SQLite requires table rebuild
111            $recorder->addSql(sprintf('CREATE TABLE %sfaqconfig_new (
112                    config_name VARCHAR(255) NOT NULL default \'\',
113                    config_value TEXT DEFAULT NULL, PRIMARY KEY (config_name)
114                 )', $this->tablePrefix), 'Create new faqconfig table (SQLite)');
115
116            $recorder->addSql(
117                sprintf(
118                    'INSERT INTO %sfaqconfig_new SELECT config_name, config_value FROM %sfaqconfig',
119                    $this->tablePrefix,
120                    $this->tablePrefix,
121                ),
122                'Copy data to new faqconfig table (SQLite)',
123            );
124
125            $recorder->addSql(
126                sprintf('DROP TABLE %sfaqconfig', $this->tablePrefix),
127                'Drop old faqconfig table (SQLite)',
128            );
129
130            $recorder->addSql(
131                sprintf('ALTER TABLE %sfaqconfig_new RENAME TO %sfaqconfig', $this->tablePrefix, $this->tablePrefix),
132                'Rename new faqconfig table (SQLite)',
133            );
134            return;
135        }
136
137        if ($this->isSqlServer()) {
138            $recorder->addSql(
139                sprintf('ALTER TABLE %sfaqconfig ALTER COLUMN config_value NVARCHAR(MAX)', $this->tablePrefix),
140                'Change faqconfig.config_value to NVARCHAR(MAX) (SQL Server)',
141            );
142        }
143    }
144
145    /**
146     * Rebuilds a table without specified columns (for SQLite).
147     *
148     * Note: This method uses hardcoded schema definitions for faqdata and faqdata_revisions
149     * tables due to SQLite limitations with ALTER TABLE DROP COLUMN.
150     */
151    private function rebuildTableWithoutColumns(OperationRecorder $recorder, string $tableName): void
152    {
153        $allowedTables = ['faqdata', 'faqdata_revisions'];
154        if (!in_array($tableName, $allowedTables, strict: true)) {
155            throw new \LogicException(sprintf(
156                'rebuildTableWithoutColumns() only supports [%s], got "%s"',
157                implode(', ', $allowedTables),
158                $tableName,
159            ));
160        }
161
162        $fullTableName = $this->tablePrefix . $tableName;
163
164        // For faqdata and faqdata_revisions, we need to define the schema without the removed columns
165        if ($tableName === 'faqdata') {
166            $recorder->addSql(
167                sprintf('CREATE TABLE %s_new (
168                        id INTEGER NOT NULL,
169                        lang VARCHAR(5) NOT NULL,
170                        solution_id INTEGER NOT NULL,
171                        revision_id INTEGER NOT NULL DEFAULT 0,
172                        active char(3) NOT NULL,
173                        sticky INTEGER NOT NULL,
174                        keywords text DEFAULT NULL,
175                        thema text NOT NULL,
176                        content text DEFAULT NULL,
177                        author VARCHAR(255) NOT NULL,
178                        email VARCHAR(255) NOT NULL,
179                        comment char(1) default \'y\',
180                        updated VARCHAR(15) NOT NULL,
181                        date_start VARCHAR(14) NOT NULL DEFAULT \'00000000000000\',
182                        date_end VARCHAR(14) NOT NULL DEFAULT \'99991231235959\',
183                        created DATETIME DEFAULT CURRENT_TIMESTAMP,
184                        notes text DEFAULT NULL,
185                        sticky_order INTEGER DEFAULT NULL,
186                        PRIMARY KEY (id, lang)
187                    )', $fullTableName),
188                sprintf('Create new %s table without link verification columns (SQLite)', $tableName),
189            );
190
191            $recorder->addSql(
192                sprintf('INSERT INTO %s_new
193                     SELECT id, lang, solution_id, revision_id, active, sticky, keywords, thema, content,
194                            author, email, comment, updated, date_start, date_end, created, notes, sticky_order
195                     FROM %s', $fullTableName, $fullTableName),
196                sprintf('Copy data to new %s table (SQLite)', $tableName),
197            );
198        }
199
200        if ($tableName === 'faqdata_revisions') {
201            $recorder->addSql(
202                sprintf('CREATE TABLE %s_new (
203                        id INTEGER NOT NULL,
204                        lang VARCHAR(5) NOT NULL,
205                        solution_id INTEGER NOT NULL,
206                        revision_id INTEGER NOT NULL DEFAULT 0,
207                        active char(3) NOT NULL,
208                        sticky INTEGER NOT NULL,
209                        keywords text DEFAULT NULL,
210                        thema text NOT NULL,
211                        content text DEFAULT NULL,
212                        author VARCHAR(255) NOT NULL,
213                        email VARCHAR(255) NOT NULL,
214                        comment char(1) default \'y\',
215                        updated VARCHAR(15) NOT NULL,
216                        date_start VARCHAR(14) NOT NULL DEFAULT \'00000000000000\',
217                        date_end VARCHAR(14) NOT NULL DEFAULT \'99991231235959\',
218                        created DATETIME DEFAULT CURRENT_TIMESTAMP,
219                        notes text DEFAULT NULL,
220                        sticky_order INTEGER DEFAULT NULL,
221                        PRIMARY KEY (id, lang, solution_id, revision_id)
222                    )', $fullTableName),
223                sprintf('Create new %s table without link verification columns (SQLite)', $tableName),
224            );
225
226            $recorder->addSql(
227                sprintf('INSERT INTO %s_new
228                     SELECT id, lang, solution_id, revision_id, active, sticky, keywords, thema, content,
229                            author, email, comment, updated, date_start, date_end, created, notes, sticky_order
230                     FROM %s', $fullTableName, $fullTableName),
231                sprintf('Copy data to new %s table (SQLite)', $tableName),
232            );
233        }
234
235        $recorder->addSql(sprintf('DROP TABLE %s', $fullTableName), sprintf('Drop old %s table (SQLite)', $tableName));
236
237        $recorder->addSql(
238            sprintf('ALTER TABLE %s_new RENAME TO %s', $fullTableName, $fullTableName),
239            sprintf('Rename new %s table (SQLite)', $tableName),
240        );
241    }
242}

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    }