diff --git a/CHANGELOG.md b/CHANGELOG.md index 90722937..053a4808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,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 1cd37d49..539866f5 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; @@ -40,6 +41,7 @@ class SleekDbal implements DbalInterface use Model; use Result; use Criteria; + use RelatedCriteria; use Reducer; use Join; @@ -95,6 +97,28 @@ 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; + + /** + * @var array + */ + protected array $autoSelectedRelatedRoots = []; + /** * Associated model name */ @@ -122,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 @@ -231,11 +254,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 { @@ -283,46 +302,168 @@ public function truncate(): bool /** * Gets the query builder object + * @throws ModelException|DatabaseException|BaseException|IOException|InvalidArgumentException|InvalidConfigurationException + */ + public function getBuilder(): QueryBuilder + { + $builder = $this->getQueryBuilder(); + if (!$this->builderPrepared) { + $this->prepareCriteriaScopesIfNeeded(); + $this->applyBuilderModifiers($builder); + $this->builderPrepared = true; + } + return $builder; + } + + /** + * Gets foreign keys + * @return array> + */ + public function getForeignKeys(): array + { + return $this->foreignKeys; + } + + /** + * Gets the associated model name + */ + public function getModelName(): string + { + return $this->modelName ?? ''; + } + + /** + * Resets the builder state + */ + protected function resetBuilderState(): void + { + $this->criterias = []; + $this->rootCriterias = []; + $this->relatedCriteriasByPath = []; + $this->requiredRelatedPaths = []; + $this->criteriaPrepared = false; + $this->autoSelectedRelatedRoots = []; + $this->havings = []; + $this->selected = []; + $this->grouped = []; + $this->ordered = []; + $this->offset = null; + $this->limit = null; + $this->joins = []; + $this->queryBuilder = null; + $this->builderPrepared = false; + } + + /** + * 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; + } + + /** + * Ensures a reusable query builder instance exists for current adapter state. + * @return QueryBuilder * @throws BaseException * @throws DatabaseException * @throws IOException * @throws InvalidArgumentException * @throws InvalidConfigurationException - * @throws ModelException */ - public function getBuilder(): QueryBuilder + protected function getQueryBuilder(): QueryBuilder { - $builder = $this->queryBuilder; + if ($this->queryBuilder === null) { + $this->queryBuilder = $this->getOrmModel()->createQueryBuilder(); + } + + return $this->queryBuilder; + } - if (!$builder) { - $builder = $this->getOrmModel()->createQueryBuilder(); - $this->queryBuilder = $builder; + /** + * 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->selected); + $builder->select($this->buildSelectForQuery()); } + } + /** + * @throws ModelException + */ + protected function applyJoinModifier(): void + { if ($this->joins !== []) { $this->applyJoins(); } + } - if ($this->criterias !== []) { - $builder->where($this->criterias); + /** + * @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); } @@ -330,40 +471,6 @@ public function getBuilder(): QueryBuilder if ($this->limit) { $builder->limit($this->limit); } - - return $builder; - } - - /** - * Gets foreign keys - * @return array> - */ - public function getForeignKeys(): array - { - return $this->foreignKeys; - } - - /** - * Gets the associated model name - */ - public function getModelName(): string - { - return $this->modelName ?? ''; } - /** - * Resets the builder state - */ - protected function resetBuilderState(): void - { - $this->criterias = []; - $this->havings = []; - $this->selected = []; - $this->grouped = []; - $this->ordered = []; - $this->offset = null; - $this->limit = null; - $this->joins = []; - $this->queryBuilder = null; - } } diff --git a/src/Database/Adapters/Sleekdb/Statements/Join.php b/src/Database/Adapters/Sleekdb/Statements/Join.php index f70dbfbc..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 */ @@ -55,7 +57,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 +66,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 +80,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 +94,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 +105,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/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; + } +} diff --git a/src/Database/Adapters/Sleekdb/Statements/Result.php b/src/Database/Adapters/Sleekdb/Statements/Result.php index e1bbc70f..60866004 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 @@ -32,17 +33,26 @@ trait Result { abstract protected function resetBuilderState(): void; + /** + * @param array> $results + * @return array> + */ + abstract protected function applyRelatedCriteriaPostFilter(array $results): array; + /** * @inheritDoc */ public function get(): array { try { + $results = $this->fetchFilteredResultsFromBuilder($this->getBuilder()); + return array_map(function ($element): object { $item = clone $this; + $item->resetBuilderState(); $item->updateOrmModel($element); return $item; - }, $this->getBuilder()->getQuery()->fetch()); + }, $results); } finally { $this->resetBuilderState(); } @@ -60,7 +70,10 @@ public function get(): array public function findOne(int $id): DbalInterface { try { - $result = $this->getBuilder()->where(['id', '=', $id])->getQuery()->first(); + $builder = $this->getBuilder(); + $builder->where(['id', '=', $id]); + $results = $this->fetchFilteredResultsFromBuilder($builder); + $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { $this->resetBuilderState(); @@ -81,7 +94,10 @@ public function findOne(int $id): DbalInterface public function findOneBy(string $column, $value): DbalInterface { try { - $result = $this->getBuilder()->where([$column, '=', $value])->getQuery()->first(); + $builder = $this->getBuilder(); + $builder->where([$column, '=', $value]); + $results = $this->fetchFilteredResultsFromBuilder($builder); + $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { $this->resetBuilderState(); @@ -92,17 +108,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->fetchFilteredResultsFromBuilder($this->getBuilder()); + $result = $results[0] ?? []; $this->updateOrmModel($result); } finally { $this->resetBuilderState(); @@ -113,16 +125,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->fetchFilteredResultsFromBuilder($this->getBuilder()); + return count($results); + } finally { + $this->resetBuilderState(); + } } /** @@ -147,4 +158,14 @@ public function setHidden(array $result): array { return array_diff_key($result, array_flip($this->hidden)); } + + /** + * Fetches results from given builder and applies post-fetch filters. + * @param QueryBuilder $builder + * @return array> + */ + protected function fetchFilteredResultsFromBuilder(QueryBuilder $builder): array + { + return $this->applyRelatedCriteriaPostFilter($builder->getQuery()->fetch()); + } } diff --git a/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php b/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php index c5031a67..0329c8ff 100644 --- a/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php +++ b/tests/Unit/Database/Adapters/Sleekdb/Statements/JoinSleekTest.php @@ -292,4 +292,116 @@ 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); + + $usersWithoutBadCriterion = ModelFactory::get(TestUserModel::class)->get(); + $this->assertGreaterThan(0, $usersWithoutBadCriterion->count()); + } + + 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')); + } }