Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.60% covered (success)
93.60%
117 / 125
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlFacetedOptionsResolver
94.35% covered (success)
94.35%
117 / 124
61.54% covered (warning)
61.54%
8 / 13
46.38
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
 findAllModuleOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 findFacetedFilterOptions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 resolveFieldFacetedOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 resolveRelationOptions
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 buildRelationOptionsSql
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
8.03
 resolveRelationLabelExpression
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 executeRelationQuery
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 findAllUserOptions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 findAllStructureOptions
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 findAllPicklistOptions
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 findPicklistValues
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 findAllUitypeOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
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\Engine\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\Security\SqlIdentifierValidator;
12use App\Core\Engine\Domain\Model\FieldMetadata;
13use App\Core\Engine\Domain\Model\ModuleMetadata;
14use App\Core\Engine\Domain\Model\PermissionContext;
15use PDO;
16
17/**
18 * SqlFacetedOptionsResolver.
19 *
20 * Resolves faceted filter options, relation options, picklists, and user reference choices from database.
21 *
22 * @package App\Core\Engine\Infrastructure\Repository
23 */
24final readonly class SqlFacetedOptionsResolver
25{
26    /**
27     * SqlFacetedOptionsResolver constructor.
28     *
29     * @param PDO $pdo Active database connection.
30     */
31    public function __construct(
32        private PDO $pdo,
33        private string $tablePrefix = 'a_',
34    ) {
35    }
36
37    /**
38     * Retrieves all active module options.
39     *
40     * @return array<int, array{id: int, name: string, label: string}>
41     */
42    public function findAllModuleOptions(): array
43    {
44        $sql = 'SELECT `id`, `name`, `label` FROM `a_core_module_records` WHERE `is_active` = 1 ORDER BY `label` ASC';
45        $stmt = $this->pdo->prepare($sql);
46        $stmt->execute();
47        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
48
49        return is_array($rows) ? $rows : [];
50    }
51
52    /**
53     * Resolves faceted filter options for an array of fields.
54     *
55     * @param array<FieldMetadata> $fields Module fields.
56     * @return array<string, array<int, array{id: int|string, label: string, is_active?: int}>>
57     */
58    public function findFacetedFilterOptions(array $fields): array
59    {
60        $result = [];
61        foreach ($fields as $field) {
62            if (!$field->isFilterable) {
63                continue;
64            }
65            $opts = $this->resolveFieldFacetedOptions($field);
66            if ($opts !== null) {
67                $result[$field->fieldKey] = $opts;
68            }
69        }
70
71        return $result;
72    }
73
74    /**
75     * Resolves faceted options for a single field based on relation or enum options.
76     *
77     * @param FieldMetadata $field Field metadata.
78     * @return array<int, array{id: int|string, label: string, is_active?: int}>|null
79     */
80    private function resolveFieldFacetedOptions(FieldMetadata $field): ?array
81    {
82        if ($field->relationTable !== null && $field->relationKey !== null && $field->relationLabel !== null) {
83            return $this->resolveRelationOptions($field);
84        }
85        if ($field->filterOptions === []) {
86            return null;
87        }
88
89        $options = [];
90        foreach ($field->filterOptions as $val => $label) {
91            $options[] = [
92                'id'    => (string) $val,
93                'label' => (string) $label,
94            ];
95        }
96
97        return $options;
98    }
99
100    /**
101     * Resolves relational foreign key choice options for a given field metadata.
102     *
103     * @param FieldMetadata $field Foreign key field definition.
104     * @return array<int, array{id: int, label: string}> List of choice options.
105     */
106    public function resolveRelationOptions(FieldMetadata $field): array
107    {
108        if ($field->relationTable === null || $field->relationKey === null) {
109            return [];
110        }
111
112        $sql = $this->buildRelationOptionsSql($field);
113
114        return $this->executeRelationQuery($sql);
115    }
116
117    private function buildRelationOptionsSql(FieldMetadata $field): string
118    {
119        $relTable = (string) $field->relationTable;
120        $relKey = (string) $field->relationKey;
121        if (!SqlIdentifierValidator::isValid($relTable) || !SqlIdentifierValidator::isValid($relKey)) {
122            return '';
123        }
124
125        $labelExpr = $this->resolveRelationLabelExpression($field);
126        if ($labelExpr === null) {
127            return '';
128        }
129
130        $isUserTable = in_array($field->relationTable, ['a_mod_users_records', 'c_mod_users_records'], true);
131        $isStructureTable = in_array(
132            $field->relationTable,
133            ['a_mod_structure_records', 'c_mod_structure_records'],
134            true
135        );
136
137        $activeSelect = ($isUserTable || $isStructureTable)
138            ? ', (CASE WHEN rel.`status` = \'active\' AND rel.`special_access` = 1 THEN 1 ELSE 0 END) AS `is_active`'
139            : '';
140        $orderBy = ($isUserTable || $isStructureTable)
141            ? 'ORDER BY `is_active` DESC, ' . $labelExpr . ' ASC'
142            : 'ORDER BY ' . $labelExpr . ' ASC';
143
144        return sprintf(
145            'SELECT rel.`%s` AS `id`, %s AS `label`%s FROM `%s` rel %s LIMIT 100',
146            $field->relationKey,
147            $labelExpr,
148            $activeSelect,
149            $field->relationTable,
150            $orderBy
151        );
152    }
153
154    private function resolveRelationLabelExpression(FieldMetadata $field): ?string
155    {
156        if (str_contains((string) $field->relationLabel, ',')) {
157            $cols = array_map('trim', explode(',', (string) $field->relationLabel));
158            $validCols = array_filter($cols, [SqlIdentifierValidator::class, 'isValid']);
159            if (empty($validCols)) {
160                return null;
161            }
162            $concatParts = array_map(
163                static fn(string $c): string => sprintf('COALESCE(rel.`%s`, \'\')', $c),
164                $validCols
165            );
166            return sprintf("TRIM(CONCAT_WS(' ', %s))", implode(', ', $concatParts));
167        }
168
169        $relCol = trim((string) $field->relationLabel);
170        return SqlIdentifierValidator::isValid($relCol) ? sprintf('rel.`%s`', $relCol) : null;
171    }
172
173    /**
174     * @return array<int, array{id: int, label: string}>
175     */
176    private function executeRelationQuery(string $sql): array
177    {
178        if ($sql === '') {
179            return [];
180        }
181
182        try {
183            $stmt = $this->pdo->query($sql);
184            $rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
185
186            return is_array($rows) ? $rows : [];
187        } catch (\Throwable) {
188            return [];
189        }
190    }
191
192    /**
193     * Retrieves all user options ordered by active state and username.
194     *
195     * @param string|null $tablePrefix Optional table prefix override.
196     * @return array<int, array{id: int, label: string, is_active: int, is_superuser?: int}>
197     */
198    public function findAllUserOptions(?string $tablePrefix = null): array
199    {
200        $prefix = $tablePrefix ?? $this->tablePrefix;
201        $table = $prefix . 'mod_users_records';
202        $sql = 'SELECT `id`, `username` AS `label`, ' .
203            '(CASE WHEN `status` = \'active\' AND `special_access` = 1 THEN 1 ELSE 0 END) AS `is_active`, ' .
204            '`is_superuser` ' .
205            "FROM `{$table}` ORDER BY `is_active` DESC, `username` ASC";
206        $stmt = $this->pdo->prepare($sql);
207        $stmt->execute();
208        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
209
210        return is_array($rows) ? $rows : [];
211    }
212
213    /**
214     * Retrieves all organizational structure node options.
215     *
216     * @param string|null $tablePrefix Optional table prefix override.
217     * @return array<int, array{id: int, label: string, is_active: int}>
218     */
219    public function findAllStructureOptions(?string $tablePrefix = null): array
220    {
221        $prefix = $tablePrefix ?? $this->tablePrefix;
222        $table = $prefix . 'mod_structure_records';
223        try {
224            $sql = 'SELECT `id`, `name` AS `label`, ' .
225                '(CASE WHEN `status` = \'active\' AND `special_access` = 1 THEN 1 ELSE 0 END) AS `is_active` ' .
226                "FROM `{$table}` ORDER BY `is_active` DESC, `sort_order` ASC, `name` ASC";
227            $stmt = $this->pdo->prepare($sql);
228            $stmt->execute();
229            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
230
231            return is_array($rows) ? $rows : [];
232        } catch (\PDOException) {
233            return [];
234        }
235    }
236
237    /**
238     * Retrieves all picklist options with optional module filtering.
239     *
240     * @param int|null $moduleId Optional module ID filter.
241     * @return array<int, array<string, mixed>>
242     */
243    public function findAllPicklistOptions(?int $moduleId = null): array
244    {
245        $whereSql = $moduleId !== null ? 'WHERE p.`module_id` = :module_id' : '';
246        $sql = 'SELECT p.`id`, p.`module_id`, p.`name`, p.`label`, p.`is_active`, ' .
247               'm.`name` AS `module_name`, m.`label` AS `module_label`, ' .
248               'COUNT(v.`id`) AS `values_count` ' .
249               'FROM `a_core_picklist_records` p ' .
250               'JOIN `a_core_module_records` m ON m.`id` = p.`module_id` ' .
251               'LEFT JOIN `a_core_picklist_value_records` v ON v.`picklist_id` = p.`id` ' .
252               $whereSql . ' ' .
253               'GROUP BY p.`id`, p.`module_id`, p.`name`, p.`label`, p.`is_active`, m.`name`, m.`label` ' .
254               'ORDER BY p.`is_active` DESC, p.`label` ASC';
255
256        $stmt = $this->pdo->prepare($sql);
257        if ($moduleId !== null) {
258            $stmt->execute([':module_id' => $moduleId]);
259        } else {
260            $stmt->execute();
261        }
262
263        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
264
265        return is_array($rows) ? $rows : [];
266    }
267
268    /**
269     * Retrieves picklist value options for a given picklist.
270     *
271     * @param int $picklistId Picklist identifier.
272     * @return array<int, array<string, mixed>>
273     */
274    public function findPicklistValues(int $picklistId): array
275    {
276        $sql = 'SELECT `id`, `value`, `label`, `color`, `icon_class`, `is_default`, `sort_order` ' .
277               'FROM `a_core_picklist_value_records` ' .
278               'WHERE `picklist_id` = :picklist_id AND `is_active` = 1 ' .
279               'ORDER BY `sort_order` ASC, `label` ASC';
280
281        $stmt = $this->pdo->prepare($sql);
282        $stmt->execute([':picklist_id' => $picklistId]);
283
284        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
285
286        return is_array($rows) ? $rows : [];
287    }
288
289    /**
290     * Retrieves all UiType options ordered by label.
291     *
292     * @return array<int, array{id: int, name: string, label: string}>
293     */
294    public function findAllUitypeOptions(): array
295    {
296        $sql = 'SELECT `id`, `name`, `label` FROM `a_core_uitype_records` ORDER BY `label` ASC';
297        $stmt = $this->pdo->prepare($sql);
298        $stmt->execute();
299
300        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
301
302        return is_array($rows) ? $rows : [];
303    }
304}