Skip to content

Commit 6ea148f

Browse files
authored
feat(database): add Query Builder havingBetween methods (#10214)
- 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>
1 parent e64be5e commit 6ea148f

6 files changed

Lines changed: 284 additions & 0 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,11 +947,75 @@ public function orWhereNotBetween(?string $key = null, $values = null, ?bool $es
947947
return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'OR ', $escape);
948948
}
949949

950+
/**
951+
* Generates a HAVING field BETWEEN minimum AND maximum SQL query,
952+
* joined with 'AND' if appropriate.
953+
*
954+
* @param array<array-key, mixed>|null $values The range values searched on
955+
*
956+
* @return $this
957+
*
958+
* @throws InvalidArgumentException
959+
*/
960+
public function havingBetween(?string $key = null, $values = null, ?bool $escape = null): static
961+
{
962+
return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'AND ', $escape);
963+
}
964+
965+
/**
966+
* Generates a HAVING field BETWEEN minimum AND maximum SQL query,
967+
* joined with 'OR' if appropriate.
968+
*
969+
* @param array<array-key, mixed>|null $values The range values searched on
970+
*
971+
* @return $this
972+
*
973+
* @throws InvalidArgumentException
974+
*/
975+
public function orHavingBetween(?string $key = null, $values = null, ?bool $escape = null): static
976+
{
977+
return $this->whereBetweenHaving('QBHaving', $key, $values, false, 'OR ', $escape);
978+
}
979+
980+
/**
981+
* Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query,
982+
* joined with 'AND' if appropriate.
983+
*
984+
* @param array<array-key, mixed>|null $values The range values searched on
985+
*
986+
* @return $this
987+
*
988+
* @throws InvalidArgumentException
989+
*/
990+
public function havingNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
991+
{
992+
return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'AND ', $escape);
993+
}
994+
995+
/**
996+
* Generates a HAVING field NOT BETWEEN minimum AND maximum SQL query,
997+
* joined with 'OR' if appropriate.
998+
*
999+
* @param array<array-key, mixed>|null $values The range values searched on
1000+
*
1001+
* @return $this
1002+
*
1003+
* @throws InvalidArgumentException
1004+
*/
1005+
public function orHavingNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
1006+
{
1007+
return $this->whereBetweenHaving('QBHaving', $key, $values, true, 'OR ', $escape);
1008+
}
1009+
9501010
/**
9511011
* @used-by whereBetween()
9521012
* @used-by orWhereBetween()
9531013
* @used-by whereNotBetween()
9541014
* @used-by orWhereNotBetween()
1015+
* @used-by havingBetween()
1016+
* @used-by orHavingBetween()
1017+
* @used-by havingNotBetween()
1018+
* @used-by orHavingNotBetween()
9551019
*
9561020
* @param 'QBHaving'|'QBWhere' $qbKey
9571021
* @param non-empty-string|null $key

system/Model.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@
4646
* @method $this groupEnd()
4747
* @method $this groupStart()
4848
* @method $this having($key, $value = null, ?bool $escape = null)
49+
* @method $this havingBetween(?string $key = null, $values = null, ?bool $escape = null)
4950
* @method $this havingGroupEnd()
5051
* @method $this havingGroupStart()
5152
* @method $this havingIn(?string $key = null, $values = null, ?bool $escape = null)
5253
* @method $this havingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
54+
* @method $this havingNotBetween(?string $key = null, $values = null, ?bool $escape = null)
5355
* @method $this havingNotIn(?string $key = null, $values = null, ?bool $escape = null)
5456
* @method $this join(string $table, string $cond, string $type = '', ?bool $escape = null)
5557
* @method $this like($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
@@ -62,9 +64,11 @@
6264
* @method $this orderBy(string $orderBy, string $direction = '', ?bool $escape = null)
6365
* @method $this orGroupStart()
6466
* @method $this orHaving($key, $value = null, ?bool $escape = null)
67+
* @method $this orHavingBetween(?string $key = null, $values = null, ?bool $escape = null)
6568
* @method $this orHavingGroupStart()
6669
* @method $this orHavingIn(?string $key = null, $values = null, ?bool $escape = null)
6770
* @method $this orHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
71+
* @method $this orHavingNotBetween(?string $key = null, $values = null, ?bool $escape = null)
6872
* @method $this orHavingNotIn(?string $key = null, $values = null, ?bool $escape = null)
6973
* @method $this orLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
7074
* @method $this orNotGroupStart()

tests/system/Database/Builder/GroupTest.php

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
namespace CodeIgniter\Database\Builder;
1515

1616
use CodeIgniter\Database\BaseBuilder;
17+
use CodeIgniter\Exceptions\InvalidArgumentException;
1718
use CodeIgniter\Test\CIUnitTestCase;
1819
use CodeIgniter\Test\Mock\MockConnection;
20+
use PHPUnit\Framework\Attributes\DataProvider;
1921
use PHPUnit\Framework\Attributes\Group;
2022

2123
/**
@@ -71,6 +73,135 @@ public function testOrHavingBy(): void
7173
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
7274
}
7375

76+
#[DataProvider('provideHavingBetweenMethods')]
77+
public function testHavingBetweenMethods(string $method, string $sql): void
78+
{
79+
$builder = new BaseBuilder('user', $this->db);
80+
81+
$builder->select('name')
82+
->groupBy('name')
83+
->{$method}('total', [10, 20]);
84+
85+
$expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "total" ' . $sql . ' 10 AND 20';
86+
$expectedBinds = [
87+
'total' => [
88+
10,
89+
true,
90+
],
91+
'total.1' => [
92+
20,
93+
true,
94+
],
95+
];
96+
97+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
98+
$this->assertSame($expectedBinds, $builder->getBinds());
99+
}
100+
101+
/**
102+
* @return iterable<string, array{string, string}>
103+
*/
104+
public static function provideHavingBetweenMethods(): iterable
105+
{
106+
return [
107+
'between' => ['havingBetween', 'BETWEEN'],
108+
'not between' => ['havingNotBetween', 'NOT BETWEEN'],
109+
];
110+
}
111+
112+
#[DataProvider('provideOrHavingBetweenMethods')]
113+
public function testOrHavingBetweenMethods(string $method, string $sql): void
114+
{
115+
$builder = new BaseBuilder('user', $this->db);
116+
117+
$builder->select('name')
118+
->groupBy('name')
119+
->having('active', 1)
120+
->{$method}('total', [10, 20]);
121+
122+
$expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING "active" = 1 OR "total" ' . $sql . ' 10 AND 20';
123+
124+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
125+
}
126+
127+
/**
128+
* @return iterable<string, array{string, string}>
129+
*/
130+
public static function provideOrHavingBetweenMethods(): iterable
131+
{
132+
return [
133+
'or between' => ['orHavingBetween', 'BETWEEN'],
134+
'or not between' => ['orHavingNotBetween', 'NOT BETWEEN'],
135+
];
136+
}
137+
138+
public function testHavingBetweenWithGroupedConditions(): void
139+
{
140+
$builder = new BaseBuilder('user', $this->db);
141+
142+
$builder->select('name')
143+
->groupBy('name')
144+
->havingGroupStart()
145+
->havingBetween('total', [10, 20])
146+
->orHavingNotBetween('score', [30, 40])
147+
->havingGroupEnd()
148+
->having('active', 1);
149+
150+
$expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING ( "total" BETWEEN 10 AND 20 OR "score" NOT BETWEEN 30 AND 40 ) AND "active" = 1';
151+
152+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
153+
}
154+
155+
public function testHavingBetweenNoEscape(): void
156+
{
157+
$builder = new BaseBuilder('user', $this->db);
158+
159+
$builder->select('name')
160+
->groupBy('name')
161+
->havingBetween('SUM(id)', [10, 20], escape: false);
162+
163+
$expectedSQL = 'SELECT "name" FROM "user" GROUP BY "name" HAVING SUM(id) BETWEEN 10 AND 20';
164+
$expectedBinds = [
165+
'SUM(id)' => [
166+
10,
167+
false,
168+
],
169+
'SUM(id).1' => [
170+
20,
171+
false,
172+
],
173+
];
174+
175+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
176+
$this->assertSame($expectedBinds, $builder->getBinds());
177+
}
178+
179+
/**
180+
* @param mixed $values
181+
*/
182+
#[DataProvider('provideHavingBetweenInvalidValuesThrowInvalidArgumentException')]
183+
public function testHavingBetweenInvalidValuesThrowInvalidArgumentException($values): void
184+
{
185+
$this->expectException(InvalidArgumentException::class);
186+
187+
$builder = new BaseBuilder('user', $this->db);
188+
$builder->havingBetween('total', $values);
189+
}
190+
191+
/**
192+
* @return iterable<string, array{mixed}>
193+
*/
194+
public static function provideHavingBetweenInvalidValuesThrowInvalidArgumentException(): iterable
195+
{
196+
return [
197+
'null' => [null],
198+
'not array' => ['not array'],
199+
'empty array' => [[]],
200+
'one value' => [[10]],
201+
'three values' => [[10, 20, 30]],
202+
];
203+
}
204+
74205
public function testHavingIn(): void
75206
{
76207
$builder = new BaseBuilder('user', $this->db);

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ Database
218218
Query Builder
219219
-------------
220220

221+
- Added ``havingBetween()``, ``orHavingBetween()``, ``havingNotBetween()``, and ``orHavingNotBetween()`` to Query Builder. See :ref:`query-builder-having-between`.
221222
- Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`.
222223
- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`.
223224
- Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`.

user_guide_src/source/database/query_builder.rst

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,44 @@ $builder->orHaving()
634634

635635
Identical to ``having()``, only separates multiple clauses with **OR**.
636636

637+
.. _query-builder-having-between:
638+
639+
$builder->havingBetween()
640+
-------------------------
641+
642+
.. versionadded:: 4.8.0
643+
644+
Generates a **HAVING** field ``BETWEEN`` minimum and maximum value SQL query.
645+
``BETWEEN`` includes both values:
646+
647+
.. literalinclude:: query_builder/127.php
648+
649+
The range array must contain exactly two values: the lower and upper bounds.
650+
These values are bound and escaped automatically. The ``$escape`` parameter
651+
controls value escaping and identifier protection.
652+
653+
.. warning:: Do not pass user-supplied data as field names. If you need a more
654+
complex SQL expression, use ``having()`` with :ref:`RawSql <query-builder-where-rawsql>`
655+
and escape values manually.
656+
657+
$builder->orHavingBetween()
658+
---------------------------
659+
660+
This method is identical to ``havingBetween()``, except that multiple instances
661+
are joined by **OR**.
662+
663+
$builder->havingNotBetween()
664+
----------------------------
665+
666+
This method is identical to ``havingBetween()``, except that it generates
667+
``NOT BETWEEN``.
668+
669+
$builder->orHavingNotBetween()
670+
------------------------------
671+
672+
This method is identical to ``havingNotBetween()``, except that multiple
673+
instances are joined by **OR**.
674+
637675
$builder->havingIn()
638676
--------------------
639677

@@ -1932,6 +1970,46 @@ Class Reference
19321970

19331971
Adds a ``HAVING`` clause to a query, separating multiple calls with ``OR``.
19341972

1973+
.. php:method:: havingBetween([$key = null[, $values = null[, $escape = null]]])
1974+
1975+
:param string $key: Name of field to examine
1976+
:param array $values: Two values defining the inclusive range
1977+
:param bool $escape: Whether to escape values and protect identifiers
1978+
:returns: ``BaseBuilder`` instance (method chaining)
1979+
:rtype: ``BaseBuilder``
1980+
1981+
Generates a ``HAVING`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate.
1982+
1983+
.. php:method:: orHavingBetween([$key = null[, $values = null[, $escape = null]]])
1984+
1985+
:param string $key: The field to search
1986+
:param array $values: Two values defining the inclusive range
1987+
:param bool $escape: Whether to escape values and protect identifiers
1988+
:returns: ``BaseBuilder`` instance (method chaining)
1989+
:rtype: ``BaseBuilder``
1990+
1991+
Generates a ``HAVING`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate.
1992+
1993+
.. php:method:: havingNotBetween([$key = null[, $values = null[, $escape = null]]])
1994+
1995+
:param string $key: Name of field to examine
1996+
:param array $values: Two values defining the inclusive range
1997+
:param bool $escape: Whether to escape values and protect identifiers
1998+
:returns: ``BaseBuilder`` instance (method chaining)
1999+
:rtype: ``BaseBuilder``
2000+
2001+
Generates a ``HAVING`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate.
2002+
2003+
.. php:method:: orHavingNotBetween([$key = null[, $values = null[, $escape = null]]])
2004+
2005+
:param string $key: The field to search
2006+
:param array $values: Two values defining the inclusive range
2007+
:param bool $escape: Whether to escape values and protect identifiers
2008+
:returns: ``BaseBuilder`` instance (method chaining)
2009+
:rtype: ``BaseBuilder``
2010+
2011+
Generates a ``HAVING`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate.
2012+
19352013
.. php:method:: orHavingIn([$key = null[, $values = null[, $escape = null]]])
19362014
19372015
:param string $key: The field to search
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?php
2+
3+
$builder->select('category')
4+
->selectCount('id', 'total')
5+
->groupBy('category')
6+
->havingBetween('COUNT(id)', [10, 20], false);

0 commit comments

Comments
 (0)