Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.86% covered (success)
92.86%
52 / 56
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
RelationResolver
94.55% covered (success)
94.55%
52 / 55
80.00% covered (warning)
80.00%
4 / 5
18.05
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
 clearCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildJoins
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 buildSelectExpressions
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 isRelationTableAccessible
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
6.35
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\Query;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Domain\Model\FieldMetadata;
12use PDO;
13
14/**
15 * Relation Resolver.
16 *
17 * Generates LEFT JOIN SQL fragments and additional SELECT expressions
18 * for fields of relational UiTypes (entity_reference, autocomplete).
19 * Used by UniversalQueryBuilder to automatically resolve FK labels.
20 *
21 * @package App\Core\Engine\Application\Query
22 */
23final class RelationResolver
24{
25    /** @var array<string, bool> In-memory cache of existing table names. */
26    private static array $existingTablesCache = [];
27
28    /**
29     * RelationResolver constructor.
30     *
31     * @param PDO|null $pdo Optional PDO database connection handle for table existence validation.
32     */
33    public function __construct(private readonly ?PDO $pdo = null)
34    {
35    }
36
37    /**
38     * Clears in-memory table existence cache (primarily for tests).
39     */
40    public static function clearCache(): void
41    {
42        self::$existingTablesCache = [];
43    }
44
45    /**
46     * Generates LEFT JOIN SQL clauses for all relational fields.
47     *
48     * For each field with a relation_table defined, produces a JOIN:
49     * LEFT JOIN `{relation_table}` `rel_{field_key}`
50     *     ON `rel_{field_key}`.`{relation_key}` = `{table_alias}`.`{field_key}`
51     *
52     * @param array<int, FieldMetadata> $fields     All field definitions for the module.
53     * @param string                    $tableAlias The main table alias (e.g., 'c').
54     * @return string SQL LEFT JOIN fragment (may be empty string if no relations).
55     */
56    public function buildJoins(array $fields, string $tableAlias): string
57    {
58        $joins = [];
59
60        foreach ($fields as $field) {
61            if (!$field->hasRelation() || !$this->isRelationTableAccessible($field->relationTable)) {
62                continue;
63            }
64
65            $joinAlias = 'rel_' . $field->fieldKey;
66            $joins[] = sprintf(
67                'LEFT JOIN `%s` `%s` ON `%s`.`%s` = `%s`.`%s`',
68                $field->relationTable,
69                $joinAlias,
70                $joinAlias,
71                $field->relationKey,
72                $tableAlias,
73                $field->fieldKey
74            );
75        }
76
77        return implode(' ', $joins);
78    }
79
80    /**
81     * Generates additional SELECT expressions for relation label columns.
82     *
83     * For each relational field, adds a SELECT expression:
84     * `rel_{field_key}`.`{relation_label}` AS `{field_key}_label`
85     *
86     * This allows the query result to carry both the FK id and the resolved label.
87     *
88     * @param array<int, FieldMetadata> $fields  All field definitions for the module.
89     * @return array<int, string> List of additional SELECT expressions.
90     */
91    public function buildSelectExpressions(array $fields): array
92    {
93        $expressions = [];
94
95        foreach ($fields as $field) {
96            if (!$field->hasRelation() || $field->relationLabel === null) {
97                continue;
98            }
99
100            if (!$this->isRelationTableAccessible($field->relationTable)) {
101                $expressions[] = sprintf('NULL AS `%s`', $field->fieldKey . '_label');
102                continue;
103            }
104
105            $joinAlias = 'rel_' . $field->fieldKey;
106            $cols = array_values(array_filter(array_map('trim', explode(',', $field->relationLabel))));
107
108            if (count($cols) > 1) {
109                $concatParts = array_map(
110                    static fn(string $col): string => sprintf("COALESCE(`%s`.`%s`, '')", $joinAlias, $col),
111                    $cols
112                );
113                $expressions[] = sprintf(
114                    "TRIM(CONCAT_WS(' ', %s)) AS `%s`",
115                    implode(', ', $concatParts),
116                    $field->fieldKey . '_label'
117                );
118            } else {
119                $expressions[] = sprintf(
120                    '`%s`.`%s` AS `%s`',
121                    $joinAlias,
122                    $field->relationLabel,
123                    $field->fieldKey . '_label'
124                );
125            }
126        }
127
128        return $expressions;
129    }
130
131    /**
132     * Checks whether the target relation table physically exists in the database.
133     *
134     * @param string|null $tableName Target relation table name.
135     * @return bool True if table is accessible or if no PDO handle is provided.
136     */
137    private function isRelationTableAccessible(?string $tableName): bool
138    {
139        if ($tableName === null || $tableName === '') {
140            return false;
141        }
142
143        if ($this->pdo === null) {
144            return true;
145        }
146
147        if (!isset(self::$existingTablesCache[$tableName])) {
148            try {
149                $stmt = $this->pdo->prepare(
150                    'SELECT 1 FROM information_schema.TABLES ' .
151                    'WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table_name LIMIT 1'
152                );
153                $stmt->execute([':table_name' => $tableName]);
154                self::$existingTablesCache[$tableName] = ($stmt->fetchColumn() !== false);
155            } catch (\Throwable) {
156                self::$existingTablesCache[$tableName] = false;
157            }
158        }
159
160        return self::$existingTablesCache[$tableName];
161    }
162}
163