Commit 4f44bb24 by Qiang Xue

Fixes #1581: Added `ActiveQuery::joinWith()` to support joining with relations

parent 2402d2d0
...@@ -386,6 +386,30 @@ $customers = Customer::find()->limit(100)->with([ ...@@ -386,6 +386,30 @@ $customers = Customer::find()->limit(100)->with([
``` ```
Joining with Relations
----------------------
When working with relational databases, a common task is to join multiple tables and apply various
query conditions and parameters to the JOIN SQL statement. Instead of calling [[ActiveQuery::join()]]
explicitly to build up the JOIN query, you may reuse the existing relation definitions and call [[ActiveQuery::joinWith()]]
to achieve the same goal. For example,
```php
// find all orders that contain books, and eager loading "books"
$orders = Order::find()->joinWith('books')->all();
// find all orders that contain books, and sort the orders by the book names.
$orders = Order::find()->joinWith([
'books' => function ($query) {
$query->orderBy('tbl_item.name');
}
])->all();
```
Note that [[ActiveQuery::joinWith()]] differs from [[ActiveQuery::with()]] in that the former will build up
and execute a JOIN SQL statement. For example, `Order::find()->joinWith('books')->all()` returns all orders that
contain books, while `Order::find()->with('books')->all()` returns all orders regardless they contain books or not.
Working with Relationships Working with Relationships
-------------------------- --------------------------
......
...@@ -27,6 +27,7 @@ Yii Framework 2 Change Log ...@@ -27,6 +27,7 @@ Yii Framework 2 Change Log
- Enh #1552: It is now possible to use multiple bootstrap NavBar in a single page (Alex-Code) - Enh #1552: It is now possible to use multiple bootstrap NavBar in a single page (Alex-Code)
- Enh #1572: Added `yii\web\Controller::createAbsoluteUrl()` (samdark) - Enh #1572: Added `yii\web\Controller::createAbsoluteUrl()` (samdark)
- Enh #1579: throw exception when the given AR relation name does not match in a case sensitive manner (qiangxue) - Enh #1579: throw exception when the given AR relation name does not match in a case sensitive manner (qiangxue)
- Enh #1581: Added `ActiveQuery::joinWith()` to support joining with relations (qiangxue)
- Enh #1601: Added support for tagName and encodeLabel parameters in ButtonDropdown (omnilight) - Enh #1601: Added support for tagName and encodeLabel parameters in ButtonDropdown (omnilight)
- Enh: Added `favicon.ico` and `robots.txt` to defauly application templates (samdark) - Enh: Added `favicon.ico` and `robots.txt` to defauly application templates (samdark)
- Enh: Added `Widget::autoIdPrefix` to support prefixing automatically generated widget IDs (qiangxue) - Enh: Added `Widget::autoIdPrefix` to support prefixing automatically generated widget IDs (qiangxue)
......
...@@ -68,6 +68,9 @@ class ActiveQuery extends Query implements ActiveQueryInterface ...@@ -68,6 +68,9 @@ class ActiveQuery extends Query implements ActiveQueryInterface
$rows = $command->queryAll(); $rows = $command->queryAll();
if (!empty($rows)) { if (!empty($rows)) {
$models = $this->createModels($rows); $models = $this->createModels($rows);
if (!empty($this->join) && $this->indexBy === null) {
$models = $this->removeDuplicatedModels($models);
}
if (!empty($this->with)) { if (!empty($this->with)) {
$this->findWith($this->with, $models); $this->findWith($this->with, $models);
} }
...@@ -78,6 +81,47 @@ class ActiveQuery extends Query implements ActiveQueryInterface ...@@ -78,6 +81,47 @@ class ActiveQuery extends Query implements ActiveQueryInterface
} }
/** /**
* 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
* @return array the distinctive models
*/
private function removeDuplicatedModels($models)
{
$hash = [];
/** @var ActiveRecord $class */
$class = $this->modelClass;
$pks = $class::primaryKey();
if (count($pks) > 1) {
foreach ($models as $i => $model) {
$key = [];
foreach ($pks as $pk) {
$key[] = $model[$pk];
}
$key = serialize($key);
if (isset($hash[$key])) {
unset($models[$i]);
} else {
$hash[$key] = true;
}
}
} else {
$pk = reset($pks);
foreach ($models as $i => $model) {
$key = $model[$pk];
if (isset($hash[$key])) {
unset($models[$i]);
} else {
$hash[$key] = true;
}
}
}
return array_values($models);
}
/**
* Executes query and returns a single row of result. * Executes query and returns a single row of result.
* @param Connection $db the DB connection used to create the DB command. * @param Connection $db the DB connection used to create the DB command.
* If null, the DB connection returned by [[modelClass]] will be used. * If null, the DB connection returned by [[modelClass]] will be used.
...@@ -144,6 +188,42 @@ class ActiveQuery extends Query implements ActiveQueryInterface ...@@ -144,6 +188,42 @@ class ActiveQuery extends Query implements ActiveQueryInterface
return $db->createCommand($sql, $params); return $db->createCommand($sql, $params);
} }
/**
* 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 eager loading 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.
* When `$eagerLoading` is true, it will call [[with()]] in addition with the specified relations.
*
* @param array $with the relations to be joined. Each array element represents a single relation.
* The array keys are relation names, and the array values are the corresponding anonymous functions that
* can be used to modify the relation queries on-the-fly. If a relation query does not need modification,
* you may use the relation name as the array value. Sub-relations can also be specified (see [[with()]]).
* For example,
*
* ```php
* // find all orders that contain books, and eager loading "books"
* Order::find()->joinWith('books')->all();
* // find all orders that contain books, and sort the orders by the book names.
* Order::find()->joinWith([
* 'books' => function ($query) {
* $query->orderBy('tbl_item.name');
* }
* ])->all();
* ```
*
* @param bool $eagerLoading
* @param string $joinType
* @return $this
*/
public function joinWith($with, $eagerLoading = true, $joinType = 'INNER JOIN') public function joinWith($with, $eagerLoading = true, $joinType = 'INNER JOIN')
{ {
$with = (array)$with; $with = (array)$with;
...@@ -167,9 +247,10 @@ class ActiveQuery extends Query implements ActiveQueryInterface ...@@ -167,9 +247,10 @@ class ActiveQuery extends Query implements ActiveQueryInterface
} }
/** /**
* @param ActiveRecord $model * Modifies the current query by adding join fragments based on the given relations.
* @param array $with * @param ActiveRecord $model the primary model
* @param string|array $joinType * @param array $with the relations to be joined
* @param string|array $joinType the join type
*/ */
private function joinWithRelations($model, $with, $joinType) private function joinWithRelations($model, $with, $joinType)
{ {
...@@ -211,6 +292,12 @@ class ActiveQuery extends Query implements ActiveQueryInterface ...@@ -211,6 +292,12 @@ class ActiveQuery extends Query implements ActiveQueryInterface
} }
} }
/**
* 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) private function getJoinType($joinType, $name)
{ {
if (is_array($joinType) && isset($joinType[$name])) { if (is_array($joinType) && isset($joinType[$name])) {
...@@ -221,8 +308,9 @@ class ActiveQuery extends Query implements ActiveQueryInterface ...@@ -221,8 +308,9 @@ class ActiveQuery extends Query implements ActiveQueryInterface
} }
/** /**
* Returns the table name used by the specified active query.
* @param ActiveQuery $query * @param ActiveQuery $query
* @return string * @return string the table name
*/ */
private function getQueryTableName($query) private function getQueryTableName($query)
{ {
...@@ -236,14 +324,32 @@ class ActiveQuery extends Query implements ActiveQueryInterface ...@@ -236,14 +324,32 @@ class ActiveQuery extends Query implements ActiveQueryInterface
} }
/** /**
* Joins a parent query with a child query.
* The current query object will be modified accordingly.
* @param ActiveQuery $parent * @param ActiveQuery $parent
* @param ActiveRelation $child * @param ActiveRelation $child
* @param string $joinType * @param string $joinType
*/ */
private function joinWithRelation($parent, $child, $joinType) private function joinWithRelation($parent, $child, $joinType)
{ {
$via = $child->via;
$child->via = null;
if ($via instanceof ActiveRelation) {
// 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;
}
$parentTable = $this->getQueryTableName($parent); $parentTable = $this->getQueryTableName($parent);
$childTable = $this->getQueryTableName($child); $childTable = $this->getQueryTableName($child);
if (!empty($child->link)) { if (!empty($child->link)) {
$on = []; $on = [];
foreach ($child->link as $childColumn => $parentColumn) { foreach ($child->link as $childColumn => $parentColumn) {
...@@ -254,6 +360,8 @@ class ActiveQuery extends Query implements ActiveQueryInterface ...@@ -254,6 +360,8 @@ class ActiveQuery extends Query implements ActiveQueryInterface
$on = ''; $on = '';
} }
$this->join($joinType, $childTable, $on); $this->join($joinType, $childTable, $on);
if (!empty($child->where)) { if (!empty($child->where)) {
$this->andWhere($child->where); $this->andWhere($child->where);
} }
......
...@@ -189,26 +189,6 @@ trait ActiveRelationTrait ...@@ -189,26 +189,6 @@ trait ActiveRelationTrait
} }
/** /**
* @param ActiveRecord|array $model
* @param array $attributes
* @return string
*/
private function getModelKey($model, $attributes)
{
if (count($attributes) > 1) {
$key = [];
foreach ($attributes as $attribute) {
$key[] = $model[$attribute];
}
return serialize($key);
} else {
$attribute = reset($attributes);
$key = $model[$attribute];
return is_scalar($key) ? $key : serialize($key);
}
}
/**
* @param array $models * @param array $models
*/ */
private function filterByModels($models) private function filterByModels($models)
...@@ -237,6 +217,26 @@ trait ActiveRelationTrait ...@@ -237,6 +217,26 @@ trait ActiveRelationTrait
} }
/** /**
* @param ActiveRecord|array $model
* @param array $attributes
* @return string
*/
private function getModelKey($model, $attributes)
{
if (count($attributes) > 1) {
$key = [];
foreach ($attributes as $attribute) {
$key[] = $model[$attribute];
}
return serialize($key);
} else {
$attribute = reset($attributes);
$key = $model[$attribute];
return is_scalar($key) ? $key : serialize($key);
}
}
/**
* @param array $primaryModels either array of AR instances or arrays * @param array $primaryModels either array of AR instances or arrays
* @return array * @return array
*/ */
......
<?php
/**
* @link http://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license http://www.yiiframework.com/license/
*/
namespace yiiunit\data\ar;
/**
* Class Category.
*
* @property integer $id
* @property string $name
*/
class Category extends ActiveRecord
{
public static function tableName()
{
return 'tbl_category';
}
public function getItems()
{
return $this->hasMany(Item::className(), ['category_id' => 'id']);
}
}
...@@ -15,4 +15,9 @@ class Item extends ActiveRecord ...@@ -15,4 +15,9 @@ class Item extends ActiveRecord
{ {
return 'tbl_item'; return 'tbl_item';
} }
public function getCategory()
{
return $this->hasOne(Category::className(), ['id' => 'category_id']);
}
} }
...@@ -243,5 +243,28 @@ class ActiveRecordTest extends DatabaseTestCase ...@@ -243,5 +243,28 @@ class ActiveRecordTest extends DatabaseTestCase
$this->assertEquals(3, $orders[1]->id); $this->assertEquals(3, $orders[1]->id);
$this->assertFalse($orders[0]->isRelationPopulated('customer')); $this->assertFalse($orders[0]->isRelationPopulated('customer'));
$this->assertFalse($orders[1]->isRelationPopulated('customer')); $this->assertFalse($orders[1]->isRelationPopulated('customer'));
// join with via-relation
$orders = Order::find()->joinWith('books')->orderBy('tbl_order.id')->all();
$this->assertEquals(2, count($orders));
$this->assertEquals(1, $orders[0]->id);
$this->assertEquals(3, $orders[1]->id);
$this->assertTrue($orders[0]->isRelationPopulated('books'));
$this->assertTrue($orders[1]->isRelationPopulated('books'));
$this->assertEquals(2, count($orders[0]->books));
$this->assertEquals(1, count($orders[1]->books));
// join with sub-relation
$orders = Order::find()->joinWith([
'items.category' => function ($q) {
$q->where('tbl_category.id = 2');
},
])->orderBy('tbl_order.id')->all();
$this->assertEquals(1, count($orders));
$this->assertTrue($orders[0]->isRelationPopulated('items'));
$this->assertEquals(2, $orders[0]->id);
$this->assertEquals(3, count($orders[0]->items));
$this->assertTrue($orders[0]->items[0]->isRelationPopulated('category'));
$this->assertEquals(2, $orders[0]->items[0]->category->id);
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment