Skip to content
Merged
15 changes: 15 additions & 0 deletions src/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
*
Expand Down
49 changes: 46 additions & 3 deletions src/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 */

Expand All @@ -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);

Expand Down Expand Up @@ -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

Expand Down
46 changes: 43 additions & 3 deletions src/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace ipl\Orm;

use Generator;
use ipl\Stdlib\Filter;
use ipl\Stdlib\Filter\Rule;
use UnexpectedValueException;

/**
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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, static, array{0: Model, 1: Model, 2: array<string, string>}, void>
*/
public function resolve(): Generator
{
$source = $this->getSource();

yield [$source, $this->getTarget(), $this->determineKeys($source)];
yield $this => [$source, $this->getTarget(), $this->determineKeys($source)];
}
}
57 changes: 48 additions & 9 deletions src/Relation/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading