diff --git a/framework/db/ActiveQuery.php b/framework/db/ActiveQuery.php index 413255fcfeb..bf18605a8be 100644 --- a/framework/db/ActiveQuery.php +++ b/framework/db/ActiveQuery.php @@ -7,6 +7,7 @@ namespace yii\db; +use yii\base\InvalidCallException; use yii\base\InvalidConfigException; /** @@ -98,6 +99,9 @@ class ActiveQuery extends Query implements ActiveQueryInterface */ public $joinWith; + protected $useJoinForVia = false; + + protected $viaAppliedByJoin = false; /** * Constructor. @@ -163,7 +167,69 @@ public function prepare($builder) // lazy loading of a relation $where = $this->where; - if ($this->via instanceof self) { + if ( + $this->useJoinForVia() + && ($this->via instanceof self + || is_array($this->via) + ) + ) { + /* @var $viaQuery ActiveQuery */ + $viaQuery = ($this->via instanceof self) ? $this->via : $this->via[1]; + + list(, $tableAlias) = $this->getTableNameAndAlias(); + if (strpos($tableAlias, '{{') === false) { + $tableAlias = '{{' . $tableAlias . '}}'; + } + + if (empty($this->select)) { + $this->select = ["$tableAlias.*"]; + } + + if (empty($this->groupBy) && !$this->distinct) { + $this->distinct(); + } + + if (!$this->viaAppliedByJoin) { + // Setup first 'via' relation in the chain based on initial link. + $previousRelation = $this; + $previousLink = $this->link; + while ($viaQuery) { // Loop over each 'via' chain while we got links. + if ($viaQuery->via) { // Check if we've got another 'via' link. + $nextViaQuery = ($viaQuery->via instanceof self) ? $viaQuery->via : $viaQuery->via[1]; + $viaQuery->via = null; + } else { + // End of 'via' chain + $nextViaQuery = null; + + // Get table alias for final junction table + list(, $viaAlias) = $viaQuery->getTableNameAndAlias(); + if (strpos($viaAlias, '{{') === false) { + $viaAlias = '{{' . $viaAlias . '}}'; + } + // Apply primary model relation as additional inner join `on` conditions for the final junction table. + foreach ($viaQuery->link as $primaryColumn => $viaColumn) { + $viaQuery->andOnCondition([ + "$viaAlias.[[$primaryColumn]]" => $this->primaryModel->getAttribute($viaColumn) + ]); + } + } + + $nextLink = $viaQuery->link; // Store the current 'via' link for the next link. + $viaQuery->link = array_flip($previousLink); // Use the inverted previous link as the current one. + + // Join the 'via' link on the previous link + $this->joinWithRelation($previousRelation, $viaQuery, 'INNER JOIN'); + + // Setup data for next iteration + $previousRelation = $viaQuery; + $viaQuery = $nextViaQuery; + $previousLink = $nextLink; + } + + // Prevent duplicate application of the join(s) e.g. for ActiveDataProvider + $this->viaAppliedByJoin = true; + } + } elseif ($this->via instanceof self) { // via junction table $viaModels = $this->via->findJunctionRows([$this->primaryModel]); $this->filterByModels($viaModels); @@ -761,6 +827,33 @@ public function orOnCondition($condition, $params = []) return $this; } + public function viaByJoin($viaByJoin = true) + { + if (!$viaByJoin) { + if ($this->viaAppliedByJoin) { + throw new InvalidCallException('`viaByJoin` can not be disabled after it has been applied.'); + } + if ($this->via instanceof self && !empty($this->via->via)) { + throw new InvalidCallException('`viaByJoin` can not be disabled when using multiple "via tables".'); + } + } + + $this->useJoinForVia = $viaByJoin; + return $this; + } + + public function useJoinForVia() + { + return $this->useJoinForVia; + } + + public function viaJoined($relationName, callable $callable = null) + { + return $this + ->viaByJoin() + ->via($relationName, $callable); + } + /** * Specifies the junction table for a relational query. * @@ -801,6 +894,24 @@ public function viaTable($tableName, $link, callable $callable = null) return $this; } + public function viaJoinedTable($tableName, $link, callable $callable = null) + { + return $this + ->viaByJoin() + ->viaTable($tableName, $link, $callable); + } + + public function viaJoinedTables($links, callable $callable = null) + { + $this->viaByJoin(); + $query = $this; + foreach ($links as $tableName => $link) { + $query->viaTable($tableName, $link, $callable); + $query = $query->via; + } + return $this; + } + /** * Define an alias for the table defined in [[modelClass]]. * diff --git a/framework/db/Query.php b/framework/db/Query.php index f0b215859dc..83d1457b337 100644 --- a/framework/db/Query.php +++ b/framework/db/Query.php @@ -465,6 +465,7 @@ protected function queryScalar($selectExpression, $db) && empty($this->groupBy) && empty($this->having) && empty($this->union) + && (!($this instanceof ActiveQuery) || !$this->useJoinForVia()) ) { $select = $this->select; $order = $this->orderBy; diff --git a/tests/framework/db/ActiveQueryTest.php b/tests/framework/db/ActiveQueryTest.php index 3d3f451865c..bc7d3b4ad54 100644 --- a/tests/framework/db/ActiveQueryTest.php +++ b/tests/framework/db/ActiveQueryTest.php @@ -10,11 +10,15 @@ use yii\base\Event; use yii\db\ActiveQuery; use yii\db\Connection; +use yii\db\Query; use yii\db\QueryBuilder; +use yii\helpers\ArrayHelper; use yiiunit\data\ar\ActiveRecord; use yiiunit\data\ar\Category; use yiiunit\data\ar\Customer; +use yiiunit\data\ar\Item; use yiiunit\data\ar\Order; +use yiiunit\data\ar\OrderItem; use yiiunit\data\ar\Profile; /** @@ -231,14 +235,280 @@ public function testOrOnCondition_on_set() } /** - * @todo tests for internal logic of viaTable() + * @dataProvider viaTableProvider */ - public function testViaTable() + public function testViaTable($viaByJoin) { - $query = new ActiveQuery(Customer::className(), ['primaryModel' => new Order()]); - $result = $query->viaTable(Profile::className(), ['id' => 'item_id']); - $this->assertInstanceOf('yii\db\ActiveQuery', $result); - $this->assertInstanceOf('yii\db\ActiveQuery', $result->via); + $orderId = 2; + $itemIdsForOrder = [3, 4, 5]; + + $query = (new ActiveQuery(Item::className(), [ + 'primaryModel' => new Order(['id' => $orderId]), + 'link' => ['id' => 'item_id'], + 'multiple' => true + ])) + ->alias('i') + ->orderBy('id'); + + $viaTable = 'order_item oi'; // Note the alias "oi" here + $viaLink = ['order_id' => 'id']; + $query->viaTable($viaTable, $viaLink); + $query->viaByJoin($viaByJoin); + + $via = $query->via; + $this->assertInstanceOf('yii\db\ActiveQuery', $via); + $this->assertEquals([$viaTable], $via->from); + $this->assertEquals(Order::className(), $via->modelClass); + $this->assertEquals($viaLink, $via->link); + + $preparedQuery = $query->prepare(new QueryBuilder(new Connection())); + $this->assertInstanceOf('yii\db\Query', $preparedQuery); + + if ($viaByJoin) { + $this->assertEquals( + [ + [ + 'INNER JOIN', + [ + $viaTable + ], + [ + 'and', + '{{i}}.[[id]] = {{oi}}.[[item_id]]', + [ + '{{oi}}.[[order_id]]' => $orderId + ] + ] + ] + ], + $query->join + ); + + $this->assertNull($preparedQuery->where); + + } else { + // Check if "Item" ids are for the correct "Order" + sort($preparedQuery->where[2]); // apply sort since some DBMS might return the ids in different order + $this->assertEquals(['in', ['id'], $itemIdsForOrder], $preparedQuery->where); + $this->assertNull($preparedQuery->join); + } + + $result = $preparedQuery->all($this->getConnection()); + + // Ensure we got the correct items and in the right order + $this->assertEquals($itemIdsForOrder, ArrayHelper::getColumn($result, 'id')); + + // Ensure we got the right columns + $this->assertEquals(['id', 'name', 'category_id'], array_keys($result[0])); + } + + public function viaTableProvider() + { + return [ + [false], + [true] + ]; + } + + public function testViaJoinedTable() + { + $query = new ActiveQuery(Item::className()); + $query->viaJoinedTable('order_item',['order_id' => 'id']); + $this->assertTrue($query->useJoinForVia()); + // For tests of actual implementation see `testViaTable` + } + + public function testViaTables() + { + $customerId = 2; + $itemIdsForCustomer = [2, 5, 4, 3]; // Customer 2 has Orders 2 and 3 + + $query = (new ActiveQuery(Item::className(), [ + 'primaryModel' => new Customer(['id' => $customerId]), + 'link' => ['id' => 'item_id'], + 'multiple' => true + ])) + ->alias('i') + ->select([ + 'i.*', + 'SUM(subtotal) AS sum_subtotal'] + ) + ->groupBy([ + 'i.id', + 'i.name', + 'i.category_id', + ]) + ->orderBy(['sum_subtotal' => SORT_DESC]); + + $viaTable1 = 'order_item oi'; // Note the alias "oi" here + $viaLink1 = ['order_id' => 'id']; + $viaTable2 = 'order o'; // Note the alias "o" here + $viaLink2 = ['customer_id' => 'id']; + $query->viaJoinedTables([ + $viaTable1 => $viaLink1, + $viaTable2 => $viaLink2, + ]); + + $via1 = $query->via; + $this->assertInstanceOf('yii\db\ActiveQuery', $via1); + $this->assertEquals([$viaTable1], $via1->from); + $this->assertEquals(Customer::className(), $via1->modelClass); + $this->assertEquals($viaLink1, $via1->link); + + $via2 = $via1->via; + $this->assertInstanceOf('yii\db\ActiveQuery', $via2); + $this->assertEquals([$viaTable2], $via2->from); + $this->assertEquals(Customer::className(), $via2->modelClass); + $this->assertEquals($viaLink2, $via2->link); + + $preparedQuery = $query->prepare(new QueryBuilder(new Connection())); + $this->assertInstanceOf('yii\db\Query', $preparedQuery); + + $this->assertEquals( + [ + [ + 'INNER JOIN', + [ + 'order_item oi' + ], + '{{i}}.[[id]] = {{oi}}.[[item_id]]' + ], + [ + 'INNER JOIN', + [ + 'order o' + ], + [ + 'and', + '{{oi}}.[[order_id]] = {{o}}.[[id]]', + [ + '{{o}}.[[customer_id]]' => $customerId + ] + ] + ] + ], + $query->join + ); + + $this->assertNull($preparedQuery->where); + + $result = $preparedQuery->all($this->getConnection()); + + // Ensure we got the correct items and in the right order + $this->assertEquals($itemIdsForCustomer, ArrayHelper::getColumn($result, 'id')); + + // Ensure we got the right columns + $this->assertEquals(['id', 'name', 'category_id', 'sum_subtotal'], array_keys($result[0])); + } + + public function testViaJoined() + { + $customerId = 2; + $subtotalCondition = ['>=', 'subtotal', 10]; + $itemIdsForCustomer = [2, 5, 4]; // Customer 2 has Orders 2 and 3, items ids have a subtotal gte 10 and are ordered desc by subtotal + + $query = (new Customer(['id' => $customerId])) + ->hasMany(Item::className(), ['id' => 'item_id']) + ->viaJoined( + 'orderItems2', // Note `Customer` defines the 'via' version of "orderItems" as "orderItems2". + function ($orderItemsQuery) use ($subtotalCondition) { // Test callable + /** @var Query $orderItemsQuery */ + $orderItemsQuery->andWhere($subtotalCondition); + } + ) + ->select([ + 'item.*', + 'SUM(subtotal) AS sum_subtotal'] + ) + ->groupBy([ + 'item.id', + 'item.name', + 'item.category_id', + ]) + ->orderBy(['sum_subtotal' => SORT_DESC]); + + $this->assertTrue($query->useJoinForVia()); + + $via1 = $query->via; + $this->assertTrue(is_array($via1)); + $this->assertEquals('orderItems2', $via1[0]); + $this->assertInstanceOf('yii\db\ActiveQuery', $via1[1]); + $this->assertEquals(OrderItem::className(), $via1[1]->modelClass); + $this->assertEquals(['order_id' => 'id'], $via1[1]->link); + + $via2 = $via1[1]->via; + $this->assertTrue(is_array($via2)); + $this->assertEquals('orders', $via2[0]); + $this->assertInstanceOf('yii\db\ActiveQuery', $via2[1]); + $this->assertEquals(Order::className(), $via2[1]->modelClass); + $this->assertEquals(['customer_id' => 'id'], $via2[1]->link); + + $preparedQuery = $query->prepare(new QueryBuilder(new Connection())); + $this->assertInstanceOf('yii\db\Query', $preparedQuery); + + $this->assertEquals( + [ + [ + 'INNER JOIN', + 'order_item', + '{{item}}.[[id]] = {{order_item}}.[[item_id]]' + ], + [ + 'INNER JOIN', + 'order', + [ + 'and', + '{{order_item}}.[[order_id]] = {{order}}.[[id]]', + [ + '{{order}}.[[customer_id]]' => $customerId + ] + ] + ] + ], + $query->join + ); + + $this->assertEquals(['sum_subtotal' => SORT_DESC, '[[id]]' => SORT_ASC], $preparedQuery->orderBy); + + // The subtotal condition should be copied over from the via + $this->assertEquals($subtotalCondition, $preparedQuery->where); + + $result = $preparedQuery->all($this->getConnection()); + + // Ensure we got the correct items and in the right order + $this->assertEquals($itemIdsForCustomer, ArrayHelper::getColumn($result, 'id')); + + // Ensure we got the right columns + $this->assertEquals(['id', 'name', 'category_id', 'sum_subtotal'], array_keys($result[0])); + } + + public function testDisablingViaByJoinIsNotPossibleAfterPrepare() + { + $query = new ActiveQuery(Item::className(), [ + 'primaryModel' => new Order(['id' => 1]), + 'link' => ['id' => 'item_id'], + 'multiple' => true + ]); + + $query->viaJoinedTable('order_item', ['order_id' => 'id']); + $query->prepare(new QueryBuilder(new Connection())); + + $this->expectException('yii\base\InvalidCallException'); + $this->expectExceptionMessage('`viaByJoin` can not be disabled after it has been applied.'); + $query->viaByJoin(false); + } + public function testDisablingViaByJoinIsNotPossibleWithMultipleViaTables() + { + $query = new ActiveQuery(Item::className()); + + $query->viaJoinedTables([ + 'order_item' => ['order_id' => 'id'], + 'order' => ['customer_id' => 'id'], + ]); + + $this->expectException('yii\base\InvalidCallException'); + $this->expectExceptionMessage('`viaByJoin` can not be disabled when using multiple "via tables".'); + $query->viaByJoin(false); } public function testAlias_not_set()