diff --git a/src/Model.php b/src/Model.php index 65c5cafd..28e2724b 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 * diff --git a/src/Query.php b/src/Query.php index d08b18f7..e1b47f31 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 2aca85c1..d9a9d9f8 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 * @@ -312,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 edf5bf1b..bf570f63 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,24 +249,24 @@ public function resolve(): Generator ->setName($this->getThroughAlias()) ->setSource($source) ->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()) ->setName($this->getName()) ->setSource($junction) ->setTarget($target) + ->setFilter($this->getFilter()) ->setCandidateKey($this->extractKey($possibleTargetCandidateKey)) - ->setForeignKey($this->extractKey($possibleTargetForeignKey)); + ->setForeignKey($this->extractKey($possibleTargetForeignKey)) + ->setJoinType($this->getJoinType()); - foreach ($toJunction->resolve() as $k => $v) { - yield $k => $v; - } - - 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 e054e48a..68cf0902 100644 --- a/src/Resolver.php +++ b/src/Resolver.php @@ -10,7 +10,9 @@ 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; use OutOfBoundsException; use SplObjectStorage; @@ -50,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 * @@ -67,6 +72,7 @@ public function __construct(Query $query) $this->selectColumns = new SplObjectStorage(); $this->metaData = new SplObjectStorage(); $this->resolvedRelations = new SplObjectStorage(); + $this->visibilityFilters = new SplObjectStorage(); } /** @@ -87,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 * @@ -451,6 +497,131 @@ 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 + * + * @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 * @@ -546,10 +717,23 @@ public function resolveRelations(string $path, ?Model $subject = null): Generato $relation = $targetRelations->get($relationName); $relation->setSource($target); + $this->resolveRelationFilter( + $relation->getFilter(), + $relationName, + $target, + $relation->getTarget() + ); $resolvedRelations[$relationPath] = $relation; if ($relation instanceof BelongsToMany) { + $this->resolveRelationFilter( + $relation->getThroughFilter(), + $relationName, + $target, + $relation->getThrough() + ); + $this->setAlias($relation->getThrough(), join('_', array_merge( array_slice($segments, 0, -1), [$relation->getThroughAlias()] diff --git a/tests/BelongsToManyTest.php b/tests/BelongsToManyTest.php index ce552f46..cf37846a 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 770ee271..d2dd5c73 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 3f977da2..aab0c348 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 167c5ae6..1c4cdfd7 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 629a770b..8f87a2f9 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 00000000..b75ac68b --- /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 00000000..58456b27 --- /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 00000000..c3fb10c3 --- /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 00000000..13a342e2 --- /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 11486451..c9ceadb3 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 a7fbad43..633cdc6a 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 00000000..26a7fcbc --- /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] + ); + } +}