Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.73% covered (success)
92.73%
102 / 110
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
AlterTableBuilder
92.73% covered (success)
92.73%
102 / 110
91.67% covered (success)
91.67%
11 / 12
41.65
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
 addInteger
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 addVarchar
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 addText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addBoolean
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 addTimestamp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 modifyColumn
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 dropColumn
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 build
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 buildCombined
75.00% covered (warning)
75.00%
24 / 32
0.00% covered (danger)
0.00%
0 / 1
17.06
 addColumn
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Fluent builder for ALTER 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 AlterTableBuilder
26{
27    private string $tableName = '';
28    private DialectInterface $dialect;
29
30    /** @var array<int, array{action: string, column: string, type: string|null, after: string|null, default: string|null, nullable: bool}> */
31    private array $alterations = [];
32
33    public function __construct(?DialectInterface $dialect = null)
34    {
35        $this->dialect = $dialect ?? DialectFactory::create();
36    }
37
38    /**
39     * Sets the table name.
40     */
41    public function table(string $name, bool $withPrefix = true): self
42    {
43        $this->tableName = $withPrefix ? Database::getTablePrefix() . $name : $name;
44        return $this;
45    }
46
47    /**
48     * Adds a new INTEGER column.
49     */
50    public function addInteger(string $name, bool $nullable = true, ?int $default = null, ?string $after = null): self
51    {
52        return $this->addColumn(
53            'ADD',
54            $name,
55            $this->dialect->integer(),
56            $nullable,
57            $default !== null ? (string) $default : null,
58            $after,
59        );
60    }
61
62    /**
63     * Adds a new VARCHAR column.
64     */
65    public function addVarchar(
66        string $name,
67        int $length,
68        bool $nullable = true,
69        ?string $default = null,
70        ?string $after = null,
71    ): self {
72        // Escape single quotes in default value per SQL string literal rules (replace ' with '')
73        $defaultVal = $default !== null ? "'" . str_replace(search: "'", replace: "''", subject: $default) . "'" : null;
74        return $this->addColumn('ADD', $name, $this->dialect->varchar($length), $nullable, $defaultVal, $after);
75    }
76
77    /**
78     * Adds a new TEXT column.
79     */
80    public function addText(string $name, bool $nullable = true, ?string $after = null): self
81    {
82        return $this->addColumn('ADD', $name, $this->dialect->text(), $nullable, null, $after);
83    }
84
85    /**
86     * Adds a new BOOLEAN column.
87     */
88    public function addBoolean(string $name, bool $nullable = true, ?bool $default = null, ?string $after = null): self
89    {
90        $defaultVal = null;
91        if ($default !== null) {
92            $defaultVal = $default ? '1' : '0';
93        }
94
95        return $this->addColumn('ADD', $name, $this->dialect->boolean(), $nullable, $defaultVal, $after);
96    }
97
98    /**
99     * Adds a new TIMESTAMP column.
100     */
101    public function addTimestamp(
102        string $name,
103        bool $nullable = true,
104        bool $defaultCurrent = false,
105        ?string $after = null,
106    ): self {
107        $default = $defaultCurrent ? $this->dialect->currentTimestamp() : null;
108        return $this->addColumn('ADD', $name, $this->dialect->timestamp(), $nullable, $default, $after);
109    }
110
111    /**
112     * Modifies an existing column type.
113     */
114    public function modifyColumn(string $name, string $type): self
115    {
116        $this->alterations[] = [
117            'action' => 'MODIFY',
118            'column' => $name,
119            'type' => $type,
120            'after' => null,
121            'default' => null,
122            'nullable' => true,
123        ];
124        return $this;
125    }
126
127    /**
128     * Drops a column.
129     */
130    public function dropColumn(string $name): self
131    {
132        $this->alterations[] = [
133            'action' => 'DROP',
134            'column' => $name,
135            'type' => null,
136            'after' => null,
137            'default' => null,
138            'nullable' => true,
139        ];
140        return $this;
141    }
142
143    /**
144     * Builds the ALTER TABLE statement(s).
145     * Returns an array of statements because some databases require separate statements for each alteration.
146     *
147     * @return string[]
148     */
149    public function build(): array
150    {
151        // Validate that table() was called before building
152        if ($this->tableName === '') {
153            throw new \RuntimeException('Table name not set. Call table() before building ALTER TABLE statements.');
154        }
155
156        $statements = [];
157
158        foreach ($this->alterations as $alt) {
159            switch ($alt['action']) {
160                case 'ADD':
161                    $type = (string) $alt['type'];
162                    if (!$alt['nullable']) {
163                        $type .= ' NOT NULL';
164                    }
165
166                    if ($alt['nullable'] && $alt['default'] === null) {
167                        $type .= ' NULL';
168                    }
169
170                    if ($alt['default'] !== null) {
171                        $type .= ' DEFAULT ' . $alt['default'];
172                    }
173                    $statements[] = $this->dialect->addColumn(
174                        $this->tableName,
175                        $alt['column'],
176                        $type,
177                        $this->dialect->supportsColumnPositioning() ? $alt['after'] : null,
178                    );
179                    break;
180
181                case 'MODIFY':
182                    $statements[] = $this->dialect->modifyColumn(
183                        $this->tableName,
184                        $alt['column'],
185                        (string) $alt['type'],
186                    );
187                    break;
188
189                case 'DROP':
190                    $statements[] = "ALTER TABLE {$this->tableName} DROP COLUMN {$alt['column']}";
191                    break;
192            }
193        }
194
195        return $statements;
196    }
197
198    /**
199     * Builds a single ALTER TABLE statement combining all alterations (MySQL only).
200     */
201    public function buildCombined(): string
202    {
203        if (!$this->dialect->supportsCombinedAlter()) {
204            throw new LogicException(sprintf(
205                'Combined ALTER TABLE is only supported on MySQL. Current dialect: %s. Use build() instead.',
206                $this->dialect->getType(),
207            ));
208        }
209
210        // Validate that table() was called before building
211        if ($this->tableName === '') {
212            throw new \RuntimeException('Table name not set. Call table() before building ALTER TABLE statements.');
213        }
214
215        if ($this->alterations === []) {
216            throw new LogicException('No alterations defined for combined ALTER TABLE statement.');
217        }
218
219        $parts = [];
220
221        foreach ($this->alterations as $alt) {
222            switch ($alt['action']) {
223                case 'ADD':
224                    $type = (string) $alt['type'];
225                    if (!$alt['nullable']) {
226                        $type .= ' NOT NULL';
227                    }
228
229                    if ($alt['nullable'] && $alt['default'] === null) {
230                        $type .= ' NULL';
231                    }
232
233                    if ($alt['default'] !== null) {
234                        $type .= ' DEFAULT ' . $alt['default'];
235                    }
236                    $part = "ADD COLUMN {$alt['column']} {$type}";
237                    if ($this->dialect->supportsColumnPositioning() && $alt['after'] !== null) {
238                        $part .= " AFTER {$alt['after']}";
239                    }
240                    $parts[] = $part;
241                    break;
242
243                case 'MODIFY':
244                    $parts[] = "MODIFY {$alt['column']} {$alt['type']}";
245                    break;
246
247                case 'DROP':
248                    $parts[] = "DROP COLUMN {$alt['column']}";
249                    break;
250            }
251        }
252
253        return "ALTER TABLE {$this->tableName} " . implode(', ', $parts);
254    }
255
256    private function addColumn(
257        string $action,
258        string $name,
259        string $type,
260        bool $nullable,
261        ?string $default,
262        ?string $after,
263    ): self {
264        $this->alterations[] = [
265            'action' => $action,
266            'column' => $name,
267            'type' => $type,
268            'after' => $after,
269            'default' => $default,
270            'nullable' => $nullable,
271        ];
272        return $this;
273    }
274}