summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/orm/src/Query.php
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/ipl/orm/src/Query.php')
-rw-r--r--vendor/ipl/orm/src/Query.php846
1 files changed, 846 insertions, 0 deletions
diff --git a/vendor/ipl/orm/src/Query.php b/vendor/ipl/orm/src/Query.php
new file mode 100644
index 0000000..0e19dd1
--- /dev/null
+++ b/vendor/ipl/orm/src/Query.php
@@ -0,0 +1,846 @@
+<?php
+
+namespace ipl\Orm;
+
+use ArrayObject;
+use Generator;
+use InvalidArgumentException;
+use ipl\Orm\Common\SortUtil;
+use ipl\Orm\Compat\FilterProcessor;
+use ipl\Sql\Connection;
+use ipl\Sql\ExpressionInterface;
+use ipl\Sql\LimitOffset;
+use ipl\Sql\LimitOffsetInterface;
+use ipl\Sql\OrderBy;
+use ipl\Sql\OrderByInterface;
+use ipl\Sql\Select;
+use ipl\Stdlib\Contract\Filterable;
+use ipl\Stdlib\Contract\Paginatable;
+use ipl\Stdlib\Events;
+use ipl\Stdlib\Filter;
+use ipl\Stdlib\Filters;
+use IteratorAggregate;
+use ReflectionClass;
+use SplObjectStorage;
+use Traversable;
+
+/**
+ * Represents a database query which is associated to a model and a database connection.
+ */
+class Query implements Filterable, LimitOffsetInterface, OrderByInterface, Paginatable, IteratorAggregate
+{
+ use Events;
+ use Filters;
+ use LimitOffset;
+ use OrderBy;
+
+ /**
+ * Event raised after assembling a {@link Select} object for the query
+ *
+ * **Example usage:**
+ *
+ * ```
+ * $query->on(Query::ON_SELECT_ASSEMBLED, function (Select $select) {
+ * // ...
+ * });
+ * ```
+ */
+ const ON_SELECT_ASSEMBLED = 'selectAssembled';
+
+ /** @var int Count cache */
+ protected $count;
+
+ /** @var Connection Database connection */
+ protected $db;
+
+ /** @var string Class to return results as */
+ protected $resultSetClass = ResultSet::class;
+
+ /** @var Model Model to query */
+ protected $model;
+
+ /** @var array Columns to select from the model (or its relations). If empty, all columns are selected */
+ protected $columns = [];
+
+ /** @var array Additional columns to select from the model (or its relations) */
+ protected $withColumns = [];
+
+ /** @var array Columns not to select from the model (or its relations) */
+ protected $withoutColumns = [];
+
+ /** @var bool Whether to peek ahead for more results */
+ protected $peekAhead = false;
+
+ /** @var Resolver Column and relation resolver */
+ protected $resolver;
+
+ /** @var Select Base SELECT query */
+ protected $selectBase;
+
+ /** @var Relation[] Relations to eager load */
+ protected $with = [];
+
+ /** @var Relation[] Relations to utilize (join) */
+ protected $utilize = [];
+
+ /** @var bool Whether to disable the default sorts of the model */
+ protected $disableDefaultSort = false;
+
+ /**
+ * Get the database connection
+ *
+ * @return Connection
+ */
+ public function getDb()
+ {
+ return $this->db;
+ }
+
+ /**
+ * Set the database connection
+ *
+ * @param Connection $db
+ *
+ * @return $this
+ */
+ public function setDb(Connection $db)
+ {
+ $this->db = $db;
+
+ return $this;
+ }
+
+ /**
+ * Get the class to return results as
+ *
+ * @return string
+ */
+ public function getResultSetClass()
+ {
+ return $this->resultSetClass;
+ }
+
+ /**
+ * Set the class to return results as
+ *
+ * @param string $class
+ *
+ * @return $this
+ *
+ * @throws InvalidArgumentException If class is not an instance of {@link ResultSet}
+ */
+ public function setResultSetClass($class)
+ {
+ if (! is_string($class)) {
+ throw new InvalidArgumentException('Argument $class must be a string');
+ }
+
+ if (! (new ReflectionClass($class))->newInstanceWithoutConstructor() instanceof ResultSet) {
+ throw new InvalidArgumentException(
+ $class . ' must be an instance of ' . ResultSet::class
+ );
+ }
+
+ $this->resultSetClass = $class;
+
+ return $this;
+ }
+
+ /**
+ * Get the model to query
+ *
+ * @return Model
+ */
+ public function getModel()
+ {
+ return $this->model;
+ }
+
+ /**
+ * Set the model to query
+ *
+ * @param $model
+ *
+ * @return $this
+ */
+ public function setModel(Model $model)
+ {
+ $this->model = $model;
+ $this->getResolver()->setAlias($model, $model->getTableAlias());
+
+ return $this;
+ }
+
+ /**
+ * Get the columns to select from the model
+ *
+ * @return array
+ */
+ public function getColumns()
+ {
+ return $this->columns;
+ }
+
+ /**
+ * Set the filter of the query
+ *
+ * @param Filter\Chain $filter
+ *
+ * @return $this
+ */
+ public function setFilter(Filter\Chain $filter)
+ {
+ $this->filter = $filter;
+
+ return $this;
+ }
+
+ /**
+ * Disable default sorts
+ *
+ * Prevents the default sort rules of the source model from being used
+ *
+ * @param bool $disable
+ *
+ * @return $this
+ */
+ public function disableDefaultSort($disable = true)
+ {
+ $this->disableDefaultSort = (bool) $disable;
+
+ return $this;
+ }
+
+ /**
+ * Get whether to not use the default sort rules of the source model
+ *
+ * @return bool
+ */
+ public function defaultSortDisabled()
+ {
+ return $this->disableDefaultSort;
+ }
+
+
+ /**
+ * Set columns to select from the model (or its relations)
+ *
+ * By default, i.e. if you do not specify any columns, all columns of the model and
+ * any relation added via {@see with()} will be selected.
+ * Multiple calls to this method will overwrite the previously specified columns.
+ * If you specify columns from the model's relations, the relations are automatically joined upon querying.
+ * Any of the given columns is guaranteed to be selected even if previously excluded via {@see withoutColumns()}.
+ * Any previously column specified via {@see withColumns()} will not be selected if not part of the given columns.
+ *
+ * @param string|array $columns The column(s) to select
+ *
+ * @return $this
+ */
+ public function columns($columns)
+ {
+ $this->columns = (array) $columns;
+ $this->withColumns = [];
+ $this->withoutColumns = [];
+
+ return $this;
+ }
+
+ /**
+ * Set additional columns to select from the model (or its relations)
+ *
+ * Multiple calls to this method will not overwrite the previous set columns but append the columns to the query.
+ * Any of the given columns is guaranteed to be selected even if previously excluded via {@see withoutColumns()}.
+ *
+ * @param string|array $columns The column(s) to select
+ *
+ * @return $this
+ */
+ public function withColumns($columns)
+ {
+ $tableName = $this->getModel()->getTableAlias();
+
+ $qualifiedColumns = [];
+ foreach ((array) $columns as $alias => $column) {
+ if (! $column instanceof ExpressionInterface) {
+ $qualifiedColumns[$alias] = $this->getResolver()->qualifyPath($column, $tableName);
+ } else {
+ $qualifiedColumns[$alias] = $column;
+ }
+ }
+
+ $this->withColumns = array_merge($this->withColumns, $qualifiedColumns);
+ $this->withoutColumns = array_diff($this->withoutColumns, array_filter($this->withColumns, 'is_string'));
+
+ return $this;
+ }
+
+ /**
+ * Set columns not to select from the model (or its relations)
+ *
+ * Multiple calls to this method will not overwrite the previous set columns but append the new ones to the set.
+ *
+ * @param string|string[] $columns The column(s) not to select
+ *
+ * @return $this
+ */
+ public function withoutColumns($columns): self
+ {
+ $tableName = $this->getModel()->getTableAlias();
+
+ $qualifiedColumns = [];
+ foreach ((array) $columns as $column) {
+ if (is_string($column)) {
+ $qualifiedColumns[] = $this->getResolver()->qualifyPath($column, $tableName);
+ }
+ }
+
+ $this->withoutColumns = array_merge($this->withoutColumns, $qualifiedColumns);
+
+ return $this;
+ }
+
+ /**
+ * Get the query's resolver
+ *
+ * @return Resolver
+ */
+ public function getResolver()
+ {
+ if ($this->resolver === null) {
+ $this->resolver = new Resolver($this);
+ }
+
+ return $this->resolver;
+ }
+
+ /**
+ * Get the SELECT base query
+ *
+ * @return Select
+ */
+ public function getSelectBase()
+ {
+ if ($this->selectBase === null) {
+ $this->selectBase = new Select();
+
+ $this->selectBase->from([
+ $this->getResolver()->getAlias($this->getModel()) => $this->getModel()->getTableName()
+ ]);
+ }
+
+ return $this->selectBase;
+ }
+
+ /**
+ * Get the relations to eager load
+ *
+ * @return Relation[]
+ */
+ public function getWith()
+ {
+ return $this->with;
+ }
+
+ /**
+ * Add a relation to eager load
+ *
+ * @param string|array $relations
+ *
+ * @return $this
+ */
+ public function with($relations)
+ {
+ $tableName = $this->getModel()->getTableAlias();
+ foreach ((array) $relations as $relation) {
+ $relation = $this->getResolver()->qualifyPath($relation, $tableName);
+ $this->with[$relation] = $this->getResolver()->resolveRelation($relation);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Remove an eager loaded relation
+ *
+ * @param string|array $relations
+ *
+ * @return $this
+ */
+ public function without($relations)
+ {
+ $tableName = $this->getModel()->getTableAlias();
+ foreach ((array) $relations as $relation) {
+ $relation = $this->getResolver()->qualifyPath($relation, $tableName);
+ unset($this->with[$relation]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get utilized (joined) relations
+ *
+ * @return Relation[]
+ */
+ public function getUtilize()
+ {
+ return $this->utilize;
+ }
+
+ /**
+ * Add a relation to utilize (join)
+ *
+ * @param string $path
+ *
+ * @return $this
+ */
+ public function utilize($path)
+ {
+ $path = $this->getResolver()->qualifyPath($path, $this->getModel()->getTableAlias());
+ $this->utilize[$path] = $this->getResolver()->resolveRelation($path);
+
+ return $this;
+ }
+
+ /**
+ * Remove a utilized (joined) relation
+ *
+ * @param string $path
+ *
+ * @return $this
+ */
+ public function omit($path)
+ {
+ $path = $this->getResolver()->qualifyPath($path, $this->getModel()->getTableAlias());
+ unset($this->utilize[$path]);
+
+ return $this;
+ }
+
+ /**
+ * Assemble and return the SELECT query
+ *
+ * @return Select
+ */
+ public function assembleSelect()
+ {
+ $columns = $this->getColumns();
+ $model = $this->getModel();
+ $resolver = $this->getResolver();
+ $select = clone $this->getSelectBase();
+
+ if (empty($columns)) {
+ $columns = $resolver->getSelectColumns($model);
+
+ foreach ($this->getWith() as $path => $relation) {
+ foreach ($resolver->getSelectColumns($relation->getTarget()) as $alias => $column) {
+ if (is_int($alias)) {
+ $columns[] = "$path.$column";
+ } else {
+ $columns[] = "$path.$alias";
+ }
+ }
+ }
+
+ $columns = array_merge($columns, $this->withColumns);
+ $customAliases = array_flip(array_filter(array_keys($this->withColumns), 'is_string'));
+ } else {
+ $columns = array_merge($columns, $this->withColumns);
+ $customAliases = array_flip(array_filter(array_keys($columns), 'is_string'));
+ }
+
+ $resolved = $this->groupColumnsByTarget($resolver->requireAndResolveColumns($columns));
+ $omitted = $this->groupColumnsByTarget($resolver->requireAndResolveColumns($this->withoutColumns));
+ foreach ($resolved as $target) {
+ $targetColumns = $resolved[$target]->getArrayCopy();
+ if (isset($omitted[$target])) {
+ $targetColumns = array_diff($targetColumns, $omitted[$target]->getArrayCopy());
+ }
+
+ if (! empty($customAliases)) {
+ $customColumns = array_intersect_key($targetColumns, $customAliases);
+ $targetColumns = array_diff_key($targetColumns, $customAliases);
+
+ $select->columns($resolver->qualifyColumns($customColumns, $target));
+ }
+
+ $select->columns(
+ $resolver->qualifyColumnsAndAliases(
+ $targetColumns,
+ $target,
+ $target !== $model
+ )
+ );
+ }
+
+ $filter = clone $this->getFilter();
+ FilterProcessor::resolveFilter($filter, $this);
+ $where = FilterProcessor::assembleFilter($filter);
+ if ($where) {
+ $select->where(...array_reverse($where));
+ }
+
+ $joinedRelations = [];
+ foreach ($this->getWith() + $this->getUtilize() as $path => $_) {
+ foreach ($resolver->resolveRelations($path) as $relationPath => $relation) {
+ if (isset($joinedRelations[$relationPath])) {
+ continue;
+ }
+
+ foreach ($relation->resolve() as list($source, $target, $relatedKeys)) {
+ /** @var Model $source */
+ /** @var Model $target */
+
+ $sourceAlias = $resolver->getAlias($source);
+ $targetAlias = $resolver->getAlias($target);
+
+ $conditions = [];
+ foreach ($relatedKeys as $fk => $ck) {
+ $conditions[] = sprintf(
+ '%s = %s',
+ $resolver->qualifyColumn($fk, $targetAlias),
+ $resolver->qualifyColumn($ck, $sourceAlias)
+ );
+ }
+
+ $table = [$targetAlias => $target->getTableName()];
+
+ switch ($relation->getJoinType()) {
+ case 'LEFT':
+ $select->joinLeft($table, $conditions);
+
+ break;
+ case 'RIGHT':
+ $select->joinRight($table, $conditions);
+
+ break;
+ case 'INNER':
+ default:
+ $select->join($table, $conditions);
+ }
+ }
+
+ $joinedRelations[$relationPath] = true;
+ }
+ }
+
+ if ($this->hasLimit()) {
+ $limit = $this->getLimit();
+
+ if ($this->peekAhead) {
+ ++$limit;
+ }
+
+ $select->limit($limit);
+ }
+ if ($this->hasOffset()) {
+ $select->offset($this->getOffset());
+ }
+
+ $this->order($select);
+
+ $this->emit(static::ON_SELECT_ASSEMBLED, [$select]);
+
+ return $select;
+ }
+
+ /**
+ * Create and return the hydrator
+ *
+ * @return Hydrator
+ */
+ public function createHydrator()
+ {
+ $hydrator = new Hydrator($this);
+
+ $hydrator->add($this->getModel()->getTableAlias());
+ foreach ($this->getWith() as $path => $_) {
+ $hydrator->add($path);
+ }
+
+ return $hydrator;
+ }
+
+ /**
+ * Derive a new query to load the specified relation from a concrete model
+ *
+ * @param string $relation
+ * @param Model $source
+ *
+ * @return static
+ *
+ * @throws InvalidArgumentException If the relation with the given name does not exist
+ */
+ public function derive($relation, Model $source)
+ {
+ // TODO: Think of a way to merge derive() and createSubQuery()
+ return $this->createSubQuery(
+ $this->getResolver()->getRelations($source)->get($relation)->getTarget(),
+ $this->getResolver()->qualifyPath($relation, $source->getTableAlias()),
+ $source
+ );
+ }
+
+ /**
+ * Create a sub-query linked to rows of this query
+ *
+ * @param Model $target The model to query
+ * @param string $targetPath The target's absolute relation path
+ * @param ?Model $from The source model
+ * @param bool $link Whether the query should be linked to the parent query
+ *
+ * @return static
+ */
+ public function createSubQuery(Model $target, $targetPath, Model $from = null, bool $link = true)
+ {
+ $subQuery = (new static())
+ ->setDb($this->getDb())
+ ->setModel($target);
+
+ $sourceParts = array_reverse(explode('.', $targetPath));
+ $sourceParts[0] = $target->getTableAlias();
+
+ $subQueryResolver = $subQuery->getResolver();
+ $sourcePath = join('.', $sourceParts);
+ $subQueryTarget = $subQueryResolver->resolveRelation($sourcePath)->getTarget();
+
+ $subQuery->utilize($sourcePath); // TODO: Don't join if there's a matching foreign key
+
+ if (! $link) {
+ return $subQuery->columns(array_map(function ($keyName) use ($sourcePath) {
+ return "$sourcePath.$keyName";
+ }, (array) $subQueryTarget->getKeyName()));
+ }
+
+ // TODO: Should be done by the caller. Though, that's not possible until we've got a filter abstraction
+ // which allows to post-pone filter column qualification.
+ $subQueryResolver->setAliasPrefix('sub_');
+
+ $resolver = $this->getResolver();
+ $baseAlias = $resolver->getAlias($this->getModel());
+ $sourceAlias = $subQueryResolver->getAlias($subQueryTarget);
+
+ $subQueryConditions = [];
+ foreach ((array) $this->getModel()->getKeyName() as $column) {
+ $fk = $subQueryResolver->qualifyColumn($column, $sourceAlias);
+
+ if (isset($from->$column)) {
+ $subQueryConditions["$fk = ?"] = $resolver
+ ->getBehaviors($from)
+ ->persistProperty($from->$column, $column);
+ } else {
+ $subQueryConditions[] = "$fk = " . $resolver->qualifyColumn($column, $baseAlias);
+ }
+ }
+
+ $subQuery->getSelectBase()->where($subQueryConditions);
+
+ return $subQuery;
+ }
+
+ /**
+ * Dump the query
+ *
+ * @return array
+ */
+ public function dump()
+ {
+ return $this->getDb()->getQueryBuilder()->assembleSelect($this->assembleSelect());
+ }
+
+ /**
+ * Execute the query
+ *
+ * @return ResultSet
+ */
+ public function execute()
+ {
+ $class = $this->getResultSetClass();
+ /** @var ResultSet $class Just for type hinting. $class is of course a string */
+
+ return $class::fromQuery($this);
+ }
+
+ /**
+ * Fetch and return the first result
+ *
+ * @return Model|null Null in case there's no result
+ */
+ public function first()
+ {
+ return $this->execute()->current();
+ }
+
+ /**
+ * Set whether to peek ahead for more results
+ *
+ * Enabling this causes the current query limit to be increased by one. The potential extra row being yielded will
+ * be removed from the result set. Note that this only applies when fetching multiple results of limited queries.
+ *
+ * @param bool $peekAhead
+ *
+ * @return $this
+ */
+ public function peekAhead($peekAhead = true)
+ {
+ $this->peekAhead = (bool) $peekAhead;
+
+ return $this;
+ }
+
+ /**
+ * Yield the query's results
+ *
+ * @return \Generator
+ */
+ public function yieldResults()
+ {
+ $select = $this->assembleSelect();
+ $stmt = $this->getDb()->select($select);
+ $stmt->setFetchMode(\PDO::FETCH_ASSOC);
+
+ $hydrator = $this->createHydrator();
+ $modelClass = get_class($this->getModel());
+
+ foreach ($stmt as $row) {
+ yield $hydrator->hydrate($row, new $modelClass());
+ }
+ }
+
+ public function count(): int
+ {
+ if ($this->count === null) {
+ $this->count = $this->getDb()->select($this->assembleSelect()->getCountQuery())->fetchColumn(0);
+ }
+
+ return $this->count;
+ }
+
+ public function getIterator(): Traversable
+ {
+ return $this->execute();
+ }
+
+ /**
+ * Group columns from {@link Resolver::requireAndResolveColumns()} by target models
+ *
+ * @param Generator $columns
+ *
+ * @return SplObjectStorage
+ */
+ protected function groupColumnsByTarget(Generator $columns)
+ {
+ $columnStorage = new SplObjectStorage();
+
+ foreach ($columns as list($target, $alias, $column)) {
+ if (! $columnStorage->contains($target)) {
+ $resolved = new ArrayObject();
+ $columnStorage->attach($target, $resolved);
+ } else {
+ $resolved = $columnStorage[$target];
+ }
+
+ if (is_int($alias)) {
+ $resolved[] = $column;
+ } else {
+ $resolved[$alias] = $column;
+ }
+ }
+
+ return $columnStorage;
+ }
+
+ /**
+ * Resolve, require and apply ORDER BY columns
+ *
+ * @param Select $select
+ *
+ * @return $this
+ */
+ protected function order(Select $select)
+ {
+ $orderBy = $this->getOrderBy();
+ $defaultSort = [];
+ if (! $this->defaultSortDisabled()) {
+ $defaultSort = (array) $this->getModel()->getDefaultSort();
+ }
+
+ if (empty($orderBy)) {
+ if (empty($defaultSort)) {
+ return $this;
+ }
+
+ $orderBy = SortUtil::createOrderBy($defaultSort);
+ }
+
+ $columns = [];
+ $directions = [];
+ $orderByResolved = [];
+ $resolver = $this->getResolver();
+ $selectedColumns = $select->getColumns();
+
+ foreach ($orderBy as $part) {
+ list($column, $direction) = $part;
+
+ if (! $column instanceof ExpressionInterface && isset($selectedColumns[$column])) {
+ // If it's a custom alias, we have no other way of knowing it,
+ // unless the caller explicitly uses it in the sort rule.
+ $orderByResolved[] = $part;
+ } else {
+ // Prepare flat ORDER BY column(s) and direction(s) for requireAndResolveColumns()
+ $columns[] = $column;
+ $directions[] = $direction;
+ }
+ }
+
+ foreach ($resolver->requireAndResolveColumns($columns) as list($model, $alias, $column)) {
+ $direction = array_shift($directions);
+ $selectColumns = $resolver->getSelectColumns($model);
+ $tableName = $resolver->getAlias($model);
+
+ if ($column instanceof ExpressionInterface) {
+ if (is_int($alias) && $column instanceof AliasedExpression) {
+ $alias = $column->getAlias();
+ } elseif (is_string($alias) && $model !== $this->getModel()) {
+ $alias = $resolver->qualifyColumnAlias($alias, $tableName);
+ } elseif ($column instanceof ResolvedExpression) {
+ // We are doing this in an else if, since a resolved expression can't be an aliased
+ // expression at the same time and thus doesn't influence the functionality in any way.
+ $column->setColumns($resolver->qualifyColumns($column->getResolvedColumns()));
+ }
+
+ if (is_string($alias) && isset($selectedColumns[$alias])) {
+ // An expression's alias can only be used if the expression is also selected
+ $column = $alias;
+ }
+ } else {
+ if (isset($selectColumns[$column])) {
+ $column = $selectColumns[$column];
+ }
+
+ if (is_string($column)) {
+ $column = $resolver->qualifyColumn($column, $tableName);
+ }
+ }
+
+ $orderByResolved[] = [$column, $direction];
+ }
+
+ $select->orderBy($orderByResolved);
+
+ return $this;
+ }
+
+ public function __clone()
+ {
+ $this->resolver = clone $this->resolver;
+
+ if ($this->filter !== null) {
+ $this->filter = clone $this->filter;
+ }
+
+ if ($this->selectBase !== null) {
+ $this->selectBase = clone $this->selectBase;
+ }
+ }
+}