Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.60% covered (success)
95.60%
174 / 182
55.56% covered (warning)
55.56%
10 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
HierarchyService
95.58% covered (success)
95.58%
173 / 181
55.56% covered (warning)
55.56%
10 / 18
72
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
 getHierarchy
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 countRelated
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getExcludedDescendantIds
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 searchRelationOptions
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
7
 buildSearchQueryConditions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 resolveSublabel
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 hasColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasParentColumn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findRootAncestorId
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 buildNodeTree
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
11
 resolveTitle
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 resolveFallbackTitle
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 resolvePersonTitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 resolveFieldTitle
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 resolveFieldColumns
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 countTreeNodes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 collectNodeIds
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
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\Domain\Model\FieldMetadata;
12use App\Core\Engine\Infrastructure\Schema\SqlTableSchemaHelper;
13use PDO;
14
15/**
16 * Builds hierarchical record trees (up to 5 levels) and calculates related records in self-referencing modules.
17 *
18 * @package App\Core\Engine\Application\Service
19 */
20final readonly class HierarchyService
21{
22    private const int MAX_TREE_DEPTH = 5;
23    private const string IDENTIFIER_PATTERN = '/^\w+$/';
24
25    /**
26     * @param PDO $pdo Active database connection.
27     */
28    public function __construct(
29        private PDO $pdo
30    ) {
31    }
32
33    /**
34     * Retrieves the entire hierarchy tree and total related records count for a given record.
35     *
36     * @param string               $moduleName Active module name.
37     * @param int                  $recordId   Target record ID.
38     * @param string               $tableName  Module database table name.
39     * @param array<FieldMetadata> $fields     Field metadata list for resolving titles.
40     * @param string               $routeUrl   Optional canonical module route URL.
41     * @return array<string, mixed> Structured hierarchy payload with tree and counts.
42     */
43    public function getHierarchy(
44        string $moduleName,
45        int    $recordId,
46        string $tableName,
47        array  $fields,
48        string $routeUrl = ''
49    ): array {
50        if (!preg_match(self::IDENTIFIER_PATTERN, $tableName)) {
51            return ['has_hierarchy' => false, 'related_count' => 0, 'tree' => null];
52        }
53
54        // 1. Verify if parent_id column exists
55        if (!$this->hasParentColumn($tableName)) {
56            return ['has_hierarchy' => false, 'related_count' => 0, 'tree' => null];
57        }
58
59        // 2. Find Root Ancestor (Traverse up to MAX_TREE_DEPTH)
60        $rootId = $this->findRootAncestorId($tableName, $recordId);
61
62        // 3. Build Tree from Root
63        $tree = $this->buildNodeTree(
64            $moduleName,
65            $tableName,
66            $rootId,
67            $recordId,
68            0,
69            $fields,
70            $routeUrl
71        );
72        $totalNodes = $this->countTreeNodes($tree);
73        $relatedCount = max(0, $totalNodes - 1);
74
75        return [
76            'has_hierarchy' => $relatedCount > 0,
77            'related_count' => $relatedCount,
78            'tree'          => $tree,
79        ];
80    }
81
82    /**
83     * Quickly counts total related hierarchical records (ancestors + descendants) for header badge.
84     *
85     * @param string $tableName Module database table name.
86     * @param int    $recordId  Target record ID.
87     * @return int Number of related records in the hierarchy.
88     */
89    public function countRelated(string $tableName, int $recordId): int
90    {
91        if (!preg_match(self::IDENTIFIER_PATTERN, $tableName) || !$this->hasParentColumn($tableName)) {
92            return 0;
93        }
94
95        $rootId = $this->findRootAncestorId($tableName, $recordId);
96        $visited = [];
97        $this->collectNodeIds($tableName, $rootId, $visited, 0);
98
99        return max(0, count($visited) - 1);
100    }
101
102    /**
103     * Collects all descendant IDs for a record to prevent cyclic assignment in pickers.
104     *
105     * @param string $tableName Target table name.
106     * @param int    $recordId  Parent record ID.
107     * @return array<int> List of descendant IDs including the record itself.
108     */
109    public function getExcludedDescendantIds(string $tableName, int $recordId): array
110    {
111        if (!preg_match(self::IDENTIFIER_PATTERN, $tableName) || !$this->hasParentColumn($tableName)) {
112            return [$recordId];
113        }
114
115        $visited = [];
116        $this->collectNodeIds($tableName, $recordId, $visited, 0);
117
118        return array_map('intval', array_keys($visited));
119    }
120
121    /**
122     * Searches relation options for autocomplete and modal pickers with cycle exclusion.
123     *
124     * @param string               $tableName   Target table name.
125     * @param array<FieldMetadata> $fields      Field metadata definitions.
126     * @param string               $query       Search filter string.
127     * @param array<int>           $excludedIds Record IDs to exclude from results.
128     * @param int                  $limit       Maximum rows to return.
129     * @return array<int, array<string, mixed>> List of matching records.
130     */
131    public function searchRelationOptions(
132        string $tableName,
133        array  $fields,
134        string $query,
135        array  $excludedIds,
136        int    $limit = 20
137    ): array {
138        if (!preg_match(self::IDENTIFIER_PATTERN, $tableName)) {
139            return [];
140        }
141
142        $conditions = [];
143        $params = [];
144
145        if (!empty($excludedIds)) {
146            $inPlaceholders = [];
147            foreach (array_values($excludedIds) as $idx => $exId) {
148                $pName = ':ex_' . $idx;
149                $inPlaceholders[] = $pName;
150                $params[$pName] = (int) $exId;
151            }
152            $conditions[] = 'id NOT IN (' . implode(', ', $inPlaceholders) . ')';
153        }
154
155        $searchCondition = $this->buildSearchQueryConditions($tableName, $query, $fields, $params);
156        if ($searchCondition !== null) {
157            $conditions[] = $searchCondition;
158        }
159
160        $selectCols = $this->resolveFieldColumns($fields, $tableName);
161        $whereClause = $conditions !== [] ? ' WHERE ' . implode(' AND ', $conditions) : '';
162        $sql = "SELECT {$selectCols} FROM `{$tableName}`{$whereClause} ORDER BY id DESC LIMIT {$limit}";
163        $stmt = $this->pdo->prepare($sql);
164        $stmt->execute($params);
165        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
166
167        $items = [];
168        foreach ($rows as $row) {
169            $id = (int) $row['id'];
170            $title = $this->resolveTitle($fields, $row, $id);
171            $items[] = [
172                'id'       => $id,
173                'label'    => $title,
174                'sublabel' => $this->resolveSublabel($row),
175                'value'    => $id,
176                'text'     => $title,
177                'row'      => $row,
178            ];
179        }
180
181        return $items;
182    }
183
184    /**
185     * Builds general search condition clause and parameter bindings.
186     *
187     * @param string               $tableName Target table name.
188     * @param string               $query     Search query string.
189     * @param array<FieldMetadata> $fields    Fields definitions.
190     * @param array<string, mixed> $params    Query parameters map.
191     */
192    private function buildSearchQueryConditions(
193        string $tableName,
194        string $query,
195        array  $fields,
196        array  &$params
197    ): ?string {
198        if ($query === '') {
199            return null;
200        }
201
202        $searchClauses = ['id = :q_exact'];
203        $params[':q_exact'] = is_numeric($query) ? (int) $query : 0;
204
205        $targetFields = ['first_name', 'last_name', 'name', 'title', 'email', 'tax_identifier', 'job_title'];
206        foreach ($fields as $field) {
207            if (($field->isTitleField() || in_array($field->fieldKey, $targetFields, true))
208                && $this->hasColumn($tableName, $field->fieldKey)
209            ) {
210                $clauseParam = ':q_' . $field->fieldKey;
211                $searchClauses[] = "`{$field->fieldKey}` LIKE {$clauseParam}";
212                $params[$clauseParam] = '%' . $query . '%';
213            }
214        }
215
216        return '(' . implode(' OR ', $searchClauses) . ')';
217    }
218
219    /**
220     * Resolves secondary metadata subtitle from row columns.
221     *
222     * @param array<string, mixed> $row
223     */
224    private function resolveSublabel(array $row): ?string
225    {
226        foreach (['job_title', 'email', 'tax_identifier'] as $key) {
227            if (!empty($row[$key])) {
228                return (string) $row[$key];
229            }
230        }
231
232        return null;
233    }
234
235    /**
236     * Checks if a column exists in table using cached schema introspection.
237     */
238    private function hasColumn(string $tableName, string $column): bool
239    {
240        return SqlTableSchemaHelper::hasColumn($this->pdo, $tableName, $column);
241    }
242
243    /**
244     * Checks if parent_id column exists in table.
245     */
246    private function hasParentColumn(string $tableName): bool
247    {
248        return $this->hasColumn($tableName, 'parent_id');
249    }
250
251    /**
252     * Traverses up parent_id pointers to locate the root of the hierarchy tree.
253     */
254    private function findRootAncestorId(string $tableName, int $recordId): int
255    {
256        $currentId = $recordId;
257        $visited = [$recordId => true];
258        $depth = 0;
259
260        while ($depth < self::MAX_TREE_DEPTH) {
261            $stmt = $this->pdo->prepare(
262                "SELECT parent_id FROM `{$tableName}` WHERE id = :id LIMIT 1"
263            );
264            $stmt->execute([':id' => $currentId]);
265            $rawParent = $stmt->fetchColumn();
266
267            if ($rawParent === false || $rawParent === null || (int)$rawParent <= 0) {
268                break;
269            }
270
271            $parentId = (int)$rawParent;
272            if (isset($visited[$parentId])) {
273                break;
274            }
275
276            $visited[$parentId] = true;
277            $currentId = $parentId;
278            $depth++;
279        }
280
281        return $currentId;
282    }
283
284    /**
285     * Recursively builds tree nodes up to MAX_TREE_DEPTH.
286     *
287     * @param array<FieldMetadata> $fields
288     * @return array<string, mixed>
289     */
290    private function buildNodeTree(
291        string $moduleName,
292        string $tableName,
293        int    $nodeId,
294        int    $currentRecordId,
295        int    $level,
296        array  $fields,
297        string $routeUrl = ''
298    ): array {
299        $selectCols = $this->resolveFieldColumns($fields, $tableName);
300        $stmt = $this->pdo->prepare(
301            "SELECT {$selectCols} FROM `{$tableName}` WHERE id = :id LIMIT 1"
302        );
303        $stmt->execute([':id' => $nodeId]);
304        $row = $stmt->fetch(PDO::FETCH_ASSOC);
305
306        $title = $row !== false ? $this->resolveTitle($fields, $row, $nodeId) : ('#' . $nodeId);
307        $jobTitle = is_array($row) && isset($row['job_title']) ? (string)$row['job_title'] : null;
308
309        $children = [];
310        if ($level < self::MAX_TREE_DEPTH) {
311            $childStmt = $this->pdo->prepare(
312                "SELECT id FROM `{$tableName}` WHERE parent_id = :parent_id ORDER BY id ASC"
313            );
314            $childStmt->execute([':parent_id' => $nodeId]);
315            $childIds = $childStmt->fetchAll(PDO::FETCH_COLUMN);
316
317            foreach ($childIds as $childId) {
318                if ((int)$childId !== $nodeId) {
319                    $children[] = $this->buildNodeTree(
320                        $moduleName,
321                        $tableName,
322                        (int)$childId,
323                        $currentRecordId,
324                        $level + 1,
325                        $fields,
326                        $routeUrl
327                    );
328                }
329            }
330        }
331
332        $baseRoute = ($routeUrl !== '' && $routeUrl !== '0')
333            ? '/' . trim($routeUrl, '/')
334            : "/{$moduleName}";
335
336        return [
337            'id'         => $nodeId,
338            'title'      => $title,
339            'job_title'  => $jobTitle,
340            'parent_id'  => is_array($row) && isset($row['parent_id']) ? (int)$row['parent_id'] : null,
341            'is_current' => $nodeId === $currentRecordId,
342            'level'      => $level,
343            'url'        => "{$baseRoute}/{$nodeId}",
344            'children'   => $children,
345        ];
346    }
347
348    /**
349     * Resolves human-readable title for a row based on module field heuristics.
350     *
351     * @param array<FieldMetadata> $fields
352     * @param array<string, mixed> $row
353     */
354    private function resolveTitle(array $fields, array $row, int $id): string
355    {
356        $personTitle = $this->resolvePersonTitle($row);
357        if ($personTitle !== null) {
358            return $personTitle;
359        }
360
361        $fieldTitle = $this->resolveFieldTitle($fields, $row);
362        if ($fieldTitle !== null) {
363            return $fieldTitle;
364        }
365
366        return $this->resolveFallbackTitle($row, $id);
367    }
368
369    /**
370     * Resolves fallback title from common record attributes.
371     *
372     * @param array<string, mixed> $row Record columns map.
373     * @param int                  $id  Record primary key.
374     * @return string Human-readable label or fallback identifier.
375     */
376    private function resolveFallbackTitle(array $row, int $id): string
377    {
378        foreach (['name', 'subject', 'title', 'contract_name'] as $key) {
379            if (!empty($row[$key])) {
380                return (string) $row[$key];
381            }
382        }
383
384        return '#' . $id;
385    }
386
387    /**
388     * Resolves composite person title.
389     *
390     * @param array<string, mixed> $row
391     */
392    private function resolvePersonTitle(array $row): ?string
393    {
394        if (!empty($row['first_name']) || !empty($row['last_name'])) {
395            return trim(($row['first_name'] ?? '') . ' ' . ($row['last_name'] ?? ''));
396        }
397
398        return null;
399    }
400
401    /**
402     * Resolves title using designated title fields.
403     *
404     * @param array<FieldMetadata> $fields
405     * @param array<string, mixed> $row
406     */
407    private function resolveFieldTitle(array $fields, array $row): ?string
408    {
409        foreach ($fields as $field) {
410            if ($field->isTitleField() && !empty($row[$field->fieldKey])) {
411                return (string) $row[$field->fieldKey];
412            }
413        }
414
415        return null;
416    }
417
418    /**
419     * Resolves explicit column projection list based on module fields.
420     *
421     * @param array<FieldMetadata> $fields Module field definitions.
422     * @param string $tableName Target database table name.
423     * @return string Escaped comma-separated column list.
424     */
425    private function resolveFieldColumns(array $fields, string $tableName): string
426    {
427        $cols = ['id'];
428        foreach ($fields as $field) {
429            if ($this->hasColumn($tableName, $field->fieldKey)) {
430                $cols[] = $field->fieldKey;
431            }
432        }
433        $candidates = [
434            'name', 'title', 'subject', 'first_name', 'last_name',
435            'parent_id', 'job_title', 'email', 'tax_identifier',
436        ];
437        foreach ($candidates as $candidate) {
438            if ($this->hasColumn($tableName, $candidate)) {
439                $cols[] = $candidate;
440            }
441        }
442        $escaped = array_map(static fn(string $col): string => "`{$col}`", array_unique($cols));
443
444        return implode(', ', $escaped);
445    }
446
447    /**
448     * Traverses and counts total nodes in the tree structure.
449     *
450     * @param array<string, mixed> $node
451     */
452    private function countTreeNodes(array $node): int
453    {
454        $count = 1;
455        if (!empty($node['children']) && is_array($node['children'])) {
456            foreach ($node['children'] as $child) {
457                $count += $this->countTreeNodes($child);
458            }
459        }
460
461        return $count;
462    }
463
464    /**
465     * Helper to collect all node IDs in a subtree.
466     *
467     * @param array<int, bool> $visited
468     */
469    private function collectNodeIds(string $tableName, int $nodeId, array &$visited, int $level): void
470    {
471        if (isset($visited[$nodeId]) || $level > self::MAX_TREE_DEPTH) {
472            return;
473        }
474
475        $visited[$nodeId] = true;
476        $stmt = $this->pdo->prepare(
477            "SELECT id FROM `{$tableName}` WHERE parent_id = :parent_id"
478        );
479        $stmt->execute([':parent_id' => $nodeId]);
480        $childIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
481
482        foreach ($childIds as $cid) {
483            $this->collectNodeIds($tableName, (int)$cid, $visited, $level + 1);
484        }
485    }
486}