Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.90% covered (warning)
54.90%
28 / 51
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SchemaInstaller
54.90% covered (warning)
54.90%
28 / 51
50.00% covered (danger)
50.00%
3 / 6
76.83
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
 getSchema
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createTables
88.89% covered (success)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
10.14
 createAndUseSchema
27.78% covered (danger)
27.78%
5 / 18
0.00% covered (danger)
0.00%
0 / 1
19.56
 dropTables
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 executeSql
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Installs the database schema using the dialect-agnostic DatabaseSchema.
5 *
6 * Iterates over each table definition from DatabaseSchema, builds CREATE TABLE
7 * and CREATE INDEX statements, and executes them via the database connection.
8 * Implements DriverInterface for backward compatibility with the existing
9 * Instance\Database factory.
10 *
11 * This Source Code Form is subject to the terms of the Mozilla Public License,
12 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
13 * obtain one at https://mozilla.org/MPL/2.0/.
14 *
15 * @package   phpMyFAQ
16 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
17 * @copyright 2026 phpMyFAQ Team
18 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
19 * @link      https://www.phpmyfaq.de
20 * @since     2026-01-31
21 */
22
23declare(strict_types=1);
24
25namespace phpMyFAQ\Setup\Installation;
26
27use phpMyFAQ\Configuration;
28use phpMyFAQ\Database;
29use phpMyFAQ\Instance\Database\DriverInterface;
30use phpMyFAQ\Setup\Migration\QueryBuilder\DialectFactory;
31use phpMyFAQ\Setup\Migration\QueryBuilder\DialectInterface;
32
33class SchemaInstaller implements DriverInterface
34{
35    private readonly DialectInterface $dialect;
36
37    private readonly DatabaseSchema $schema;
38
39    /** @var string[] Collected SQL for dry-run */
40    public array $collectedSql = [];
41
42    public bool $dryRun = false;
43
44    public function __construct(
45        private readonly Configuration $configuration,
46        ?DialectInterface $dialect = null,
47    ) {
48        $this->dialect = $dialect ?? DialectFactory::create();
49        $this->schema = new DatabaseSchema($this->dialect);
50    }
51
52    /**
53     * Returns the DatabaseSchema instance.
54     */
55    public function getSchema(): DatabaseSchema
56    {
57        return $this->schema;
58    }
59
60    /**
61     * Executes all CREATE TABLE and CREATE INDEX statements.
62     *
63     * @param string $prefix Table prefix to apply. The previous prefix is restored after execution.
64     * @param string|null $schema Schema or database name for schema/database-based tenant isolation.
65     *                            For MySQL: creates and switches to a database.
66     *                            For PostgreSQL: creates and switches to a schema.
67     */
68    public function createTables(string $prefix = '', ?string $schema = null): bool
69    {
70        $previousPrefix = Database::getTablePrefix();
71
72        if ($prefix !== '') {
73            Database::setTablePrefix($prefix);
74        }
75
76        $this->collectedSql = [];
77
78        try {
79            if ($schema !== null && $schema !== '') {
80                if (!$this->createAndUseSchema($schema)) {
81                    return false;
82                }
83            }
84
85            foreach ($this->schema->getAllTables() as $tableBuilder) {
86                $createTableSql = $tableBuilder->build();
87
88                if (!$this->executeSql($createTableSql)) {
89                    return false;
90                }
91
92                foreach ($tableBuilder->buildIndexStatements() as $indexSql) {
93                    if ($this->executeSql($indexSql)) {
94                        continue;
95                    }
96
97                    return false;
98                }
99            }
100
101            return true;
102        } finally {
103            if ($prefix !== '') {
104                Database::setTablePrefix($previousPrefix ?? '');
105            }
106        }
107    }
108
109    /**
110     * Creates a schema/database and switches to it.
111     *
112     * For MySQL: CREATE DATABASE + USE.
113     * For PostgreSQL: CREATE SCHEMA + SET search_path.
114     */
115    private function createAndUseSchema(string $schema): bool
116    {
117        $dialectClass = $this->dialect::class;
118
119        if (str_contains($dialectClass, 'Mysql')) {
120            return (
121                $this->executeSql(sprintf('CREATE DATABASE IF NOT EXISTS `%s`', $schema))
122                && $this->executeSql(sprintf('USE `%s`', $schema))
123            );
124        }
125
126        if (str_contains($dialectClass, 'Pgsql')) {
127            return (
128                $this->executeSql(sprintf('CREATE SCHEMA IF NOT EXISTS "%s"', $schema))
129                && $this->executeSql(sprintf('SET search_path TO "%s"', $schema))
130            );
131        }
132
133        if (str_contains($dialectClass, 'Sqlsrv')) {
134            return $this->executeSql(sprintf(
135                "IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '%s') EXEC('CREATE SCHEMA [%s]')",
136                $schema,
137                $schema,
138            ));
139        }
140
141        return true;
142    }
143
144    /**
145     * Executes all DROP TABLE statements for the schema tables.
146     */
147    public function dropTables(string $prefix = ''): bool
148    {
149        if ($prefix === '') {
150            $prefix = Database::getTablePrefix();
151        }
152
153        foreach ($this->schema->getTableNames() as $tableName) {
154            $sql = sprintf('DROP TABLE %s%s', $prefix, $tableName);
155            $result = $this->configuration->getDb()->query($sql);
156
157            if (!$result) {
158                return false;
159            }
160        }
161
162        return true;
163    }
164
165    private function executeSql(string $sql): bool
166    {
167        $this->collectedSql[] = $sql;
168
169        if ($this->dryRun) {
170            return true;
171        }
172
173        return (bool) $this->configuration->getDb()->query($sql);
174    }
175}