diff options
Diffstat (limited to 'vendor/ipl/orm/src/Resolver.php')
-rw-r--r-- | vendor/ipl/orm/src/Resolver.php | 783 |
1 files changed, 783 insertions, 0 deletions
diff --git a/vendor/ipl/orm/src/Resolver.php b/vendor/ipl/orm/src/Resolver.php new file mode 100644 index 0000000..a910716 --- /dev/null +++ b/vendor/ipl/orm/src/Resolver.php @@ -0,0 +1,783 @@ +<?php + +namespace ipl\Orm; + +use AppendIterator; +use ArrayIterator; +use Generator; +use InvalidArgumentException; +use ipl\Orm\Contract\QueryAwareBehavior; +use ipl\Orm\Exception\InvalidColumnException; +use ipl\Orm\Exception\InvalidRelationException; +use ipl\Orm\Relation\BelongsToMany; +use ipl\Sql\ExpressionInterface; +use LogicException; +use OutOfBoundsException; +use SplObjectStorage; + +use function ipl\Stdlib\get_php_type; + +/** + * Column and relation resolver. Acts as glue between queries and models + */ +class Resolver +{ + /** @var Query The query to resolve */ + protected $query; + + /** @var SplObjectStorage Model relations */ + protected $relations; + + /** @var SplObjectStorage Model behaviors */ + protected $behaviors; + + /** @var SplObjectStorage Model defaults */ + protected $defaults; + + /** @var SplObjectStorage Model aliases */ + protected $aliases; + + /** @var string The alias prefix to use */ + protected $aliasPrefix; + + /** @var SplObjectStorage Selectable columns from resolved models */ + protected $selectableColumns; + + /** @var SplObjectStorage Select columns from resolved models */ + protected $selectColumns; + + /** @var SplObjectStorage Meta data from models and their direct relations */ + protected $metaData; + + /** @var SplObjectStorage Resolved relations */ + protected $resolvedRelations; + + /** + * Create a new resolver + * + * @param Query $query The query to resolve + */ + public function __construct(Query $query) + { + $this->query = $query; + + $this->relations = new SplObjectStorage(); + $this->behaviors = new SplObjectStorage(); + $this->defaults = new SplObjectStorage(); + $this->aliases = new SplObjectStorage(); + $this->selectableColumns = new SplObjectStorage(); + $this->selectColumns = new SplObjectStorage(); + $this->metaData = new SplObjectStorage(); + $this->resolvedRelations = new SplObjectStorage(); + } + + /** + * Get a model's relations + * + * @param Model $model + * + * @return Relations + */ + public function getRelations(Model $model) + { + if (! $this->relations->contains($model)) { + $relations = new Relations(); + $model->createRelations($relations); + $this->relations->attach($model, $relations); + } + + return $this->relations[$model]; + } + + /** + * Get a model's behaviors + * + * @param Model $model + * + * @return Behaviors + */ + public function getBehaviors(Model $model) + { + if (! $this->behaviors->contains($model)) { + $behaviors = new Behaviors(); + $model->createBehaviors($behaviors); + $this->behaviors->attach($model, $behaviors); + + foreach ($behaviors as $behavior) { + if ($behavior instanceof QueryAwareBehavior) { + $behavior->setQuery($this->query); + } + } + } + + return $this->behaviors[$model]; + } + + /** + * Get a model's defaults + * + * @param Model $model + * + * @return Defaults + */ + public function getDefaults(Model $model): Defaults + { + if (! $this->defaults->contains($model)) { + $defaults = new Defaults($this->query); + $model->createDefaults($defaults); + $this->defaults->attach($model, $defaults); + } + + return $this->defaults[$model]; + } + + /** + * Get a model alias + * + * @param Model $model + * + * @return string + * + * @throws OutOfBoundsException If no alias exists for the given model + */ + public function getAlias(Model $model) + { + if (! $this->aliases->contains($model)) { + throw new OutOfBoundsException(sprintf( + "Can't get alias for model '%s'. Alias does not exist", + get_class($model) + )); + } + + return $this->aliasPrefix . $this->aliases[$model]; + } + + /** + * Set a model alias + * + * @param Model $model + * @param string $alias + * + * @return $this + */ + public function setAlias(Model $model, $alias) + { + $this->aliases[$model] = $alias; + + return $this; + } + + /** + * Get the alias prefix + * + * @return string + */ + public function getAliasPrefix() + { + return $this->aliasPrefix; + } + + /** + * Set the alias prefix + * + * @param string $alias + * + * @return $this + */ + public function setAliasPrefix($alias) + { + $this->aliasPrefix = $alias; + + return $this; + } + + /** + * Get whether the specified model provides the given selectable column + * + * @param Model $subject + * @param string $column + * + * @return bool + */ + public function hasSelectableColumn(Model $subject, $column) + { + if (! $this->selectableColumns->contains($subject)) { + $this->collectColumns($subject); + } + + $columns = $this->selectableColumns[$subject]; + if (! isset($columns[$column])) { + $columns[$column] = $this->getBehaviors($subject)->isSelectableColumn($column); + } + + return $columns[$column]; + } + + /** + * Get all selectable columns from the given model + * + * @param Model $subject + * + * @return array + */ + public function getSelectableColumns(Model $subject) + { + if (! $this->selectableColumns->contains($subject)) { + $this->collectColumns($subject); + } + + return array_keys($this->selectableColumns[$subject]); + } + + /** + * Get all select columns from the given model + * + * @param Model $subject + * + * @return array Select columns suitable for {@link \ipl\Sql\Select::columns()} + */ + public function getSelectColumns(Model $subject) + { + if (! $this->selectColumns->contains($subject)) { + $this->collectColumns($subject); + } + + return $this->selectColumns[$subject]; + } + + /** + * Get all meta data from the given model and its direct relations + * + * @param Model $subject + * + * @return array Column paths as keys (relative to $subject) and their meta data as values + */ + public function getColumnDefinitions(Model $subject) + { + if (! $this->metaData->contains($subject)) { + $this->metaData->attach($subject, $this->collectMetaData($subject)); + } + + return $this->metaData[$subject]; + } + + /** + * Get definition of the given column + * + * @param string $columnPath + * + * @return ColumnDefinition + */ + public function getColumnDefinition(string $columnPath): ColumnDefinition + { + $parts = explode('.', $columnPath); + $model = $this->query->getModel(); + + if ($parts[0] !== $model->getTableName()) { + array_unshift($parts, $model->getTableName()); + } + + do { + $relationPath[] = array_shift($parts); + $column = implode('.', $parts); + + if (count($relationPath) === 1) { + $subject = $model; + } else { + $subject = $this->resolveRelation(implode('.', $relationPath))->getTarget(); + } + + if ($this->hasSelectableColumn($subject, $column)) { + break; + } + } while ($parts); + + $definition = $this->getColumnDefinitions($subject)[$column] ?? new ColumnDefinition($column); + $this->getBehaviors($subject)->rewriteColumnDefinition($definition, implode('.', $relationPath)); + + return $definition; + } + + /** + * Qualify the given alias by the specified table name + * + * @param string $alias + * @param string $tableName + * + * @return string + */ + public function qualifyColumnAlias($alias, $tableName) + { + return $tableName . '_' . $alias; + } + + /** + * Qualify the given column by the specified table name + * + * @param string $column + * @param string $tableName + * + * @return string + */ + public function qualifyColumn($column, $tableName) + { + return $tableName . '.' . $column; + } + + /** + * Qualify the given columns by the specified model + * + * @param iterable $columns + * @param Model $model Leave null in case $columns is {@see Resolver::requireAndResolveColumns()} + * + * @return array + * + * @throws InvalidArgumentException If $columns is not iterable + * @throws InvalidArgumentException If $model is not passed and $columns is not a generator + */ + public function qualifyColumns($columns, Model $model = null) + { + $target = $model ?: $this->query->getModel(); + $targetAlias = $this->getAlias($target); + + if (! is_iterable($columns)) { + throw new InvalidArgumentException( + sprintf('$columns is not iterable, got %s instead', get_php_type($columns)) + ); + } + + $qualified = []; + foreach ($columns as $alias => $column) { + if (is_int($alias) && is_array($column)) { + // $columns is $this->requireAndResolveColumns() + list($target, $alias, $columnName) = $column; + $targetAlias = $this->getAlias($target); + + // Thanks to PHP 5.6 where `list` is evaluated from right to left. It will extract + // the values for `$target` and `$alias` then from the third argument (`$column`). + $column = $columnName; + } elseif ($target === null) { + throw new InvalidArgumentException( + 'Passing no model is only possible if $columns is a generator' + ); + } + + if ($column instanceof ResolvedExpression) { + $column->setColumns($this->qualifyColumns($column->getResolvedColumns())); + } elseif ($column instanceof ExpressionInterface) { + $column = clone $column; // The expression may be part of a model and those shouldn't change implicitly + $column->setColumns($this->qualifyColumns($column->getColumns(), $target)); + } else { + $column = $this->qualifyColumn($column, $targetAlias); + } + + $qualified[$alias] = $column; + } + + return $qualified; + } + + /** + * Qualify the given columns and aliases by the specified model + * + * @param iterable $columns + * @param Model $model Leave null in case $columns is {@see Resolver::requireAndResolveColumns()} + * @param bool $autoAlias Set an alias for columns which have none + * + * @return array + * + * @throws InvalidArgumentException If $columns is not iterable + * @throws InvalidArgumentException If $model is not passed and $columns is not a generator + */ + public function qualifyColumnsAndAliases($columns, Model $model = null, $autoAlias = true) + { + $target = $model ?: $this->query->getModel(); + $targetAlias = $this->getAlias($target); + + if (! is_iterable($columns)) { + throw new InvalidArgumentException( + sprintf('$columns is not iterable, got %s instead', get_php_type($columns)) + ); + } + + $qualified = []; + foreach ($columns as $alias => $column) { + if (is_int($alias) && is_array($column)) { + // $columns is $this->requireAndResolveColumns() + list($target, $alias, $columnName) = $column; + $targetAlias = $this->getAlias($target); + + // Thanks to PHP 5.6 where `list` is evaluated from right to left. It will extract + // the values for `$target` and `$alias` then from the third argument (`$column`). + $column = $columnName; + } elseif ($target === null) { + throw new InvalidArgumentException( + 'Passing no model is only possible if $columns is a generator' + ); + } + + if (is_int($alias)) { + if ($column instanceof AliasedExpression) { + $alias = $column->getAlias(); + } elseif ($autoAlias && ! $column instanceof ExpressionInterface) { + $alias = $this->qualifyColumnAlias($column, $targetAlias); + } + } elseif ($target !== $this->query->getModel()) { + if (strpos($alias, '.') !== false) { + // This is safe, because custom aliases won't be qualified + $alias = str_replace('.', '_', $alias); + } else { + $alias = $this->qualifyColumnAlias($alias, $targetAlias); + } + } + + if ($column instanceof ResolvedExpression) { + $column->setColumns($this->qualifyColumns($column->getResolvedColumns())); + } elseif ($column instanceof ExpressionInterface) { + $column = clone $column; // The expression may be part of a model and those shouldn't change implicitly + $column->setColumns($this->qualifyColumns($column->getColumns(), $target)); + } else { + $column = $this->qualifyColumn($column, $targetAlias); + } + + $qualified[$alias] = $column; + } + + return $qualified; + } + + /** + * Qualify the given path by the specified table name + * + * @param string $path + * @param string $tableName + * + * @return string + */ + public function qualifyPath($path, $tableName) + { + $segments = explode('.', $path, 2); + + if ($segments[0] !== $tableName) { + array_unshift($segments, $tableName); + } + + $path = implode('.', $segments); + + return $path; + } + + /** + * Get whether the given relation path points to a distinct entity + * + * @param string $path + * + * @return bool + */ + public function isDistinctRelation($path) + { + foreach ($this->resolveRelations($path) as $relation) { + if (! $relation->isOne()) { + return false; + } + } + + return true; + } + + /** + * Resolve the rightmost relation of the given path + * + * Also resolves all other relations. + * + * @param string $path + * @param Model $subject + * + * @return Relation + */ + public function resolveRelation($path, Model $subject = null) + { + $subject = $subject ?: $this->query->getModel(); + if (! $this->resolvedRelations->contains($subject) || ! isset($this->resolvedRelations[$subject][$path])) { + foreach ($this->resolveRelations($path, $subject) as $_) { + // run and exhaust generator + } + } + + return $this->resolvedRelations[$subject][$path]; + } + + /** + * Resolve all relations of the given path + * + * Traverses the entire path and yields the path travelled so far as key and the relation as value. + * + * @param string $path + * @param Model $subject + * + * @return Generator + * @throws InvalidArgumentException In case $path is not fully qualified + * @throws InvalidRelationException In case a relation is unknown + */ + public function resolveRelations($path, Model $subject = null) + { + $relations = explode('.', $path); + $subject = $subject ?: $this->query->getModel(); + + if ($relations[0] !== $subject->getTableName()) { + throw new InvalidArgumentException(sprintf( + 'Cannot resolve relation path "%s". Base table name is missing.', + $path + )); + } + + $resolvedRelations = []; + if ($this->resolvedRelations->contains($subject)) { + $resolvedRelations = $this->resolvedRelations[$subject]; + } + + $target = $subject; + $pathBeingResolved = null; + $segments = [array_shift($relations)]; + while (! empty($relations)) { + $newPath = $this->getBehaviors($target) + ->rewritePath(join('.', $relations), join('.', $segments)); + if ($newPath !== null) { + $relations = explode('.', $newPath); + $pathBeingResolved = $path; + } + + $relationName = array_shift($relations); + $segments[] = $relationName; + $relationPath = join('.', $segments); + + if (isset($resolvedRelations[$relationPath])) { + $relation = $resolvedRelations[$relationPath]; + } else { + $targetRelations = $this->getRelations($target); + if (! $targetRelations->has($relationName)) { + throw new InvalidRelationException($relationName, $target); + } + + $relation = $targetRelations->get($relationName); + $relation->setSource($target); + + $resolvedRelations[$relationPath] = $relation; + + if ($relation instanceof BelongsToMany) { + $through = $relation->getThrough(); + $this->setAlias($through, join('_', array_merge( + array_slice($segments, 0, -1), + [$through->getTableName()] + ))); + } + + $this->setAlias($relation->getTarget(), join('_', $segments)); + } + + yield $relationPath => $relation; + + $target = $relation->getTarget(); + } + + if ($pathBeingResolved !== null) { + $resolvedRelations[$pathBeingResolved] = $relation; + } + + $this->resolvedRelations->attach($subject, $resolvedRelations); + } + + /** + * Require and resolve columns + * + * Related models will be automatically added for eager-loading. + * + * @param array $columns + * @param Model $model + * + * @return Generator + * + * @throws InvalidColumnException If a column does not exist + */ + public function requireAndResolveColumns(array $columns, Model $model = null) + { + $model = $model ?: $this->query->getModel(); + $tableName = $model->getTableName(); + + foreach ($columns as $alias => $column) { + $columnPath = &$column; + if ($column instanceof ExpressionInterface) { + $column = new ResolvedExpression( + $column, + $this->requireAndResolveColumns($column->getColumns(), $model) + ); + + if (is_int($alias)) { + // Scalar queries and such + yield [$model, $alias, $column]; + + continue; + } + + $columnPath = &$alias; + } elseif ($column === '*') { + yield [$model, $alias, $column]; + + continue; + } + + $dot = strrpos($columnPath, '.'); + + switch (true) { + /** @noinspection PhpMissingBreakStatementInspection */ + case $dot !== false: + $hydrationPath = substr($columnPath, 0, $dot); + $columnPath = substr($columnPath, $dot + 1); // Updates also $column or $alias + + if ($hydrationPath !== $tableName) { + $hydrationPath = $this->qualifyPath($hydrationPath, $tableName); + + $relations = new AppendIterator(); + $relations->append(new ArrayIterator([$tableName => null])); + $relations->append($this->resolveRelations($hydrationPath)); + foreach ($relations as $relationPath => $relation) { + if ($column instanceof ExpressionInterface) { + continue; + } + + if ($relationPath === $tableName) { + $subject = $model; + } else { + /** @var Relation $relation */ + $subject = $relation->getTarget(); + } + + $columnName = $columnPath; + if ($relationPath !== $hydrationPath) { + // It's still an intermediate relation, not the target + $columnName = substr($hydrationPath, strlen($relationPath) + 1) . ".$columnName"; + } + + $newColumn = $this->getBehaviors($subject)->rewriteColumn($columnName, $relationPath); + if ($newColumn !== null) { + if ($newColumn instanceof ExpressionInterface) { + $column = $newColumn; + $target = $subject; + break 2; // Expressions don't need to be *withed* and get no automatic alias either + } + + $column = $newColumn; + break; + } + } + + if (is_int($alias) && $relationPath !== $hydrationPath) { + // If the actual relation is resolved differently, + // ensure the hydration path is not an unexpected one + $alias = "$hydrationPath.$column"; + } + + $this->query->with($hydrationPath); + $target = $relation->getTarget(); + + break; + } + // Move to default + default: + $relationPath = null; + $target = $model; + + if (! $column instanceof ExpressionInterface) { + $column = $this->getBehaviors($target)->rewriteColumn($column) ?: $column; + } + } + + if (! $column instanceof ExpressionInterface) { + $targetColumns = $target->getColumns(); + if (isset($targetColumns[$column])) { + // $column is actually an alias + $alias = is_string($alias) ? $alias : ($relationPath ? "$hydrationPath.$column" : $column); + $column = $targetColumns[$column]; + + if ($column instanceof ExpressionInterface) { + $column = new ResolvedExpression( + $column, + $this->requireAndResolveColumns($column->getColumns(), $target) + ); + } + } + } + + if (! $column instanceof ExpressionInterface && ! $this->hasSelectableColumn($target, $columnPath)) { + throw new InvalidColumnException($columnPath, $target); + } + + yield [$target, $alias, $column]; + } + } + + /** + * Collect all selectable columns from the given model + * + * @param Model $subject + */ + protected function collectColumns(Model $subject) + { + // Don't fail if Model::getColumns() also contains the primary key columns + $columns = array_merge((array) $subject->getKeyName(), (array) $subject->getColumns()); + + $this->selectColumns->attach($subject, $columns); + + $selectable = []; + + foreach ($columns as $alias => $column) { + if (is_string($alias)) { + $selectable[$alias] = true; + } + + if (is_string($column)) { + $selectable[$column] = true; + } + } + + $this->selectableColumns->attach($subject, $selectable); + } + + /** + * Collect all meta data from the given model and its direct relations + * + * @param Model $subject + * + * @return array + */ + protected function collectMetaData(Model $subject) + { + $definitions = []; + foreach ($subject->getColumnDefinitions() as $name => $data) { + if ($data instanceof ColumnDefinition) { + $definition = $data; + } else { + if (is_string($data)) { + $data = ['name' => $name, 'label' => $data]; + } elseif (! isset($data[$name])) { + $data['name'] = $name; + } + + $definition = ColumnDefinition::fromArray($data); + } + + if (is_string($name) && $definition->getName() !== $name) { + throw new LogicException(sprintf( + 'Model %s provides a column definition with a different name (%s) than the index (%s)', + get_class($subject), + $definition->getName(), + $name + )); + } + + $definitions[$name] = $definition; + } + + return $definitions; + } +} |