Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.83% covered (success)
95.83%
23 / 24
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
HierarchyCycleValidator
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
2 / 2
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateNoCycle
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
11
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\Domain\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Exception\ValidationException;
12use PDO;
13
14/**
15 * Validates self-referential hierarchical relations to prevent recursive loops and circular dependencies.
16 *
17 * @package App\Core\Engine\Domain\Service
18 */
19final readonly class HierarchyCycleValidator
20{
21    private const int MAX_TRAVERSAL_DEPTH = 50;
22
23    /**
24     * @param PDO $pdo Active database connection.
25     */
26    public function __construct(
27        private PDO $pdo
28    ) {
29    }
30
31    /**
32     * Validates that assigning $parentId to $recordId within $tableName will not cause a loop.
33     *
34     * @param string   $tableName Target database table name.
35     * @param int|null $recordId  Target record ID being updated (null for create).
36     * @param int|null $parentId  Desired parent record ID.
37     * @throws ValidationException If a direct or indirect circular dependency is detected.
38     */
39    public function validateNoCycle(string $tableName, ?int $recordId, ?int $parentId): void
40    {
41        if ($recordId === null || $parentId === null || $parentId <= 0) {
42            return;
43        }
44
45        if ($recordId === $parentId) {
46            throw new ValidationException(['parent_id' => 'A record cannot be assigned as its own parent.']);
47        }
48
49        // Sanitize table name to prevent SQL injection
50        if (!preg_match('/^\w+$/', $tableName)) {
51            throw new ValidationException(['parent_id' => 'Invalid table name format for hierarchy validation.']);
52        }
53
54        $visited = [$recordId => true];
55        $currentParentId = $parentId;
56        $depth = 0;
57
58        while ($currentParentId !== null && $depth < self::MAX_TRAVERSAL_DEPTH) {
59            if (isset($visited[$currentParentId])) {
60                throw new ValidationException([
61                    'parent_id' => 'Hierarchical loop detected: assigning this parent creates a circular dependency.'
62                ]);
63            }
64
65            $visited[$currentParentId] = true;
66            $depth++;
67
68            $stmt = $this->pdo->prepare(
69                "SELECT parent_id FROM `{$tableName}` WHERE id = :id LIMIT 1"
70            );
71            $stmt->execute([':id' => $currentParentId]);
72            $rawParent = $stmt->fetchColumn();
73
74            $currentParentId = $rawParent !== false && $rawParent !== null ? (int)$rawParent : null;
75        }
76    }
77}