Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.96% covered (warning)
87.96%
95 / 108
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
UniversalRecordEnricher
88.79% covered (warning)
88.79%
95 / 107
58.33% covered (warning)
58.33%
7 / 12
59.27
0.00% covered (danger)
0.00%
0 / 1
 clearRelationLabelCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 transformRecords
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 transformRow
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
6.29
 enrichSingleRecord
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
11
 resolveFieldDisplayLabel
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 resolveRelationLabel
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 fetchRelationLabelFromDb
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 fetchCompositeRelationLabel
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 enrichFieldRecordData
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 enrichColumnExpression
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
8.12
 enrichRelationData
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
9.00
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Transformer\UiTypeTransformerPipeline;
12use App\Core\Engine\Domain\Model\FieldMetadata;
13use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
14use App\Core\Grid\GridResult;
15use PDO;
16use Throwable;
17
18/**
19 * UniversalRecordEnricher.
20 *
21 * Enriches field metadata configurations and transforms raw database query records to display models.
22 *
23 * @package App\Core\Engine\Application\Service
24 */
25final class UniversalRecordEnricher
26{
27    /** @var array<string, string> In-memory cache of resolved relation labels. */
28    private static array $relationLabelCache = [];
29
30    /**
31     * Clears in-memory relation label cache (primarily for tests).
32     */
33    public static function clearRelationLabelCache(): void
34    {
35        self::$relationLabelCache = [];
36    }
37
38    /**
39     * UniversalRecordEnricher constructor.
40     *
41     * @param MetadataRepositoryInterface $metadata     Metadata repository contract.
42     * @param UiTypeTransformerPipeline   $transformers Transformer pipeline.
43     */
44    public function __construct(
45        private readonly MetadataRepositoryInterface $metadata,
46        private readonly UiTypeTransformerPipeline   $transformers,
47    ) {
48    }
49
50    /**
51     * Applies read transformers to all records in a GridResult.
52     *
53     * @param GridResult                $result Raw query result.
54     * @param array<int, FieldMetadata> $fields All field definitions.
55     * @return GridResult Result with all cell values display-transformed.
56     */
57    public function transformRecords(GridResult $result, array $fields): GridResult
58    {
59        $fieldMap = [];
60        foreach ($fields as $field) {
61            $fieldMap[$field->fieldKey] = $field;
62        }
63
64        $transformed = [];
65        foreach ($result->rows as $row) {
66            $transformed[] = $this->transformRow($row, $fieldMap);
67        }
68
69        return new GridResult(
70            rows:         $transformed,
71            totalRecords: $result->totalRecords,
72            gridRequest:  $result->gridRequest,
73            columns:      $result->columns,
74            statusCounts: $result->statusCounts,
75        );
76    }
77
78    /**
79     * Transforms a single row applying field transformers and relation labels.
80     *
81     * @param array<string, mixed>         $row
82     * @param array<string, FieldMetadata> $fieldMap
83     * @return array<string, mixed>
84     */
85    private function transformRow(array $row, array $fieldMap): array
86    {
87        $newRow = [];
88        foreach ($row as $key => $value) {
89            $labelKey = $key . '_label';
90            if (isset($fieldMap[$key])) {
91                $field = $fieldMap[$key];
92                $displayValue = isset($row[$labelKey])
93                    ? (string) $row[$labelKey]
94                    : $this->transformers->transformRead($value, $field);
95                $newRow[$key] = $displayValue;
96                if ($field->hasRelation() && !empty($field->relationModule)) {
97                    $newRow[$key . '_id'] = $value;
98                    $newRow[$key . '_module'] = $field->relationModule;
99                    $newRow[$labelKey] = $displayValue;
100                }
101            } else {
102                $newRow[$key] = $value;
103            }
104        }
105
106        return $newRow;
107    }
108
109    /**
110     * Enriches a single record snapshot with relation display labels for detail/edit views.
111     *
112     * @param array<string, mixed>      $record Record raw data.
113     * @param array<int, FieldMetadata> $fields Module field metadata definitions.
114     * @param PDO|null                  $pdo    Optional database handle for resolving relation labels.
115     * @return array<string, mixed> Enriched record with `{field_key}_label` properties.
116     */
117    public function enrichSingleRecord(array $record, array $fields, ?PDO $pdo = null): array
118    {
119        if ($pdo === null) {
120            return $record;
121        }
122
123        $enriched = $record;
124        foreach ($fields as $field) {
125            $key = $field->fieldKey;
126            $val = $record[$key] ?? null;
127            $labelKey = $key . '_label';
128
129            if (isset($enriched[$labelKey]) || $val === null || $val === '' || $val === []) {
130                continue;
131            }
132
133            if (!$field->hasRelation() || $field->relationTable === '' || $field->relationLabel === null) {
134                continue;
135            }
136
137            $label = $this->resolveFieldDisplayLabel($pdo, $field, $val);
138            if ($label !== null) {
139                $enriched[$labelKey] = $label;
140            }
141        }
142
143        return $enriched;
144    }
145
146    /**
147     * Resolves display label for either scalar or array value.
148     */
149    private function resolveFieldDisplayLabel(PDO $pdo, FieldMetadata $field, mixed $val): ?string
150    {
151        if (is_array($val)) {
152            $labels = [];
153            foreach ($val as $itemVal) {
154                if ($itemVal !== null && $itemVal !== '') {
155                    $label = $this->resolveRelationLabel($pdo, $field, (string) $itemVal);
156                    if ($label !== null) {
157                        $labels[] = $label;
158                    }
159                }
160            }
161            return $labels !== [] ? implode(', ', $labels) : null;
162        }
163
164        return $this->resolveRelationLabel($pdo, $field, (string) $val);
165    }
166
167    /**
168     * Resolves and caches relation display label for a specific field and value.
169     */
170    private function resolveRelationLabel(PDO $pdo, FieldMetadata $field, string $val): ?string
171    {
172        $relKey = $field->relationKey !== '' ? $field->relationKey : 'id';
173        $cacheKey = sprintf('%s:%s:%s:%s', $field->relationTable, $relKey, $val, (string) $field->relationLabel);
174
175        if (isset(self::$relationLabelCache[$cacheKey])) {
176            return self::$relationLabelCache[$cacheKey];
177        }
178
179        try {
180            $label = $this->fetchRelationLabelFromDb($pdo, $field, $relKey, $val);
181            if ($label !== null) {
182                self::$relationLabelCache[$cacheKey] = $label;
183            }
184            return $label;
185        } catch (Throwable) {
186            return null;
187        }
188    }
189
190    /**
191     * Fetches relation label directly from database.
192     */
193    private function fetchRelationLabelFromDb(PDO $pdo, FieldMetadata $field, string $relKey, string $val): ?string
194    {
195        $cols = array_values(array_filter(array_map('trim', explode(',', (string) $field->relationLabel))));
196        if (count($cols) > 1) {
197            return $this->fetchCompositeRelationLabel($pdo, $field->relationTable, $relKey, $val, $cols);
198        }
199
200        $sql = sprintf(
201            'SELECT `%s` FROM `%s` WHERE `%s` = :id LIMIT 1',
202            (string) $field->relationLabel,
203            $field->relationTable,
204            $relKey
205        );
206        $stmt = $pdo->prepare($sql);
207        $stmt->bindValue(':id', $val);
208        $stmt->execute();
209        $labelVal = $stmt->fetchColumn();
210
211        return ($labelVal !== false && $labelVal !== null) ? (string) $labelVal : null;
212    }
213
214    /**
215     * Concatenates composite relation label columns into single string.
216     *
217     * @param array<int, string> $cols
218     */
219    private function fetchCompositeRelationLabel(
220        PDO $pdo,
221        string $table,
222        string $relKey,
223        string $val,
224        array $cols
225    ): ?string {
226        $escapedCols = implode(', ', array_map(static fn(string $c): string => sprintf('`%s`', $c), $cols));
227        $sql = sprintf('SELECT %s FROM `%s` WHERE `%s` = :id LIMIT 1', $escapedCols, $table, $relKey);
228        $stmt = $pdo->prepare($sql);
229        $stmt->bindValue(':id', $val);
230        $stmt->execute();
231        $row = $stmt->fetch(PDO::FETCH_ASSOC);
232        if (is_array($row)) {
233            $joined = trim(implode(' ', array_filter(array_map('strval', $row))));
234            return $joined !== '' ? $joined : null;
235        }
236        return null;
237    }
238
239    /**
240     * Auto-derives missing technical and relational attributes for system_fields records.
241     *
242     * @param array<string, mixed> $data Field input data modified in-place.
243     */
244    public function enrichFieldRecordData(array &$data): void
245    {
246        $this->enrichColumnExpression($data);
247        $this->enrichRelationData($data);
248
249        $data['is_sortable'] = $data['is_sortable'] ?? 1;
250        $data['is_filterable'] = $data['is_filterable'] ?? 1;
251        $data['is_system'] = $data['is_system'] ?? 0;
252    }
253
254    /**
255     * Auto-derives column_expression from module alias and field key.
256     *
257     * @param array<string, mixed> $data
258     */
259    private function enrichColumnExpression(array &$data): void
260    {
261        if (!isset($data['module_id'], $data['field_key'])) {
262            return;
263        }
264
265        if (isset($data['column_expression']) && $data['column_expression'] !== '') {
266            return;
267        }
268
269        try {
270            $targetModule = $this->metadata->findModuleById((int) $data['module_id']);
271            $data['column_expression'] = $targetModule->tableAlias . '.' . $data['field_key'];
272        } catch (Throwable) {
273            $data['column_expression'] = 'c.' . $data['field_key'];
274        }
275    }
276
277    /**
278     * Auto-derives relation_table and relation_key from relation_module.
279     *
280     * @param array<string, mixed> $data
281     */
282    private function enrichRelationData(array &$data): void
283    {
284        if (empty($data['relation_module'])) {
285            return;
286        }
287
288        try {
289            $relModule = $this->metadata->findModule((string) $data['relation_module']);
290            if (!isset($data['relation_table']) || $data['relation_table'] === '') {
291                $data['relation_table'] = $relModule->tableName;
292            }
293            if (!isset($data['relation_key']) || $data['relation_key'] === '') {
294                $data['relation_key'] = $relModule->primaryKey !== '' ? $relModule->primaryKey : 'id';
295            }
296        } catch (Throwable) {
297            // Ignore if relation module cannot be resolved
298        }
299    }
300}