Permalink
843 lines (771 sloc) 30.5 KB
<?php
/**
* @link http://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license http://www.yiiframework.com/license/
*/
namespace yii\db;
use yii\base\InvalidConfigException;
/**
* ActiveQuery represents a DB query associated with an Active Record class.
*
* An ActiveQuery can be a normal query or be used in a relational context.
*
* ActiveQuery instances are usually created by [[ActiveRecord::find()]] and [[ActiveRecord::findBySql()]].
* Relational queries are created by [[ActiveRecord::hasOne()]] and [[ActiveRecord::hasMany()]].
*
* Normal Query
* ------------
*
* ActiveQuery mainly provides the following methods to retrieve the query results:
*
* - [[one()]]: returns a single record populated with the first row of data.
* - [[all()]]: returns all records based on the query results.
* - [[count()]]: returns the number of records.
* - [[sum()]]: returns the sum over the specified column.
* - [[average()]]: returns the average over the specified column.
* - [[min()]]: returns the min over the specified column.
* - [[max()]]: returns the max over the specified column.
* - [[scalar()]]: returns the value of the first column in the first row of the query result.
* - [[column()]]: returns the value of the first column in the query result.
* - [[exists()]]: returns a value indicating whether the query result has data or not.
*
* Because ActiveQuery extends from [[Query]], one can use query methods, such as [[where()]],
* [[orderBy()]] to customize the query options.
*
* ActiveQuery also provides the following additional query options:
*
* - [[with()]]: list of relations that this query should be performed with.
* - [[joinWith()]]: reuse a relation query definition to add a join to a query.
* - [[indexBy()]]: the name of the column by which the query result should be indexed.
* - [[asArray()]]: whether to return each record as an array.
*
* These options can be configured using methods of the same name. For example:
*
* ```php
* $customers = Customer::find()->with('orders')->asArray()->all();
* ```
*
* Relational query
* ----------------
*
* In relational context ActiveQuery represents a relation between two Active Record classes.
*
* Relational ActiveQuery instances are usually created by calling [[ActiveRecord::hasOne()]] and
* [[ActiveRecord::hasMany()]]. An Active Record class declares a relation by defining
* a getter method which calls one of the above methods and returns the created ActiveQuery object.
*
* A relation is specified by [[link]] which represents the association between columns
* of different tables; and the multiplicity of the relation is indicated by [[multiple]].
*
* If a relation involves a junction table, it may be specified by [[via()]] or [[viaTable()]] method.
* These methods may only be called in a relational context. Same is true for [[inverseOf()]], which
* marks a relation as inverse of another relation and [[onCondition()]] which adds a condition that
* is to be added to relational query join condition.
*
* @author Qiang Xue <qiang.xue@gmail.com>
* @author Carsten Brandt <mail@cebe.cc>
* @since 2.0
*/
class ActiveQuery extends Query implements ActiveQueryInterface
{
use ActiveQueryTrait;
use ActiveRelationTrait;
/**
* @event Event an event that is triggered when the query is initialized via [[init()]].
*/
const EVENT_INIT = 'init';
/**
* @var string the SQL statement to be executed for retrieving AR records.
* This is set by [[ActiveRecord::findBySql()]].
*/
public $sql;
/**
* @var string|array the join condition to be used when this query is used in a relational context.
* The condition will be used in the ON part when [[ActiveQuery::joinWith()]] is called.
* Otherwise, the condition will be used in the WHERE part of a query.
* Please refer to [[Query::where()]] on how to specify this parameter.
* @see onCondition()
*/
public $on;
/**
* @var array a list of relations that this query should be joined with
*/
public $joinWith;
/**
* Constructor.
* @param string $modelClass the model class associated with this query
* @param array $config configurations to be applied to the newly created query object
*/
public function __construct($modelClass, $config = [])
{
$this->modelClass = $modelClass;
parent::__construct($config);
}
/**
* Initializes the object.
* This method is called at the end of the constructor. The default implementation will trigger
* an [[EVENT_INIT]] event. If you override this method, make sure you call the parent implementation at the end
* to ensure triggering of the event.
*/
public function init()
{
parent::init();
$this->trigger(self::EVENT_INIT);
}
/**
* Executes query and returns all results as an array.
* @param Connection $db the DB connection used to create the DB command.
* If null, the DB connection returned by [[modelClass]] will be used.
* @return array|ActiveRecord[] the query results. If the query results in nothing, an empty array will be returned.
*/
public function all($db = null)
{
return parent::all($db);
}
/**
* {@inheritdoc}
*/
public function prepare($builder)
{
// NOTE: because the same ActiveQuery may be used to build different SQL statements
// (e.g. by ActiveDataProvider, one for count query, the other for row data query,
// it is important to make sure the same ActiveQuery can be used to build SQL statements
// multiple times.
if (!empty($this->joinWith)) {
$this->buildJoinWith();
$this->joinWith = null; // clean it up to avoid issue https://github.com/yiisoft/yii2/issues/2687
}
if (empty($this->from)) {
$this->from = [$this->getPrimaryTableName()];
}
if (empty($this->select) && !empty($this->join)) {
list(, $alias) = $this->getTableNameAndAlias();
$this->select = ["$alias.*"];
}
if ($this->primaryModel === null) {
// eager loading
$query = Query::create($this);
} else {
// lazy loading of a relation
$where = $this->where;
if ($this->via instanceof self) {
// via junction table
$viaModels = $this->via->findJunctionRows([$this->primaryModel]);
$this->filterByModels($viaModels);
} elseif (is_array($this->via)) {
// via relation
/* @var $viaQuery ActiveQuery */
list($viaName, $viaQuery) = $this->via;
if ($viaQuery->multiple) {
if ($this->primaryModel->isRelationPopulated($viaName)) {
$viaModels = $this->primaryModel->$viaName;
} else {
$viaModels = $viaQuery->all();
$this->primaryModel->populateRelation($viaName, $viaModels);
}
} else {
if ($this->primaryModel->isRelationPopulated($viaName)) {
$model = $this->primaryModel->$viaName;
} else {
$model = $viaQuery->one();
$this->primaryModel->populateRelation($viaName, $model);
}
$viaModels = $model === null ? [] : [$model];
}
$this->filterByModels($viaModels);
} else {
$this->filterByModels([$this->primaryModel]);
}
$query = Query::create($this);
$this->where = $where;
}
if (!empty($this->on)) {
$query->andWhere($this->on);
}
return $query;
}
/**
* {@inheritdoc}
*/
public function populate($rows)
{
if (empty($rows)) {
return [];
}
$models = $this->createModels($rows);
if (!empty($this->join) && $this->indexBy === null) {
$models = $this->removeDuplicatedModels($models);
}
if (!empty($this->with)) {
$this->findWith($this->with, $models);
}
if ($this->inverseOf !== null) {
$this->addInverseRelations($models);
}
if (!$this->asArray) {
foreach ($models as $model) {
$model->afterFind();
}
}
return parent::populate($models);
}
/**
* Removes duplicated models by checking their primary key values.
* This method is mainly called when a join query is performed, which may cause duplicated rows being returned.
* @param array $models the models to be checked
* @throws InvalidConfigException if model primary key is empty
* @return array the distinctive models
*/
private function removeDuplicatedModels($models)
{
$hash = [];
/* @var $class ActiveRecord */
$class = $this->modelClass;
$pks = $class::primaryKey();
if (count($pks) > 1) {
// composite primary key
foreach ($models as $i => $model) {
$key = [];
foreach ($pks as $pk) {
if (!isset($model[$pk])) {
// do not continue if the primary key is not part of the result set
break 2;
}
$key[] = $model[$pk];
}
$key = serialize($key);
if (isset($hash[$key])) {
unset($models[$i]);
} else {
$hash[$key] = true;
}
}
} elseif (empty($pks)) {
throw new InvalidConfigException("Primary key of '{$class}' can not be empty.");
} else {
// single column primary key
$pk = reset($pks);
foreach ($models as $i => $model) {
if (!isset($model[$pk])) {
// do not continue if the primary key is not part of the result set
break;
}
$key = $model[$pk];
if (isset($hash[$key])) {
unset($models[$i]);
} elseif ($key !== null) {
$hash[$key] = true;
}
}
}
return array_values($models);
}
/**
* Executes query and returns a single row of result.
* @param Connection|null $db the DB connection used to create the DB command.
* If `null`, the DB connection returned by [[modelClass]] will be used.
* @return ActiveRecord|array|null a single row of query result. Depending on the setting of [[asArray]],
* the query result may be either an array or an ActiveRecord object. `null` will be returned
* if the query results in nothing.
*/
public function one($db = null)
{
$row = parent::one($db);
if ($row !== false) {
$models = $this->populate([$row]);
return reset($models) ?: null;
}
return null;
}
/**
* Creates a DB command that can be used to execute this query.
* @param Connection|null $db the DB connection used to create the DB command.
* If `null`, the DB connection returned by [[modelClass]] will be used.
* @return Command the created DB command instance.
*/
public function createCommand($db = null)
{
/* @var $modelClass ActiveRecord */
$modelClass = $this->modelClass;
if ($db === null) {
$db = $modelClass::getDb();
}
if ($this->sql === null) {
list($sql, $params) = $db->getQueryBuilder()->build($this);
} else {
$sql = $this->sql;
$params = $this->params;
}
$command = $db->createCommand($sql, $params);
$this->setCommandCache($command);
return $command;
}
/**
* {@inheritdoc}
*/
protected function queryScalar($selectExpression, $db)
{
/* @var $modelClass ActiveRecord */
$modelClass = $this->modelClass;
if ($db === null) {
$db = $modelClass::getDb();
}
if ($this->sql === null) {
return parent::queryScalar($selectExpression, $db);
}
$command = (new Query())->select([$selectExpression])
->from(['c' => "({$this->sql})"])
->params($this->params)
->createCommand($db);
$this->setCommandCache($command);
return $command->queryScalar();
}
/**
* Joins with the specified relations.
*
* This method allows you to reuse existing relation definitions to perform JOIN queries.
* Based on the definition of the specified relation(s), the method will append one or multiple
* JOIN statements to the current query.
*
* If the `$eagerLoading` parameter is true, the method will also perform eager loading for the specified relations,
* which is equivalent to calling [[with()]] using the specified relations.
*
* Note that because a JOIN query will be performed, you are responsible to disambiguate column names.
*
* This method differs from [[with()]] in that it will build up and execute a JOIN SQL statement
* for the primary table. And when `$eagerLoading` is true, it will call [[with()]] in addition with the specified relations.
*
* @param string|array $with the relations to be joined. This can either be a string, representing a relation name or
* an array with the following semantics:
*
* - Each array element represents a single relation.
* - You may specify the relation name as the array key and provide an anonymous functions that
* can be used to modify the relation queries on-the-fly as the array value.
* - If a relation query does not need modification, you may use the relation name as the array value.
*
* The relation name may optionally contain an alias for the relation table (e.g. `books b`).
*
* Sub-relations can also be specified, see [[with()]] for the syntax.
*
* In the following you find some examples:
*
* ```php
* // find all orders that contain books, and eager loading "books"
* Order::find()->joinWith('books', true, 'INNER JOIN')->all();
* // find all orders, eager loading "books", and sort the orders and books by the book names.
* Order::find()->joinWith([
* 'books' => function (\yii\db\ActiveQuery $query) {
* $query->orderBy('item.name');
* }
* ])->all();
* // find all orders that contain books of the category 'Science fiction', using the alias "b" for the books table
* Order::find()->joinWith(['books b'], true, 'INNER JOIN')->where(['b.category' => 'Science fiction'])->all();
* ```
*
* The alias syntax is available since version 2.0.7.
*
* @param bool|array $eagerLoading whether to eager load the relations
* specified in `$with`. When this is a boolean, it applies to all
* relations specified in `$with`. Use an array to explicitly list which
* relations in `$with` need to be eagerly loaded. Note, that this does
* not mean, that the relations are populated from the query result. An
* extra query will still be performed to bring in the related data.
* Defaults to `true`.
* @param string|array $joinType the join type of the relations specified in `$with`.
* When this is a string, it applies to all relations specified in `$with`. Use an array
* in the format of `relationName => joinType` to specify different join types for different relations.
* @return $this the query object itself
*/
public function joinWith($with, $eagerLoading = true, $joinType = 'LEFT JOIN')
{
$relations = [];
foreach ((array) $with as $name => $callback) {
if (is_int($name)) {
$name = $callback;
$callback = null;
}
if (preg_match('/^(.*?)(?:\s+AS\s+|\s+)(\w+)$/i', $name, $matches)) {
// relation is defined with an alias, adjust callback to apply alias
list(, $relation, $alias) = $matches;
$name = $relation;
$callback = function ($query) use ($callback, $alias) {
/* @var $query ActiveQuery */
$query->alias($alias);
if ($callback !== null) {
call_user_func($callback, $query);
}
};
}
if ($callback === null) {
$relations[] = $name;
} else {
$relations[$name] = $callback;
}
}
$this->joinWith[] = [$relations, $eagerLoading, $joinType];
return $this;
}
private function buildJoinWith()
{
$join = $this->join;
$this->join = [];
/* @var $modelClass ActiveRecordInterface */
$modelClass = $this->modelClass;
$model = $modelClass::instance();
foreach ($this->joinWith as $config) {
list($with, $eagerLoading, $joinType) = $config;
$this->joinWithRelations($model, $with, $joinType);
if (is_array($eagerLoading)) {
foreach ($with as $name => $callback) {
if (is_int($name)) {
if (!in_array($callback, $eagerLoading, true)) {
unset($with[$name]);
}
} elseif (!in_array($name, $eagerLoading, true)) {
unset($with[$name]);
}
}
} elseif (!$eagerLoading) {
$with = [];
}
$this->with($with);
}
// remove duplicated joins added by joinWithRelations that may be added
// e.g. when joining a relation and a via relation at the same time
$uniqueJoins = [];
foreach ($this->join as $j) {
$uniqueJoins[serialize($j)] = $j;
}
$this->join = array_values($uniqueJoins);
if (!empty($join)) {
// append explicit join to joinWith()
// https://github.com/yiisoft/yii2/issues/2880
$this->join = empty($this->join) ? $join : array_merge($this->join, $join);
}
}
/**
* Inner joins with the specified relations.
* This is a shortcut method to [[joinWith()]] with the join type set as "INNER JOIN".
* Please refer to [[joinWith()]] for detailed usage of this method.
* @param string|array $with the relations to be joined with.
* @param bool|array $eagerLoading whether to eager load the relations.
* Note, that this does not mean, that the relations are populated from the
* query result. An extra query will still be performed to bring in the
* related data.
* @return $this the query object itself
* @see joinWith()
*/
public function innerJoinWith($with, $eagerLoading = true)
{
return $this->joinWith($with, $eagerLoading, 'INNER JOIN');
}
/**
* Modifies the current query by adding join fragments based on the given relations.
* @param ActiveRecord $model the primary model
* @param array $with the relations to be joined
* @param string|array $joinType the join type
*/
private function joinWithRelations($model, $with, $joinType)
{
$relations = [];
foreach ($with as $name => $callback) {
if (is_int($name)) {
$name = $callback;
$callback = null;
}
$primaryModel = $model;
$parent = $this;
$prefix = '';
while (($pos = strpos($name, '.')) !== false) {
$childName = substr($name, $pos + 1);
$name = substr($name, 0, $pos);
$fullName = $prefix === '' ? $name : "$prefix.$name";
if (!isset($relations[$fullName])) {
$relations[$fullName] = $relation = $primaryModel->getRelation($name);
$this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName));
} else {
$relation = $relations[$fullName];
}
/* @var $relationModelClass ActiveRecordInterface */
$relationModelClass = $relation->modelClass;
$primaryModel = $relationModelClass::instance();
$parent = $relation;
$prefix = $fullName;
$name = $childName;
}
$fullName = $prefix === '' ? $name : "$prefix.$name";
if (!isset($relations[$fullName])) {
$relations[$fullName] = $relation = $primaryModel->getRelation($name);
if ($callback !== null) {
call_user_func($callback, $relation);
}
if (!empty($relation->joinWith)) {
$relation->buildJoinWith();
}
$this->joinWithRelation($parent, $relation, $this->getJoinType($joinType, $fullName));
}
}
}
/**
* Returns the join type based on the given join type parameter and the relation name.
* @param string|array $joinType the given join type(s)
* @param string $name relation name
* @return string the real join type
*/
private function getJoinType($joinType, $name)
{
if (is_array($joinType) && isset($joinType[$name])) {
return $joinType[$name];
}
return is_string($joinType) ? $joinType : 'INNER JOIN';
}
/**
* Returns the table name and the table alias for [[modelClass]].
* @return array the table name and the table alias.
* @since 2.0.16
*/
protected function getTableNameAndAlias()
{
if (empty($this->from)) {
$tableName = $this->getPrimaryTableName();
} else {
$tableName = '';
// if the first entry in "from" is an alias-tablename-pair return it directly
foreach ($this->from as $alias => $tableName) {
if (is_string($alias)) {
return [$tableName, $alias];
}
break;
}
}
if (preg_match('/^(.*?)\s+({{\w+}}|\w+)$/', $tableName, $matches)) {
$alias = $matches[2];
} else {
$alias = $tableName;
}
return [$tableName, $alias];
}
/**
* Joins a parent query with a child query.
* The current query object will be modified accordingly.
* @param ActiveQuery $parent
* @param ActiveQuery $child
* @param string $joinType
*/
private function joinWithRelation($parent, $child, $joinType)
{
$via = $child->via;
$child->via = null;
if ($via instanceof self) {
// via table
$this->joinWithRelation($parent, $via, $joinType);
$this->joinWithRelation($via, $child, $joinType);
return;
} elseif (is_array($via)) {
// via relation
$this->joinWithRelation($parent, $via[1], $joinType);
$this->joinWithRelation($via[1], $child, $joinType);
return;
}
list($parentTable, $parentAlias) = $parent->getTableNameAndAlias();
list($childTable, $childAlias) = $child->getTableNameAndAlias();
if (!empty($child->link)) {
if (strpos($parentAlias, '{{') === false) {
$parentAlias = '{{' . $parentAlias . '}}';
}
if (strpos($childAlias, '{{') === false) {
$childAlias = '{{' . $childAlias . '}}';
}
$on = [];
foreach ($child->link as $childColumn => $parentColumn) {
$on[] = "$parentAlias.[[$parentColumn]] = $childAlias.[[$childColumn]]";
}
$on = implode(' AND ', $on);
if (!empty($child->on)) {
$on = ['and', $on, $child->on];
}
} else {
$on = $child->on;
}
$this->join($joinType, empty($child->from) ? $childTable : $child->from, $on);
if (!empty($child->where)) {
$this->andWhere($child->where);
}
if (!empty($child->having)) {
$this->andHaving($child->having);
}
if (!empty($child->orderBy)) {
$this->addOrderBy($child->orderBy);
}
if (!empty($child->groupBy)) {
$this->addGroupBy($child->groupBy);
}
if (!empty($child->params)) {
$this->addParams($child->params);
}
if (!empty($child->join)) {
foreach ($child->join as $join) {
$this->join[] = $join;
}
}
if (!empty($child->union)) {
foreach ($child->union as $union) {
$this->union[] = $union;
}
}
}
/**
* Sets the ON condition for a relational query.
* The condition will be used in the ON part when [[ActiveQuery::joinWith()]] is called.
* Otherwise, the condition will be used in the WHERE part of a query.
*
* Use this method to specify additional conditions when declaring a relation in the [[ActiveRecord]] class:
*
* ```php
* public function getActiveUsers()
* {
* return $this->hasMany(User::className(), ['id' => 'user_id'])
* ->onCondition(['active' => true]);
* }
* ```
*
* Note that this condition is applied in case of a join as well as when fetching the related records.
* Thus only fields of the related table can be used in the condition. Trying to access fields of the primary
* record will cause an error in a non-join-query.
*
* @param string|array $condition the ON condition. Please refer to [[Query::where()]] on how to specify this parameter.
* @param array $params the parameters (name => value) to be bound to the query.
* @return $this the query object itself
*/
public function onCondition($condition, $params = [])
{
$this->on = $condition;
$this->addParams($params);
return $this;
}
/**
* Adds an additional ON condition to the existing one.
* The new condition and the existing one will be joined using the 'AND' operator.
* @param string|array $condition the new ON condition. Please refer to [[where()]]
* on how to specify this parameter.
* @param array $params the parameters (name => value) to be bound to the query.
* @return $this the query object itself
* @see onCondition()
* @see orOnCondition()
*/
public function andOnCondition($condition, $params = [])
{
if ($this->on === null) {
$this->on = $condition;
} else {
$this->on = ['and', $this->on, $condition];
}
$this->addParams($params);
return $this;
}
/**
* Adds an additional ON condition to the existing one.
* The new condition and the existing one will be joined using the 'OR' operator.
* @param string|array $condition the new ON condition. Please refer to [[where()]]
* on how to specify this parameter.
* @param array $params the parameters (name => value) to be bound to the query.
* @return $this the query object itself
* @see onCondition()
* @see andOnCondition()
*/
public function orOnCondition($condition, $params = [])
{
if ($this->on === null) {
$this->on = $condition;
} else {
$this->on = ['or', $this->on, $condition];
}
$this->addParams($params);
return $this;
}
/**
* Specifies the junction table for a relational query.
*
* Use this method to specify a junction table when declaring a relation in the [[ActiveRecord]] class:
*
* ```php
* public function getItems()
* {
* return $this->hasMany(Item::className(), ['id' => 'item_id'])
* ->viaTable('order_item', ['order_id' => 'id']);
* }
* ```
*
* @param string $tableName the name of the junction table.
* @param array $link the link between the junction table and the table associated with [[primaryModel]].
* The keys of the array represent the columns in the junction table, and the values represent the columns
* in the [[primaryModel]] table.
* @param callable $callable a PHP callback for customizing the relation associated with the junction table.
* Its signature should be `function($query)`, where `$query` is the query to be customized.
* @return $this the query object itself
* @see via()
*/
public function viaTable($tableName, $link, callable $callable = null)
{
$modelClass = $this->primaryModel !== null ? get_class($this->primaryModel) : __CLASS__;
$relation = new self($modelClass, [
'from' => [$tableName],
'link' => $link,
'multiple' => true,
'asArray' => true,
]);
$this->via = $relation;
if ($callable !== null) {
call_user_func($callable, $relation);
}
return $this;
}
/**
* Define an alias for the table defined in [[modelClass]].
*
* This method will adjust [[from]] so that an already defined alias will be overwritten.
* If none was defined, [[from]] will be populated with the given alias.
*
* @param string $alias the table alias.
* @return $this the query object itself
* @since 2.0.7
*/
public function alias($alias)
{
if (empty($this->from) || count($this->from) < 2) {
list($tableName) = $this->getTableNameAndAlias();
$this->from = [$alias => $tableName];
} else {
$tableName = $this->getPrimaryTableName();
foreach ($this->from as $key => $table) {
if ($table === $tableName) {
unset($this->from[$key]);
$this->from[$alias] = $tableName;
}
}
}
return $this;
}
/**
* {@inheritdoc}
* @since 2.0.12
*/
public function getTablesUsedInFrom()
{
if (empty($this->from)) {
return $this->cleanUpTableNames([$this->getPrimaryTableName()]);
}
return parent::getTablesUsedInFrom();
}
/**
* @return string primary table name
* @since 2.0.12
*/
protected function getPrimaryTableName()
{
/* @var $modelClass ActiveRecord */
$modelClass = $this->modelClass;
return $modelClass::tableName();
}
}