From 2196fa2c7007e8be0355b6736c37cadd069253f7 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 11 May 2026 19:15:08 +0400 Subject: [PATCH 1/8] [#508] Support related criteria paths in SleekDB joins --- CHANGELOG.md | 1 + src/Database/Adapters/Sleekdb/SleekDbal.php | 269 +++++++++++++++++- .../Adapters/Sleekdb/Statements/Join.php | 34 ++- .../Adapters/Sleekdb/Statements/Result.php | 55 ++-- .../Sleekdb/Statements/JoinSleekTest.php | 77 +++++ 5 files changed, 411 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90722937..41325647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ - PHP 8.4 forward compatibility: Added missing type hint to `SleekDbal::__get()` magic method - Fixed deprecated `E_STRICT` constant usage in test bootstrap - Fixed cURL error message assertions for cross-version compatibility +- SleekDB adapter now supports related-model criteria path filtering across join depths while preserving DBAL public API (#508) ### Added - `AppContext` class representing the runtime identity of a single application execution diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index 1cd37d49..4b1b7493 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -28,7 +28,9 @@ use Quantum\Model\Exceptions\ModelException; use Quantum\App\Exceptions\BaseException; use SleekDB\Exceptions\IOException; +use Quantum\Model\DbModel; use SleekDB\QueryBuilder; +use RuntimeException; use SleekDB\Store; /** @@ -95,6 +97,23 @@ class SleekDbal implements DbalInterface */ protected $joins = []; + /** + * @var array|string> + */ + protected array $rootCriterias = []; + + /** + * @var array> + */ + protected array $relatedCriteriasByPath = []; + + /** + * @var array + */ + protected array $requiredRelatedPaths = []; + + protected bool $criteriaPrepared = false; + /** * Associated model name */ @@ -299,6 +318,10 @@ public function getBuilder(): QueryBuilder $this->queryBuilder = $builder; } + if (!$this->criteriaPrepared) { + $this->prepareCriteriaScopes(); + } + if ($this->selected !== []) { $builder->select($this->selected); } @@ -307,8 +330,8 @@ public function getBuilder(): QueryBuilder $this->applyJoins(); } - if ($this->criterias !== []) { - $builder->where($this->criterias); + if ($this->rootCriterias !== []) { + $builder->where($this->rootCriterias); } if ($this->havings !== []) { @@ -357,6 +380,10 @@ public function getModelName(): string protected function resetBuilderState(): void { $this->criterias = []; + $this->rootCriterias = []; + $this->relatedCriteriasByPath = []; + $this->requiredRelatedPaths = []; + $this->criteriaPrepared = false; $this->havings = []; $this->selected = []; $this->grouped = []; @@ -366,4 +393,242 @@ protected function resetBuilderState(): void $this->joins = []; $this->queryBuilder = null; } + + /** + * Splits collected criteria into root-store and related-join scopes. + * Related path criteria are mapped to join paths and excluded from root where. + */ + protected function prepareCriteriaScopes(): void + { + $this->rootCriterias = $this->criterias; + $this->relatedCriteriasByPath = []; + $this->requiredRelatedPaths = []; + $this->criteriaPrepared = true; + + if ($this->joins === [] || $this->criterias === []) { + return; + } + + $joinPaths = $this->collectJoinPaths(); + + if ($joinPaths === []) { + return; + } + + $rootCriterias = []; + $foundRelated = false; + $hasOr = false; + + foreach ($this->criterias as $criteria) { + if (is_string($criteria)) { + $hasOr = $hasOr || strtoupper($criteria) === 'OR'; + $rootCriterias[] = $criteria; + continue; + } + + if (!is_array($criteria) || !isset($criteria[0]) || !is_string($criteria[0])) { + $rootCriterias[] = $criteria; + continue; + } + + $column = $criteria[0]; + $relatedMatch = $this->matchRelatedPath($column, $joinPaths); + + if ($relatedMatch === null) { + $rootCriterias[] = $criteria; + continue; + } + + $foundRelated = true; + $path = $relatedMatch['path']; + $localColumn = $relatedMatch['column']; + + $this->relatedCriteriasByPath[$path][] = [$localColumn, $criteria[1], $criteria[2] ?? null]; + $this->requiredRelatedPaths[] = $path; + } + + if ($foundRelated && $hasOr) { + throw new RuntimeException( + 'SleekDB related-model criterias do not support OR combinations with root/related scopes yet.' + ); + } + + $this->requiredRelatedPaths = array_values(array_unique($this->requiredRelatedPaths)); + $this->rootCriterias = $rootCriterias; + } + + /** + * Builds all accessible join paths (including nested ones) from current join chain. + * @return array + */ + protected function collectJoinPaths(): array + { + $paths = []; + $this->collectJoinPathsRecursive($this->joins, 0, '', $paths); + usort($paths, static fn (string $a, string $b): int => substr_count($b, '.') <=> substr_count($a, '.')); + return array_values(array_unique($paths)); + } + + /** + * Recursively resolves join paths while respecting switch mode for same-level joins. + * @param array> $joins + * @param array $paths + */ + protected function collectJoinPathsRecursive(array $joins, int $level, string $currentPath, array &$paths): void + { + if (!isset($joins[$level])) { + return; + } + + $nextItem = $joins[$level]; + $model = unserialize($nextItem['model']); + + if (!$model instanceof DbModel) { + return; + } + + $joinPath = $this->buildJoinPath($currentPath, $model->table); + $paths[] = $joinPath; + + $switch = (bool) ($nextItem['switch'] ?? true); + + if ($switch) { + $this->collectJoinPathsRecursive($joins, $level + 1, $joinPath, $paths); + return; + } + + $this->collectJoinPathsRecursive($joins, $level + 1, $currentPath, $paths); + } + + /** + * Matches a dotted criteria column to the deepest known join path. + * @param array $joinPaths + * @return array{path:string,column:string}|null + */ + protected function matchRelatedPath(string $column, array $joinPaths): ?array + { + $parts = explode('.', $column); + + if (count($parts) <= 1) { + return null; + } + + if ($parts[0] === $this->table) { + return null; + } + + foreach ($joinPaths as $path) { + $pathParts = explode('.', $path); + + if (count($parts) <= count($pathParts)) { + continue; + } + + if (array_slice($parts, 0, count($pathParts)) !== $pathParts) { + continue; + } + + $localColumn = implode('.', array_slice($parts, count($pathParts))); + + return [ + 'path' => $path, + 'column' => $localColumn, + ]; + } + + return null; + } + + /** + * Removes parent rows that do not contain data on required related paths. + * @param array> $results + * @return array> + */ + public function applyRelatedCriteriaPostFilter(array $results): array + { + if ($this->requiredRelatedPaths === []) { + return $results; + } + + return array_values(array_filter($results, function (array $row): bool { + foreach ($this->requiredRelatedPaths as $path) { + if (!$this->pathHasData($row, explode('.', $path))) { + return false; + } + } + + return true; + })); + } + + /** + * Checks whether a nested relation path exists and contains at least one result. + * @param array $row + * @param array $segments + */ + protected function pathHasData(array $row, array $segments): bool + { + $nodes = [$row]; + + foreach ($segments as $segment) { + $nextNodes = []; + + foreach ($nodes as $node) { + if (!array_key_exists($segment, $node)) { + continue; + } + + $value = $node[$segment]; + + if (!is_array($value) || $value === []) { + continue; + } + + if ($this->isList($value)) { + foreach ($value as $item) { + if (is_array($item)) { + $nextNodes[] = $item; + } + } + continue; + } + + $nextNodes[] = $value; + } + + if ($nextNodes === []) { + return false; + } + + $nodes = $nextNodes; + } + + return true; + } + + /** + * Determines whether the given array has sequential integer keys from zero. + * @param array $value + */ + protected function isList(array $value): bool + { + $index = 0; + + foreach ($value as $key => $_) { + if ($key !== $index) { + return false; + } + $index++; + } + + return true; + } + + /** + * Builds a dot-notated join path from current path and next relation table. + */ + protected function buildJoinPath(string $currentPath, string $table): string + { + return $currentPath !== '' ? $currentPath . '.' . $table : $table; + } } diff --git a/src/Database/Adapters/Sleekdb/Statements/Join.php b/src/Database/Adapters/Sleekdb/Statements/Join.php index f70dbfbc..b94fb356 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Join.php +++ b/src/Database/Adapters/Sleekdb/Statements/Join.php @@ -55,7 +55,7 @@ private function applyJoins(): void throw new RuntimeException('Cannot apply joins without an initialized query builder.'); } - $this->applyJoin($this->queryBuilder, $this, $this->joins[0]); + $this->applyJoin($this->queryBuilder, $this, $this->joins[0], 1, ''); } } @@ -64,8 +64,13 @@ private function applyJoins(): void * @param array $nextItem * @throws ModelException */ - private function applyJoin(QueryBuilder $queryBuilder, SleekDbal $currentItem, array $nextItem, int $level = 1): QueryBuilder - { + private function applyJoin( + QueryBuilder $queryBuilder, + SleekDbal $currentItem, + array $nextItem, + int $level = 1, + string $currentPath = '' + ): QueryBuilder { $modelToJoin = unserialize($nextItem['model']); $switch = $nextItem['switch']; @@ -73,7 +78,9 @@ private function applyJoin(QueryBuilder $queryBuilder, SleekDbal $currentItem, a throw new RuntimeException('Failed to unserialize join model.'); } - $queryBuilder->join(function ($item) use ($currentItem, $modelToJoin, $switch, $level) { + $joinPath = $this->buildJoinPath($currentPath, $modelToJoin->table); + + $queryBuilder->join(function ($item) use ($currentItem, $modelToJoin, $switch, $level, $joinPath) { $sleekModel = new self( $modelToJoin->table, @@ -85,9 +92,10 @@ private function applyJoin(QueryBuilder $queryBuilder, SleekDbal $currentItem, a $newQueryBuilder = $sleekModel->getOrmModel()->createQueryBuilder(); $this->applyJoinTo($newQueryBuilder, $modelToJoin, $currentItem, $item); + $this->applyRelatedCriteriaToJoin($newQueryBuilder, $joinPath); if ($switch && isset($this->joins[$level])) { - $this->applyJoin($newQueryBuilder, $sleekModel, $this->joins[$level], $level + 1); + $this->applyJoin($newQueryBuilder, $sleekModel, $this->joins[$level], $level + 1, $joinPath); } return $newQueryBuilder; @@ -95,12 +103,26 @@ private function applyJoin(QueryBuilder $queryBuilder, SleekDbal $currentItem, a }, $modelToJoin->table); if (!$switch && isset($this->joins[$level])) { - $this->applyJoin($queryBuilder, $currentItem, $this->joins[$level], $level + 1); + $this->applyJoin($queryBuilder, $currentItem, $this->joins[$level], $level + 1, $currentPath); } return $queryBuilder; } + /** + * Applies criteria assigned to a specific join path on that join subquery. + */ + private function applyRelatedCriteriaToJoin(QueryBuilder $queryBuilder, string $joinPath): void + { + if (!isset($this->relatedCriteriasByPath[$joinPath])) { + return; + } + + foreach ($this->relatedCriteriasByPath[$joinPath] as $criteria) { + $queryBuilder->where($criteria); + } + } + /** * Apply join condition for JOINTO type * @param array $currentItem diff --git a/src/Database/Adapters/Sleekdb/Statements/Result.php b/src/Database/Adapters/Sleekdb/Statements/Result.php index e1bbc70f..6e6ee8c4 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Result.php +++ b/src/Database/Adapters/Sleekdb/Statements/Result.php @@ -38,11 +38,13 @@ abstract protected function resetBuilderState(): void; public function get(): array { try { + $results = $this->fetchFilteredResults(); + return array_map(function ($element): object { $item = clone $this; $item->updateOrmModel($element); return $item; - }, $this->getBuilder()->getQuery()->fetch()); + }, $results); } finally { $this->resetBuilderState(); } @@ -60,7 +62,9 @@ public function get(): array public function findOne(int $id): DbalInterface { try { - $result = $this->getBuilder()->where(['id', '=', $id])->getQuery()->first(); + $this->getBuilder()->where(['id', '=', $id]); + $results = $this->fetchFilteredResults(); + $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { $this->resetBuilderState(); @@ -81,7 +85,9 @@ public function findOne(int $id): DbalInterface public function findOneBy(string $column, $value): DbalInterface { try { - $result = $this->getBuilder()->where([$column, '=', $value])->getQuery()->first(); + $this->getBuilder()->where([$column, '=', $value]); + $results = $this->fetchFilteredResults(); + $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { $this->resetBuilderState(); @@ -92,17 +98,13 @@ public function findOneBy(string $column, $value): DbalInterface /** * @inheritDoc - * @throws BaseException - * @throws DatabaseException - * @throws IOException - * @throws InvalidArgumentException - * @throws InvalidConfigurationException - * @throws ModelException + * @throws ModelException|DatabaseException|BaseException|IOException|InvalidArgumentException|InvalidConfigurationException */ public function first(): DbalInterface { try { - $result = $this->getBuilder()->getQuery()->first(); + $results = $this->fetchFilteredResults(); + $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { $this->resetBuilderState(); @@ -113,16 +115,15 @@ public function first(): DbalInterface /** * @inheritDoc - * @throws DatabaseException - * @throws IOException - * @throws InvalidArgumentException - * @throws InvalidConfigurationException - * @throws ModelException - * @throws BaseException */ public function count(): int { - return count($this->getBuilder()->getQuery()->fetch()); + try { + $results = $this->fetchFilteredResults(); + return count($results); + } finally { + $this->resetBuilderState(); + } } /** @@ -147,4 +148,24 @@ public function setHidden(array $result): array { return array_diff_key($result, array_flip($this->hidden)); } + + /** + * Applies adapter-specific post-fetch filters when available (SleekDB only). + * @param array> $results + * @return array> + */ + protected function applyPostFetchFilters(array $results): array + { + return $this->applyRelatedCriteriaPostFilter($results); + } + + /** + * Fetches current query results and applies post-fetch filters. + * @return array> + * @throws ModelException|DatabaseException|BaseException|IOException|InvalidArgumentException|InvalidConfigurationException + */ + protected function fetchFilteredResults(): array + { + return $this->applyPostFetchFilters($this->getBuilder()->getQuery()->fetch()); + } } diff --git a/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php b/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php index c5031a67..27d68f03 100644 --- a/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php +++ b/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php @@ -292,4 +292,81 @@ public function testSleekJoinThrowsExceptionForUnsupportedRelationType(): void ->joinTo(ModelFactory::get(TestUserModel::class)) ->get(); } + + public function testSleekRelatedCriteriaFiltersJoinedLevel(): void + { + $userModel = ModelFactory::get(TestUserModel::class); + $profileModel = ModelFactory::get(TestProfileModel::class); + + $users = $userModel + ->joinTo($profileModel) + ->criteria('profiles.firstname', '=', 'Jane') + ->get(); + + $this->assertCount(1, $users); + $this->assertEquals('jane@test.com', $users->first()->email); + $this->assertEquals('Jane', $users->first()->prop('profiles')[0]['firstname']); + } + + public function testSleekDeepRelatedCriteriaFiltersNestedJoinLevel(): void + { + $userModel = ModelFactory::get(TestUserModel::class); + $meetingModel = ModelFactory::get(TestUserMeetingModel::class); + $ticketModel = ModelFactory::get(TestTicketModel::class); + + $users = $userModel + ->joinTo($meetingModel) + ->joinTo($ticketModel) + ->criteria('user_meetings.tickets.type', '=', 'regular') + ->get(); + + $this->assertCount(1, $users); + $this->assertEquals('john@test.com', $users->first()->email); + } + + public function testSleekRootAndRelatedCriteriaWithAnd(): void + { + $userModel = ModelFactory::get(TestUserModel::class); + $profileModel = ModelFactory::get(TestProfileModel::class); + + $users = $userModel + ->joinTo($profileModel) + ->criteria('email', 'LIKE', '%jane%') + ->criteria('profiles.country', '=', 'England') + ->get(); + + $this->assertCount(1, $users); + $this->assertEquals('jane@test.com', $users->first()->email); + } + + public function testSleekRelatedCriteriaRejectsOrCombination(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('SleekDB related-model criterias do not support OR combinations'); + + $userModel = ModelFactory::get(TestUserModel::class); + $profileModel = ModelFactory::get(TestProfileModel::class); + + $userModel + ->joinTo($profileModel) + ->criterias( + ['profiles.firstname', '=', 'Jane'], + [['email', 'LIKE', '%john%'], ['email', 'LIKE', '%jane%']] + ) + ->get(); + } + + public function testSleekInvalidRelatedCriteriaPathReturnsNoMatches(): void + { + $userModel = ModelFactory::get(TestUserModel::class); + $profileModel = ModelFactory::get(TestProfileModel::class); + + $users = $userModel + ->joinTo($profileModel) + ->criteria('unknown_relation.firstname', '=', 'Jane') + ->get(); + + $this->assertInstanceOf(ModelCollection::class, $users); + $this->assertCount(0, $users); + } } From d39cc108e0d384d0aa5524f353b602f5b04e4526 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 11 May 2026 20:06:25 +0400 Subject: [PATCH 2/8] [#508] Preserve related criteria filtering with select projections --- src/Database/Adapters/Sleekdb/SleekDbal.php | 79 ++++++++++++++++++- .../Sleekdb/Statements/JoinSleekTest.php | 32 ++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index 4b1b7493..2c604d40 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -114,6 +114,11 @@ class SleekDbal implements DbalInterface protected bool $criteriaPrepared = false; + /** + * @var array + */ + protected array $autoSelectedRelatedRoots = []; + /** * Associated model name */ @@ -323,7 +328,7 @@ public function getBuilder(): QueryBuilder } if ($this->selected !== []) { - $builder->select($this->selected); + $builder->select($this->buildSelectForQuery()); } if ($this->joins !== []) { @@ -384,6 +389,7 @@ protected function resetBuilderState(): void $this->relatedCriteriasByPath = []; $this->requiredRelatedPaths = []; $this->criteriaPrepared = false; + $this->autoSelectedRelatedRoots = []; $this->havings = []; $this->selected = []; $this->grouped = []; @@ -550,7 +556,7 @@ public function applyRelatedCriteriaPostFilter(array $results): array return $results; } - return array_values(array_filter($results, function (array $row): bool { + $results = array_values(array_filter($results, function (array $row): bool { foreach ($this->requiredRelatedPaths as $path) { if (!$this->pathHasData($row, explode('.', $path))) { return false; @@ -559,6 +565,12 @@ public function applyRelatedCriteriaPostFilter(array $results): array return true; })); + + if ($this->autoSelectedRelatedRoots !== []) { + $results = $this->removeAutoSelectedRelatedRoots($results); + } + + return $results; } /** @@ -631,4 +643,67 @@ protected function buildJoinPath(string $currentPath, string $table): string { return $currentPath !== '' ? $currentPath . '.' . $table : $table; } + + /** + * Adds related roots required for post-filtering to query selection only. + * @return array + */ + protected function buildSelectForQuery(): array + { + $selected = $this->selected; + $this->autoSelectedRelatedRoots = []; + + if ($this->requiredRelatedPaths === []) { + return $selected; + } + + foreach ($this->requiredRelatedPaths as $path) { + $relatedRoot = explode('.', $path)[0]; + + if ($this->selectReferencesRoot($selected, $relatedRoot)) { + continue; + } + + $selected[] = $relatedRoot; + $this->autoSelectedRelatedRoots[] = $relatedRoot; + } + + return $selected; + } + + /** + * Checks whether current selection already references the given relation root. + * @param array $selected + */ + protected function selectReferencesRoot(array $selected, string $root): bool + { + foreach ($selected as $column) { + if (!is_string($column)) { + continue; + } + + if ($column === $root || strpos($column, $root . '.') === 0) { + return true; + } + } + + return false; + } + + /** + * Removes relation roots auto-selected only for post-filter evaluation. + * @param array> $results + * @return array> + */ + protected function removeAutoSelectedRelatedRoots(array $results): array + { + foreach ($results as &$row) { + foreach ($this->autoSelectedRelatedRoots as $root) { + unset($row[$root]); + } + } + unset($row); + + return $results; + } } diff --git a/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php b/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php index 27d68f03..baeaea6a 100644 --- a/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php +++ b/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php @@ -369,4 +369,36 @@ public function testSleekInvalidRelatedCriteriaPathReturnsNoMatches(): void $this->assertInstanceOf(ModelCollection::class, $users); $this->assertCount(0, $users); } + + public function testSleekRelatedCriteriaWithRootSelectKeepsMatchedRow(): void + { + $userModel = ModelFactory::get(TestUserModel::class); + $profileModel = ModelFactory::get(TestProfileModel::class); + + $users = $userModel + ->joinTo($profileModel) + ->criteria('profiles.firstname', '=', 'Jane') + ->select('email') + ->get(); + + $this->assertGreaterThan(0, $users->count()); + $this->assertIsString($users->first()->email); + $this->assertNull($users->first()->prop('profiles')); + } + + public function testSleekRelatedCriteriaWithAliasedSelectKeepsMatchedRow(): void + { + $userModel = ModelFactory::get(TestUserModel::class); + $profileModel = ModelFactory::get(TestProfileModel::class); + + $users = $userModel + ->joinTo($profileModel) + ->criteria('profiles.firstname', '=', 'Jane') + ->select(['email' => 'user_email']) + ->get(); + + $this->assertGreaterThan(0, $users->count()); + $this->assertIsString($users->first()->user_email); + $this->assertNull($users->first()->prop('profiles')); + } } From 7e820224e96cf79cee91ed3e9d540f9972457aa2 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 11 May 2026 20:39:34 +0400 Subject: [PATCH 3/8] [#508] Simplify SleekDB result filtering helper flow --- .../Adapters/Sleekdb/Statements/Result.php | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/src/Database/Adapters/Sleekdb/Statements/Result.php b/src/Database/Adapters/Sleekdb/Statements/Result.php index 6e6ee8c4..b118d1e4 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Result.php +++ b/src/Database/Adapters/Sleekdb/Statements/Result.php @@ -23,6 +23,7 @@ use Quantum\Model\Exceptions\ModelException; use Quantum\App\Exceptions\BaseException; use SleekDB\Exceptions\IOException; +use SleekDB\QueryBuilder; /** * Trait Result @@ -38,7 +39,7 @@ abstract protected function resetBuilderState(): void; public function get(): array { try { - $results = $this->fetchFilteredResults(); + $results = $this->fetchFilteredResultsFromBuilder($this->getBuilder()); return array_map(function ($element): object { $item = clone $this; @@ -62,8 +63,9 @@ public function get(): array public function findOne(int $id): DbalInterface { try { - $this->getBuilder()->where(['id', '=', $id]); - $results = $this->fetchFilteredResults(); + $builder = $this->getBuilder(); + $builder->where(['id', '=', $id]); + $results = $this->fetchFilteredResultsFromBuilder($builder); $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { @@ -85,8 +87,9 @@ public function findOne(int $id): DbalInterface public function findOneBy(string $column, $value): DbalInterface { try { - $this->getBuilder()->where([$column, '=', $value]); - $results = $this->fetchFilteredResults(); + $builder = $this->getBuilder(); + $builder->where([$column, '=', $value]); + $results = $this->fetchFilteredResultsFromBuilder($builder); $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { @@ -103,7 +106,7 @@ public function findOneBy(string $column, $value): DbalInterface public function first(): DbalInterface { try { - $results = $this->fetchFilteredResults(); + $results = $this->fetchFilteredResultsFromBuilder($this->getBuilder()); $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { @@ -119,7 +122,7 @@ public function first(): DbalInterface public function count(): int { try { - $results = $this->fetchFilteredResults(); + $results = $this->fetchFilteredResultsFromBuilder($this->getBuilder()); return count($results); } finally { $this->resetBuilderState(); @@ -150,22 +153,12 @@ public function setHidden(array $result): array } /** - * Applies adapter-specific post-fetch filters when available (SleekDB only). - * @param array> $results + * Fetches results from given builder and applies post-fetch filters. + * @param QueryBuilder $builder * @return array> */ - protected function applyPostFetchFilters(array $results): array + protected function fetchFilteredResultsFromBuilder(QueryBuilder $builder): array { - return $this->applyRelatedCriteriaPostFilter($results); - } - - /** - * Fetches current query results and applies post-fetch filters. - * @return array> - * @throws ModelException|DatabaseException|BaseException|IOException|InvalidArgumentException|InvalidConfigurationException - */ - protected function fetchFilteredResults(): array - { - return $this->applyPostFetchFilters($this->getBuilder()->getQuery()->fetch()); + return $this->applyRelatedCriteriaPostFilter($builder->getQuery()->fetch()); } } From bffc86b818c9595aece94745163122ea66db7baf Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 11 May 2026 20:52:21 +0400 Subject: [PATCH 4/8] [#508] Address review follow-ups for criteria preparation and tests --- CHANGELOG.md | 2 +- src/Database/Adapters/Sleekdb/SleekDbal.php | 4 +++- .../Database/Adapters/Sleekdb/Statements/JoinSleekTest.php | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41325647..053a4808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,7 +79,6 @@ - PHP 8.4 forward compatibility: Added missing type hint to `SleekDbal::__get()` magic method - Fixed deprecated `E_STRICT` constant usage in test bootstrap - Fixed cURL error message assertions for cross-version compatibility -- SleekDB adapter now supports related-model criteria path filtering across join depths while preserving DBAL public API (#508) ### Added - `AppContext` class representing the runtime identity of a single application execution @@ -117,6 +116,7 @@ - Added rate limit response headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After`) - Added route-level metadata support on `Route` and `RouteBuilder` for rate limiting - Added unit test coverage for router integration, middleware flow, adapters, factory resolution, and exception behavior +- SleekDB adapter now supports related-model criteria path filtering across join depths while preserving DBAL public API (#508) ### Removed - Support for PHP 7.3 and earlier versions diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index 2c604d40..eeaf46a6 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -409,15 +409,16 @@ protected function prepareCriteriaScopes(): void $this->rootCriterias = $this->criterias; $this->relatedCriteriasByPath = []; $this->requiredRelatedPaths = []; - $this->criteriaPrepared = true; if ($this->joins === [] || $this->criterias === []) { + $this->criteriaPrepared = true; return; } $joinPaths = $this->collectJoinPaths(); if ($joinPaths === []) { + $this->criteriaPrepared = true; return; } @@ -461,6 +462,7 @@ protected function prepareCriteriaScopes(): void $this->requiredRelatedPaths = array_values(array_unique($this->requiredRelatedPaths)); $this->rootCriterias = $rootCriterias; + $this->criteriaPrepared = true; } /** diff --git a/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php b/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php index baeaea6a..0329c8ff 100644 --- a/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php +++ b/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php @@ -368,6 +368,9 @@ public function testSleekInvalidRelatedCriteriaPathReturnsNoMatches(): void $this->assertInstanceOf(ModelCollection::class, $users); $this->assertCount(0, $users); + + $usersWithoutBadCriterion = ModelFactory::get(TestUserModel::class)->get(); + $this->assertGreaterThan(0, $usersWithoutBadCriterion->count()); } public function testSleekRelatedCriteriaWithRootSelectKeepsMatchedRow(): void From a8772f253eca35b1069b2a452cedcbc302dd194b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 11 May 2026 21:05:54 +0400 Subject: [PATCH 5/8] [#508] Declare explicit trait contracts for SleekDB statements --- src/Database/Adapters/Sleekdb/Statements/Join.php | 2 ++ src/Database/Adapters/Sleekdb/Statements/Result.php | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/Database/Adapters/Sleekdb/Statements/Join.php b/src/Database/Adapters/Sleekdb/Statements/Join.php index b94fb356..89639722 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Join.php +++ b/src/Database/Adapters/Sleekdb/Statements/Join.php @@ -31,6 +31,8 @@ */ trait Join { + abstract protected function buildJoinPath(string $currentPath, string $table): string; + /** * @inheritDoc */ diff --git a/src/Database/Adapters/Sleekdb/Statements/Result.php b/src/Database/Adapters/Sleekdb/Statements/Result.php index b118d1e4..6ffd4cee 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Result.php +++ b/src/Database/Adapters/Sleekdb/Statements/Result.php @@ -33,6 +33,12 @@ trait Result { abstract protected function resetBuilderState(): void; + /** + * @param array> $results + * @return array> + */ + abstract protected function applyRelatedCriteriaPostFilter(array $results): array; + /** * @inheritDoc */ From d0deac0cfb77523d8ff530422a3084e5e6a96353 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Mon, 11 May 2026 21:37:39 +0400 Subject: [PATCH 6/8] [#508] Decompose SleekDbal builder orchestration to reduce method complexity --- src/Database/Adapters/Sleekdb/SleekDbal.php | 170 +++++++++++++------- 1 file changed, 116 insertions(+), 54 deletions(-) diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index eeaf46a6..59097b34 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -255,11 +255,7 @@ public function getTable(): string /** * Gets the ORM model - * @throws DatabaseException - * @throws IOException - * @throws InvalidArgumentException - * @throws InvalidConfigurationException - * @throws BaseException + * @throws DatabaseException|BaseException|IOException|InvalidArgumentException|InvalidConfigurationException */ public function getOrmModel(): Store { @@ -307,58 +303,13 @@ public function truncate(): bool /** * Gets the query builder object - * @throws BaseException - * @throws DatabaseException - * @throws IOException - * @throws InvalidArgumentException - * @throws InvalidConfigurationException - * @throws ModelException + * @throws ModelException|DatabaseException|BaseException|IOException|InvalidArgumentException|InvalidConfigurationException */ public function getBuilder(): QueryBuilder { - $builder = $this->queryBuilder; - - if (!$builder) { - $builder = $this->getOrmModel()->createQueryBuilder(); - $this->queryBuilder = $builder; - } - - if (!$this->criteriaPrepared) { - $this->prepareCriteriaScopes(); - } - - if ($this->selected !== []) { - $builder->select($this->buildSelectForQuery()); - } - - if ($this->joins !== []) { - $this->applyJoins(); - } - - if ($this->rootCriterias !== []) { - $builder->where($this->rootCriterias); - } - - if ($this->havings !== []) { - $builder->having($this->havings); - } - - if ($this->grouped !== []) { - $builder->groupBy($this->grouped); - } - - if ($this->ordered !== []) { - $builder->orderBy($this->ordered); - } - - if ($this->offset) { - $builder->skip($this->offset); - } - - if ($this->limit) { - $builder->limit($this->limit); - } - + $builder = $this->getQueryBuilder(); + $this->prepareCriteriaScopesIfNeeded(); + $this->applyBuilderModifiers($builder); return $builder; } @@ -646,6 +597,117 @@ protected function buildJoinPath(string $currentPath, string $table): string return $currentPath !== '' ? $currentPath . '.' . $table : $table; } + /** + * Ensures a reusable query builder instance exists for current adapter state. + * @return QueryBuilder + * @throws BaseException + * @throws DatabaseException + * @throws IOException + * @throws InvalidArgumentException + * @throws InvalidConfigurationException + */ + protected function getQueryBuilder(): QueryBuilder + { + if ($this->queryBuilder === null) { + $this->queryBuilder = $this->getOrmModel()->createQueryBuilder(); + } + + return $this->queryBuilder; + } + + /** + * Prepares root/related criteria scopes once per builder lifecycle. + */ + protected function prepareCriteriaScopesIfNeeded(): void + { + if (!$this->criteriaPrepared) { + $this->prepareCriteriaScopes(); + } + } + + /** + * Applies all collected query modifiers on the given builder. + * @throws ModelException|InvalidArgumentException + */ + protected function applyBuilderModifiers(QueryBuilder $builder): void + { + $this->applySelectModifier($builder); + $this->applyJoinModifier(); + $this->applyWhereModifier($builder); + $this->applyHavingModifier($builder); + $this->applyGroupModifier($builder); + $this->applyOrderModifier($builder); + $this->applyPaginationModifier($builder); + } + + protected function applySelectModifier(QueryBuilder $builder): void + { + if ($this->selected !== []) { + $builder->select($this->buildSelectForQuery()); + } + } + + /** + * @throws ModelException + */ + protected function applyJoinModifier(): void + { + if ($this->joins !== []) { + $this->applyJoins(); + } + } + + /** + * @throws InvalidArgumentException + */ + protected function applyWhereModifier(QueryBuilder $builder): void + { + if ($this->rootCriterias !== []) { + $builder->where($this->rootCriterias); + } + } + + /** + * @throws InvalidArgumentException + */ + protected function applyHavingModifier(QueryBuilder $builder): void + { + if ($this->havings !== []) { + $builder->having($this->havings); + } + } + + protected function applyGroupModifier(QueryBuilder $builder): void + { + if ($this->grouped !== []) { + $builder->groupBy($this->grouped); + } + } + + /** + * @throws InvalidArgumentException + */ + protected function applyOrderModifier(QueryBuilder $builder): void + { + if ($this->ordered !== []) { + $builder->orderBy($this->ordered); + } + } + + /** + * @throws InvalidArgumentException + */ + protected function applyPaginationModifier(QueryBuilder $builder): void + { + if ($this->offset) { + $builder->skip($this->offset); + } + + if ($this->limit) { + $builder->limit($this->limit); + } + } + /** * Adds related roots required for post-filtering to query selection only. * @return array From c448cf691fb765b4286e2e1c08377c6fa5248bab Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 12 May 2026 12:58:45 +0400 Subject: [PATCH 7/8] [#508] Extract SleekDB related-criteria workflow into statement trait --- src/Database/Adapters/Sleekdb/SleekDbal.php | 304 +--------------- .../Sleekdb/Statements/RelatedCriteria.php | 330 ++++++++++++++++++ 2 files changed, 332 insertions(+), 302 deletions(-) create mode 100644 src/Database/Adapters/Sleekdb/Statements/RelatedCriteria.php diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index 59097b34..46328fc7 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -16,6 +16,7 @@ namespace Quantum\Database\Adapters\Sleekdb; +use Quantum\Database\Adapters\Sleekdb\Statements\RelatedCriteria; use Quantum\Database\Adapters\Sleekdb\Statements\Criteria; use Quantum\Database\Adapters\Sleekdb\Statements\Reducer; use Quantum\Database\Adapters\Sleekdb\Statements\Result; @@ -28,9 +29,7 @@ use Quantum\Model\Exceptions\ModelException; use Quantum\App\Exceptions\BaseException; use SleekDB\Exceptions\IOException; -use Quantum\Model\DbModel; use SleekDB\QueryBuilder; -use RuntimeException; use SleekDB\Store; /** @@ -42,6 +41,7 @@ class SleekDbal implements DbalInterface use Model; use Result; use Criteria; + use RelatedCriteria; use Reducer; use Join; @@ -351,244 +351,6 @@ protected function resetBuilderState(): void $this->queryBuilder = null; } - /** - * Splits collected criteria into root-store and related-join scopes. - * Related path criteria are mapped to join paths and excluded from root where. - */ - protected function prepareCriteriaScopes(): void - { - $this->rootCriterias = $this->criterias; - $this->relatedCriteriasByPath = []; - $this->requiredRelatedPaths = []; - - if ($this->joins === [] || $this->criterias === []) { - $this->criteriaPrepared = true; - return; - } - - $joinPaths = $this->collectJoinPaths(); - - if ($joinPaths === []) { - $this->criteriaPrepared = true; - return; - } - - $rootCriterias = []; - $foundRelated = false; - $hasOr = false; - - foreach ($this->criterias as $criteria) { - if (is_string($criteria)) { - $hasOr = $hasOr || strtoupper($criteria) === 'OR'; - $rootCriterias[] = $criteria; - continue; - } - - if (!is_array($criteria) || !isset($criteria[0]) || !is_string($criteria[0])) { - $rootCriterias[] = $criteria; - continue; - } - - $column = $criteria[0]; - $relatedMatch = $this->matchRelatedPath($column, $joinPaths); - - if ($relatedMatch === null) { - $rootCriterias[] = $criteria; - continue; - } - - $foundRelated = true; - $path = $relatedMatch['path']; - $localColumn = $relatedMatch['column']; - - $this->relatedCriteriasByPath[$path][] = [$localColumn, $criteria[1], $criteria[2] ?? null]; - $this->requiredRelatedPaths[] = $path; - } - - if ($foundRelated && $hasOr) { - throw new RuntimeException( - 'SleekDB related-model criterias do not support OR combinations with root/related scopes yet.' - ); - } - - $this->requiredRelatedPaths = array_values(array_unique($this->requiredRelatedPaths)); - $this->rootCriterias = $rootCriterias; - $this->criteriaPrepared = true; - } - - /** - * Builds all accessible join paths (including nested ones) from current join chain. - * @return array - */ - protected function collectJoinPaths(): array - { - $paths = []; - $this->collectJoinPathsRecursive($this->joins, 0, '', $paths); - usort($paths, static fn (string $a, string $b): int => substr_count($b, '.') <=> substr_count($a, '.')); - return array_values(array_unique($paths)); - } - - /** - * Recursively resolves join paths while respecting switch mode for same-level joins. - * @param array> $joins - * @param array $paths - */ - protected function collectJoinPathsRecursive(array $joins, int $level, string $currentPath, array &$paths): void - { - if (!isset($joins[$level])) { - return; - } - - $nextItem = $joins[$level]; - $model = unserialize($nextItem['model']); - - if (!$model instanceof DbModel) { - return; - } - - $joinPath = $this->buildJoinPath($currentPath, $model->table); - $paths[] = $joinPath; - - $switch = (bool) ($nextItem['switch'] ?? true); - - if ($switch) { - $this->collectJoinPathsRecursive($joins, $level + 1, $joinPath, $paths); - return; - } - - $this->collectJoinPathsRecursive($joins, $level + 1, $currentPath, $paths); - } - - /** - * Matches a dotted criteria column to the deepest known join path. - * @param array $joinPaths - * @return array{path:string,column:string}|null - */ - protected function matchRelatedPath(string $column, array $joinPaths): ?array - { - $parts = explode('.', $column); - - if (count($parts) <= 1) { - return null; - } - - if ($parts[0] === $this->table) { - return null; - } - - foreach ($joinPaths as $path) { - $pathParts = explode('.', $path); - - if (count($parts) <= count($pathParts)) { - continue; - } - - if (array_slice($parts, 0, count($pathParts)) !== $pathParts) { - continue; - } - - $localColumn = implode('.', array_slice($parts, count($pathParts))); - - return [ - 'path' => $path, - 'column' => $localColumn, - ]; - } - - return null; - } - - /** - * Removes parent rows that do not contain data on required related paths. - * @param array> $results - * @return array> - */ - public function applyRelatedCriteriaPostFilter(array $results): array - { - if ($this->requiredRelatedPaths === []) { - return $results; - } - - $results = array_values(array_filter($results, function (array $row): bool { - foreach ($this->requiredRelatedPaths as $path) { - if (!$this->pathHasData($row, explode('.', $path))) { - return false; - } - } - - return true; - })); - - if ($this->autoSelectedRelatedRoots !== []) { - $results = $this->removeAutoSelectedRelatedRoots($results); - } - - return $results; - } - - /** - * Checks whether a nested relation path exists and contains at least one result. - * @param array $row - * @param array $segments - */ - protected function pathHasData(array $row, array $segments): bool - { - $nodes = [$row]; - - foreach ($segments as $segment) { - $nextNodes = []; - - foreach ($nodes as $node) { - if (!array_key_exists($segment, $node)) { - continue; - } - - $value = $node[$segment]; - - if (!is_array($value) || $value === []) { - continue; - } - - if ($this->isList($value)) { - foreach ($value as $item) { - if (is_array($item)) { - $nextNodes[] = $item; - } - } - continue; - } - - $nextNodes[] = $value; - } - - if ($nextNodes === []) { - return false; - } - - $nodes = $nextNodes; - } - - return true; - } - - /** - * Determines whether the given array has sequential integer keys from zero. - * @param array $value - */ - protected function isList(array $value): bool - { - $index = 0; - - foreach ($value as $key => $_) { - if ($key !== $index) { - return false; - } - $index++; - } - - return true; - } - /** * Builds a dot-notated join path from current path and next relation table. */ @@ -708,66 +470,4 @@ protected function applyPaginationModifier(QueryBuilder $builder): void } } - /** - * Adds related roots required for post-filtering to query selection only. - * @return array - */ - protected function buildSelectForQuery(): array - { - $selected = $this->selected; - $this->autoSelectedRelatedRoots = []; - - if ($this->requiredRelatedPaths === []) { - return $selected; - } - - foreach ($this->requiredRelatedPaths as $path) { - $relatedRoot = explode('.', $path)[0]; - - if ($this->selectReferencesRoot($selected, $relatedRoot)) { - continue; - } - - $selected[] = $relatedRoot; - $this->autoSelectedRelatedRoots[] = $relatedRoot; - } - - return $selected; - } - - /** - * Checks whether current selection already references the given relation root. - * @param array $selected - */ - protected function selectReferencesRoot(array $selected, string $root): bool - { - foreach ($selected as $column) { - if (!is_string($column)) { - continue; - } - - if ($column === $root || strpos($column, $root . '.') === 0) { - return true; - } - } - - return false; - } - - /** - * Removes relation roots auto-selected only for post-filter evaluation. - * @param array> $results - * @return array> - */ - protected function removeAutoSelectedRelatedRoots(array $results): array - { - foreach ($results as &$row) { - foreach ($this->autoSelectedRelatedRoots as $root) { - unset($row[$root]); - } - } - unset($row); - - return $results; - } } diff --git a/src/Database/Adapters/Sleekdb/Statements/RelatedCriteria.php b/src/Database/Adapters/Sleekdb/Statements/RelatedCriteria.php new file mode 100644 index 00000000..98364a7d --- /dev/null +++ b/src/Database/Adapters/Sleekdb/Statements/RelatedCriteria.php @@ -0,0 +1,330 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Database\Adapters\Sleekdb\Statements; + +use Quantum\Model\DbModel; +use RuntimeException; + +/** + * Trait RelatedCriteria + * @package Quantum\Database + */ +trait RelatedCriteria +{ + abstract protected function buildJoinPath(string $currentPath, string $table): string; + + /** + * Splits collected criteria into root-store and related-join scopes. + * Related path criteria are mapped to join paths and excluded from root where. + */ + protected function prepareCriteriaScopes(): void + { + $this->rootCriterias = $this->criterias; + $this->relatedCriteriasByPath = []; + $this->requiredRelatedPaths = []; + + if ($this->joins === [] || $this->criterias === []) { + $this->criteriaPrepared = true; + return; + } + + $joinPaths = $this->collectJoinPaths(); + + if ($joinPaths === []) { + $this->criteriaPrepared = true; + return; + } + + $rootCriterias = []; + $foundRelated = false; + $hasOr = false; + + foreach ($this->criterias as $criteria) { + if (is_string($criteria)) { + $hasOr = $hasOr || strtoupper($criteria) === 'OR'; + $rootCriterias[] = $criteria; + continue; + } + + if (!is_array($criteria) || !isset($criteria[0]) || !is_string($criteria[0])) { + $rootCriterias[] = $criteria; + continue; + } + + $column = $criteria[0]; + $relatedMatch = $this->matchRelatedPath($column, $joinPaths); + + if ($relatedMatch === null) { + $rootCriterias[] = $criteria; + continue; + } + + $foundRelated = true; + $path = $relatedMatch['path']; + $localColumn = $relatedMatch['column']; + + $this->relatedCriteriasByPath[$path][] = [$localColumn, $criteria[1], $criteria[2] ?? null]; + $this->requiredRelatedPaths[] = $path; + } + + if ($foundRelated && $hasOr) { + throw new RuntimeException( + 'SleekDB related-model criterias do not support OR combinations with root/related scopes yet.' + ); + } + + $this->requiredRelatedPaths = array_values(array_unique($this->requiredRelatedPaths)); + $this->rootCriterias = $rootCriterias; + $this->criteriaPrepared = true; + } + + /** + * Builds all accessible join paths (including nested ones) from current join chain. + * @return array + */ + protected function collectJoinPaths(): array + { + $paths = []; + $this->collectJoinPathsRecursive($this->joins, 0, '', $paths); + usort($paths, static fn (string $a, string $b): int => substr_count($b, '.') <=> substr_count($a, '.')); + return array_values(array_unique($paths)); + } + + /** + * Recursively resolves join paths while respecting switch mode for same-level joins. + * @param array> $joins + * @param array $paths + */ + protected function collectJoinPathsRecursive(array $joins, int $level, string $currentPath, array &$paths): void + { + if (!isset($joins[$level])) { + return; + } + + $nextItem = $joins[$level]; + $model = unserialize($nextItem['model']); + + if (!$model instanceof DbModel) { + return; + } + + $joinPath = $this->buildJoinPath($currentPath, $model->table); + $paths[] = $joinPath; + + $switch = (bool) ($nextItem['switch'] ?? true); + + if ($switch) { + $this->collectJoinPathsRecursive($joins, $level + 1, $joinPath, $paths); + return; + } + + $this->collectJoinPathsRecursive($joins, $level + 1, $currentPath, $paths); + } + + /** + * Matches a dotted criteria column to the deepest known join path. + * @param array $joinPaths + * @return array{path:string,column:string}|null + */ + protected function matchRelatedPath(string $column, array $joinPaths): ?array + { + $parts = explode('.', $column); + + if (count($parts) <= 1) { + return null; + } + + if ($parts[0] === $this->table) { + return null; + } + + foreach ($joinPaths as $path) { + $pathParts = explode('.', $path); + + if (count($parts) <= count($pathParts)) { + continue; + } + + if (array_slice($parts, 0, count($pathParts)) !== $pathParts) { + continue; + } + + $localColumn = implode('.', array_slice($parts, count($pathParts))); + + return [ + 'path' => $path, + 'column' => $localColumn, + ]; + } + + return null; + } + + /** + * Removes parent rows that do not contain data on required related paths. + * @param array> $results + * @return array> + */ + public function applyRelatedCriteriaPostFilter(array $results): array + { + if ($this->requiredRelatedPaths === []) { + return $results; + } + + $results = array_values(array_filter($results, function (array $row): bool { + foreach ($this->requiredRelatedPaths as $path) { + if (!$this->pathHasData($row, explode('.', $path))) { + return false; + } + } + + return true; + })); + + if ($this->autoSelectedRelatedRoots !== []) { + $results = $this->removeAutoSelectedRelatedRoots($results); + } + + return $results; + } + + /** + * Checks whether a nested relation path exists and contains at least one result. + * @param array $row + * @param array $segments + */ + protected function pathHasData(array $row, array $segments): bool + { + $nodes = [$row]; + + foreach ($segments as $segment) { + $nextNodes = []; + + foreach ($nodes as $node) { + if (!array_key_exists($segment, $node)) { + continue; + } + + $value = $node[$segment]; + + if (!is_array($value) || $value === []) { + continue; + } + + if ($this->isList($value)) { + foreach ($value as $item) { + if (is_array($item)) { + $nextNodes[] = $item; + } + } + continue; + } + + $nextNodes[] = $value; + } + + if ($nextNodes === []) { + return false; + } + + $nodes = $nextNodes; + } + + return true; + } + + /** + * Determines whether the given array has sequential integer keys from zero. + * @param array $value + */ + protected function isList(array $value): bool + { + $index = 0; + + foreach ($value as $key => $_) { + if ($key !== $index) { + return false; + } + $index++; + } + + return true; + } + + /** + * Adds related roots required for post-filtering to query selection only. + * @return array + */ + protected function buildSelectForQuery(): array + { + $selected = $this->selected; + $this->autoSelectedRelatedRoots = []; + + if ($this->requiredRelatedPaths === []) { + return $selected; + } + + foreach ($this->requiredRelatedPaths as $path) { + $relatedRoot = explode('.', $path)[0]; + + if ($this->selectReferencesRoot($selected, $relatedRoot)) { + continue; + } + + $selected[] = $relatedRoot; + $this->autoSelectedRelatedRoots[] = $relatedRoot; + } + + return $selected; + } + + /** + * Checks whether current selection already references the given relation root. + * @param array $selected + */ + protected function selectReferencesRoot(array $selected, string $root): bool + { + foreach ($selected as $column) { + if (!is_string($column)) { + continue; + } + + if ($column === $root || strpos($column, $root . '.') === 0) { + return true; + } + } + + return false; + } + + /** + * Removes relation roots auto-selected only for post-filter evaluation. + * @param array> $results + * @return array> + */ + protected function removeAutoSelectedRelatedRoots(array $results): array + { + foreach ($results as &$row) { + foreach ($this->autoSelectedRelatedRoots as $root) { + unset($row[$root]); + } + } + unset($row); + + return $results; + } +} From 26cc67bf0f3a18025e7d6ce7e87d73b0dbe5a465 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Tue, 12 May 2026 13:28:57 +0400 Subject: [PATCH 8/8] [#508] Prevent SleekDB builder replay and clone stale query state --- src/Database/Adapters/Sleekdb/SleekDbal.php | 13 ++++++++----- src/Database/Adapters/Sleekdb/Statements/Result.php | 1 + 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Database/Adapters/Sleekdb/SleekDbal.php b/src/Database/Adapters/Sleekdb/SleekDbal.php index 46328fc7..539866f5 100644 --- a/src/Database/Adapters/Sleekdb/SleekDbal.php +++ b/src/Database/Adapters/Sleekdb/SleekDbal.php @@ -146,13 +146,12 @@ class SleekDbal implements DbalInterface */ public array $hidden = []; - /** - * ORM Model - */ private ?Store $ormModel = null; private ?QueryBuilder $queryBuilder = null; + private bool $builderPrepared = false; + /** * Active connection * @var array|null @@ -308,8 +307,11 @@ public function truncate(): bool public function getBuilder(): QueryBuilder { $builder = $this->getQueryBuilder(); - $this->prepareCriteriaScopesIfNeeded(); - $this->applyBuilderModifiers($builder); + if (!$this->builderPrepared) { + $this->prepareCriteriaScopesIfNeeded(); + $this->applyBuilderModifiers($builder); + $this->builderPrepared = true; + } return $builder; } @@ -349,6 +351,7 @@ protected function resetBuilderState(): void $this->limit = null; $this->joins = []; $this->queryBuilder = null; + $this->builderPrepared = false; } /** diff --git a/src/Database/Adapters/Sleekdb/Statements/Result.php b/src/Database/Adapters/Sleekdb/Statements/Result.php index 6ffd4cee..60866004 100644 --- a/src/Database/Adapters/Sleekdb/Statements/Result.php +++ b/src/Database/Adapters/Sleekdb/Statements/Result.php @@ -49,6 +49,7 @@ public function get(): array return array_map(function ($element): object { $item = clone $this; + $item->resetBuilderState(); $item->updateOrmModel($element); return $item; }, $results);