Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.58% covered (success)
97.58%
121 / 124
86.36% covered (success)
86.36%
19 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
TableBuilder
97.58% covered (success)
97.58%
121 / 124
86.36% covered (success)
86.36%
19 / 22
54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 table
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 ifNotExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 integer
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 bigInteger
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 smallInteger
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 varchar
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 text
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 longText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 blob
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 boolean
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 timestamp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 date
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 char
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 autoIncrement
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 primaryKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fullTextIndex
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 uniqueIndex
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 build
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
18
 buildIndexStatements
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 addColumn
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Fluent builder for CREATE TABLE statements.
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\QueryBuilder;
21
22use LogicException;
23use phpMyFAQ\Database;
24
25class TableBuilder
26{
27    private string $tableName = '';
28    private bool $ifNotExists = false;
29    private DialectInterface $dialect;
30
31    /** @var array<string, array{type: string, nullable: bool, default: string|null, extra: string|null}> */
32    private array $columns = [];
33
34    /** @var string[] */
35    private array $primaryKey = [];
36
37    /** @var array<string, array{columns: string[], unique: bool}> */
38    private array $indexes = [];
39
40    /** @var array<string[]> */
41    private array $fullTextIndexes = [];
42
43    public function __construct(?DialectInterface $dialect = null)
44    {
45        $this->dialect = $dialect ?? DialectFactory::create();
46    }
47
48    /**
49     * Sets the table name.
50     */
51    public function table(string $name, bool $withPrefix = true): self
52    {
53        $this->tableName = $withPrefix ? Database::getTablePrefix() . $name : $name;
54        return $this;
55    }
56
57    /**
58     * Adds IF NOT EXISTS clause.
59     */
60    public function ifNotExists(): self
61    {
62        $this->ifNotExists = true;
63        return $this;
64    }
65
66    /**
67     * Adds an INTEGER column.
68     */
69    public function integer(string $name, bool $nullable = true, ?int $default = null): self
70    {
71        return $this->addColumn(
72            $name,
73            $this->dialect->integer(),
74            $nullable,
75            $default !== null ? (string) $default : null,
76        );
77    }
78
79    /**
80     * Adds a BIGINT column.
81     */
82    public function bigInteger(string $name, bool $nullable = true, ?int $default = null): self
83    {
84        return $this->addColumn(
85            $name,
86            $this->dialect->bigInteger(),
87            $nullable,
88            $default !== null ? (string) $default : null,
89        );
90    }
91
92    /**
93     * Adds a SMALLINT column.
94     */
95    public function smallInteger(string $name, bool $nullable = true, ?int $default = null): self
96    {
97        return $this->addColumn(
98            $name,
99            $this->dialect->smallInteger(),
100            $nullable,
101            $default !== null ? (string) $default : null,
102        );
103    }
104
105    /**
106     * Adds a VARCHAR column.
107     */
108    public function varchar(string $name, int $length, bool $nullable = true, ?string $default = null): self
109    {
110        // Escape single quotes in default value per SQL string literal rules (replace ' with '')
111        $defaultVal = $default !== null ? "'" . str_replace(search: "'", replace: "''", subject: $default) . "'" : null;
112        return $this->addColumn($name, $this->dialect->varchar($length), $nullable, $defaultVal);
113    }
114
115    /**
116     * Adds a TEXT column.
117     */
118    public function text(string $name, bool $nullable = true): self
119    {
120        return $this->addColumn($name, $this->dialect->text(), $nullable);
121    }
122
123    /**
124     * Adds a LONGTEXT column (or equivalent).
125     */
126    public function longText(string $name, bool $nullable = true): self
127    {
128        return $this->addColumn($name, $this->dialect->longText(), $nullable);
129    }
130
131    /**
132     * Adds a BLOB column (or equivalent).
133     */
134    public function blob(string $name, bool $nullable = true): self
135    {
136        return $this->addColumn($name, $this->dialect->blob(), $nullable);
137    }
138
139    /**
140     * Adds a BOOLEAN/TINYINT column.
141     */
142    public function boolean(string $name, bool $nullable = true, ?bool $default = null): self
143    {
144        $defaultVal = match (true) {
145            $default === null => null,
146            $default => '1',
147            default => '0',
148        };
149        return $this->addColumn($name, $this->dialect->boolean(), $nullable, $defaultVal);
150    }
151
152    /**
153     * Adds a TIMESTAMP/DATETIME column.
154     */
155    public function timestamp(string $name, bool $nullable = true, bool $defaultCurrent = false): self
156    {
157        $default = $defaultCurrent ? $this->dialect->currentTimestamp() : null;
158        return $this->addColumn($name, $this->dialect->timestamp(), $nullable, $default);
159    }
160
161    /**
162     * Adds a DATE column.
163     */
164    public function date(string $name, bool $nullable = true, bool $defaultCurrent = false): self
165    {
166        $default = $defaultCurrent ? $this->dialect->currentDate() : null;
167        return $this->addColumn($name, $this->dialect->date(), $nullable, $default);
168    }
169
170    /**
171     * Adds a CHAR column.
172     */
173    public function char(string $name, int $length, bool $nullable = true, ?string $default = null): self
174    {
175        // Escape single quotes in default value per SQL string literal rules (replace ' with '')
176        $defaultVal = $default !== null ? "'" . str_replace(search: "'", replace: "''", subject: $default) . "'" : null;
177        return $this->addColumn($name, $this->dialect->char($length), $nullable, $defaultVal);
178    }
179
180    /**
181     * Adds an auto-increment primary key column.
182     */
183    public function autoIncrement(string $name = 'id'): self
184    {
185        $this->columns[$name] = [
186            'type' => 'AUTO_INCREMENT',
187            'nullable' => false,
188            'default' => null,
189            'extra' => null,
190        ];
191
192        if ($this->primaryKey === []) {
193            $this->primaryKey = [$name];
194        }
195
196        return $this;
197    }
198
199    /**
200     * Sets the primary key column(s).
201     *
202     * @param string|string[] $columns
203     */
204    public function primaryKey(string|array $columns): self
205    {
206        $this->primaryKey = (array) $columns;
207        return $this;
208    }
209
210    /**
211     * Adds an index.
212     *
213     * @param string|string[] $columns
214     */
215    public function index(string $name, string|array $columns): self
216    {
217        $this->indexes[$name] = [
218            'columns' => (array) $columns,
219            'unique' => false,
220        ];
221        return $this;
222    }
223
224    /**
225     * Adds a FULLTEXT index (MySQL only, ignored for other dialects).
226     *
227     * @param string|string[] $columns
228     */
229    public function fullTextIndex(string|array $columns): self
230    {
231        $this->fullTextIndexes[] = (array) $columns;
232        return $this;
233    }
234
235    /**
236     * Adds a unique index.
237     *
238     * @param string|string[] $columns
239     */
240    public function uniqueIndex(string $name, string|array $columns): self
241    {
242        $this->indexes[$name] = [
243            'columns' => (array) $columns,
244            'unique' => true,
245        ];
246        return $this;
247    }
248
249    /**
250     * Builds the CREATE TABLE statement.
251     */
252    public function build(): string
253    {
254        if ($this->tableName === '') {
255            throw new LogicException('Table name not set: call table() before build()');
256        }
257
258        $parts = [];
259
260        foreach ($this->columns as $name => $def) {
261            if ($def['type'] === 'AUTO_INCREMENT') {
262                $parts[] = $this->dialect->autoIncrement($name);
263                continue;
264            }
265
266            $col = "{$name} {$def['type']}";
267            if (!$def['nullable']) {
268                $col .= ' NOT NULL';
269            }
270
271            if ($def['nullable'] && $def['default'] === null) {
272                $col .= ' NULL';
273            }
274
275            if ($def['default'] !== null) {
276                $col .= ' DEFAULT ' . $def['default'];
277            }
278
279            $parts[] = $col;
280        }
281
282        // Add primary key if set and not already added via autoIncrement
283        // For SQLite and MySQL, autoIncrement() already includes PRIMARY KEY, so skip explicit PRIMARY KEY
284        $hasAutoIncrement = false;
285        foreach ($this->columns as $def) {
286            if ($def['type'] !== 'AUTO_INCREMENT') {
287                continue;
288            }
289
290            $hasAutoIncrement = true;
291            break;
292        }
293
294        $dialectType = $this->dialect->getType();
295        $autoIncrementIncludesPk = in_array($dialectType, ['sqlite3', 'mysqli', 'pdo_mysql'], strict: true);
296        if ($this->primaryKey !== [] && !($hasAutoIncrement && $autoIncrementIncludesPk)) {
297            $pkColumns = implode(', ', $this->primaryKey);
298            $parts[] = "PRIMARY KEY ({$pkColumns})";
299        }
300
301        // Add inline indexes only for MySQL (MySQL supports this, other databases don't)
302        $isMysql = in_array($this->dialect->getType(), ['mysqli', 'pdo_mysql'], strict: true);
303        if ($isMysql) {
304            foreach ($this->fullTextIndexes as $ftColumns) {
305                $columnList = implode(',', $ftColumns);
306                $parts[] = "FULLTEXT ({$columnList})";
307            }
308
309            foreach ($this->indexes as $indexName => $indexDef) {
310                $columnList = implode(', ', $indexDef['columns']);
311                $indexType = $indexDef['unique'] ? 'UNIQUE INDEX' : 'INDEX';
312                $parts[] = "{$indexType} {$indexName} ({$columnList})";
313            }
314        }
315
316        $columnDefs = implode(",\n    ", $parts);
317        $prefix = $this->dialect->createTablePrefix($this->tableName, $this->ifNotExists);
318        $suffix = $this->dialect->createTableSuffix();
319
320        $sql = "{$prefix} (\n    {$columnDefs}\n)";
321        if ($suffix !== '') {
322            $sql .= ' ' . $suffix;
323        }
324
325        return $sql;
326    }
327
328    /**
329     * Returns separate CREATE INDEX statements.
330     * For MySQL, returns empty array since indexes are inlined in CREATE TABLE.
331     *
332     * @return string[]
333     */
334    public function buildIndexStatements(): array
335    {
336        if ($this->tableName === '') {
337            throw new LogicException('Table name not set: call table() before buildIndexStatements()');
338        }
339
340        // MySQL already has indexes inlined in CREATE TABLE, so no separate statements needed
341        $isMysql = in_array($this->dialect->getType(), ['mysqli', 'pdo_mysql'], strict: true);
342        if ($isMysql) {
343            return [];
344        }
345
346        $statements = [];
347        foreach ($this->indexes as $indexName => $indexDef) {
348            $statements[] = $this->dialect->createIndex(
349                $indexName,
350                $this->tableName,
351                $indexDef['columns'],
352                $this->ifNotExists,
353            );
354        }
355        return $statements;
356    }
357
358    private function addColumn(
359        string $name,
360        string $type,
361        bool $nullable,
362        ?string $default = null,
363        ?string $extra = null,
364    ): self {
365        $this->columns[$name] = [
366            'type' => $type,
367            'nullable' => $nullable,
368            'default' => $default,
369            'extra' => $extra,
370        ];
371        return $this;
372    }
373}