diff --git a/docs/guide/db-active-record.md b/docs/guide/db-active-record.md index e370df196d..8f52841893 100644 --- a/docs/guide/db-active-record.md +++ b/docs/guide/db-active-record.md @@ -1089,7 +1089,28 @@ Note that this differs from our earlier example which only brings back customers > Info: When [[yii\db\ActiveQuery]] is specified with a condition via [[yii\db\ActiveQuery::onCondition()|onCondition()]], the condition will be put in the `ON` part if the query involves a JOIN query. If the query does not involve JOIN, the on-condition will be automatically appended to the `WHERE` part of the query. + Thus it may only contain conditions including columns of the related table. +#### Relation table aliases + +As noted before, when using JOIN in a query, we need to disambiguate column names. Therefor often an alias is +defined for a table. Setting an alias for the relational query would be possible by customizing the relation query in the following way: + +```php +$query->joinWith([ + 'orders' => function ($q) { + $q->from(['o' => Order::tableName()]); + }, +]) +``` + +This however looks very complicated and involves either hardcoding the related objects table name or calling `Order::tableName()`. +Since version 2.0.7, Yii provides a shortcut for this. You may now define and use the alias for the relation table like the following: + +```php +// join the orders relation and sort the result by orders.id +$query->joinWith(['orders o'])->orderBy('o.id'); +``` ### Inverse Relations diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 5592ecede0..e10e914ae3 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -63,6 +63,7 @@ Yii Framework 2 Change Log - Bug #10760: `yii\widgets\DetailView::normalizeAttributes()` fixed for arrayable models (boehsermoe) - Bug: Fixed generation of canonical URLs for `ViewAction` pages (samdark) - Bug: Fixed `mb_*` functions calls to use `UTF-8` or `Yii::$app->charset` (silverfire) +- Enh #2377: Allow specifying a table alias when joining relations via `joinWith()` (cebe, nainoon) - Enh #3506: Added `yii\validators\IpValidator` to perform validation of IP addresses and subnets (SilverFire, samdark) - Enh #4972: Added `yii\db\ActiveQuery::alias()` to allow specifying a table alias for the model table without having to know the name (cebe, stepanselyuk) - Enh #5146: Added `yii\i18n\Formatter::asDuration()` method (nineinchnick, SilverFire) diff --git a/framework/db/ActiveQuery.php b/framework/db/ActiveQuery.php index f730e5d29f..21c2bb16cb 100644 --- a/framework/db/ActiveQuery.php +++ b/framework/db/ActiveQuery.php @@ -355,11 +355,19 @@ class ActiveQuery extends Query implements ActiveQueryInterface * 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. 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, + * @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" @@ -370,8 +378,12 @@ class ActiveQuery extends Query implements ActiveQueryInterface * $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 boolean|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. Defaults to `true`. @@ -382,8 +394,33 @@ class ActiveQuery extends Query implements ActiveQueryInterface */ public function joinWith($with, $eagerLoading = true, $joinType = 'LEFT JOIN') { - $this->joinWith[] = [(array) $with, $eagerLoading, $joinType]; + $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; } diff --git a/tests/framework/db/ActiveRecordTest.php b/tests/framework/db/ActiveRecordTest.php index 819c362932..ab1116041c 100644 --- a/tests/framework/db/ActiveRecordTest.php +++ b/tests/framework/db/ActiveRecordTest.php @@ -551,6 +551,114 @@ class ActiveRecordTest extends DatabaseTestCase ])->all(); } + /** + * Tests the alias syntax for joinWith: 'alias' => 'relation' + */ + public function testJoinWithAlias() + { + // left join and eager loading + /** @var ActiveQuery $query */ + $query = Order::find()->joinWith(['customer c']); + $orders = $query->orderBy($query->applyRelationAlias('customer', 'id') /* c.id */ . ' DESC, order.id')->all(); + $this->assertEquals(3, count($orders)); + $this->assertEquals(2, $orders[0]->id); + $this->assertEquals(3, $orders[1]->id); + $this->assertEquals(1, $orders[2]->id); + $this->assertTrue($orders[0]->isRelationPopulated('customer')); + $this->assertTrue($orders[1]->isRelationPopulated('customer')); + $this->assertTrue($orders[2]->isRelationPopulated('customer')); + + // inner join filtering and eager loading + $orders = Order::find()->innerJoinWith(['customer c'])->where('{{c}}.[[id]]=2')->orderBy('order.id')->all(); + $this->assertEquals(2, count($orders)); + $this->assertEquals(2, $orders[0]->id); + $this->assertEquals(3, $orders[1]->id); + $this->assertTrue($orders[0]->isRelationPopulated('customer')); + $this->assertTrue($orders[1]->isRelationPopulated('customer')); + + // inner join filtering without eager loading + $orders = Order::find()->innerJoinWith(['customer c'], false)->where('{{c}}.[[id]]=2')->orderBy('order.id')->all(); + $this->assertEquals(2, count($orders)); + $this->assertEquals(2, $orders[0]->id); + $this->assertEquals(3, $orders[1]->id); + $this->assertFalse($orders[0]->isRelationPopulated('customer')); + $this->assertFalse($orders[1]->isRelationPopulated('customer')); + + // join with via-relation + $query = Order::find()->innerJoinWith(['books b']); + $orders = $query->where([$query->getRelationAlias('books') . '.name' => 'Yii 1.1 Application Development Cookbook'])->orderBy($query->applyAlias('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)); + + + $orders = Order::find()->innerJoinWith([ + 'items i' => function ($q) { + /** @var $q ActiveQuery */ + $q->orderBy($q->applyAlias('item', 'id')); + }, + 'items.category c' => function ($q) { + /** @var $q ActiveQuery */ + $q->where('{{c}}.[[id]] = 2'); + }, + ])->orderBy('i.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); + + // join with ON condition + $orders = Order::find()->joinWith(['books2 b'])->orderBy('order.id')->all(); + $this->assertEquals(3, count($orders)); + $this->assertEquals(1, $orders[0]->id); + $this->assertEquals(2, $orders[1]->id); + $this->assertEquals(3, $orders[2]->id); + $this->assertTrue($orders[0]->isRelationPopulated('books2')); + $this->assertTrue($orders[1]->isRelationPopulated('books2')); + $this->assertTrue($orders[2]->isRelationPopulated('books2')); + $this->assertEquals(2, count($orders[0]->books2)); + $this->assertEquals(0, count($orders[1]->books2)); + $this->assertEquals(1, count($orders[2]->books2)); + + // join with count and query + /** @var $query ActiveQuery */ + $query = Order::find()->joinWith(['customer c']); + $count = $query->count('c.id'); + $this->assertEquals(3, $count); + $orders = $query->all(); + $this->assertEquals(3, count($orders)); + + // relational query + $order = Order::findOne(1); + $customer = $order->getCustomer()->innerJoinWith(['orders o'], false)->where(['o.id' => 1])->one(); + $this->assertNotNull($customer); + $this->assertEquals(1, $customer->id); + + // join with sub-relation called inside Closure + $orders = Order::find()->joinWith([ + 'items' => function ($q) { + /** @var $q ActiveQuery */ + $q->orderBy('item.id'); + $q->joinWith(['category c']); + $q->where('{{c}}.[[id]] = 2'); + }, + ])->orderBy('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); + + } + public function testAlias() { $query = Order::find();