From 1356ef0b2370d02e5492d4e5d074c64316d9f1b0 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:03:11 +0200 Subject: [PATCH 1/9] Model: Add method `createVisibilityFilter(Filter\Chain $filter): void` Visibility filters are supposed to constrain a model's query result at all times. Be it by selecting from it or joining it. Usecases such as soft-deletions are thus made possible to guarantee that ORM queries never fetch related rows. --- src/Model.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Model.php b/src/Model.php index 65c5caf..28e2724 100644 --- a/src/Model.php +++ b/src/Model.php @@ -5,6 +5,7 @@ use ipl\Orm\Common\PropertiesWithDefaults; use ipl\Sql\Connection; use ipl\Sql\ExpressionInterface; +use ipl\Stdlib\Filter; /** * Models represent single database tables or parts of it. @@ -132,6 +133,20 @@ public function createRelations(Relations $relations) { } + /** + * Create filter constraints to always limit results for this model + * + * Only actual columns of the model's table itself are allowed. Qualification happens at runtime. + * Comparison values are passed as-is to ipl-sql's query builder, thus any behaviors are not applied. + * Custom filter types other than those extending {@see Filter\Condition} are not allowed. Condition + * values of type {@see ExpressionInterface} are supported and must adhere to the same assumptions. + * + * @param Filter\Chain $filter + */ + public function createVisibilityFilter(Filter\Chain $filter): void + { + } + /** * Initialize the model * From 6435432d113681e3accca8efe84d81b54787987a Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:07:14 +0200 Subject: [PATCH 2/9] Relation: Add method `setFilter(Filter\Rule $filter): static` Relation filters are supposed to constrain joined or eager loaded results, but only given the direction they were declared in. For example, if the opposite relation does not declare the same or any filter at all, joining from this side does not constrain the result the same way as the other way round. --- src/Relation.php | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/Relation.php b/src/Relation.php index 2aca85c..77e47e9 100644 --- a/src/Relation.php +++ b/src/Relation.php @@ -3,6 +3,8 @@ namespace ipl\Orm; use Generator; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Filter\Rule; use UnexpectedValueException; /** @@ -38,6 +40,9 @@ class Relation /** @var bool Whether this is a to-one relationship */ protected bool $isOne = true; + /** @var ?Filter\Chain Additional JOIN conditions */ + protected ?Filter\Chain $filter = null; + /** * Get the default column name(s) in the source table used to match the foreign key * @@ -259,6 +264,40 @@ public function setJoinType(string $joinType): static return $this; } + /** + * Get the filter to constrain results of the target model + * + * @return Filter\Chain + */ + public function getFilter(): Filter\Chain + { + return $this->filter ?? Filter::all(); + } + + /** + * Set a filter that constraints results of the target model + * + * Only actual columns of the source's or target's table itself are allowed. Qualification happens at runtime. + * Use the source's table alias or the relation name (default) to reference one or the other. Comparison values are + * passed as-is to ipl-sql's query builder, thus any behaviors by either the source or target are not applied. + * Custom filter types other than those extending {@see Filter\Condition} are not allowed. Condition values of + * type {@see ExpressionInterface} are supported and must adhere to the same assumptions. + * + * @param Rule $filter + * + * @return $this + */ + public function setFilter(Filter\Rule $filter): static + { + if (! $filter instanceof Filter\Chain) { + $filter = Filter::all($filter); + } + + $this->filter = $filter; + + return $this; + } + /** * Determine the candidate key-foreign key construct of the relation * From f9f2424b8c87935b23f1cc404f0a718022300289 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:16:09 +0200 Subject: [PATCH 3/9] BelongsToMany: Add method `setThroughFilter(Filter\Rule $filter): static` This is the equivalent of `Relation::setFilter()` but for the junction table. --- src/Relation/BelongsToMany.php | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/Relation/BelongsToMany.php b/src/Relation/BelongsToMany.php index edf5bf1..410a7c7 100644 --- a/src/Relation/BelongsToMany.php +++ b/src/Relation/BelongsToMany.php @@ -6,6 +6,8 @@ use ipl\Orm\Model; use ipl\Orm\Relation; use ipl\Orm\Relations; +use ipl\Stdlib\Filter; +use ipl\Stdlib\Filter\Rule; use LogicException; /** @@ -33,6 +35,9 @@ class BelongsToMany extends Relation /** @var string|array|null Candidate key column name(s) in the target table which references the target foreign key */ protected string|array|null $targetCandidateKey = null; + /** @var ?Filter\Chain Additional JOIN conditions for the join table */ + protected ?Filter\Chain $throughFilter = null; + /** * Get the name of the join table or junction model class * @@ -172,6 +177,40 @@ public function setTargetCandidateKey(string|array $targetCandidateKey): static return $this; } + /** + * Get the filter to constrain results of the join table or junction model + * + * @return Filter\Chain + */ + public function getThroughFilter(): Filter\Chain + { + return $this->throughFilter ?? Filter::all(); + } + + /** + * Set a filter that constraints results of the join table or junction model + * + * Only actual columns of the source's or junction's table itself are allowed. Qualification happens at runtime. + * Use the source's table alias or the junction's one (default) to reference one or the other. Comparison values + * are passed as-is to ipl-sql's query builder, thus any behaviors by either the source or junction are not applied. + * Custom filter types other than those extending {@see Filter\Condition} are not allowed. Condition values of + * type {@see ExpressionInterface} are supported and must adhere to the same assumptions. + * + * @param Rule $filter + * + * @return $this + */ + public function setThroughFilter(Filter\Rule $filter): static + { + if (! $filter instanceof Filter\Chain) { + $filter = Filter::all($filter); + } + + $this->throughFilter = $filter; + + return $this; + } + public function resolve(): Generator { $source = $this->getSource(); @@ -210,6 +249,7 @@ public function resolve(): Generator ->setName($this->getThroughAlias()) ->setSource($source) ->setTarget($junction) + ->setFilter($this->getThroughFilter()) ->setCandidateKey($this->extractKey($possibleCandidateKey)) ->setForeignKey($this->extractKey($possibleForeignKey)); @@ -218,6 +258,7 @@ public function resolve(): Generator ->setName($this->getName()) ->setSource($junction) ->setTarget($target) + ->setFilter($this->getFilter()) ->setCandidateKey($this->extractKey($possibleTargetCandidateKey)) ->setForeignKey($this->extractKey($possibleTargetForeignKey)); From c1c554038484516e050d75a90cebfcc3eb8b746f Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:25:06 +0200 Subject: [PATCH 4/9] Resolver: Add method `qualifyFilter(Filter\Chain $filter, Model|Relation $subject): Filter\Chain` Utility method to qualify a simple filter using only base table columns (e.g. `'base.column'`) --- src/Resolver.php | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/Resolver.php b/src/Resolver.php index e054e48..2701aac 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -11,6 +11,7 @@ use ipl\Orm\Exception\InvalidRelationException; use ipl\Orm\Relation\BelongsToMany; use ipl\Sql\ExpressionInterface; +use ipl\Stdlib\Filter; use LogicException; use OutOfBoundsException; use SplObjectStorage; @@ -451,6 +452,66 @@ public function qualifyPath(string $path, string $tableName): string return $path; } + /** + * Qualify the columns of the given filter + * + * @param Filter\Chain $filter + * @param Model|Relation $subject + * + * @return Filter\Chain + * + * @throws InvalidArgumentException If a non-condition rule is used or an unknown model is referenced + */ + public function qualifyFilter(Filter\Chain $filter, Model|Relation $subject): Filter\Chain + { + $qualifyColumn = function (string $column) use ($subject): string { + [$alias, $column] = explode('.', $column, 2); + + if ($subject instanceof Model) { + if ($subject->getTableAlias() !== $alias) { + throw new InvalidArgumentException(sprintf( + 'Unknown model alias "%s" for filter column "%s"', + $alias, + $column + )); + } + + return $this->qualifyColumn($column, $this->getAlias($subject)); + } + + return $this->qualifyColumn( + $column, + match ($alias) { + $subject->getSource()->getTableAlias() => $this->getAlias($subject->getSource()), + $subject->getTarget()->getTableAlias() => $this->getAlias($subject->getTarget()), + $subject->getName() => $this->getAlias($subject->getTarget()), + default => throw new InvalidArgumentException(sprintf( + 'Unknown model alias "%s" for filter column "%s"', + $alias, + $column + )) + } + ); + }; + + $filter = clone $filter; // Deep clone + foreach ($filter->yieldRules() as $rule) { + if (! $rule instanceof Filter\Condition) { + throw new InvalidArgumentException(sprintf('Invalid filter rule "%s"', get_class($rule))); + } + + $rule->setColumn($qualifyColumn($rule->getColumn())); + if ($rule->getValue() instanceof ExpressionInterface) { + $rule->setValue( + (clone $rule->getValue()) + ->setColumns(array_map($qualifyColumn(...), $rule->getValue()->getColumns())) + ); + } + } + + return $filter; + } + /** * Get whether the given relation path points to a distinct entity * From 50c791b8c0de9a05ecaec7620d781dac977735e3 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:27:39 +0200 Subject: [PATCH 5/9] Resolver: Add method `resolveRelationFilter(Filter\Chain $filter, string $name, Model $source, Model $target): void` Supposed to validate and resolve relation filters so that they reference a model which `qualifyFilter` can work with. --- src/Resolver.php | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/Resolver.php b/src/Resolver.php index 2701aac..defa72d 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -10,6 +10,7 @@ use ipl\Orm\Exception\InvalidColumnException; use ipl\Orm\Exception\InvalidRelationException; use ipl\Orm\Relation\BelongsToMany; +use ipl\Orm\Relation\Junction; use ipl\Sql\ExpressionInterface; use ipl\Stdlib\Filter; use LogicException; @@ -452,6 +453,71 @@ public function qualifyPath(string $path, string $tableName): string return $path; } + /** + * Resolve the given relation filter + * + * Resolves each condition's column according to the referenced subject or, by default, the target. + * The target may also be referenced by the relation's name. + * + * @param Filter\Chain $filter + * @param string $name The name of the relation + * @param Model $source + * @param Model $target + * + * @throws InvalidArgumentException If a non-condition rule or invalid column is used in the filter + */ + public function resolveRelationFilter(Filter\Chain $filter, string $name, Model $source, Model $target): void + { + $resolveColumn = function (string $column) use ($name, $source, $target): string { + // A column may reference the source or target table by its alias, defaulting to the target + if (str_contains($column, '.')) { + [$alias, $column] = explode('.', $column, 2); + } else { + $alias = $target->getTableAlias(); + } + + $subject = match ($alias) { + $name => $target, + $source->getTableAlias() => $source, + $target->getTableAlias() => $target, + default => throw new InvalidArgumentException(sprintf( + 'Invalid relation alias "%s" for models "%s" and "%s"', + $alias, + get_class($source), + get_class($target) + )) + }; + + if (! $subject instanceof Junction && ! $this->hasSelectableColumn($subject, $column)) { + throw new InvalidArgumentException(sprintf( + 'Relation filter for model "%s" contains a non-selectable column "%s"', + get_class($subject), + $column + )); + } + + return "$alias.$column"; + }; + + foreach ($filter->yieldRules() as $rule) { + if (! $rule instanceof Filter\Condition) { + throw new InvalidArgumentException(sprintf( + 'Relation filter for model "%s" contains a non-condition rule of type "%s"', + get_class($target), + get_class($rule) + )); + } + + $rule->setColumn($resolveColumn($rule->getColumn())); + if ($rule->getValue() instanceof ExpressionInterface) { + $rule->setValue( + (clone $rule->getValue()) + ->setColumns(array_map($resolveColumn(...), $rule->getValue()->getColumns())) + ); + } + } + } + /** * Qualify the columns of the given filter * From 09d9e9a9e18b97355a80bf154bce82a0c4bd5e60 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:30:22 +0200 Subject: [PATCH 6/9] Resolver: Add method `getVisibilityFilter(Model $model): Filter\Chain` Provides access to a model's visibility filter and resolves it as well, similar to what `resolveRelationFilter` does but for relation filters. --- src/Resolver.php | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/Resolver.php b/src/Resolver.php index defa72d..8c98258 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -52,6 +52,9 @@ class Resolver /** @var SplObjectStorage Resolved relations */ protected SplObjectStorage $resolvedRelations; + /** @var SplObjectStorage Visibility filters from resolved models */ + protected SplObjectStorage $visibilityFilters; + /** * Create a new resolver * @@ -69,6 +72,7 @@ public function __construct(Query $query) $this->selectColumns = new SplObjectStorage(); $this->metaData = new SplObjectStorage(); $this->resolvedRelations = new SplObjectStorage(); + $this->visibilityFilters = new SplObjectStorage(); } /** @@ -89,6 +93,46 @@ public function getRelations(Model $model): Relations return $this->relations[$model]; } + /** + * Get a model's visibility filter + * + * @param Model $model + * + * @return Filter\Chain + * + * @throws LogicException If a non-condition rule is used in the filter + */ + public function getVisibilityFilter(Model $model): Filter\Chain + { + if (! isset($this->visibilityFilters[$model])) { + $visibilityFilter = Filter::all(); + $model->createVisibilityFilter($visibilityFilter); + foreach ($visibilityFilter->yieldRules() as $rule) { + if (! $rule instanceof Filter\Condition) { + throw new LogicException(sprintf( + 'Visibility filter for model "%s" contains a non-condition rule of type "%s"', + get_class($model), + get_class($rule) + )); + } + + $rule->setColumn($this->qualifyColumn($rule->getColumn(), $model->getTableAlias())); + if ($rule->getValue() instanceof ExpressionInterface) { + $resolvedColumns = []; + foreach ($rule->getValue()->getColumns() as $column) { + $resolvedColumns[] = $this->qualifyColumn($column, $model->getTableAlias()); + } + + $rule->setValue((clone $rule->getValue())->setColumns($resolvedColumns)); + } + } + + $this->visibilityFilters[$model] = $visibilityFilter; + } + + return $this->visibilityFilters[$model]; + } + /** * Get a model's behaviors * From 8c4527c9b6d2d0e3b5ffdd59124dfef170a6fedc Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:32:35 +0200 Subject: [PATCH 7/9] Resolver: Resolve its filters when resolving a relation --- src/Resolver.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Resolver.php b/src/Resolver.php index 8c98258..c6b697d 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -717,10 +717,13 @@ public function resolveRelations(string $path, ?Model $subject = null): Generato $relation = $targetRelations->get($relationName); $relation->setSource($target); + $this->resolveRelationFilter($relation->getFilter(), $target, $relation->getTarget()); $resolvedRelations[$relationPath] = $relation; if ($relation instanceof BelongsToMany) { + $this->resolveRelationFilter($relation->getThroughFilter(), $target, $relation->getThrough()); + $this->setAlias($relation->getThrough(), join('_', array_merge( array_slice($segments, 0, -1), [$relation->getThroughAlias()] From f26c7269402e351c64c360d1d9e81c59893bddab Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:38:27 +0200 Subject: [PATCH 8/9] Query|Relation: Respect visiblity and relation filters * `getSelectBase` applies the base model's visibility filters. * `assembleSelect` applies visibility and relation filters to joins. This introduces a compatibility related change to `Relation::resolve()` which now yields a key and extending classes need to re-yield it. `BelongsToMany` is such a case and did that already for a long time. A deprecation notice will appear if a relation still yields an int. * `createSubQuery` overrides the filters of the reversed relations with those of the original path, preserving the semantics as if the results were joined normally. --- src/Query.php | 49 +++++++++++++++++++++++++++++++--- src/Relation.php | 7 ++--- src/Relation/BelongsToMany.php | 16 +++++------ src/Resolver.php | 14 ++++++++-- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/Query.php b/src/Query.php index d08b18f..e1b47f3 100644 --- a/src/Query.php +++ b/src/Query.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use ipl\Orm\Common\SortUtil; use ipl\Orm\Compat\FilterProcessor; +use ipl\Orm\Relation\BelongsToMany; use ipl\Sql\Connection; use ipl\Sql\ExpressionInterface; use ipl\Sql\LimitOffset; @@ -328,6 +329,16 @@ public function getSelectBase(): Select $this->selectBase->from([ $this->getResolver()->getAlias($this->getModel()) => $this->getModel()->getTableName() ]); + + $visibilityFilter = FilterProcessor::assembleFilter( + $this->getResolver()->qualifyFilter( + $this->getResolver()->getVisibilityFilter($this->getModel()), + $this->getModel() + ) + ); + if ($visibilityFilter) { + $this->selectBase->where(...array_reverse($visibilityFilter)); + } } return $this->selectBase; @@ -502,7 +513,18 @@ public function assembleSelect(): Select continue; } - foreach ($relation->resolve() as [$source, $target, $relatedKeys]) { + foreach ($relation->resolve() as $targetRelation => [$source, $target, $relatedKeys]) { + if (is_int($targetRelation)) { + $targetRelation = $relation; + trigger_error(sprintf( + 'Relation implementation of %s::resolve() returned a numeric key for the target' + . ' relation. This is deprecated and will be removed in a future version. Please return' + . ' the target relation as key instead.', + $relation::class + ), E_USER_DEPRECATED); + } + + /** @var Relation $targetRelation */ /** @var Model $source */ /** @var Model $target */ @@ -518,9 +540,17 @@ public function assembleSelect(): Select ); } + $visibilityConditions = FilterProcessor::assembleFilter(Filter::all( + $resolver->qualifyFilter($targetRelation->getFilter(), $targetRelation), + $resolver->qualifyFilter($resolver->getVisibilityFilter($target), $target) + )); + if ($visibilityConditions) { + $conditions[] = $visibilityConditions; + } + $table = [$targetAlias => $target->getTableName()]; - switch ($relation->getJoinType()) { + switch ($targetRelation->getJoinType()) { case 'LEFT': $select->joinLeft($table, $conditions); @@ -617,7 +647,20 @@ public function createSubQuery(Model $target, string $targetPath, ?Model $from = $subQueryResolver = $subQuery->getResolver(); $sourcePath = join('.', $sourceParts); - $subQueryTarget = $subQueryResolver->resolveRelation($sourcePath)->getTarget(); + + $originalRelations = iterator_to_array($this->getResolver()->resolveRelations($targetPath, $from), false); + foreach ($subQuery->getResolver()->resolveRelations($sourcePath) as $relation) { + $original = array_pop($originalRelations); + + if ($relation instanceof BelongsToMany) { + $relation->setFilter($original->getThroughFilter()); + $relation->setThroughFilter($original->getFilter()); + } else { + $relation->setFilter($original->getFilter()); + } + + $subQueryTarget = $relation->getTarget(); + } $subQuery->utilize($sourcePath); // TODO: Don't join if there's a matching foreign key diff --git a/src/Relation.php b/src/Relation.php index 77e47e9..d9a9d9f 100644 --- a/src/Relation.php +++ b/src/Relation.php @@ -351,14 +351,15 @@ public function determineKeys(Model $source): array /** * Resolve the relation * - * Yields a three-element array consisting of the source model, target model and the join keys. + * Yields the relation to join as key and a three-element array consisting of the source model, + * target model and the join keys as value. * - * @return Generator + * @return Generator}, void> */ public function resolve(): Generator { $source = $this->getSource(); - yield [$source, $this->getTarget(), $this->determineKeys($source)]; + yield $this => [$source, $this->getTarget(), $this->determineKeys($source)]; } } diff --git a/src/Relation/BelongsToMany.php b/src/Relation/BelongsToMany.php index 410a7c7..bf570f6 100644 --- a/src/Relation/BelongsToMany.php +++ b/src/Relation/BelongsToMany.php @@ -251,7 +251,10 @@ public function resolve(): Generator ->setTarget($junction) ->setFilter($this->getThroughFilter()) ->setCandidateKey($this->extractKey($possibleCandidateKey)) - ->setForeignKey($this->extractKey($possibleForeignKey)); + ->setForeignKey($this->extractKey($possibleForeignKey)) + ->setJoinType($this->getJoinType()); + + yield from $toJunction->resolve(); $targetClass = static::RELATION_CLASS; $toTarget = (new $targetClass()) @@ -260,15 +263,10 @@ public function resolve(): Generator ->setTarget($target) ->setFilter($this->getFilter()) ->setCandidateKey($this->extractKey($possibleTargetCandidateKey)) - ->setForeignKey($this->extractKey($possibleTargetForeignKey)); - - foreach ($toJunction->resolve() as $k => $v) { - yield $k => $v; - } + ->setForeignKey($this->extractKey($possibleTargetForeignKey)) + ->setJoinType($this->getJoinType()); - foreach ($toTarget->resolve() as $k => $v) { - yield $k => $v; - } + yield from $toTarget->resolve(); } protected function extractKey(array $possibleKey): string|array|null diff --git a/src/Resolver.php b/src/Resolver.php index c6b697d..68cf090 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -717,12 +717,22 @@ public function resolveRelations(string $path, ?Model $subject = null): Generato $relation = $targetRelations->get($relationName); $relation->setSource($target); - $this->resolveRelationFilter($relation->getFilter(), $target, $relation->getTarget()); + $this->resolveRelationFilter( + $relation->getFilter(), + $relationName, + $target, + $relation->getTarget() + ); $resolvedRelations[$relationPath] = $relation; if ($relation instanceof BelongsToMany) { - $this->resolveRelationFilter($relation->getThroughFilter(), $target, $relation->getThrough()); + $this->resolveRelationFilter( + $relation->getThroughFilter(), + $relationName, + $target, + $relation->getThrough() + ); $this->setAlias($relation->getThrough(), join('_', array_merge( array_slice($segments, 0, -1), From 94b79654aa1d22c3e1f19dfb2ae1f6e334cfd5fc Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 24 Jul 2026 14:54:59 +0200 Subject: [PATCH 9/9] tests: Test visibility and relation filters Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/BelongsToManyTest.php | 65 +++++ tests/Car.php | 4 + tests/FilterProcessorTest.php | 12 +- tests/Lib/Model/Department.php | 8 + tests/Lib/Model/Employee.php | 10 + tests/Lib/Model/Node.php | 45 ++++ tests/Lib/Model/RestrictedGroup.php | 37 +++ tests/Lib/Model/RestrictedUser.php | 46 ++++ tests/Lib/Model/Ticket.php | 33 +++ tests/RelationTest.php | 44 ++++ tests/ResolverTest.php | 128 ++++++++++ tests/VisibilityFilterTest.php | 360 ++++++++++++++++++++++++++++ 12 files changed, 789 insertions(+), 3 deletions(-) create mode 100644 tests/Lib/Model/Node.php create mode 100644 tests/Lib/Model/RestrictedGroup.php create mode 100644 tests/Lib/Model/RestrictedUser.php create mode 100644 tests/Lib/Model/Ticket.php create mode 100644 tests/VisibilityFilterTest.php diff --git a/tests/BelongsToManyTest.php b/tests/BelongsToManyTest.php index ce552f4..cf37846 100644 --- a/tests/BelongsToManyTest.php +++ b/tests/BelongsToManyTest.php @@ -3,8 +3,10 @@ namespace ipl\Tests\Orm; use ipl\Orm\Query; +use ipl\Orm\Relation\BelongsToMany; use ipl\Orm\Relations; use ipl\Sql\Test\SqlAssertions; +use ipl\Stdlib\Filter; class BelongsToManyTest extends \PHPUnit\Framework\TestCase { @@ -109,4 +111,67 @@ public function testUniqueAliasesAreUsedToJoinThroughTables() $this->assertSql($sql, $profile->assembleSelect()); } + + public function testGetThroughFilterReturnsAnEmptyChainByDefault() + { + $filter = (new BelongsToMany())->getThroughFilter(); + + $this->assertInstanceOf(Filter\Chain::class, $filter); + $this->assertTrue($filter->isEmpty(), 'Default through filter is not empty'); + } + + public function testSetThroughFilterWrapsABareConditionInAnAllChain() + { + $condition = Filter::equal('foo', 'bar'); + $filter = (new BelongsToMany()) + ->setThroughFilter($condition) + ->getThroughFilter(); + + $this->assertInstanceOf(Filter\All::class, $filter); + $this->assertSame([$condition], iterator_to_array($filter)); + } + + public function testResolveYieldsJunctionAndTargetRelationsWithTheirFiltersAndJoinType() + { + $model = new Car(); + $relations = new Relations(); + $model->createRelations($relations); + + $throughFilter = Filter::equal('user_id', 5); + $targetFilter = Filter::equal('username', 'root'); + + $relation = $relations + ->get('user') + ->setSource($model) + ->setJoinType('LEFT') + ->setThroughFilter($throughFilter) + ->setFilter($targetFilter); + + $resolved = []; + foreach ($relation->resolve() as $key => $_) { + $resolved[] = $key; + } + + $this->assertCount(2, $resolved, 'A many-to-many relation must resolve to two joins'); + + [$toJunction, $toTarget] = $resolved; + + // The join type is propagated to both joins + $this->assertSame('LEFT', $toJunction->getJoinType()); + $this->assertSame('LEFT', $toTarget->getJoinType()); + + // The junction join carries the through filter ... + $this->assertSame( + [$throughFilter], + iterator_to_array($toJunction->getFilter()), + 'The junction join does not carry the through filter' + ); + + // ... and the target join carries the relation filter + $this->assertSame( + [$targetFilter], + iterator_to_array($toTarget->getFilter()), + 'The target join does not carry the relation filter' + ); + } } diff --git a/tests/Car.php b/tests/Car.php index 770ee27..d2dd5c7 100644 --- a/tests/Car.php +++ b/tests/Car.php @@ -4,6 +4,7 @@ use ipl\Orm\Model; use ipl\Orm\Relations; +use ipl\Tests\Orm\Lib\Model\RestrictedUser; class Car extends Model { @@ -35,5 +36,8 @@ public function createRelations(Relations $relations) $relations->belongsToMany('user_custom_keys', User::class) ->through(CarUserWithCustomKeys::class); + + $relations->belongsToMany('restricted_user', RestrictedUser::class) + ->through(CarUser::class); } } diff --git a/tests/FilterProcessorTest.php b/tests/FilterProcessorTest.php index 3f977da..aab0c34 100644 --- a/tests/FilterProcessorTest.php +++ b/tests/FilterProcessorTest.php @@ -120,8 +120,14 @@ public function testUnequalTargetingAnOptionalToManyRelationIgnoresFalsePositive #[DataProvider('databases')] public function testNegationOfAToManyRelationWorksAcrossDatabaseAdapters(Connection $db): void { - $db->insert('employee', ['id' => 1, 'department_id' => 1, 'name' => 'Minnie', 'role' => 'CEO']); - $db->insert('employee', ['id' => 2, 'department_id' => 2, 'name' => 'Goofy', 'role' => 'Developer']); + $db->insert( + 'employee', + ['id' => 1, 'department_id' => 1, 'name' => 'Minnie', 'role' => 'CEO', 'deleted' => 'n'] + ); + $db->insert( + 'employee', + ['id' => 2, 'department_id' => 2, 'name' => 'Goofy', 'role' => 'Developer', 'deleted' => 'n'] + ); $db->insert('chair', ['department_id' => 1, 'employee_id' => 1, 'vendor' => 'Acme Chairs']); $db->insert('chair', ['department_id' => 2, 'employee_id' => 1, 'vendor' => 'Bcme Chairs']); $db->insert('chair', ['department_id' => 3, 'employee_id' => 2, 'vendor' => 'Bcme Chairs']); @@ -140,7 +146,7 @@ protected function createSchema(Connection $db, string $driver): void $db->exec('CREATE TABLE department (id INT PRIMARY KEY, name VARCHAR(255))'); $db->exec( 'CREATE TABLE employee (id INT PRIMARY KEY, department_id INT,' - . ' office_id INT, name VARCHAR(255), role VARCHAR(255))' + . ' office_id INT, name VARCHAR(255), role VARCHAR(255), active VARCHAR(1), deleted VARCHAR(1))' ); $db->exec('CREATE TABLE chair (department_id INT, employee_id INT, vendor VARCHAR(255))'); } diff --git a/tests/Lib/Model/Department.php b/tests/Lib/Model/Department.php index 167c5ae..1c4cdfd 100644 --- a/tests/Lib/Model/Department.php +++ b/tests/Lib/Model/Department.php @@ -4,6 +4,7 @@ use ipl\Orm\Model; use ipl\Orm\Relations; +use ipl\Stdlib\Filter; class Department extends Model { @@ -27,6 +28,13 @@ public function getColumns() public function createRelations(Relations $relations) { $relations->hasMany('employee', Employee::class) + ->setFilter(Filter::equal('active', 'y')) ->setJoinType('LEFT'); + // Relation filter referencing the target (default) and the source table alias + $relations->hasMany('lead', Employee::class) + ->setFilter(Filter::all( + Filter::equal('role', 'lead'), + Filter::equal('department.name', 'Engineering') + )); } } diff --git a/tests/Lib/Model/Employee.php b/tests/Lib/Model/Employee.php index 629a770..8f87a2f 100644 --- a/tests/Lib/Model/Employee.php +++ b/tests/Lib/Model/Employee.php @@ -4,6 +4,7 @@ use ipl\Orm\Model; use ipl\Orm\Relations; +use ipl\Stdlib\Filter; class Employee extends Model { @@ -21,6 +22,8 @@ public function getColumns() { return [ 'name', + 'active', + 'deleted', 'role', 'department_id', 'office_id' @@ -33,5 +36,12 @@ public function createRelations(Relations $relations) $relations->belongsTo('office', Office::class) ->setJoinType('LEFT'); $relations->hasMany('chair', Chair::class); + $relations->hasMany('ticket', Ticket::class) + ->setFilter(Filter::equal('open', 'y')); + } + + public function createVisibilityFilter(Filter\Chain $filter): void + { + $filter->add(Filter::equal('deleted', 'n')); } } diff --git a/tests/Lib/Model/Node.php b/tests/Lib/Model/Node.php new file mode 100644 index 0000000..b75ac68 --- /dev/null +++ b/tests/Lib/Model/Node.php @@ -0,0 +1,45 @@ +belongsTo('parent', self::class) + ->setCandidateKey('parent_id') + ->setJoinType('LEFT'); + + $relations->hasMany('child', self::class) + ->setForeignKey('parent_id') + ->setFilter(Filter::equal('child.name', 'foo')); + } + + public function createVisibilityFilter(Filter\Chain $filter): void + { + $filter->add(Filter::equal('deleted', 'n')); + } +} diff --git a/tests/Lib/Model/RestrictedGroup.php b/tests/Lib/Model/RestrictedGroup.php new file mode 100644 index 0000000..58456b2 --- /dev/null +++ b/tests/Lib/Model/RestrictedGroup.php @@ -0,0 +1,37 @@ +add(Filter::equal('deleted', 'n')); + } + + public function createRelations(Relations $relations) + { + } +} diff --git a/tests/Lib/Model/RestrictedUser.php b/tests/Lib/Model/RestrictedUser.php new file mode 100644 index 0000000..c3fb10c --- /dev/null +++ b/tests/Lib/Model/RestrictedUser.php @@ -0,0 +1,46 @@ +hasMany('restricted_group', RestrictedGroup::class); + $relations->hasMany('vip_group', Group::class) + ->setFilter(Filter::equal('name', 'vip')); + $relations->belongsToMany('car', Car::class) + ->through(CarUser::class) + ->setThroughFilter(Filter::equal('user_id', 5)) + ->setFilter(Filter::equal('manufacturer', 'Icinga')); + $relations->belongsToMany('shared_group', Group::class) + ->setThroughAlias('sg') + ->through('user_group') + ->setThroughFilter(Filter::equal('active', 'y')); + } +} diff --git a/tests/Lib/Model/Ticket.php b/tests/Lib/Model/Ticket.php new file mode 100644 index 0000000..13a342e --- /dev/null +++ b/tests/Lib/Model/Ticket.php @@ -0,0 +1,33 @@ +belongsTo('employee', Employee::class); + } +} diff --git a/tests/RelationTest.php b/tests/RelationTest.php index 1148645..c9ceadb 100644 --- a/tests/RelationTest.php +++ b/tests/RelationTest.php @@ -3,6 +3,7 @@ namespace ipl\Tests\Orm; use ipl\Orm\Relation; +use ipl\Stdlib\Filter; class RelationTest extends \PHPUnit\Framework\TestCase { @@ -162,4 +163,47 @@ public function testMultipleCallsToGetTargetAlwaysReturnsTheVerySameTargetInstan $this->assertSame($target, $relation->getTarget()); $this->assertSame($target, $relation->getTarget()); } + + public function testGetFilterReturnsAnEmptyChainByDefault() + { + $filter = (new Relation())->getFilter(); + + $this->assertInstanceOf(Filter\Chain::class, $filter); + $this->assertTrue($filter->isEmpty(), 'Default filter is not empty'); + } + + public function testSetFilterWrapsABareConditionInAnAllChain() + { + $condition = Filter::equal('foo', 'bar'); + $filter = (new Relation()) + ->setFilter($condition) + ->getFilter(); + + $this->assertInstanceOf(Filter\All::class, $filter); + $this->assertSame([$condition], iterator_to_array($filter)); + } + + public function testSetFilterKeepsAChainAsIs() + { + $chain = Filter::any(Filter::equal('foo', 'bar')); + $relation = (new Relation()) + ->setFilter($chain); + + $this->assertSame($chain, $relation->getFilter()); + } + + public function testResolveYieldsTheRelationItselfAsKey() + { + $relation = (new Relation()) + ->setName('test') + ->setSource(new TestModelWithPrimaryKey()) + ->setTargetClass(TestModelWithPrimaryKey::class); + + $keys = []; + foreach ($relation->resolve() as $key => $_) { + $keys[] = $key; + } + + $this->assertSame([$relation], $keys); + } } diff --git a/tests/ResolverTest.php b/tests/ResolverTest.php index a7fbad4..633cdc6 100644 --- a/tests/ResolverTest.php +++ b/tests/ResolverTest.php @@ -2,9 +2,15 @@ namespace ipl\Tests\Orm; +use InvalidArgumentException; use ipl\Orm\Query; +use ipl\Orm\Relation\Junction; use ipl\Sql\Expression; use ipl\Sql\QueryBuilder; +use ipl\Stdlib\Filter; +use ipl\Tests\Orm\Lib\Model\Department; +use ipl\Tests\Orm\Lib\Model\Employee; +use ipl\Tests\Orm\Lib\Model\RestrictedGroup; use PHPUnit\Framework\TestCase; class ResolverTest extends TestCase @@ -189,4 +195,126 @@ public function testColumnsAreQualifiedByTableAlias() $model = $query->getWith()['test_user_profile.test_user']->getTarget(); $this->assertSame($qualified, $query->getResolver()->qualifyColumnsAndAliases($columns, $model)); } + + public function testGetVisibilityFilterReturnsAnEmptyChainForModelsWithoutOne() + { + $query = (new Query())->setModel(new User()); + $filter = $query->getResolver()->getVisibilityFilter($query->getModel()); + + $this->assertInstanceOf(Filter\Chain::class, $filter); + $this->assertTrue($filter->isEmpty(), 'Visibility filter of a model without one is not empty'); + } + + public function testGetVisibilityFilterResolvesAndCachesTheModelsFilter() + { + $query = (new Query())->setModel(new RestrictedGroup()); + $resolver = $query->getResolver(); + + $filter = $resolver->getVisibilityFilter($query->getModel()); + + $rules = iterator_to_array($filter->yieldRules()); + $this->assertCount(1, $rules); + $this->assertSame('restricted_group.deleted', $rules[0]->getColumn()); + $this->assertSame('n', $rules[0]->getValue()); + + // Subsequent calls return the very same (cached) resolved instance + $this->assertSame($filter, $resolver->getVisibilityFilter($query->getModel())); + } + + public function testResolveRelationFilterQualifiesTargetColumnsByDefault() + { + $resolver = (new Query())->setModel(new Department())->getResolver(); + + $filter = Filter::all(Filter::equal('active', 'y'), Filter::equal('employee.role', 'lead')); + $resolver->resolveRelationFilter($filter, 'relation', new Department(), new Employee()); + + $columns = array_map(fn ($rule) => $rule->getColumn(), iterator_to_array($filter->yieldRules())); + $this->assertSame(['employee.active', 'employee.role'], $columns); + } + + public function testResolveRelationFilterQualifiesSourceColumns() + { + $resolver = (new Query())->setModel(new Department())->getResolver(); + + $filter = Filter::all(Filter::equal('department.name', 'Engineering')); + $resolver->resolveRelationFilter($filter, 'relation', new Department(), new Employee()); + + $this->assertSame('department.name', iterator_to_array($filter->yieldRules())[0]->getColumn()); + } + + public function testResolveRelationFilterQualifiesRelationColumns() + { + $resolver = (new Query())->setModel(new Department())->getResolver(); + + $filter = Filter::all(Filter::equal('supplementary.name', 'Q/A')); + $resolver->resolveRelationFilter($filter, 'supplementary', new Department(), new Department()); + + $this->assertSame('supplementary.name', iterator_to_array($filter->yieldRules())[0]->getColumn()); + } + + public function testResolveRelationFilterThrowsForAnUnknownAlias() + { + $resolver = (new Query())->setModel(new Department())->getResolver(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid relation alias "office"'); + + $resolver->resolveRelationFilter( + Filter::all(Filter::equal('office.city', 'London')), + 'relation', + new Department(), + new Employee() + ); + } + + public function testResolveRelationFilterThrowsForANonSelectableColumn() + { + $resolver = (new Query())->setModel(new Department())->getResolver(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('non-selectable column "unknown"'); + + $resolver->resolveRelationFilter( + Filter::all(Filter::equal('unknown', 'x')), + 'relation', + new Department(), + new Employee() + ); + } + + public function testResolveRelationFilterDoesNotValidateJunctionColumns() + { + $resolver = (new Query())->setModel(new Department())->getResolver(); + $junction = (new Junction())->setTableName('membership'); + + $filter = Filter::all(Filter::equal('membership.since', '2020')); + $resolver->resolveRelationFilter($filter, 'relation', new Department(), $junction); + + $this->assertSame('membership.since', iterator_to_array($filter->yieldRules())[0]->getColumn()); + } + + public function testQualifyFilterThrowsForAnUnknownModelAlias() + { + $query = (new Query())->setModel(new Department()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown model alias "employee"'); + + $query->getResolver()->qualifyFilter( + Filter::all(Filter::equal('employee.active', 'y')), + $query->getModel() + ); + } + + public function testQualifyFilterDoesNotModifyTheGivenFilter() + { + $query = (new Query())->setModel(new Department()); + + $original = Filter::all(Filter::equal('department.name', 'Engineering')); + $qualified = $query->getResolver()->qualifyFilter($original, $query->getModel()); + + // The chain is deep cloned, hence the original is left untouched + $this->assertNotSame($original, $qualified, 'The given filter has not been cloned'); + $this->assertSame('department.name', iterator_to_array($original->yieldRules())[0]->getColumn()); + } } diff --git a/tests/VisibilityFilterTest.php b/tests/VisibilityFilterTest.php new file mode 100644 index 0000000..26a7fcb --- /dev/null +++ b/tests/VisibilityFilterTest.php @@ -0,0 +1,360 @@ +setUpSqlAssertions(); + } + + public function testBaseModelVisibilityFilterIsAppliedToWhereClause() + { + $query = (new Query()) + ->setModel(new RestrictedGroup()); + + $this->assertSql( + <<<'SQL' + SELECT restricted_group.id, restricted_group.name, restricted_group.deleted + FROM restricted_group + WHERE restricted_group.deleted = ? + SQL, + $query->assembleSelect(), + ['n'] + ); + } + + public function testModelWithoutVisibilityFilterAddsNoWhereClause() + { + $query = (new Query()) + ->setModel(new User()) + ->columns('username'); + + $this->assertSql( + 'SELECT user.username FROM user', + $query->assembleSelect() + ); + } + + public function testJoinedTargetModelVisibilityFilterIsAppliedToJoinCondition() + { + $query = (new Query()) + ->setModel(new RestrictedUser()) + ->columns('username') + ->utilize('restricted_group'); + + $this->assertSql( + <<<'SQL' + SELECT restricted_user.username + FROM restricted_user + INNER JOIN restricted_group restricted_user_restricted_group + ON (restricted_user_restricted_group.restricted_user_id = restricted_user.id) + AND (restricted_user_restricted_group.deleted = ?) + SQL, + $query->assembleSelect(), + ['n'] + ); + } + + public function testSelfReferencingRelationAppliesTheTargetsVisibilityFilterToTheTarget() + { + $query = Node::on(new TestConnection()) + ->columns('name') + ->utilize('parent'); + + $this->assertSql( + <<<'SQL' + SELECT node.name + FROM node + LEFT JOIN node node_parent + ON (node_parent.id = node.parent_id) + AND (node_parent.deleted = ?) + WHERE node.deleted = ? + SQL, + $query->assembleSelect(), + ['n', 'n'] + ); + } + + public function testSelfReferencingRelationFilterIsAppliedToTheTarget() + { + $query = Node::on(new TestConnection()) + ->columns('name') + ->utilize('child'); + + $this->assertSql( + <<<'SQL' + SELECT node.name + FROM node + INNER JOIN node node_child + ON (node_child.parent_id = node.id) + AND ((node_child.name = ?) AND (node_child.deleted = ?)) + WHERE node.deleted = ? + SQL, + $query->assembleSelect(), + ['foo', 'n', 'n'] + ); + } + + public function testRelationFilterIsAppliedToJoinCondition() + { + $query = (new Query()) + ->setModel(new RestrictedUser()) + ->columns('username') + ->utilize('vip_group'); + + $this->assertSql( + <<<'SQL' + SELECT restricted_user.username + FROM restricted_user + INNER JOIN group restricted_user_vip_group + ON (restricted_user_vip_group.restricted_user_id = restricted_user.id) + AND (restricted_user_vip_group.name = ?) + SQL, + $query->assembleSelect(), + ['vip'] + ); + } + + public function testRelationFilterMayReferenceTheSourceTable() + { + // The "lead" relation's filter references the target (role, default) and the source (department.name) + $query = (new Query()) + ->setModel(new Department()) + ->columns('name') + ->utilize('lead'); + + $this->assertSql( + 'SELECT department.name FROM department' + . ' INNER JOIN employee department_lead ON (department_lead.department_id = department.id)' + . ' AND (((department_lead.role = ?) AND (department.name = ?)) AND (department_lead.deleted = ?))', + $query->assembleSelect(), + ['lead', 'Engineering', 'n'] + ); + } + + public function testRelationFilterReferencingSourceAndTargetIsAppliedAsIsInAReversedSubQuery() + { + // Filtering on the to-many "lead" relation reverses the join. The relation filter must still apply + // unchanged: role constrains the target (now the sub query's base) and department.name the source + // (now joined), because both are addressed by their table alias regardless of the join direction. + $query = (new Query()) + ->setDb(new TestConnection()) + ->setModel(new Department()) + ->columns('name') + ->filter(Filter::equal('lead.name', 'x')); + + $this->assertSql( + 'SELECT department.name FROM department WHERE department.id IN ((SELECT' + . ' sub_employee_department.id AS sub_employee_department_id FROM employee sub_employee' + . ' INNER JOIN department sub_employee_department' + . ' ON (sub_employee_department.id = sub_employee.department_id)' + . ' AND ((sub_employee.role = ?) AND (sub_employee_department.name = ?))' + . ' WHERE (sub_employee.deleted = ?) AND (sub_employee.name = ?)))', + $query->assembleSelect(), + ['lead', 'Engineering', 'n', 'x'] + ); + } + + public function testBelongsToManyThroughAndRelationFiltersAreAppliedToJoinConditions() + { + $query = (new Query()) + ->setModel(new RestrictedUser()) + ->columns('username') + ->utilize('car'); + + $this->assertSql( + <<<'SQL' + SELECT restricted_user.username + FROM restricted_user + INNER JOIN car_user restricted_user_car_user + ON (restricted_user_car_user.restricted_user_id = restricted_user.id) + AND (restricted_user_car_user.user_id = ?) + INNER JOIN car restricted_user_car + ON (restricted_user_car.id = restricted_user_car_user.car_id) + AND (restricted_user_car.manufacturer = ?) + SQL, + $query->assembleSelect(), + [5, 'Icinga'] + ); + } + + public function testBelongsToManyThroughAndRelationFiltersAreAppliedToReversedJoinConditionsInASubQuery(): void + { + $query = (new Query()) + ->setDb(new TestConnection()) + ->setModel(new RestrictedUser()) + ->filter(Filter::equal('car.model_name', 'volkswagen')); + + $this->assertSql( + <<<'SQL' + SELECT restricted_user.id, restricted_user.username + FROM restricted_user + WHERE restricted_user.id IN + ((SELECT sub_car_restricted_user.id AS sub_car_restricted_user_id + FROM car sub_car + INNER JOIN car_user sub_car_car_user + ON (sub_car_car_user.car_id = sub_car.id) + AND (sub_car.manufacturer = ?) + INNER JOIN restricted_user sub_car_restricted_user + ON (sub_car_restricted_user.id = sub_car_car_user.restricted_user_id) + AND (sub_car_car_user.user_id = ?) + WHERE sub_car.model_name = ?)) + SQL, + $query->assembleSelect(), + ['Icinga', 5, 'volkswagen'] + ); + } + + public function testBelongsToManyThroughFilterIsNotValidatedForPlainJunctions() + { + // A plain junction (no through-model) has no selectable columns of its own, so its + // through filter columns must not be validated but still be qualified and applied. + $query = (new Query()) + ->setModel(new RestrictedUser()) + ->columns('username') + ->utilize('shared_group'); + + $this->assertSql( + <<<'SQL' + SELECT restricted_user.username + FROM restricted_user + INNER JOIN user_group restricted_user_sg + ON (restricted_user_sg.restricted_user_id = restricted_user.id) + AND (restricted_user_sg.active = ?) + INNER JOIN group restricted_user_shared_group + ON restricted_user_shared_group.id = restricted_user_sg.group_id + SQL, + $query->assembleSelect(), + ['y'] + ); + } + + public function testModelVisibilityAndRelationFiltersAreAppliedInAFilterSubQuery() + { + // Filtering on a to-many relation is turned into a reversed sub query by the FilterProcessor. + // The sub query's base (employee) must carry both its own visibility filter (deleted = n) and + // the relation filter declared on the forward relation (active = y). + $query = (new Query()) + ->setDb(new TestConnection()) + ->setModel(new Department()) + ->columns('name') + ->filter(Filter::equal('employee.name', 'x')); + + $this->assertSql( + 'SELECT department.name FROM department WHERE department.id IN ((SELECT' + . ' sub_employee_department.id AS sub_employee_department_id FROM employee sub_employee' + . ' INNER JOIN department sub_employee_department' + . ' ON (sub_employee_department.id = sub_employee.department_id) AND (sub_employee.active = ?)' + . ' WHERE (sub_employee.deleted = ?) AND (sub_employee.name = ?)))', + $query->assembleSelect(), + ['y', 'n', 'x'] + ); + } + + public function testFiltersArePropagatedThroughTheReversedJoinsOfASubQuery() + { + // Two-hop path: the sub query's base is the final target (ticket) and the intermediate model + // (employee) is joined. Each must receive the filters that constrain it: ticket gets the relation + // filter of employee->ticket (open = y), employee gets both its visibility filter (deleted = n) + // and the relation filter of department->employee (active = y). + $query = (new Query()) + ->setDb(new TestConnection()) + ->setModel(new Department()) + ->columns('name') + ->filter(Filter::equal('employee.ticket.subject', 'x')); + + $this->assertSql( + 'SELECT department.name FROM department WHERE department.id IN ((SELECT' + . ' sub_ticket_employee_department.id AS sub_ticket_employee_department_id FROM ticket sub_ticket' + . ' INNER JOIN employee sub_ticket_employee ON (sub_ticket_employee.id = sub_ticket.employee_id)' + . ' AND ((sub_ticket.open = ?) AND (sub_ticket_employee.deleted = ?))' + . ' INNER JOIN department sub_ticket_employee_department' + . ' ON (sub_ticket_employee_department.id = sub_ticket_employee.department_id)' + . ' AND (sub_ticket_employee.active = ?) WHERE sub_ticket.subject = ?))', + $query->assembleSelect(), + ['y', 'n', 'y', 'x'] + ); + } + + public function testDeriveAppliesTheModelVisibilityFilterAndTheRelationFilter() + { + // derive() loads a relation for a concrete source model via a reversed sub query. Since the + // relation's target becomes the sub query's base, its visibility filter (deleted = n) ends up in the + // WHERE, while the relation filter (active = y) is carried by the inverse join. Both are applied by + // createSubQuery alone, each exactly once and qualified with the sub query's alias (sub_employee). + $query = (new Query()) + ->setDb(new TestConnection()) + ->setModel(new Department()); + + $derived = $query->derive('employee', new Department(['id' => 1])); + + $this->assertSql( + 'SELECT sub_employee.id, sub_employee.name, sub_employee.active, sub_employee.deleted,' + . ' sub_employee.role, sub_employee.department_id, sub_employee.office_id' + . ' FROM employee sub_employee' + . ' INNER JOIN department sub_employee_department' + . ' ON (sub_employee_department.id = sub_employee.department_id) AND (sub_employee.active = ?)' + . ' WHERE (sub_employee.deleted = ?) AND (sub_employee_department.id = ?)', + $derived->assembleSelect(), + ['y', 'n', 1] + ); + } + + public function testDeriveAppliesARelationFilterThatReferencesTheSourceTable() + { + // The "lead" relation filter references both the target (role) and the source (department.name). + // Both must be qualified with the respective sub query aliases without deriving the filter twice. + $query = (new Query()) + ->setDb(new TestConnection()) + ->setModel(new Department()); + + $derived = $query->derive('lead', new Department(['id' => 1])); + + $this->assertSql( + 'SELECT sub_employee.id, sub_employee.name, sub_employee.active, sub_employee.deleted,' + . ' sub_employee.role, sub_employee.department_id, sub_employee.office_id' + . ' FROM employee sub_employee' + . ' INNER JOIN department sub_employee_department' + . ' ON (sub_employee_department.id = sub_employee.department_id)' + . ' AND ((sub_employee.role = ?) AND (sub_employee_department.name = ?))' + . ' WHERE (sub_employee.deleted = ?) AND (sub_employee_department.id = ?)', + $derived->assembleSelect(), + ['lead', 'Engineering', 'n', 1] + ); + } + + public function testModelVisibilityFilterColumnsAreNotValidated() + { + // Unlike relation filters, a model's visibility filter is not validated against selectable columns; + // its columns are qualified and emitted as-is (the model is trusted to reference actual columns). + $model = new class () extends RestrictedGroup { + public function createVisibilityFilter(Filter\Chain $filter): void + { + $filter->add(Filter::equal('not_a_column', 1)); + } + }; + + $query = (new Query()) + ->columns('name') + ->setModel($model); + + $this->assertSql( + 'SELECT restricted_group.name FROM restricted_group WHERE restricted_group.not_a_column = ?', + $query->assembleSelect(), + [1] + ); + } +}