Skip to content

Commit c437ac0

Browse files
committed
update
0 parents  commit c437ac0

15 files changed

Lines changed: 1591 additions & 0 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/vendor/
2+
/composer.lock
3+
/.phpunit.result.cache
4+
/test_config.ini
5+
/composer.phar

LICENSE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Maurits van der Schee
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of
6+
this software and associated documentation files (the "Software"), to deal in
7+
the Software without restriction, including without limitation the rights to
8+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
the Software, and to permit persons to whom the Software is furnished to do so,
10+
subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# MintyPHP PathQL
2+
3+
PathQL query processor for MintyPHP ORM - automatic path inference for
4+
hierarchical query results.

composer.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "mintyphp/pathql",
3+
"description": "PathQL query processor for MintyPHP ORM - automatic path inference for hierarchical query results",
4+
"license": "MIT",
5+
"authors": [
6+
{
7+
"name": "Maurits van der Schee",
8+
"email": "maurits@vdschee.nl",
9+
"homepage": "https://www.tqdev.com"
10+
}
11+
],
12+
"require": {
13+
"php": ">=8.0"
14+
},
15+
"require-dev": {
16+
"phpunit/phpunit": "*"
17+
},
18+
"autoload": {
19+
"psr-4": {
20+
"MintyPHP\\PathQL\\": "src/"
21+
}
22+
},
23+
"autoload-dev": {
24+
"psr-4": {
25+
"MintyPHP\\PathQL\\Tests\\": "tests/"
26+
}
27+
}
28+
}

phpunit.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<phpunit bootstrap="./vendor/autoload.php">
3+
4+
<testsuites>
5+
<testsuite name="PathQL Test Suite">
6+
<directory>./tests</directory>
7+
</testsuite>
8+
</testsuites>
9+
10+
</phpunit>

src/Error/PathError.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace MintyPHP\PathQL\Error;
4+
5+
class PathError extends \Exception {}

src/PathInference.php

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
<?php
2+
3+
namespace MintyPHP\PathQL;
4+
5+
use MintyPHP\Core\DB;
6+
7+
class PathInference
8+
{
9+
private Schema $schema;
10+
11+
/**
12+
* Constructs a PathInference instance.
13+
*
14+
* @param Schema $schema The schema object for foreign key information
15+
*/
16+
public function __construct(Schema $schema)
17+
{
18+
$this->schema = $schema;
19+
}
20+
21+
/**
22+
* Infer paths for all columns in a query result.
23+
*
24+
* Analyzes the query structure, foreign key relationships, and path hints
25+
* to determine the hierarchical path for each column in the result set.
26+
*
27+
* @param QueryAnalyzer $analysis The analyzed query information
28+
* @param array<int,string> $columns Array of column names to infer paths for
29+
* @param DB $db Database connection for schema queries
30+
* @return array<int,string> Array of paths corresponding to each column
31+
*/
32+
public function inferPaths(QueryAnalyzer $analysis, array $columns, DB $db): array
33+
{
34+
$paths = [];
35+
$cardinality = $this->buildCardinalityMap($analysis, $db);
36+
37+
foreach ($columns as $idx => $col) {
38+
$paths[$idx] = $this->inferColumnPath($col, $analysis, $cardinality);
39+
}
40+
41+
return $paths;
42+
}
43+
44+
/**
45+
* @return array<string,bool>
46+
*/
47+
private function buildCardinalityMap(QueryAnalyzer $analysis, DB $db): array
48+
{
49+
$cardinality = [];
50+
$allFks = $this->schema->getForeignKeys($db);
51+
52+
$rootAlias = '';
53+
$joinedAliases = [];
54+
foreach ($analysis->joins as $join) {
55+
if (isset($join['rightAlias']) && is_string($join['rightAlias'])) {
56+
$joinedAliases[$join['rightAlias']] = true;
57+
}
58+
}
59+
60+
foreach ($analysis->tables as $alias => $tableName) {
61+
if (!isset($joinedAliases[$alias])) {
62+
$rootAlias = $alias;
63+
break;
64+
}
65+
}
66+
67+
// Special case: if there's a PATH hint for $ (root), use it
68+
if (isset($analysis->pathHints['$'])) {
69+
if ($rootAlias === '') {
70+
$rootAlias = '$';
71+
$analysis->tables['$'] = '$';
72+
}
73+
$analysis->pathHints[$rootAlias] = $analysis->pathHints['$'];
74+
}
75+
76+
if ($rootAlias !== '') {
77+
if (isset($analysis->pathHints[$rootAlias])) {
78+
$hintPath = $analysis->pathHints[$rootAlias];
79+
if (substr($hintPath, -2) === '[]') {
80+
$cardinality[$rootAlias] = true;
81+
} elseif ($hintPath === '$') {
82+
$cardinality[$rootAlias] = false;
83+
} else {
84+
$cardinality[$rootAlias] = count($analysis->joins) > 0;
85+
}
86+
} else {
87+
$cardinality[$rootAlias] = true;
88+
}
89+
}
90+
91+
foreach ($analysis->joins as $join) {
92+
if (isset($join['rightAlias']) && is_string($join['rightAlias'])) {
93+
$cardinality[$join['rightAlias']] = $this->isOneToManyJoin($join, $allFks);
94+
}
95+
}
96+
97+
foreach ($analysis->tables as $alias => $tableName) {
98+
if (!isset($cardinality[$alias])) {
99+
$cardinality[(string)$alias] = true;
100+
}
101+
}
102+
103+
return $cardinality;
104+
}
105+
106+
/**
107+
* @param array<string,mixed> $join
108+
* @param array<int, array<string,string>> $allFks
109+
*/
110+
private function isOneToManyJoin(array $join, array $allFks): bool
111+
{
112+
if (empty($join['onColumns'])) {
113+
return in_array($join['joinType'], ['LEFT', 'LEFT OUTER']);
114+
}
115+
116+
if (!is_array($join['onColumns'])) {
117+
return in_array($join['joinType'], ['LEFT', 'LEFT OUTER']);
118+
}
119+
120+
foreach ($join['onColumns'] as $jc) {
121+
if (!is_array($jc)) {
122+
continue;
123+
}
124+
foreach ($allFks as $fk) {
125+
// Right table has FK to left table -> One to Many
126+
if ($fk['from_table'] === $join['rightTable'] && $fk['to_table'] === $join['leftTable']) {
127+
if ((isset($jc['rightAlias']) && $jc['rightAlias'] === $join['rightAlias'] && isset($jc['rightColumn']) && $jc['rightColumn'] === $fk['from_column']) ||
128+
(isset($jc['leftAlias']) && $jc['leftAlias'] === $join['rightAlias'] && isset($jc['leftColumn']) && $jc['leftColumn'] === $fk['from_column'])
129+
) {
130+
return true;
131+
}
132+
}
133+
134+
// Left table has FK to right table -> Many to One
135+
if ($fk['from_table'] === $join['leftTable'] && $fk['to_table'] === $join['rightTable']) {
136+
if ((isset($jc['leftAlias']) && $jc['leftAlias'] === $join['leftAlias'] && isset($jc['leftColumn']) && $jc['leftColumn'] === $fk['from_column']) ||
137+
(isset($jc['rightAlias']) && $jc['rightAlias'] === $join['leftAlias'] && isset($jc['rightColumn']) && $jc['rightColumn'] === $fk['from_column'])
138+
) {
139+
return false;
140+
}
141+
}
142+
}
143+
}
144+
145+
return in_array($join['joinType'], ['LEFT', 'LEFT OUTER']);
146+
}
147+
148+
/**
149+
* @param array<string,bool> $cardinality
150+
*/
151+
private function inferColumnPath(string $column, QueryAnalyzer $analysis, array $cardinality): string
152+
{
153+
$parts = explode('.', $column);
154+
$alias = '';
155+
$colName = '';
156+
157+
if (count($parts) === 2) {
158+
$alias = $parts[0];
159+
$colName = $parts[1];
160+
161+
if (isset($analysis->pathHints[$alias])) {
162+
$hintPath = $analysis->pathHints[$alias];
163+
// Path hint already specifies the structure, just append column name
164+
return rtrim($hintPath, '.') . '.' . $colName;
165+
}
166+
} else {
167+
$colName = $column;
168+
$alias = $this->guessAliasForColumn($colName, $analysis);
169+
170+
if ($alias === '') {
171+
if (isset($analysis->pathHints['$'])) {
172+
$hintPath = $analysis->pathHints['$'];
173+
// Check if the hint already ends with the column name
174+
if (substr($hintPath, -strlen($colName)) === $colName) {
175+
return $hintPath;
176+
}
177+
return rtrim($hintPath, '.') . '.' . $colName;
178+
}
179+
return '$.' . $colName;
180+
}
181+
182+
if (isset($analysis->pathHints[$alias])) {
183+
$hintPath = $analysis->pathHints[$alias];
184+
// Check if the hint already ends with the column name
185+
if (substr($hintPath, -strlen($colName)) === $colName) {
186+
return $hintPath;
187+
}
188+
return rtrim($hintPath, '.') . '.' . $colName;
189+
}
190+
}
191+
192+
if (count($analysis->joins) === 0) {
193+
if (!empty($cardinality[$alias])) {
194+
return '$[].' . $colName;
195+
}
196+
return '$.' . $colName;
197+
}
198+
199+
$rootAlias = $this->findRootAlias($analysis);
200+
201+
// If this column belongs to the root table, use simplified path
202+
if ($alias === $rootAlias) {
203+
if (isset($analysis->pathHints[$rootAlias])) {
204+
$rootHint = $analysis->pathHints[$rootAlias];
205+
return rtrim($rootHint, '.') . '.' . $colName;
206+
}
207+
if (!empty($cardinality[$alias])) {
208+
return '$[].' . $colName;
209+
}
210+
return '$.' . $colName;
211+
}
212+
213+
// For non-root columns, build full path
214+
if (isset($analysis->pathHints[$rootAlias])) {
215+
$rootHint = $analysis->pathHints[$rootAlias];
216+
if (!empty($cardinality[$rootAlias])) {
217+
if (!empty($cardinality[$alias])) {
218+
if (substr($rootHint, -2) !== '[]') {
219+
return $rootHint . '[].' . $alias . '[].' . $colName;
220+
}
221+
return $rootHint . '.' . $alias . '[].' . $colName;
222+
}
223+
if (substr($rootHint, -2) !== '[]') {
224+
return $rootHint . '[].' . $alias . '.' . $colName;
225+
}
226+
return $rootHint . '.' . $alias . '.' . $colName;
227+
} else {
228+
if (!empty($cardinality[$alias])) {
229+
return $rootHint . '.' . $alias . '[].' . $colName;
230+
}
231+
return $rootHint . '.' . $alias . '.' . $colName;
232+
}
233+
}
234+
235+
$path = $this->buildPathToTable($alias, $analysis, $cardinality);
236+
return $path . '.' . $colName;
237+
}
238+
239+
private function findRootAlias(QueryAnalyzer $analysis): string
240+
{
241+
$joinedAliases = [];
242+
foreach ($analysis->joins as $join) {
243+
if (isset($join['rightAlias']) && is_string($join['rightAlias'])) {
244+
$joinedAliases[$join['rightAlias']] = true;
245+
}
246+
}
247+
foreach ($analysis->tables as $alias => $tableName) {
248+
if (!isset($joinedAliases[$alias])) {
249+
return $alias;
250+
}
251+
}
252+
return '';
253+
}
254+
255+
private function guessAliasForColumn(string $column, QueryAnalyzer $analysis): string
256+
{
257+
if (count($analysis->tables) === 1) {
258+
reset($analysis->tables);
259+
return key($analysis->tables) ?: '';
260+
}
261+
262+
foreach ($analysis->tables as $alias => $tableName) {
263+
return $alias;
264+
}
265+
266+
return '';
267+
}
268+
269+
/**
270+
* @param array<string,bool> $cardinality
271+
*/
272+
private function buildPathToTable(string $targetAlias, QueryAnalyzer $analysis, array $cardinality): string
273+
{
274+
$visited = [];
275+
return $this->buildPathRecursive($targetAlias, $analysis, $cardinality, $visited);
276+
}
277+
278+
/**
279+
* @param array<string,bool> $cardinality
280+
* @param array<string,bool> $visited
281+
*/
282+
private function buildPathRecursive(string $targetAlias, QueryAnalyzer $analysis, array $cardinality, array &$visited): string
283+
{
284+
if (isset($visited[$targetAlias])) {
285+
return '';
286+
}
287+
$visited[$targetAlias] = true;
288+
289+
$rootAlias = $this->findRootAlias($analysis);
290+
$isRoot = ($targetAlias === $rootAlias);
291+
292+
if ($isRoot) {
293+
// Root should not include alias in path
294+
if (!empty($cardinality[$targetAlias])) {
295+
return '$[]';
296+
}
297+
return '$';
298+
}
299+
300+
if (!empty($cardinality[$rootAlias])) {
301+
if (!empty($cardinality[$targetAlias])) {
302+
return '$[].' . $targetAlias . '[]';
303+
}
304+
return '$[].' . $targetAlias;
305+
}
306+
307+
if (!empty($cardinality[$targetAlias])) {
308+
return '$.' . $targetAlias . '[]';
309+
}
310+
return '$.' . $targetAlias;
311+
}
312+
}

0 commit comments

Comments
 (0)