From 9bc1a7e0df689237f37b02d37b003d14ffe2a0fd Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Mon, 18 May 2026 11:36:17 +0200 Subject: [PATCH] feat(database): add Query Builder havingBetween methods - Add HAVING BETWEEN and HAVING NOT BETWEEN builder methods - Support AND/OR variants through the shared between helper - Document the new HAVING range methods - Add builder tests Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseBuilder.php | 64 +++++++++ system/Model.php | 4 + tests/system/Database/Builder/GroupTest.php | 131 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + .../source/database/query_builder.rst | 78 +++++++++++ .../source/database/query_builder/127.php | 6 + 6 files changed, 284 insertions(+) create mode 100644 user_guide_src/source/database/query_builder/127.php diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index e12ad26a466c..cde718232988 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -947,11 +947,75 @@ public function orWhereNotBetween(?string $key = null, $values = null, ?bool $es return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'OR ', $escape); } + /** + * Generates a HAVING field BETWEEN minimum AND maximum SQL query, + * joined with 'AND' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function havingBetween(?string $key = null, $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'AND ', $escape); + } + + /** + * Generates a HAVING field BETWEEN minimum AND maximum SQL query, + * joined with 'OR' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orHavingBetween(?string $key = null, $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'OR ', $escape); + } + + /** + * Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query, + * joined with 'AND' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function havingNotBetween(?string $key = null, $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'AND ', $escape); + } + + /** + * Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query, + * joined with 'OR' if appropriate. + * + * @param array|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orHavingNotBetween(?string $key = null, $values = null, ?bool $escape = null): static + { + return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'OR ', $escape); + } + /** * @used-by whereBetween() * @used-by orWhereBetween() * @used-by whereNotBetween() * @used-by orWhereNotBetween() + * @used-by havingBetween() + * @used-by orHavingBetween() + * @used-by havingNotBetween() + * @used-by orHavingNotBetween() * * @param 'QBHaving'|'QBWhere' $qbKey * @param non-empty-string|null $key diff --git a/system/Model.php b/system/Model.php index 86d5da6d7095..7eb04c77d083 100644 --- a/system/Model.php +++ b/system/Model.php @@ -46,10 +46,12 @@ * @method $this groupEnd() * @method $this groupStart() * @method $this having($key, $value = null, ?bool $escape = null) + * @method $this havingBetween(?string $key = null, $values = null, ?bool $escape = null) * @method $this havingGroupEnd() * @method $this havingGroupStart() * @method $this havingIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + * @method $this havingNotBetween(?string $key = null, $values = null, ?bool $escape = null) * @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null) * @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) @@ -62,9 +64,11 @@ * @method $this orderBy(string $orderBy, string $direction = '', ?bool $escape = null) * @method $this orGroupStart() * @method $this orHaving($key, $value = null, ?bool $escape = null) + * @method $this orHavingBetween(?string $key = null, $values = null, ?bool $escape = null) * @method $this orHavingGroupStart() * @method $this orHavingIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) + * @method $this orHavingNotBetween(?string $key = null, $values = null, ?bool $escape = null) * @method $this orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null) * @method $this orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false) * @method $this orNotGroupStart() diff --git a/tests/system/Database/Builder/GroupTest.php b/tests/system/Database/Builder/GroupTest.php index d03937ca6b3f..cc940cf0d07d 100644 --- a/tests/system/Database/Builder/GroupTest.php +++ b/tests/system/Database/Builder/GroupTest.php @@ -14,8 +14,10 @@ namespace CodeIgniter\Database\Builder; use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -71,6 +73,135 @@ public function testOrHavingBy(): void $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); } + #[DataProvider('provideHavingBetweenMethods')] + public function testHavingBetweenMethods(string $method, string $sql): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->select('name') + ->groupBy('name') + ->{$method}('total', [10, 20]); + + $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "total" ' . $sql . ' 10 AND 20'; + $expectedBinds = [ + 'total' => [ + 10, + true, + ], + 'total.1' => [ + 20, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @return iterable + */ + public static function provideHavingBetweenMethods(): iterable + { + return [ + 'between' => ['havingBetween', 'BETWEEN'], + 'not between' => ['havingNotBetween', 'NOT BETWEEN'], + ]; + } + + #[DataProvider('provideOrHavingBetweenMethods')] + public function testOrHavingBetweenMethods(string $method, string $sql): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->select('name') + ->groupBy('name') + ->having('active', 1) + ->{$method}('total', [10, 20]); + + $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "active" = 1 OR "total" ' . $sql . ' 10 AND 20'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + /** + * @return iterable + */ + public static function provideOrHavingBetweenMethods(): iterable + { + return [ + 'or between' => ['orHavingBetween', 'BETWEEN'], + 'or not between' => ['orHavingNotBetween', 'NOT BETWEEN'], + ]; + } + + public function testHavingBetweenWithGroupedConditions(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->select('name') + ->groupBy('name') + ->havingGroupStart() + ->havingBetween('total', [10, 20]) + ->orHavingNotBetween('score', [30, 40]) + ->havingGroupEnd() + ->having('active', 1); + + $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING ( "total" BETWEEN 10 AND 20 OR "score" NOT BETWEEN 30 AND 40 ) AND "active" = 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testHavingBetweenNoEscape(): void + { + $builder = new BaseBuilder('user', $this->db); + + $builder->select('name') + ->groupBy('name') + ->havingBetween('SUM(id)', [10, 20], escape: false); + + $expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING SUM(id) BETWEEN 10 AND 20'; + $expectedBinds = [ + 'SUM(id)' => [ + 10, + false, + ], + 'SUM(id).1' => [ + 20, + false, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @param mixed $values + */ + #[DataProvider('provideHavingBetweenInvalidValuesThrowInvalidArgumentException')] + public function testHavingBetweenInvalidValuesThrowInvalidArgumentException($values): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = new BaseBuilder('user', $this->db); + $builder->havingBetween('total', $values); + } + + /** + * @return iterable + */ + public static function provideHavingBetweenInvalidValuesThrowInvalidArgumentException(): iterable + { + return [ + 'null' => [null], + 'not array' => ['not array'], + 'empty array' => [[]], + 'one value' => [[10]], + 'three values' => [[10, 20, 30]], + ]; + } + public function testHavingIn(): void { $builder = new BaseBuilder('user', $this->db); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 7670598cde1f..14546fe63b5e 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -217,6 +217,7 @@ Database Query Builder ------------- +- Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`. - Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`. - Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`. - Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index d77deff5fbe8..803297b54c2e 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -634,6 +634,44 @@ $builder->orHaving() Identical to ``having()``, only separates multiple clauses with **OR**. +.. _query-builder-having-between: + +$builder->havingBetween() +------------------------- + +.. versionadded:: 4.8.0 + +Generates a **HAVING** field ``BETWEEN`` minimum and maximum value SQL query. +``BETWEEN`` includes both values: + +.. literalinclude:: query_builder/127.php + +The range array must contain exactly two values: the lower and upper bounds. +These values are bound and escaped automatically. The ``$escape`` parameter +controls value escaping and identifier protection. + +.. warning:: Do not pass user-supplied data as field names. If you need a more + complex SQL expression, use ``having()`` with :ref:`RawSql ` + and escape values manually. + +$builder->orHavingBetween() +--------------------------- + +This method is identical to ``havingBetween()``, except that multiple instances +are joined by **OR**. + +$builder->havingNotBetween() +---------------------------- + +This method is identical to ``havingBetween()``, except that it generates +``NOT BETWEEN``. + +$builder->orHavingNotBetween() +------------------------------ + +This method is identical to ``havingNotBetween()``, except that multiple +instances are joined by **OR**. + $builder->havingIn() -------------------- @@ -1932,6 +1970,46 @@ Class Reference Adds a ``HAVING`` clause to a query, separating multiple calls with ``OR``. + .. php:method:: havingBetween([$key = null[, $values = null[, $escape = null]]]) + + :param string $key: Name of field to examine + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``HAVING`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate. + + .. php:method:: orHavingBetween([$key = null[, $values = null[, $escape = null]]]) + + :param string $key: The field to search + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``HAVING`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + + .. php:method:: havingNotBetween([$key = null[, $values = null[, $escape = null]]]) + + :param string $key: Name of field to examine + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``HAVING`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate. + + .. php:method:: orHavingNotBetween([$key = null[, $values = null[, $escape = null]]]) + + :param string $key: The field to search + :param array $values: Two values defining the inclusive range + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``HAVING`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + .. php:method:: orHavingIn([$key = null[, $values = null[, $escape = null]]]) :param string $key: The field to search diff --git a/user_guide_src/source/database/query_builder/127.php b/user_guide_src/source/database/query_builder/127.php new file mode 100644 index 000000000000..02f8a13642ba --- /dev/null +++ b/user_guide_src/source/database/query_builder/127.php @@ -0,0 +1,6 @@ +select('category') + ->selectCount('id', 'total') + ->groupBy('category') + ->havingBetween('COUNT(id)', [10, 20], false);