Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.48% covered (warning)
84.48%
98 / 116
30.77% covered (danger)
30.77%
4 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PhysicalTableManager
84.35% covered (warning)
84.35%
97 / 115
30.77% covered (danger)
30.77%
4 / 13
50.09
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
 resolveFullTableName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tableExists
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 hasColumn
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 createTableIfNotExists
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 ensureColumnsExist
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 dropTableIfExists
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 createTable
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
8.58
 syncTableColumns
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 getExistingColumnNames
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
7.33
 buildColumnDefinition
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 resolveSqlType
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 mapToSqliteType
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
7.39
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\ModuleBuilder\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\ModuleBuilder\Domain\Exception\ModuleBuilderException;
12use App\Core\ModuleBuilder\Domain\Model\ModuleDefinitionDto;
13use App\Core\ModuleBuilder\Domain\Model\ModuleFieldDto;
14use PDO;
15
16/**
17 * Manages physical SQL database table creation and schema modifications (DDL).
18 * Compatible with MySQL, MariaDB, and SQLite (for unit tests).
19 *
20 * @package App\Core\ModuleBuilder\Application\Service
21 */
22final class PhysicalTableManager
23{
24    /**
25     * @param PDO    $pdo         Active database connection.
26     * @param string $tablePrefix Optional table prefix for modules.
27     */
28    public function __construct(
29        private readonly PDO $pdo,
30        private readonly string $tablePrefix = ''
31    ) {
32    }
33
34    public function resolveFullTableName(string $tableName): string
35    {
36        return $this->tablePrefix . $tableName;
37    }
38
39    public function tableExists(string $tableName): bool
40    {
41        $resolved = $this->resolveFullTableName($tableName);
42        $columns = $this->getExistingColumnNames($resolved);
43        if (!empty($columns)) {
44            return true;
45        }
46
47        return !empty($this->getExistingColumnNames($tableName));
48    }
49
50    public function hasColumn(string $tableName, string $columnName): bool
51    {
52        $resolved = $this->resolveFullTableName($tableName);
53        $columns = $this->getExistingColumnNames($resolved);
54        if (in_array(strtolower($columnName), $columns, true)) {
55            return true;
56        }
57
58        $plain = $this->getExistingColumnNames($tableName);
59        return in_array(strtolower($columnName), $plain, true);
60    }
61
62    /**
63     * @param string $tableName
64     * @param list<ModuleFieldDto> $fields
65     */
66    public function createTableIfNotExists(string $tableName, array $fields = []): string
67    {
68        $fullName = $this->resolveFullTableName($tableName);
69        $dto = new ModuleDefinitionDto(
70            basicInfo: new \App\Core\ModuleBuilder\Domain\Model\ModuleBasicInfoDto(
71                name: $tableName,
72                label: ucfirst($tableName),
73                tableName: $fullName
74            ),
75            fields: $fields
76        );
77
78        return $this->createTable($dto);
79    }
80
81    /**
82     * @param string $tableName
83     * @param list<ModuleFieldDto> $fields
84     * @return list<string>
85     */
86    public function ensureColumnsExist(string $tableName, array $fields): array
87    {
88        $fullName = $this->resolveFullTableName($tableName);
89        $dto = new ModuleDefinitionDto(
90            basicInfo: new \App\Core\ModuleBuilder\Domain\Model\ModuleBasicInfoDto(
91                name: $tableName,
92                label: ucfirst($tableName),
93                tableName: $fullName
94            ),
95            fields: $fields
96        );
97
98        return $this->syncTableColumns($dto);
99    }
100
101    /**
102     * Drops physical database table if it exists (used during rollback on creation error).
103     *
104     * @param string $tableName Table name to drop.
105     */
106    public function dropTableIfExists(string $tableName): void
107    {
108        if ($tableName === '') {
109            return;
110        }
111
112        $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
113        $isSqlite = $driver === 'sqlite';
114
115        $sql = sprintf(
116            'DROP TABLE IF EXISTS %s',
117            $isSqlite ? "\"{$tableName}\"" : "`{$tableName}`"
118        );
119
120        $this->pdo->exec($sql);
121    }
122
123    /**
124     * Creates physical SQL table for a new module if it does not already exist.
125     *
126     * @param ModuleDefinitionDto $definition
127     * @return string Executed DDL statement.
128     */
129    public function createTable(ModuleDefinitionDto $definition): string
130    {
131        $tableName = $definition->basicInfo->tableName;
132        if ($tableName === '') {
133            throw new ModuleBuilderException('Cannot create table: table_name is empty.');
134        }
135
136        $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
137        $isSqlite = $driver === 'sqlite';
138
139        $columnDefs = [];
140        if ($isSqlite) {
141            $columnDefs[] = 'id INTEGER PRIMARY KEY AUTOINCREMENT';
142            $columnDefs[] = 'created_at DATETIME NOT NULL';
143            $columnDefs[] = 'updated_at DATETIME NOT NULL';
144            $columnDefs[] = 'created_by INTEGER NOT NULL DEFAULT 1';
145            $columnDefs[] = 'owner INTEGER NOT NULL DEFAULT 1';
146            $columnDefs[] = 'co_owners TEXT NULL';
147            $columnDefs[] = 'special_access INTEGER NOT NULL DEFAULT 1';
148        } else {
149            $columnDefs[] = '`id` INT UNSIGNED NOT NULL AUTO_INCREMENT';
150            $columnDefs[] = '`created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)';
151            $columnDefs[] = '`updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ' .
152                'ON UPDATE CURRENT_TIMESTAMP(6)';
153            $columnDefs[] = '`created_by` INT UNSIGNED NOT NULL DEFAULT 1';
154            $columnDefs[] = '`owner` INT UNSIGNED NOT NULL DEFAULT 1';
155            $columnDefs[] = '`co_owners` JSON NULL DEFAULT NULL';
156            $columnDefs[] = '`special_access` TINYINT UNSIGNED NOT NULL DEFAULT 1';
157        }
158
159        // Standard system columns not to be duplicated
160        $systemCols = ['id', 'created_at', 'updated_at', 'created_by', 'owner', 'co_owners', 'special_access'];
161
162        foreach ($definition->fields as $field) {
163            if (in_array(strtolower($field->fieldKey), $systemCols, true)) {
164                continue;
165            }
166            $columnDefs[] = $this->buildColumnDefinition($field, $isSqlite);
167        }
168
169        if (!$isSqlite) {
170            $columnDefs[] = 'PRIMARY KEY (`id`)';
171            $columnDefs[] = 'KEY `idx_special_access` (`special_access`)';
172            $columnDefs[] = 'KEY `idx_owner` (`owner`)';
173        }
174
175        $ddl = sprintf(
176            'CREATE TABLE IF NOT EXISTS %s (%s)%s',
177            $isSqlite ? "\"{$tableName}\"" : "`{$tableName}`",
178            implode(', ', $columnDefs),
179            $isSqlite ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
180        );
181
182        $this->pdo->exec($ddl);
183
184        return $ddl;
185    }
186
187    /**
188     * Checks existing physical columns and applies delta ALTER TABLE statements for new fields.
189     *
190     * @param ModuleDefinitionDto $definition
191     * @return list<string> List of executed ALTER TABLE statements.
192     */
193    public function syncTableColumns(ModuleDefinitionDto $definition): array
194    {
195        $tableName = $definition->basicInfo->tableName;
196        if ($tableName === '') {
197            return [];
198        }
199
200        $existingColumns = $this->getExistingColumnNames($tableName);
201        $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
202        $isSqlite = $driver === 'sqlite';
203
204        $executedSqls = [];
205
206        foreach ($definition->fields as $field) {
207            $colName = strtolower($field->fieldKey);
208            if (in_array($colName, $existingColumns, true)) {
209                continue;
210            }
211
212            $colDef = $this->buildColumnDefinition($field, $isSqlite);
213            $sql = sprintf(
214                'ALTER TABLE %s ADD COLUMN %s',
215                $isSqlite ? "\"{$tableName}\"" : "`{$tableName}`",
216                $colDef
217            );
218
219            $this->pdo->exec($sql);
220            $executedSqls[] = $sql;
221            $existingColumns[] = $colName;
222        }
223
224        return $executedSqls;
225    }
226
227    /**
228     * Returns list of lowercase existing column names from physical table.
229     *
230     * @param string $tableName
231     * @return list<string>
232     */
233    public function getExistingColumnNames(string $tableName): array
234    {
235        $driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
236        $columns = [];
237
238        if ($driver === 'sqlite') {
239            $stmt = $this->pdo->query("PRAGMA table_info(\"{$tableName}\")");
240            if ($stmt !== false) {
241                while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
242                    $columns[] = strtolower((string) $row['name']);
243                }
244            }
245        } else {
246            $stmt = $this->pdo->query("SHOW COLUMNS FROM `{$tableName}`");
247            if ($stmt !== false) {
248                while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
249                    $columns[] = strtolower((string) $row['Field']);
250                }
251            }
252        }
253
254        return $columns;
255    }
256
257    /**
258     * Formats physical column definition string.
259     */
260    private function buildColumnDefinition(ModuleFieldDto $field, bool $isSqlite): string
261    {
262        $name = $field->fieldKey;
263        $sqlType = $this->resolveSqlType($field->sqlType, $isSqlite);
264
265        $nullClause = 'NULL';
266        $defaultClause = '';
267        if ($field->defaultValue !== null && $field->defaultValue !== '') {
268            $defaultClause = ' DEFAULT ' . $this->pdo->quote($field->defaultValue);
269        }
270
271        if ($isSqlite) {
272            return sprintf('"%s" %s %s%s', $name, $sqlType, $nullClause, $defaultClause);
273        }
274
275        return sprintf('`%s` %s %s%s', $name, $sqlType, $nullClause, $defaultClause);
276    }
277
278    /**
279     * Maps MySQL data types to SQLite equivalents when running under SQLite.
280     */
281    private function resolveSqlType(string $sqlType, bool $isSqlite): string
282    {
283        if (!$isSqlite) {
284            return $sqlType;
285        }
286
287        return $this->mapToSqliteType(strtoupper($sqlType));
288    }
289
290    /**
291     * Maps uppercase MySQL type string to SQLite affinity.
292     */
293    private function mapToSqliteType(string $upper): string
294    {
295        if (str_contains($upper, 'INT')) {
296            return 'INTEGER';
297        }
298        if (str_contains($upper, 'DECIMAL') || str_contains($upper, 'FLOAT') || str_contains($upper, 'DOUBLE')) {
299            return 'NUMERIC';
300        }
301
302        return (str_contains($upper, 'DATE') || str_contains($upper, 'TIME')) ? 'DATETIME' : 'TEXT';
303    }
304}