Lines 92.72% 102 / 110
Methods 91.66% 11 / 12
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 table 100.00% 2 / 2 100.00% 1 / 1 2
 addInteger 100.00% 8 / 8 100.00% 1 / 1 2
 addVarchar 100.00% 2 / 2 100.00% 1 / 1 2
 addText 100.00% 1 / 1 100.00% 1 / 1 1
 addBoolean 100.00% 4 / 4 100.00% 1 / 1 3
 addTimestamp 100.00% 2 / 2 100.00% 1 / 1 2
 modifyColumn 100.00% 9 / 9 100.00% 1 / 1 1
 dropColumn 100.00% 9 / 9 100.00% 1 / 1 1
 build 100.00% 31 / 31 100.00% 1 / 1 11
 buildCombined 75.00% 24 / 32 0.00% 0 / 1 17.06
 addColumn 100.00% 9 / 9 100.00% 1 / 1 1
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}