Repository: yiisoft/db
Pull request #961: Added protection against accidental overwriting for the "->having()" method.
Author: login: lav45
Created: 2025-04-19T08:40:34Z | Merged: 2025-04-22T06:08:50Z

| Q             | A
| ------------- | ---
| Is bugfix?    | ❌
| New feature?  | ✔️
| Breaks BC?    | ✔️
| Fixed issues  | https://github.com/yiisoft/db/issues/77



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a6fb3eec..42a62824d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -74,12 +74,13 @@
 - New #939: Add `caseSensitive` option to like condition (@vjik)
 - New #942: Allow PHP backed enums as values (@Tigrov)
 - Enh #943: Add `getCacheKey()` and `getCacheTag()` methods to `AbstractPdoSchema` class (@Tigrov)
-- Enh #944: Added `setWhere()` as method a forced for overwriting `where()` (@lav45)
+- Enh #944: Added `setWhere()` as a forced method to overwrite `where()` (@lav45)
 - Enh #925, #951: Add callback to `Query::all()` and `Query::one()` methods (@Tigrov, @vjik)
 - New #954: Add `DbArrayHelper::arrange()` method (@Tigrov)
 - Chg #956: Remove nullable from `PdoConnectionInterface::getActivePdo()` result (@vjik)
 - New #945: Add option for typecasting values retrieved from DB (@Tigrov)
 - Enh #941: Add the ability for user-defined type casting (@Tigrov)
+- Enh #961: Added `setHaving()` as a forced method to overwrite `having()` (@lav45)
 - Enh #822: Refactor data readers (@Tigrov)
 
 ## 1.3.0 March 21, 2024
diff --git a/UPGRADE.md b/UPGRADE.md
index 67f4df558..a4c1903f8 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -121,6 +121,7 @@ Each table column has its own class in the `Yiisoft\Db\Schema\Column` namespace
 - `QueryInterface::getResultCallback()` - returns the callback to be called on all rows of the query result or 
   `null` if the callback is not set;
 - `QueryPartsInterface::setWhere()` - overwrites the `WHERE` part of the query;
+- `QueryPartsInterface::setHaving()` - overwrites the `HAVING` part of the query;
 - `ConnectionInterface::getColumnFactory()` - returns the column factory object for concrete DBMS;
 - `ConnectionInterface::getServerInfo()` - returns `ServerInfoInterface` instance which provides server information;
 - `QueryBuilderInterface::buildColumnDefinition()` - builds column definition for `CREATE TABLE` statement;
diff --git a/src/Query/Query.php b/src/Query/Query.php
index 5857293b2..4a4263dec 100644
--- a/src/Query/Query.php
+++ b/src/Query/Query.php
@@ -491,6 +491,17 @@ public function groupBy(array|string|ExpressionInterface $columns): static
     }
 
     public function having(array|ExpressionInterface|string|null $condition, array $params = []): static
+    {
+        if ($this->having === null) {
+            $this->having = $condition;
+        } else {
+            throw new LogicException('The `having` condition was set earlier. Use the `setHaving()`, `andHaving()` or `orHaving()` method.');
+        }
+        $this->addParams($params);
+        return $this;
+    }
+
+    public function setHaving(array|ExpressionInterface|string|null $condition, array $params = []): static
     {
         $this->having = $condition;
         $this->addParams($params);
diff --git a/src/Query/QueryPartsInterface.php b/src/Query/QueryPartsInterface.php
index a3374a2b0..bc331b0aa 100644
--- a/src/Query/QueryPartsInterface.php
+++ b/src/Query/QueryPartsInterface.php
@@ -301,7 +301,7 @@ public function from(array|ExpressionInterface|string $tables): static;
     public function groupBy(array|string|ExpressionInterface $columns): static;
 
     /**
-     * Sets the `HAVING` part of the query.
+     * Initially sets the `HAVING` part of the query.
      *
      * @param array|ExpressionInterface|string|null $condition The conditions to be put after `HAVING`.
      * Please refer to {@see where()} on how to specify this parameter.
@@ -309,11 +309,25 @@ public function groupBy(array|string|ExpressionInterface $columns): static;
      *
      * @psalm-param ParamsType $params
      *
+     * @throws LogicException If `having` was set previously.
+     *
      * @see andHaving()
      * @see orHaving()
      */
     public function having(array|ExpressionInterface|string|null $condition, array $params = []): static;
 
+    /**
+     * Overwrites the `HAVING` part of the query.
+     *
+     * @param array|ExpressionInterface|string|null $condition The conditions to be put after `HAVING`.
+     * @param array $params The parameters (name => value) to bind to the query.
+     *
+     * @psalm-param ParamsType $params
+     *
+     * @see having()
+     */
+    public function setHaving(array|ExpressionInterface|string|null $condition, array $params = []): static;
+
     /**
      * Sets the {@see indexBy} property.
      *
diff --git a/tests/AbstractQueryBuilderTest.php b/tests/AbstractQueryBuilderTest.php
index c4f18df72..45c7d4b09 100644
--- a/tests/AbstractQueryBuilderTest.php
+++ b/tests/AbstractQueryBuilderTest.php
@@ -12,8 +12,8 @@
 use PHPUnit\Framework\TestCase;
 use stdClass;
 use Throwable;
-use Yiisoft\Db\Constant\DataType;
 use Yiisoft\Db\Command\Param;
+use Yiisoft\Db\Constant\DataType;
 use Yiisoft\Db\Exception\Exception;
 use Yiisoft\Db\Exception\InvalidArgumentException;
 use Yiisoft\Db\Exception\InvalidConfigException;
@@ -421,6 +421,34 @@ public function testBuildHaving(): void
         );
     }
 
+    public function testOverwriteHavingCondition(): void
+    {
+        $db = $this->getConnection();
+
+        try {
+            (new Query($db))
+                ->from('admin_user')
+                ->having(['id' => 1])
+                ->having(['id' => 2]);
+
+            $this->fail('LogicException should be thrown.');
+        } catch (LogicException $e) {
+            $this->assertEquals('The `having` condition was set earlier. Use the `setHaving()`, `andHaving()` or `orHaving()` method.', $e->getMessage());
+        }
+
+        $query = (new Query($db))
+            ->from('admin_user')
+            ->having(['id' => 1])
+            ->setHaving(['id' => 2]);
+
+        $this->assertEquals(['id' => 2], $query->getHaving());
+
+        $query->setHaving('id = :id', [':id' => 200]);
+
+        $this->assertEquals('id = :id', $query->getHaving());
+        $this->assertEquals([':id' => 200], $query->getParams());
+    }
+
     /**
      * @throws Exception
      */
@@ -486,6 +514,8 @@ public function testOverwriteWhereCondition(): void
             (new Query($db))
                 ->where(['like', 'name', 'foo%'])
                 ->where(['not like', 'name', 'foo%']);
+
+            $this->fail('LogicException should be thrown.');
         } catch (LogicException $e) {
             $this->assertEquals('The `where` condition was set earlier. Use the `setWhere()`, `andWhere()` or `orWhere()` method.', $e->getMessage());
         }
@@ -495,6 +525,11 @@ public function testOverwriteWhereCondition(): void
             ->setWhere(['not like', 'name', 'foo%']);
 
         $this->assertEquals(['not like', 'name', 'foo%'], $query->getWhere());
+
+        $query->setWhere('id = :id', [':id' => 200]);
+
+        $this->assertEquals('id = :id', $query->getWhere());
+        $this->assertEquals([':id' => 200], $query->getParams());
     }
 
     public function testBuildLimit(): void


Reviews:
user: login: Tigrov
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-19T10:51:43Z
user: login: samdark
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-19T19:27:57Z
user: login: vjik
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-21T12:40:08Z
user: login: vjik
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T05:20:47Z
user: login: Tigrov
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T05:50:43Z

Review comments:
diff_hunk: @@ -421,6 +421,32 @@ public function testBuildHaving(): void
         );
     }
 
+    public function testOverwriteHavingCondition(): void
+    {
+        $db = $this->getConnection();
+
+        try {
+            (new Query($db))
+                ->from('admin_user')
+                ->having(['id' => 1])
+                ->having(['id' => 2]);
+        } catch (RuntimeException $e) {
path: tests/AbstractQueryBuilderTest.php
user: login: Tigrov
body: ```suggestion

            $this->fail('RuntimeException should be thrown.');
        } catch (RuntimeException $e) {
```
created_at: 2025-04-19T10:50:03Z
author_association: MEMBER
diff_hunk: @@ -491,6 +491,17 @@ public function groupBy(array|string|ExpressionInterface $columns): static
     }
 
     public function having(array|ExpressionInterface|string|null $condition, array $params = []): static
+    {
+        if ($this->having === null) {
+            $this->having = $condition;
+        } else {
+            throw new RuntimeException('The `having` condition was set earlier. Use the `setHaving()`, `andHaving()` or `orHaving()` method.');
path: src/Query/Query.php
user: login: vjik
body: LogicException is better for this case. See [docs](https://www.php.net/manual/en/class.logicexception.php):

> Exception that represents error in the program logic. This kind of exception should lead directly to a fix in your code.


created_at: 2025-04-21T12:39:24Z
author_association: MEMBER
diff_hunk: @@ -421,6 +421,34 @@ public function testBuildHaving(): void
         );
     }
 
+    public function testOverwriteHavingCondition(): void
+    {
+        $db = $this->getConnection();
+
+        try {
+            (new Query($db))
+                ->from('admin_user')
+                ->having(['id' => 1])
+                ->having(['id' => 2]);
+
+            $this->fail('RuntimeException should be thrown.');
path: tests/AbstractQueryBuilderTest.php
user: login: Tigrov
body: ```suggestion
            $this->fail('LogicException should be thrown.');
```
created_at: 2025-04-22T05:49:53Z
author_association: MEMBER
diff_hunk: @@ -486,6 +514,8 @@ public function testOverwriteWhereCondition(): void
             (new Query($db))
                 ->where(['like', 'name', 'foo%'])
                 ->where(['not like', 'name', 'foo%']);
+
+            $this->fail('RuntimeException should be thrown.');
path: tests/AbstractQueryBuilderTest.php
user: login: Tigrov
body: ```suggestion
            $this->fail('LogicException should be thrown.');
```
created_at: 2025-04-22T05:50:08Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-19T08:41:18Z
body: ## [Codecov](https://app.codecov.io/gh/yiisoft/db/pull/961?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yiisoft) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.18%. Comparing base [(`a090d66`)](https://app.codecov.io/gh/yiisoft/db/commit/a090d6602dd9cd55f72fe3149fc789fb93a4511d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yiisoft) to head [(`e0a1d21`)](https://app.codecov.io/gh/yiisoft/db/commit/e0a1d21a8aef3099d0b5a88c96b1f3331a8e73b8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yiisoft).
> Report is 1 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##             master     #961   +/-   ##
=========================================
  Coverage     99.18%   99.18%           
- Complexity     1589     1591    +2     
=========================================
  Files            98       98           
  Lines          3922     3928    +6     
=========================================
+ Hits           3890     3896    +6     
  Misses           32       32           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/yiisoft/db/pull/961?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yiisoft).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=yiisoft).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: Tigrov
created_at: 2025-04-22T06:09:00Z
body: 👍 
author_association: MEMBER