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; } } }