diff --git a/docs/guide/active-record.md b/docs/guide/active-record.md index 70d41e0..6498dfc 100644 --- a/docs/guide/active-record.md +++ b/docs/guide/active-record.md @@ -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 -------------------------- diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index ba1cff7..562277e 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -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 #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 #1581: Added `ActiveQuery::joinWith()` to support joining with relations (qiangxue) - 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 `Widget::autoIdPrefix` to support prefixing automatically generated widget IDs (qiangxue) diff --git a/framework/yii/db/ActiveQuery.php b/framework/yii/db/ActiveQuery.php index 098724d..714ff61 100644 --- a/framework/yii/db/ActiveQuery.php +++ b/framework/yii/db/ActiveQuery.php @@ -68,6 +68,9 @@ class ActiveQuery extends Query implements ActiveQueryInterface $rows = $command->queryAll(); if (!empty($rows)) { $models = $this->createModels($rows); + if (!empty($this->join) && $this->indexBy === null) { + $models = $this->removeDuplicatedModels($models); + } if (!empty($this->with)) { $this->findWith($this->with, $models); } @@ -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. * @param Connection $db the DB connection used to create the DB command. * If null, the DB connection returned by [[modelClass]] will be used. @@ -144,6 +188,42 @@ class ActiveQuery extends Query implements ActiveQueryInterface 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') { $with = (array)$with; @@ -167,9 +247,10 @@ class ActiveQuery extends Query implements ActiveQueryInterface } /** - * @param ActiveRecord $model - * @param array $with - * @param string|array $joinType + * 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) { @@ -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) { if (is_array($joinType) && isset($joinType[$name])) { @@ -221,8 +308,9 @@ class ActiveQuery extends Query implements ActiveQueryInterface } /** + * Returns the table name used by the specified active query. * @param ActiveQuery $query - * @return string + * @return string the table name */ private function getQueryTableName($query) { @@ -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 ActiveRelation $child * @param string $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); $childTable = $this->getQueryTableName($child); + + if (!empty($child->link)) { $on = []; foreach ($child->link as $childColumn => $parentColumn) { @@ -254,6 +360,8 @@ class ActiveQuery extends Query implements ActiveQueryInterface $on = ''; } $this->join($joinType, $childTable, $on); + + if (!empty($child->where)) { $this->andWhere($child->where); } diff --git a/framework/yii/db/ActiveRelationTrait.php b/framework/yii/db/ActiveRelationTrait.php index c885006..dac3028 100644 --- a/framework/yii/db/ActiveRelationTrait.php +++ b/framework/yii/db/ActiveRelationTrait.php @@ -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 */ private function filterByModels($models) @@ -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 * @return array */ diff --git a/tests/unit/data/ar/Category.php b/tests/unit/data/ar/Category.php new file mode 100644 index 0000000..cebacb0 --- /dev/null +++ b/tests/unit/data/ar/Category.php @@ -0,0 +1,27 @@ +hasMany(Item::className(), ['category_id' => 'id']); + } +} diff --git a/tests/unit/data/ar/Item.php b/tests/unit/data/ar/Item.php index e725be9..2d04f9e 100644 --- a/tests/unit/data/ar/Item.php +++ b/tests/unit/data/ar/Item.php @@ -15,4 +15,9 @@ class Item extends ActiveRecord { return 'tbl_item'; } + + public function getCategory() + { + return $this->hasOne(Category::className(), ['id' => 'category_id']); + } } diff --git a/tests/unit/framework/db/ActiveRecordTest.php b/tests/unit/framework/db/ActiveRecordTest.php index d112ff4..276547e 100644 --- a/tests/unit/framework/db/ActiveRecordTest.php +++ b/tests/unit/framework/db/ActiveRecordTest.php @@ -243,5 +243,28 @@ class ActiveRecordTest extends DatabaseTestCase $this->assertEquals(3, $orders[1]->id); $this->assertFalse($orders[0]->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); } }