Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.51% covered (success)
91.51%
625 / 683
50.00% covered (danger)
50.00%
15 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
ModuleBuilderService
91.50% covered (success)
91.50%
624 / 682
50.00% covered (danger)
50.00%
15 / 30
181.75
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
 createModule
88.52% covered (warning)
88.52%
54 / 61
0.00% covered (danger)
0.00%
0 / 1
13.26
 updateModule
92.86% covered (success)
92.86%
52 / 56
0.00% covered (danger)
0.00%
0 / 1
12.05
 deleteModule
91.49% covered (success)
91.49%
43 / 47
0.00% covered (danger)
0.00%
0 / 1
6.02
 getModuleDefinition
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 fetchBasicInfo
92.11% covered (success)
92.11%
35 / 38
0.00% covered (danger)
0.00%
0 / 1
8.03
 fetchSections
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 fetchFields
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 mapFieldRowToDto
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
16
 decodeJsonList
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
6.00
 decodeJsonMap
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
6.00
 fetchFilters
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 fetchRelations
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
4.02
 fetchActiveModulesForMetadata
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
3.71
 fetchPicklistsForMetadata
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
6.50
 fetchProfilesForMetadata
50.00% covered (danger)
50.00%
11 / 22
0.00% covered (danger)
0.00%
0 / 1
13.12
 getBuilderMetadata
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 insertSections
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 insertFields
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
3
 resolveFieldSectionId
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 resolveFieldUitypeId
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 resolveFieldColumnExpression
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 executeInsertField
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
13
 insertFilters
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 insertRelations
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 syncSections
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
7
 syncFields
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 executeUpdateField
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 grantInitialPermissions
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 registerNavigationMenuItem
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
6.01
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\Engine\Domain\Model\PermissionContext;
12use App\Core\ModuleBuilder\Domain\Exception\ModuleBuilderException;
13use App\Core\ModuleBuilder\Domain\Model\ModuleBasicInfoDto;
14use App\Core\ModuleBuilder\Domain\Model\ModuleDefinitionDto;
15use App\Core\ModuleBuilder\Domain\Model\ModuleFieldDto;
16use App\Core\ModuleBuilder\Domain\Model\ModuleFilterDto;
17use App\Core\ModuleBuilder\Domain\Model\ModuleRelationDto;
18use App\Core\ModuleBuilder\Domain\Model\ModuleSectionDto;
19use App\Core\ModuleBuilder\Domain\Service\UiTypeMetadataCatalog;
20use InvalidArgumentException;
21use PDO;
22use PDOStatement;
23use Throwable;
24
25/**
26 * Core Application Service orchestrating atomic module creation, schema evolution, and queries.
27 *
28 * @package App\Core\ModuleBuilder\Application\Service
29 */
30final class ModuleBuilderService implements ModuleBuilderServiceInterface
31{
32    private const string DEFAULT_MODULE_ICON = 'bi bi-box-seam';
33    private const AUDIT_COLUMNS_SQL = 'special_access, created_at, updated_at, created_by, owner) ';
34
35    private readonly PhysicalTableManager $tableManager;
36
37    /**
38     * @param PDO                       $pdo          Active database connection.
39     * @param PhysicalTableManager|null $tableManager Optional DDL executor for physical database tables.
40     * @param string                    $tablePrefix  Database table prefix.
41     */
42    public function __construct(
43        private readonly PDO $pdo,
44        ?PhysicalTableManager $tableManager = null,
45        private readonly string $tablePrefix = 'a_'
46    ) {
47        $this->tableManager = $tableManager ?? new PhysicalTableManager($this->pdo, $this->tablePrefix);
48    }
49
50    /** {@inheritdoc} */
51    public function createModule(ModuleDefinitionDto $definition, ?PermissionContext $context = null): int
52    {
53        $basic = $definition->basicInfo;
54        if (trim($basic->name) === '' || trim($basic->label) === '') {
55            throw new InvalidArgumentException('Module machine name and label are required.');
56        }
57
58        // 1. Ensure physical SQL table exists
59        $this->tableManager->createTable($definition);
60
61        $now = date('Y-m-d H:i:s');
62        $userId = ($context !== null && $context->actorUserId > 0) ? $context->actorUserId : 1;
63
64        $this->pdo->beginTransaction();
65        try {
66            // 2. Insert into a_core_module_records
67            $sql = "INSERT INTO {$this->tablePrefix}core_module_records " .
68                '(type, name, label, route_url, api_endpoint, icon_class, version, is_active, ' .
69                'has_list, has_grid, has_calendar, has_kanban, has_gantt, default_view, ' .
70                'default_create_mode, create_presentation_mode, description, table_name, ' .
71                'table_alias, primary_key, allowed_hosts, special_access, created_at, ' .
72                'updated_at, created_by, owner) ' .
73                'VALUES (:type, :name, :label, :route, :api, :icon, :ver, 1, ' .
74                ':has_list, :has_grid, :has_cal, :has_kan, :has_gantt, :def_view, ' .
75                ':def_create, :pres_mode, :desc, :table, ' .
76                ':alias, :pk, :hosts, :special, :created_at, ' .
77                ':updated_at, :uid_cby, :uid_owner)';
78
79            $stmt = $this->pdo->prepare($sql);
80            $stmt->execute([
81                ':type' => $basic->type,
82                ':name' => $basic->name,
83                ':label' => $basic->label,
84                ':route' => $basic->routeUrl,
85                ':api' => '/api/v1/engine/' . $basic->name,
86                ':icon' => $basic->iconClass,
87                ':ver' => $basic->version,
88                ':has_list' => in_array('list', $basic->supportedViews, true) ? 1 : 0,
89                ':has_grid' => in_array('grid', $basic->supportedViews, true) ? 1 : 0,
90                ':has_cal' => in_array('calendar', $basic->supportedViews, true) ? 1 : 0,
91                ':has_kan' => in_array('kanban', $basic->supportedViews, true) ? 1 : 0,
92                ':has_gantt' => in_array('gantt', $basic->supportedViews, true) ? 1 : 0,
93                ':def_view' => $basic->defaultView,
94                ':def_create' => $basic->defaultCreateMode,
95                ':pres_mode' => $basic->createPresentationMode,
96                ':desc' => $basic->description,
97                ':table' => $basic->tableName,
98                ':alias' => $basic->tableAlias,
99                ':pk' => 'id',
100                ':hosts' => json_encode([1]),
101                ':special' => $basic->specialAccess,
102                ':created_at' => $now,
103                ':updated_at' => $now,
104                ':uid_cby' => $userId,
105                ':uid_owner' => $userId,
106            ]);
107
108            $moduleId = (int) $this->pdo->lastInsertId();
109
110            // 3. Insert sections
111            $secMap = $this->insertSections($moduleId, $definition->sections, $userId, $now);
112
113            // 4. Insert fields
114            $this->insertFields($moduleId, $definition->fields, $secMap, $basic->tableAlias, $userId, $now);
115
116            // 5. Insert filters
117            $this->insertFilters($moduleId, $definition->filters, $userId, $now);
118
119            // 6. Insert relations
120            $this->insertRelations($moduleId, $definition->relations, $userId, $now);
121
122            // 7. Auto-grant permissions in a_core_profile_modules if table exists
123            $this->grantInitialPermissions($moduleId);
124
125            // 8. Register navigation menu item in a_mod_menu_records
126            $this->registerNavigationMenuItem($basic, $userId);
127
128            $this->pdo->commit();
129
130            return $moduleId;
131        } catch (Throwable $e) {
132            if ($this->pdo->inTransaction()) {
133                $this->pdo->rollBack();
134            }
135            try {
136                $this->tableManager->dropTableIfExists($basic->tableName);
137            } catch (Throwable) {
138                // Table cleanup best-effort on creation failure
139            }
140            throw $e;
141        }
142    }
143
144    /** {@inheritdoc} */
145    public function updateModule(
146        int $moduleId,
147        ModuleDefinitionDto $definition,
148        ?PermissionContext $context = null
149    ): void {
150        $chk = $this->pdo->prepare(
151            "SELECT id, name, table_name FROM {$this->tablePrefix}core_module_records WHERE id = :id LIMIT 1"
152        );
153        $chk->execute([':id' => $moduleId]);
154        $existing = $chk->fetch(PDO::FETCH_ASSOC);
155        $chk->closeCursor();
156        if ($existing === false) {
157            throw new ModuleBuilderException("Module ID {$moduleId} does not exist.");
158        }
159
160        // 1. Sync physical columns for newly added fields
161        $this->tableManager->syncTableColumns($definition);
162
163        $now = date('Y-m-d H:i:s');
164        $userId = ($context !== null && $context->actorUserId > 0) ? $context->actorUserId : 1;
165        $basic = $definition->basicInfo;
166
167        $this->pdo->beginTransaction();
168        try {
169            // 2. Update basic info in a_core_module_records
170            $sql = "UPDATE {$this->tablePrefix}core_module_records SET " .
171                'label = :label, icon_class = :icon, default_view = :def_view, ' .
172                'default_create_mode = :def_create, create_presentation_mode = :pres_mode, ' .
173                'has_list = :has_list, has_grid = :has_grid, has_calendar = :has_cal, ' .
174                'has_kanban = :has_kan, has_gantt = :has_gantt, description = :desc, ' .
175                'updated_at = :updated_at WHERE id = :id';
176
177            $stmt = $this->pdo->prepare($sql);
178            $stmt->execute([
179                ':label' => $basic->label,
180                ':icon' => $basic->iconClass,
181                ':def_view' => $basic->defaultView,
182                ':def_create' => $basic->defaultCreateMode,
183                ':pres_mode' => $basic->createPresentationMode,
184                ':has_list' => in_array('list', $basic->supportedViews, true) ? 1 : 0,
185                ':has_grid' => in_array('grid', $basic->supportedViews, true) ? 1 : 0,
186                ':has_cal' => in_array('calendar', $basic->supportedViews, true) ? 1 : 0,
187                ':has_kan' => in_array('kanban', $basic->supportedViews, true) ? 1 : 0,
188                ':has_gantt' => in_array('gantt', $basic->supportedViews, true) ? 1 : 0,
189                ':desc' => $basic->description,
190                ':updated_at' => $now,
191                ':id' => $moduleId,
192            ]);
193
194            // 3. Sync sections
195            $secMap = $this->syncSections($moduleId, $definition->sections, $userId, $now);
196
197            // 4. Sync fields
198            $this->syncFields($moduleId, $definition->fields, $secMap, $basic->tableAlias, $userId, $now);
199
200            // 5. Update filters
201            if (!empty($definition->filters)) {
202                $flt = $definition->filters[0];
203                $uFlt = $this->pdo->prepare(
204                    "UPDATE {$this->tablePrefix}core_filter_records SET visible_fields = :vis, " .
205                    'default_sort = :sort, default_order = :ord, updated_at = :upd ' .
206                    'WHERE module_id = :mid AND is_default = 1'
207                );
208                $uFlt->execute([
209                    ':vis' => json_encode($flt->visibleFields),
210                    ':sort' => $flt->defaultSort,
211                    ':ord' => $flt->defaultOrder,
212                    ':upd' => $now,
213                    ':mid' => $moduleId,
214                ]);
215            }
216
217            $this->pdo->commit();
218        } catch (Throwable $e) {
219            if ($this->pdo->inTransaction()) {
220                $this->pdo->rollBack();
221            }
222            throw $e;
223        }
224    }
225
226    /** {@inheritdoc} */
227    public function deleteModule(
228        int $moduleId,
229        bool $dropTable = true,
230        ?PermissionContext $context = null,
231    ): void {
232        $chk = $this->pdo->prepare(
233            "SELECT id, name, table_name, route_url FROM {$this->tablePrefix}core_module_records WHERE id = :id LIMIT 1"
234        );
235        $chk->execute([':id' => $moduleId]);
236        $mod = $chk->fetch(PDO::FETCH_ASSOC);
237        $chk->closeCursor();
238        if ($mod === false) {
239            throw new ModuleBuilderException("Module ID {$moduleId} does not exist.");
240        }
241
242        $tableName = (string) $mod['table_name'];
243        $routeUrl = (string) $mod['route_url'];
244
245        $this->pdo->beginTransaction();
246        try {
247            // 1. Delete navigation menu item if exists
248            $delMenu = $this->pdo->prepare(
249                "DELETE FROM {$this->tablePrefix}mod_menu_records WHERE route_url = :route"
250            );
251            $delMenu->execute([':route' => $routeUrl]);
252
253            // 2. Delete profile permissions
254            $delPerm = $this->pdo->prepare(
255                "DELETE FROM {$this->tablePrefix}core_profile_modules WHERE module_id = :id"
256            );
257            $delPerm->execute([':id' => $moduleId]);
258
259            // 3. Delete relations (both source and target)
260            $delRel = $this->pdo->prepare(
261                "DELETE FROM {$this->tablePrefix}core_relation_1m_records " .
262                'WHERE source_module_id = :src_id OR target_module_id = :tgt_id'
263            );
264            $delRel->execute([':src_id' => $moduleId, ':tgt_id' => $moduleId]);
265
266            // 4. Delete filters
267            $delFlt = $this->pdo->prepare(
268                "DELETE FROM {$this->tablePrefix}core_filter_records WHERE module_id = :id"
269            );
270            $delFlt->execute([':id' => $moduleId]);
271
272            // 5. Delete fields
273            $delFld = $this->pdo->prepare(
274                "DELETE FROM {$this->tablePrefix}core_field_records WHERE module_id = :id"
275            );
276            $delFld->execute([':id' => $moduleId]);
277
278            // 6. Delete sections
279            $delSec = $this->pdo->prepare(
280                "DELETE FROM {$this->tablePrefix}core_section_records WHERE module_id = :id"
281            );
282            $delSec->execute([':id' => $moduleId]);
283
284            // 7. Delete core module record
285            $delMod = $this->pdo->prepare(
286                "DELETE FROM {$this->tablePrefix}core_module_records WHERE id = :id"
287            );
288            $delMod->execute([':id' => $moduleId]);
289
290            $this->pdo->commit();
291
292            // 8. Drop physical SQL table if requested
293            if ($dropTable && $tableName !== '') {
294                $this->tableManager->dropTableIfExists($tableName);
295            }
296        } catch (Throwable $e) {
297            if ($this->pdo->inTransaction()) {
298                $this->pdo->rollBack();
299            }
300            throw $e;
301        }
302    }
303
304    /** {@inheritdoc} */
305    public function getModuleDefinition(int $moduleId, ?PermissionContext $context = null): ModuleDefinitionDto
306    {
307        $basicInfo = $this->fetchBasicInfo($moduleId);
308        [$sections, $secIdToName] = $this->fetchSections($moduleId);
309        $fields = $this->fetchFields($moduleId, $secIdToName);
310        $filters = $this->fetchFilters($moduleId);
311        $relations = $this->fetchRelations($moduleId);
312
313        return new ModuleDefinitionDto(
314            basicInfo: $basicInfo,
315            sections: $sections,
316            fields: $fields,
317            filters: $filters,
318            relations: $relations,
319            id: $moduleId
320        );
321    }
322
323    private function fetchBasicInfo(int $moduleId): ModuleBasicInfoDto
324    {
325        $stmt = $this->pdo->prepare(
326            "SELECT id, type, name, label, route_url, api_endpoint, icon_class, version, " .
327            'is_active, has_list, has_grid, has_calendar, has_kanban, has_gantt, ' .
328            'default_view, default_create_mode, create_presentation_mode, description, ' .
329            "table_name, table_alias, primary_key, special_access " .
330            "FROM {$this->tablePrefix}core_module_records WHERE id = :id LIMIT 1"
331        );
332        $stmt->execute([':id' => $moduleId]);
333        $modRow = $stmt->fetch(PDO::FETCH_ASSOC);
334        if ($modRow === false) {
335            throw new ModuleBuilderException("Module ID {$moduleId} not found.");
336        }
337
338        $supportedViews = [];
339        if (!empty($modRow['has_list'])) {
340            $supportedViews[] = 'list';
341        }
342        if (!empty($modRow['has_grid'])) {
343            $supportedViews[] = 'grid';
344        }
345        if (!empty($modRow['has_calendar'])) {
346            $supportedViews[] = 'calendar';
347        }
348        if (!empty($modRow['has_kanban'])) {
349            $supportedViews[] = 'kanban';
350        }
351        if (!empty($modRow['has_gantt'])) {
352            $supportedViews[] = 'gantt';
353        }
354
355        return new ModuleBasicInfoDto(
356            name: (string) $modRow['name'],
357            label: (string) $modRow['label'],
358            type: (string) $modRow['type'],
359            routeUrl: (string) $modRow['route_url'],
360            tableName: (string) $modRow['table_name'],
361            tableAlias: (string) $modRow['table_alias'],
362            iconClass: (string) ($modRow['icon_class'] ?? self::DEFAULT_MODULE_ICON),
363            version: (string) ($modRow['version'] ?? '0.0.1'),
364            description: isset($modRow['description']) ? (string) $modRow['description'] : null,
365            supportedViews: $supportedViews,
366            defaultView: (string) ($modRow['default_view'] ?? 'list'),
367            defaultCreateMode: (string) ($modRow['default_create_mode'] ?? 'standard'),
368            createPresentationMode: (string) ($modRow['create_presentation_mode'] ?? 'drawer'),
369            specialAccess: (int) ($modRow['special_access'] ?? 1)
370        );
371    }
372
373    /**
374     * @return array{0: list<ModuleSectionDto>, 1: array<int, string>}
375     */
376    private function fetchSections(int $moduleId): array
377    {
378        $secStmt = $this->pdo->prepare(
379            "SELECT id, name, label, icon_class, sort_order, is_default, is_active " .
380            "FROM {$this->tablePrefix}core_section_records WHERE module_id = :id ORDER BY sort_order ASC, id ASC"
381        );
382        $secStmt->execute([':id' => $moduleId]);
383        $sections = [];
384        $secIdToName = [];
385        while ($s = $secStmt->fetch(PDO::FETCH_ASSOC)) {
386            $sDto = new ModuleSectionDto(
387                name: (string) $s['name'],
388                label: (string) $s['label'],
389                id: (int) $s['id'],
390                iconClass: isset($s['icon_class']) ? (string) $s['icon_class'] : null,
391                sortOrder: (int) $s['sort_order'],
392                isDefault: !empty($s['is_default']),
393                isActive: !empty($s['is_active'])
394            );
395            $sections[] = $sDto;
396            $secIdToName[(int) $s['id']] = (string) $s['name'];
397        }
398
399        return [$sections, $secIdToName];
400    }
401
402    /**
403     * @param array<int, string> $secIdToName
404     * @return list<ModuleFieldDto>
405     */
406    private function fetchFields(int $moduleId, array $secIdToName): array
407    {
408        $fldStmt = $this->pdo->prepare(
409            "SELECT f.id, f.field_key, f.label, f.uitype_id, f.section_id, f.column_expression, " .
410            'f.is_mandatory, f.is_unique, f.is_sortable, f.is_filterable, f.is_summary, ' .
411            'f.is_quick_create, f.is_global_search, f.is_system, f.is_readonly, ' .
412            'f.default_value, f.placeholder, f.hidden_views, f.picklist_id, f.relation_module, ' .
413            'f.relation_table, f.relation_key, f.relation_label, f.filter_options, ' .
414            "f.validation_rules, f.sort_order, u.name AS uitype_name, u.db_type AS sql_type " .
415            "FROM {$this->tablePrefix}core_field_records f " .
416            "JOIN {$this->tablePrefix}core_uitype_records u ON u.id = f.uitype_id " .
417            "WHERE f.module_id = :id ORDER BY f.sort_order ASC, f.id ASC"
418        );
419        $fldStmt->execute([':id' => $moduleId]);
420        $fields = [];
421        while ($f = $fldStmt->fetch(PDO::FETCH_ASSOC)) {
422            $fields[] = $this->mapFieldRowToDto($f, $secIdToName);
423        }
424
425        return $fields;
426    }
427
428    /**
429     * @param array<string, mixed> $f
430     * @param array<int, string>   $secIdToName
431     */
432    private function mapFieldRowToDto(array $f, array $secIdToName): ModuleFieldDto
433    {
434        $opts = $this->decodeJsonList(isset($f['filter_options']) ? (string) $f['filter_options'] : null);
435        $rules = $this->decodeJsonMap(isset($f['validation_rules']) ? (string) $f['validation_rules'] : null);
436
437        $secId = isset($f['section_id']) && is_numeric($f['section_id']) ? (int) $f['section_id'] : null;
438        $secName = $secId !== null && isset($secIdToName[$secId]) ? $secIdToName[$secId] : null;
439
440        return new ModuleFieldDto(
441            fieldKey: (string) $f['field_key'],
442            label: (string) $f['label'],
443            uitypeName: (string) $f['uitype_name'],
444            id: (int) $f['id'],
445            uitypeId: (int) $f['uitype_id'],
446            sectionName: $secName,
447            sectionId: $secId,
448            columnExpression: (string) $f['column_expression'],
449            sqlType: (string) ($f['sql_type'] ?? 'VARCHAR(255)'),
450            isMandatory: !empty($f['is_mandatory']),
451            isUnique: !empty($f['is_unique']),
452            isSortable: !empty($f['is_sortable']),
453            isFilterable: !empty($f['is_filterable']),
454            isSummary: !empty($f['is_summary']),
455            isQuickCreate: !empty($f['is_quick_create']),
456            isGlobalSearch: !empty($f['is_global_search']),
457            isSystem: !empty($f['is_system']),
458            isReadonly: !empty($f['is_readonly']),
459            defaultValue: isset($f['default_value']) ? (string) $f['default_value'] : null,
460            placeholder: isset($f['placeholder']) ? (string) $f['placeholder'] : null,
461            hiddenViews: isset($f['hidden_views']) ? (string) $f['hidden_views'] : null,
462            picklistId: isset($f['picklist_id']) && is_numeric($f['picklist_id']) ? (int) $f['picklist_id'] : null,
463            relationModule: isset($f['relation_module']) ? (string) $f['relation_module'] : null,
464            relationTable: isset($f['relation_table']) ? (string) $f['relation_table'] : null,
465            relationKey: isset($f['relation_key']) ? (string) $f['relation_key'] : null,
466            relationLabel: isset($f['relation_label']) ? (string) $f['relation_label'] : null,
467            options: $opts,
468            validationRules: $rules,
469            sortOrder: (int) $f['sort_order']
470        );
471    }
472
473    /**
474     * @return list<string>
475     */
476    private function decodeJsonList(?string $raw): array
477    {
478        if ($raw === null || $raw === '') {
479            return [];
480        }
481        $dec = json_decode($raw, true);
482
483        return is_array($dec) ? array_values(array_map('strval', $dec)) : [];
484    }
485
486    /**
487     * @return array<string, mixed>
488     */
489    private function decodeJsonMap(?string $raw): array
490    {
491        if ($raw === null || $raw === '') {
492            return [];
493        }
494        $dec = json_decode($raw, true);
495
496        return is_array($dec) ? $dec : [];
497    }
498
499    /**
500     * @return list<ModuleFilterDto>
501     */
502    private function fetchFilters(int $moduleId): array
503    {
504        $fltStmt = $this->pdo->prepare(
505            "SELECT id, name, label, icon_class, visible_fields, default_sort, default_order, " .
506            "per_page, is_default, scope, conditions FROM {$this->tablePrefix}core_filter_records " .
507            "WHERE module_id = :id ORDER BY is_default DESC, id ASC"
508        );
509        $fltStmt->execute([':id' => $moduleId]);
510        $filters = [];
511        while ($fl = $fltStmt->fetch(PDO::FETCH_ASSOC)) {
512            $vis = [];
513            if (!empty($fl['visible_fields'])) {
514                $decV = json_decode((string) $fl['visible_fields'], true);
515                if (is_array($decV)) {
516                    $vis = array_values(array_map('strval', $decV));
517                }
518            }
519            $cond = !empty($fl['conditions']) ? json_decode((string) $fl['conditions'], true) : null;
520
521            $filters[] = new ModuleFilterDto(
522                name: (string) $fl['name'],
523                label: (string) $fl['label'],
524                visibleFields: $vis,
525                id: (int) $fl['id'],
526                iconClass: (string) ($fl['icon_class'] ?? 'bi bi-filter'),
527                defaultSort: (string) ($fl['default_sort'] ?? 'id'),
528                defaultOrder: (string) ($fl['default_order'] ?? 'DESC'),
529                perPage: (int) ($fl['per_page'] ?? 25),
530                isDefault: !empty($fl['is_default']),
531                scope: (string) ($fl['scope'] ?? 'public'),
532                conditions: is_array($cond) ? $cond : null
533            );
534        }
535
536        return $filters;
537    }
538
539    /**
540     * @return list<ModuleRelationDto>
541     */
542    private function fetchRelations(int $moduleId): array
543    {
544        $relStmt = $this->pdo->prepare(
545            "SELECT r.id, r.name, r.label, r.relation_type, r.target_field_key, r.sort_order, " .
546            "r.visible_fields, r.is_active, m.name AS target_module " .
547            "FROM {$this->tablePrefix}core_relation_1m_records r " .
548            "JOIN {$this->tablePrefix}core_module_records m ON m.id = r.target_module_id " .
549            "WHERE r.source_module_id = :id ORDER BY r.sort_order ASC, r.id ASC"
550        );
551        $relStmt->execute([':id' => $moduleId]);
552        $relations = [];
553        while ($rl = $relStmt->fetch(PDO::FETCH_ASSOC)) {
554            $rVis = [];
555            if (!empty($rl['visible_fields'])) {
556                $decR = json_decode((string) $rl['visible_fields'], true);
557                if (is_array($decR)) {
558                    $rVis = array_values(array_map('strval', $decR));
559                }
560            }
561            $relations[] = new ModuleRelationDto(
562                name: (string) $rl['name'],
563                label: (string) $rl['label'],
564                targetModule: (string) $rl['target_module'],
565                id: (int) $rl['id'],
566                relationType: (string) ($rl['relation_type'] ?? '1:M'),
567                targetFieldKey: (string) ($rl['target_field_key'] ?? 'parent_id'),
568                visibleFields: $rVis,
569                sortOrder: (int) $rl['sort_order'],
570                isActive: !empty($rl['is_active'])
571            );
572        }
573
574        return $relations;
575    }
576
577    /**
578     * @return list<array{id: int, name: string, label: string, icon: string}>
579     */
580    private function fetchActiveModulesForMetadata(): array
581    {
582        $mStmt = $this->pdo->query(
583            "SELECT id, name, label, icon_class FROM {$this->tablePrefix}core_module_records " .
584            'WHERE is_active = 1 ORDER BY label ASC'
585        );
586        $modules = [];
587        if ($mStmt !== false) {
588            while ($row = $mStmt->fetch(PDO::FETCH_ASSOC)) {
589                $modules[] = [
590                    'id' => (int) $row['id'],
591                    'name' => (string) $row['name'],
592                    'label' => (string) $row['label'],
593                    'icon' => (string) ($row['icon_class'] ?? self::DEFAULT_MODULE_ICON),
594                ];
595            }
596        }
597
598        return $modules;
599    }
600
601    /**
602     * @return list<array{id: int, name: string, label: string}>
603     */
604    private function fetchPicklistsForMetadata(): array
605    {
606        $picklists = [];
607        try {
608            $pStmt = $this->pdo->query(
609                "SELECT id, name, label FROM {$this->tablePrefix}core_picklist_records ORDER BY label ASC"
610            );
611            if ($pStmt !== false) {
612                while ($pRow = $pStmt->fetch(PDO::FETCH_ASSOC)) {
613                    $picklists[] = [
614                        'id' => (int) $pRow['id'],
615                        'name' => (string) $pRow['name'],
616                        'label' => (string) $pRow['label'],
617                    ];
618                }
619            }
620        } catch (Throwable) {
621            // Picklist table optional in minimal test setups
622        }
623
624        return $picklists;
625    }
626
627    /**
628     * @return list<array{id: int, name: string}>
629     */
630    private function fetchProfilesForMetadata(): array
631    {
632        $profiles = [];
633        try {
634            $pStmt = $this->pdo->query(
635                "SELECT id, name FROM {$this->tablePrefix}mod_profiles_records ORDER BY id ASC"
636            );
637            if ($pStmt !== false) {
638                while ($p = $pStmt->fetch(PDO::FETCH_ASSOC)) {
639                    $profiles[] = [
640                        'id' => (int) $p['id'],
641                        'name' => (string) $p['name'],
642                    ];
643                }
644            }
645        } catch (Throwable) {
646            try {
647                $pStmt = $this->pdo->query(
648                    "SELECT id, name FROM {$this->tablePrefix}core_profile_records ORDER BY id ASC"
649                );
650                if ($pStmt !== false) {
651                    while ($p = $pStmt->fetch(PDO::FETCH_ASSOC)) {
652                        $profiles[] = [
653                            'id' => (int) $p['id'],
654                            'name' => (string) $p['name'],
655                        ];
656                    }
657                }
658            } catch (Throwable) {
659                // Profile table optional in minimal test setups
660            }
661        }
662
663        return $profiles;
664    }
665
666    /** {@inheritdoc} */
667    public function getBuilderMetadata(?PermissionContext $context = null): array
668    {
669        $groupedUiTypes = UiTypeMetadataCatalog::getGroupedByCategory();
670
671        return [
672            'categories' => UiTypeMetadataCatalog::getAllCategories(),
673            'grouped_uitypes' => $groupedUiTypes,
674            'uitypes' => UiTypeMetadataCatalog::getAllUiTypes(),
675            'uitype_categories' => $groupedUiTypes,
676            'modules' => $this->fetchActiveModulesForMetadata(),
677            'picklists' => $this->fetchPicklistsForMetadata(),
678            'profiles' => $this->fetchProfilesForMetadata(),
679            'icons' => UiTypeMetadataCatalog::getAvailableIcons(),
680            'view_options' => ['list', 'grid', 'calendar', 'kanban', 'gantt'],
681            'create_modes' => ['standard', 'quick'],
682            'presentation_modes' => ['drawer', 'modal', 'popup'],
683        ];
684    }
685
686    /**
687     * Inserts sections for a new module.
688     *
689     * @param int                   $moduleId
690     * @param list<ModuleSectionDto>$sections
691     * @param int                   $userId
692     * @param string                $now
693     * @return array<string, int> Map of section name => section ID.
694     */
695    private function insertSections(int $moduleId, array $sections, int $userId, string $now): array
696    {
697        $secMap = [];
698        $sort = 10;
699        $sql = "INSERT INTO {$this->tablePrefix}core_section_records " .
700            '(module_id, name, label, icon_class, sort_order, is_active, is_default, ' .
701            self::AUDIT_COLUMNS_SQL .
702            'VALUES (:mid, :name, :label, :icon, :sort, 1, :def, 1, :created_at, :updated_at, :s_cby, :s_owner)';
703        $stmt = $this->pdo->prepare($sql);
704
705        foreach ($sections as $sec) {
706            $stmt->execute([
707                ':mid' => $moduleId,
708                ':name' => $sec->name,
709                ':label' => $sec->label,
710                ':icon' => $sec->iconClass,
711                ':sort' => $sec->sortOrder > 0 ? $sec->sortOrder : $sort,
712                ':def' => $sec->isDefault ? 1 : 0,
713                ':created_at' => $now,
714                ':updated_at' => $now,
715                ':s_cby' => $userId,
716                ':s_owner' => $userId,
717            ]);
718            $secId = (int) $this->pdo->lastInsertId();
719            $secMap[$sec->name] = $secId;
720            $sort += 10;
721        }
722
723        return $secMap;
724    }
725
726    /**
727     * Inserts fields for a new module.
728     *
729     * @param int                 $moduleId
730     * @param list<ModuleFieldDto>$fields
731     * @param array<string, int>  $secMap
732     * @param string              $tableAlias
733     * @param int                 $userId
734     * @param string              $now
735     */
736    private function insertFields(
737        int $moduleId,
738        array $fields,
739        array $secMap,
740        string $tableAlias,
741        int $userId,
742        string $now
743    ): void {
744        $firstSecId = !empty($secMap) ? reset($secMap) : null;
745
746        $stmt = $this->pdo->prepare(
747            "INSERT INTO {$this->tablePrefix}core_field_records " .
748            '(module_id, field_key, column_expression, label, uitype_id, ' .
749            'is_sortable, is_filterable, is_mandatory, is_unique, is_system, ' .
750            'is_readonly, is_summary, is_quick_create, is_global_search, ' .
751            'sort_order, section_id, picklist_id, relation_module, relation_table, ' .
752            'relation_key, relation_label, filter_options, validation_rules, ' .
753            'default_value, placeholder, hidden_views, special_access, ' .
754            'created_at, updated_at, created_by, owner) ' .
755            'VALUES (:mid, :key, :col, :label, :uid, :sortable, :filterable, ' .
756            ':mandatory, :unique, :sys, :ro, :sum, :qc, :gs, :sort, :sec, ' .
757            ':picklist, :rel_mod, :rel_tbl, :rel_key, :rel_lbl, :opts, :rules, ' .
758            ':def, :place, :hidden, 1, :created_at, :updated_at, :fld_cby, :fld_owner)'
759        );
760
761        $sort = 10;
762        foreach ($fields as $field) {
763            $secId = $this->resolveFieldSectionId($field, $secMap, $firstSecId);
764            $uitypeId = $this->resolveFieldUitypeId($field);
765            $colExpr = $this->resolveFieldColumnExpression($field, $tableAlias);
766
767            $this->executeInsertField(
768                $stmt,
769                $field,
770                [
771                    'moduleId' => $moduleId,
772                    'secId' => $secId,
773                    'uitypeId' => $uitypeId,
774                    'colExpr' => $colExpr,
775                    'sort' => $sort,
776                    'userId' => $userId,
777                    'now' => $now,
778                ]
779            );
780
781            $sort += 10;
782        }
783    }
784
785    /**
786     * @param array<string, int> $secMap
787     */
788    private function resolveFieldSectionId(ModuleFieldDto $field, array $secMap, ?int $firstSecId): ?int
789    {
790        if ($field->sectionName !== null && isset($secMap[$field->sectionName])) {
791            return $secMap[$field->sectionName];
792        }
793        if ($field->sectionId !== null) {
794            return $field->sectionId;
795        }
796
797        return $firstSecId;
798    }
799
800    private function resolveFieldUitypeId(ModuleFieldDto $field): int
801    {
802        if ($field->uitypeId !== null && $field->uitypeId > 0) {
803            return $field->uitypeId;
804        }
805        $meta = UiTypeMetadataCatalog::get($field->uitypeName);
806
807        return $meta !== null ? (int) $meta['id'] : 1001;
808    }
809
810    private function resolveFieldColumnExpression(ModuleFieldDto $field, string $tableAlias): string
811    {
812        if ($field->columnExpression !== '') {
813            return $field->columnExpression;
814        }
815        $prefix = $tableAlias !== '' ? $tableAlias . '.' : '';
816
817        return $prefix . $field->fieldKey;
818    }
819
820    /**
821     * @param array{
822     *     moduleId: int,
823     *     secId: ?int,
824     *     uitypeId: int,
825     *     colExpr: string,
826     *     sort: int,
827     *     userId: int,
828     *     now: string
829     * } $ctx
830     */
831    private function executeInsertField(
832        PDOStatement $stmt,
833        ModuleFieldDto $field,
834        array $ctx
835    ): void {
836        $optsJson = !empty($field->options) ? json_encode($field->options) : null;
837        $rulesJson = !empty($field->validationRules) ? json_encode($field->validationRules) : null;
838
839        $stmt->execute([
840            ':mid' => $ctx['moduleId'],
841            ':key' => $field->fieldKey,
842            ':col' => $ctx['colExpr'],
843            ':label' => $field->label,
844            ':uid' => $ctx['uitypeId'],
845            ':sortable' => $field->isSortable ? 1 : 0,
846            ':filterable' => $field->isFilterable ? 1 : 0,
847            ':mandatory' => $field->isMandatory ? 1 : 0,
848            ':unique' => $field->isUnique ? 1 : 0,
849            ':sys' => $field->isSystem ? 1 : 0,
850            ':ro' => $field->isReadonly ? 1 : 0,
851            ':sum' => $field->isSummary ? 1 : 0,
852            ':qc' => $field->isQuickCreate ? 1 : 0,
853            ':gs' => $field->isGlobalSearch ? 1 : 0,
854            ':sort' => $field->sortOrder > 0 ? $field->sortOrder : $ctx['sort'],
855            ':sec' => $ctx['secId'],
856            ':picklist' => $field->picklistId,
857            ':rel_mod' => $field->relationModule,
858            ':rel_tbl' => $field->relationTable,
859            ':rel_key' => $field->relationKey,
860            ':rel_lbl' => $field->relationLabel,
861            ':opts' => $optsJson,
862            ':rules' => $rulesJson,
863            ':def' => $field->defaultValue,
864            ':place' => $field->placeholder,
865            ':hidden' => $field->hiddenViews,
866            ':created_at' => $ctx['now'],
867            ':updated_at' => $ctx['now'],
868            ':fld_cby' => $ctx['userId'],
869            ':fld_owner' => $ctx['userId'],
870        ]);
871    }
872
873    /**
874     * Inserts filters for a new module.
875     *
876     * @param int                  $moduleId
877     * @param list<ModuleFilterDto>$filters
878     * @param int                  $userId
879     * @param string               $now
880     */
881    private function insertFilters(int $moduleId, array $filters, int $userId, string $now): void
882    {
883        $stmt = $this->pdo->prepare(
884            "INSERT INTO {$this->tablePrefix}core_filter_records " .
885            '(module_id, name, label, icon_class, visible_fields, default_sort, ' .
886            'default_order, per_page, is_default, is_system, scope, conditions, ' .
887            self::AUDIT_COLUMNS_SQL .
888            'VALUES (:mid, :name, :label, :icon, :vis, :sort, :ord, :per, :def, ' .
889            '0, :scope, :cond, 1, :created_at, :updated_at, :flt_cby, :flt_owner)'
890        );
891
892        foreach ($filters as $flt) {
893            $stmt->execute([
894                ':mid' => $moduleId,
895                ':name' => $flt->name,
896                ':label' => $flt->label,
897                ':icon' => $flt->iconClass,
898                ':vis' => json_encode($flt->visibleFields),
899                ':sort' => $flt->defaultSort,
900                ':ord' => $flt->defaultOrder,
901                ':per' => $flt->perPage,
902                ':def' => $flt->isDefault ? 1 : 0,
903                ':scope' => $flt->scope,
904                ':cond' => $flt->conditions !== null ? json_encode($flt->conditions) : null,
905                ':created_at' => $now,
906                ':updated_at' => $now,
907                ':flt_cby' => $userId,
908                ':flt_owner' => $userId,
909            ]);
910        }
911    }
912
913    /**
914     * Inserts relations for a new module.
915     *
916     * @param int                    $moduleId
917     * @param list<ModuleRelationDto>$relations
918     * @param int                    $userId
919     * @param string                 $now
920     */
921    private function insertRelations(int $moduleId, array $relations, int $userId, string $now): void
922    {
923        $findMod = $this->pdo->prepare(
924            "SELECT id FROM {$this->tablePrefix}core_module_records WHERE name = :name LIMIT 1"
925        );
926
927        $stmt = $this->pdo->prepare(
928            "INSERT INTO {$this->tablePrefix}core_relation_1m_records " .
929            '(name, label, source_module_id, target_module_id, target_field_key, ' .
930            'relation_type, view_scope, record_limit, is_active, sort_order, ' .
931            'visible_fields, special_access, created_at, updated_at, created_by, owner) ' .
932            'VALUES (:name, :label, :src_mid, :tgt_mid, :tgt_key, :type, :scope, ' .
933            '20, 1, :sort, :vis, 1, :created_at, :updated_at, :rel_cby, :rel_owner)'
934        );
935
936        $sort = 10;
937        foreach ($relations as $rel) {
938            $findMod->execute([':name' => $rel->targetModule]);
939            $tgtId = $findMod->fetchColumn();
940            if ($tgtId === false) {
941                continue;
942            }
943
944            $stmt->execute([
945                ':name' => $rel->name,
946                ':label' => $rel->label,
947                ':src_mid' => $moduleId,
948                ':tgt_mid' => (int) $tgtId,
949                ':tgt_key' => $rel->targetFieldKey,
950                ':type' => $rel->relationType,
951                ':scope' => 'detail',
952                ':sort' => $rel->sortOrder > 0 ? $rel->sortOrder : $sort,
953                ':vis' => !empty($rel->visibleFields) ? json_encode($rel->visibleFields) : null,
954                ':created_at' => $now,
955                ':updated_at' => $now,
956                ':rel_cby' => $userId,
957                ':rel_owner' => $userId,
958            ]);
959            $sort += 10;
960        }
961    }
962
963    /**
964     * Synchronizes sections in edit mode.
965     */
966    private function syncSections(int $moduleId, array $sections, int $userId, string $now): array
967    {
968        $secMap = [];
969        $uSec = $this->pdo->prepare(
970            "UPDATE {$this->tablePrefix}core_section_records SET label = :label, " .
971            'icon_class = :icon, sort_order = :sort, updated_at = :upd WHERE id = :id'
972        );
973
974        $iSec = $this->pdo->prepare(
975            "INSERT INTO {$this->tablePrefix}core_section_records " .
976            '(module_id, name, label, icon_class, sort_order, is_active, is_default, ' .
977            self::AUDIT_COLUMNS_SQL .
978            'VALUES (:mid, :name, :label, :icon, :sort, 1, :def, 1, :created_at, :updated_at, :s_cby, :s_owner)'
979        );
980
981        $sort = 10;
982        foreach ($sections as $sec) {
983            if ($sec->id !== null && $sec->id > 0) {
984                $uSec->execute([
985                    ':label' => $sec->label,
986                    ':icon' => $sec->iconClass,
987                    ':sort' => $sec->sortOrder > 0 ? $sec->sortOrder : $sort,
988                    ':upd' => $now,
989                    ':id' => $sec->id,
990                ]);
991                $secMap[$sec->name] = $sec->id;
992            } else {
993                $iSec->execute([
994                    ':mid' => $moduleId,
995                    ':name' => $sec->name,
996                    ':label' => $sec->label,
997                    ':icon' => $sec->iconClass,
998                    ':sort' => $sec->sortOrder > 0 ? $sec->sortOrder : $sort,
999                    ':def' => $sec->isDefault ? 1 : 0,
1000                    ':created_at' => $now,
1001                    ':updated_at' => $now,
1002                    ':s_cby' => $userId,
1003                    ':s_owner' => $userId,
1004                ]);
1005                $secMap[$sec->name] = (int) $this->pdo->lastInsertId();
1006            }
1007            $sort += 10;
1008        }
1009
1010        return $secMap;
1011    }
1012
1013    /**
1014     * Synchronizes fields in edit mode.
1015     *
1016     * @param int                 $moduleId
1017     * @param list<ModuleFieldDto>$fields
1018     * @param array<string, int>  $secMap
1019     * @param string              $tableAlias
1020     * @param int                 $userId
1021     * @param string              $now
1022     */
1023    private function syncFields(
1024        int $moduleId,
1025        array $fields,
1026        array $secMap,
1027        string $tableAlias,
1028        int $userId,
1029        string $now
1030    ): void {
1031        $firstSecId = !empty($secMap) ? reset($secMap) : null;
1032
1033        $uField = $this->pdo->prepare(
1034            "UPDATE {$this->tablePrefix}core_field_records SET label = :label, " .
1035            'section_id = :sec, is_mandatory = :mand, is_unique = :uniq, ' .
1036            'is_sortable = :sortable, is_filterable = :filterable, is_summary = :sum, ' .
1037            'is_quick_create = :qc, is_global_search = :gs, default_value = :def, ' .
1038            'placeholder = :place, sort_order = :sort, updated_at = :upd WHERE id = :id'
1039        );
1040
1041        $sort = 10;
1042        $newFields = [];
1043
1044        foreach ($fields as $field) {
1045            $secId = $this->resolveFieldSectionId($field, $secMap, $firstSecId);
1046
1047            if ($field->id !== null && $field->id > 0) {
1048                $this->executeUpdateField($uField, $field, $secId, $sort, $now);
1049            } else {
1050                $newFields[] = $field;
1051            }
1052            $sort += 10;
1053        }
1054
1055        if (!empty($newFields)) {
1056            $this->insertFields($moduleId, $newFields, $secMap, $tableAlias, $userId, $now);
1057        }
1058    }
1059
1060    private function executeUpdateField(
1061        PDOStatement $uField,
1062        ModuleFieldDto $field,
1063        ?int $secId,
1064        int $sort,
1065        string $now
1066    ): void {
1067        $uField->execute([
1068            ':label' => $field->label,
1069            ':sec' => $secId,
1070            ':mand' => $field->isMandatory ? 1 : 0,
1071            ':uniq' => $field->isUnique ? 1 : 0,
1072            ':sortable' => $field->isSortable ? 1 : 0,
1073            ':filterable' => $field->isFilterable ? 1 : 0,
1074            ':sum' => $field->isSummary ? 1 : 0,
1075            ':qc' => $field->isQuickCreate ? 1 : 0,
1076            ':gs' => $field->isGlobalSearch ? 1 : 0,
1077            ':def' => $field->defaultValue,
1078            ':place' => $field->placeholder,
1079            ':sort' => $field->sortOrder > 0 ? $field->sortOrder : $sort,
1080            ':upd' => $now,
1081            ':id' => $field->id,
1082        ]);
1083    }
1084
1085    /**
1086     * Grants initial administrative permissions for newly created module.
1087     */
1088    private function grantInitialPermissions(int $moduleId): void
1089    {
1090        try {
1091            $sql = "INSERT INTO {$this->tablePrefix}core_profile_modules " .
1092                '(profile_id, module_id, can_read, can_create, can_edit, can_delete, can_export, can_import) ' .
1093                'VALUES (1, :mid, 1, 1, 1, 1, 1, 1)';
1094            $stmt = $this->pdo->prepare($sql);
1095            $stmt->execute([':mid' => $moduleId]);
1096        } catch (Throwable) {
1097            // Table may not exist in lightweight test environments
1098        }
1099    }
1100
1101    /**
1102     * Registers a new navigation menu item in a_mod_menu_records for the module.
1103     *
1104     * @param ModuleBasicInfoDto $basic  Module basic info.
1105     * @param int                $userId Creator user identifier.
1106     */
1107    private function registerNavigationMenuItem(ModuleBasicInfoDto $basic, int $userId): void
1108    {
1109        try {
1110            $chk = $this->pdo->prepare(
1111                "SELECT id FROM {$this->tablePrefix}mod_menu_records WHERE route_url = :route LIMIT 1"
1112            );
1113            $chk->execute([':route' => $basic->routeUrl]);
1114            if ($chk->fetch() !== false) {
1115                return;
1116            }
1117
1118            $maxSortStmt = $this->pdo->query(
1119                "SELECT COALESCE(MAX(sort_order), 90) + 10 AS next_sort FROM {$this->tablePrefix}mod_menu_records " .
1120                'WHERE parent_id IS NULL'
1121            );
1122            $nextSort = 100;
1123            if ($maxSortStmt !== false && ($row = $maxSortStmt->fetch(PDO::FETCH_ASSOC))) {
1124                $nextSort = (int) $row['next_sort'];
1125            }
1126
1127            $sql = "INSERT INTO {$this->tablePrefix}mod_menu_records " .
1128                '(parent_id, type, label, route_url, icon_class, sort_order, is_active, app_profile, ' .
1129                'created_by, owner) VALUES ' .
1130                '(NULL, :type, :label, :route, :icon, :sort, 1, :profile, :menu_cby, :menu_owner)';
1131
1132            $stmt = $this->pdo->prepare($sql);
1133            $stmt->execute([
1134                ':type' => 'view',
1135                ':label' => $basic->label,
1136                ':route' => $basic->routeUrl,
1137                ':icon' => $basic->iconClass !== '' ? $basic->iconClass : self::DEFAULT_MODULE_ICON,
1138                ':sort' => $nextSort,
1139                ':profile' => 'admin',
1140                ':menu_cby' => $userId,
1141                ':menu_owner' => $userId,
1142            ]);
1143        } catch (Throwable) {
1144            // Table may not exist in lightweight test environments
1145        }
1146    }
1147}