Skip to content

Commit 37b8b37

Browse files
authored
feat: add Query Builder whereBetween methods (#10199)
* feat(database): add query builder between conditions Add whereBetween(), orWhereBetween(), whereNotBetween(), and orWhereNotBetween() to Query Builder. - Bind lower and upper range values through the existing bind system - Protect identifiers at compile time for alias and prefix correctness - Add Model method annotations, user guide docs, and changelog entry - Cover normal, OR, NOT, grouped, unescaped, aliased, prefixed, and invalid input cases Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * refactor(database): polish whereBetween helper internals Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --------- Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent 8bff1ef commit 37b8b37

7 files changed

Lines changed: 383 additions & 0 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,111 @@ private function parseWhereColumnFirst(string $first): array
887887
return [$first, '='];
888888
}
889889

890+
/**
891+
* Generates a WHERE field BETWEEN minimum AND maximum SQL query,
892+
* joined with 'AND' if appropriate.
893+
*
894+
* @param array<array-key, mixed>|null $values The range values searched on
895+
*
896+
* @return $this
897+
*
898+
* @throws InvalidArgumentException
899+
*/
900+
public function whereBetween(?string $key = null, $values = null, ?bool $escape = null): static
901+
{
902+
return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'AND ', $escape);
903+
}
904+
905+
/**
906+
* Generates a WHERE field BETWEEN minimum AND maximum SQL query,
907+
* joined with 'OR' if appropriate.
908+
*
909+
* @param array<array-key, mixed>|null $values The range values searched on
910+
*
911+
* @return $this
912+
*
913+
* @throws InvalidArgumentException
914+
*/
915+
public function orWhereBetween(?string $key = null, $values = null, ?bool $escape = null): static
916+
{
917+
return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'OR ', $escape);
918+
}
919+
920+
/**
921+
* Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query,
922+
* joined with 'AND' if appropriate.
923+
*
924+
* @param array<array-key, mixed>|null $values The range values searched on
925+
*
926+
* @return $this
927+
*
928+
* @throws InvalidArgumentException
929+
*/
930+
public function whereNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
931+
{
932+
return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'AND ', $escape);
933+
}
934+
935+
/**
936+
* Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query,
937+
* joined with 'OR' if appropriate.
938+
*
939+
* @param array<array-key, mixed>|null $values The range values searched on
940+
*
941+
* @return $this
942+
*
943+
* @throws InvalidArgumentException
944+
*/
945+
public function orWhereNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
946+
{
947+
return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'OR ', $escape);
948+
}
949+
950+
/**
951+
* @used-by whereBetween()
952+
* @used-by orWhereBetween()
953+
* @used-by whereNotBetween()
954+
* @used-by orWhereNotBetween()
955+
*
956+
* @param 'QBHaving'|'QBWhere' $qbKey
957+
* @param non-empty-string|null $key
958+
* @param array<array-key, mixed>|null $values The range values searched on
959+
*
960+
* @return $this
961+
*
962+
* @throws InvalidArgumentException
963+
*/
964+
private function whereBetweenHaving(string $qbKey, ?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null): static
965+
{
966+
if ($key === null || $key === '') {
967+
throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
968+
}
969+
970+
if (! is_array($values) || count($values) !== 2) {
971+
throw new InvalidArgumentException(sprintf('%s() expects $values to be an array containing exactly two values', debug_backtrace(0, 2)[1]['function']));
972+
}
973+
974+
$escape ??= $this->db->protectIdentifiers;
975+
$values = array_values($values);
976+
977+
$lowerBind = $this->setBind($key, $values[0], $escape);
978+
$upperBind = $this->setBind($key, $values[1], $escape);
979+
$not = $not ? ' NOT' : '';
980+
$prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);
981+
982+
$this->{$qbKey}[] = [
983+
'betweenComparison' => true,
984+
'condition' => $prefix,
985+
'escape' => $escape,
986+
'key' => $key,
987+
'lowerBind' => $lowerBind,
988+
'not' => $not,
989+
'upperBind' => $upperBind,
990+
];
991+
992+
return $this;
993+
}
994+
890995
/**
891996
* @used-by whereExists()
892997
* @used-by orWhereExists()
@@ -3395,6 +3500,12 @@ protected function compileWhereHaving(string $qbKey): string
33953500
continue;
33963501
}
33973502

3503+
if (($qbkey['betweenComparison'] ?? false) === true) {
3504+
$qbkey = $this->compileBetweenComparison($qbkey);
3505+
3506+
continue;
3507+
}
3508+
33983509
if ($qbkey['escape'] === false) {
33993510
$qbkey = $qbkey['condition'];
34003511

@@ -3473,6 +3584,20 @@ private function compileColumnComparison(array $condition): string
34733584
return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
34743585
}
34753586

3587+
/**
3588+
* @used-by compileWhereHaving()
3589+
*
3590+
* @param array{betweenComparison: true, condition: string, escape: bool, key: string, lowerBind: string, not: string, upperBind: string} $condition
3591+
*/
3592+
private function compileBetweenComparison(array $condition): string
3593+
{
3594+
if ($condition['escape']) {
3595+
$condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true);
3596+
}
3597+
3598+
return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':';
3599+
}
3600+
34763601
/**
34773602
* Escapes identifiers in GROUP BY statements at execution time.
34783603
*

system/Model.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@
7272
* @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
7373
* @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
7474
* @method $this orWhere($key, $value = null, ?bool $escape = null)
75+
* @method $this orWhereBetween(?string $key = null, $values = null, ?bool $escape = null)
7576
* @method $this orWhereColumn(string $first, string $second, ?bool $escape = null)
7677
* @method $this orWhereExists($subquery)
7778
* @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
79+
* @method $this orWhereNotBetween(?string $key = null, $values = null, ?bool $escape = null)
7880
* @method $this orWhereNotExists($subquery)
7981
* @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
8082
* @method $this select($select = '*', ?bool $escape = null)
@@ -86,9 +88,11 @@
8688
* @method $this when($condition, callable $callback, ?callable $defaultCallback = null)
8789
* @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
8890
* @method $this where($key, $value = null, ?bool $escape = null)
91+
* @method $this whereBetween(?string $key = null, $values = null, ?bool $escape = null)
8992
* @method $this whereColumn(string $first, string $second, ?bool $escape = null)
9093
* @method $this whereExists($subquery)
9194
* @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
95+
* @method $this whereNotBetween(?string $key = null, $values = null, ?bool $escape = null)
9296
* @method $this whereNotExists($subquery)
9397
* @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
9498
*

tests/system/Database/Builder/PrefixTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,28 @@ public function testPrefixesSetOnTableNamesWithWhereColumnClause(): void
6868
$this->assertSame($expectedBinds, $builder->getBinds());
6969
}
7070

71+
public function testPrefixesSetOnTableNamesWithWhereBetweenClause(): void
72+
{
73+
$builder = $this->db->table('users');
74+
75+
$expectedSQL = 'SELECT * FROM "ci_users" WHERE "ci_users"."created_at" BETWEEN 1 AND 10';
76+
$expectedBinds = [
77+
'users.created_at' => [
78+
1,
79+
true,
80+
],
81+
'users.created_at.1' => [
82+
10,
83+
true,
84+
],
85+
];
86+
87+
$builder->whereBetween('users.created_at', [1, 10]);
88+
89+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
90+
$this->assertSame($expectedBinds, $builder->getBinds());
91+
}
92+
7193
public function testPrefixWithSubquery(): void
7294
{
7395
$expected = <<<'NOWDOC'

tests/system/Database/Builder/WhereTest.php

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,153 @@ public function testWhereExistsSameBaseBuilderObject(): void
625625
$builder->whereExists($builder);
626626
}
627627

628+
#[DataProvider('provideWhereBetweenMethods')]
629+
public function testWhereBetweenMethods(string $method, string $sql): void
630+
{
631+
$builder = $this->db->table('jobs');
632+
633+
$builder->{$method}('created_at', ['2026-01-01', '2026-01-31']);
634+
635+
$expectedSQL = 'SELECT * FROM "jobs" WHERE "created_at" ' . $sql . " '2026-01-01' AND '2026-01-31'";
636+
$expectedBinds = [
637+
'created_at' => [
638+
'2026-01-01',
639+
true,
640+
],
641+
'created_at.1' => [
642+
'2026-01-31',
643+
true,
644+
],
645+
];
646+
647+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
648+
$this->assertSame($expectedBinds, $builder->getBinds());
649+
}
650+
651+
/**
652+
* @return iterable<string, array{string, string}>
653+
*/
654+
public static function provideWhereBetweenMethods(): iterable
655+
{
656+
return [
657+
'between' => ['whereBetween', 'BETWEEN'],
658+
'not between' => ['whereNotBetween', 'NOT BETWEEN'],
659+
];
660+
}
661+
662+
#[DataProvider('provideOrWhereBetweenMethods')]
663+
public function testOrWhereBetweenMethods(string $method, string $sql): void
664+
{
665+
$builder = $this->db->table('jobs');
666+
667+
$builder->where('active', 1)
668+
->{$method}('created_at', ['2026-01-01', '2026-01-31']);
669+
670+
$expectedSQL = 'SELECT * FROM "jobs" WHERE "active" = 1 OR "created_at" ' . $sql . " '2026-01-01' AND '2026-01-31'";
671+
672+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
673+
}
674+
675+
/**
676+
* @return iterable<string, array{string, string}>
677+
*/
678+
public static function provideOrWhereBetweenMethods(): iterable
679+
{
680+
return [
681+
'or between' => ['orWhereBetween', 'BETWEEN'],
682+
'or not between' => ['orWhereNotBetween', 'NOT BETWEEN'],
683+
];
684+
}
685+
686+
public function testWhereBetweenWithGroupedConditions(): void
687+
{
688+
$builder = $this->db->table('jobs');
689+
690+
$builder->groupStart()
691+
->whereBetween('created_at', ['2026-01-01', '2026-01-31'])
692+
->orWhereNotBetween('updated_at', ['2026-02-01', '2026-02-28'])
693+
->groupEnd()
694+
->where('active', 1);
695+
696+
$expectedSQL = 'SELECT * FROM "jobs" WHERE ( "created_at" BETWEEN \'2026-01-01\' AND \'2026-01-31\' OR "updated_at" NOT BETWEEN \'2026-02-01\' AND \'2026-02-28\' ) AND "active" = 1';
697+
698+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
699+
}
700+
701+
public function testWhereBetweenNoEscape(): void
702+
{
703+
$builder = $this->db->table('jobs');
704+
705+
$builder->whereBetween('DATE(created_at)', ['20260101', '20260131'], escape: false);
706+
707+
$expectedSQL = 'SELECT * FROM "jobs" WHERE DATE(created_at) BETWEEN 20260101 AND 20260131';
708+
$expectedBinds = [
709+
'DATE(created_at)' => [
710+
'20260101',
711+
false,
712+
],
713+
'DATE(created_at).1' => [
714+
'20260131',
715+
false,
716+
],
717+
];
718+
719+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
720+
$this->assertSame($expectedBinds, $builder->getBinds());
721+
}
722+
723+
public function testWhereBetweenWithAliasBeforeFrom(): void
724+
{
725+
$builder = $this->db->newQuery();
726+
727+
$builder->whereBetween('u.created_at', ['2026-01-01', '2026-01-31'])
728+
->from('users u');
729+
730+
$expectedSQL = 'SELECT * FROM "users" "u" WHERE "u"."created_at" BETWEEN \'2026-01-01\' AND \'2026-01-31\'';
731+
732+
$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
733+
}
734+
735+
/**
736+
* @param mixed $key
737+
*/
738+
#[DataProvider('provideWhereInvalidKeyThrowInvalidArgumentException')]
739+
public function testWhereBetweenInvalidKeyThrowInvalidArgumentException($key): void
740+
{
741+
$this->expectException(InvalidArgumentException::class);
742+
743+
$builder = $this->db->table('jobs');
744+
$builder->whereBetween($key, ['2026-01-01', '2026-01-31']);
745+
}
746+
747+
/**
748+
* @param mixed $values
749+
*/
750+
#[DataProvider('provideWhereBetweenInvalidValuesThrowInvalidArgumentException')]
751+
public function testWhereBetweenInvalidValuesThrowInvalidArgumentException($values): void
752+
{
753+
$this->expectException(InvalidArgumentException::class);
754+
755+
$builder = $this->db->table('jobs');
756+
$builder->whereBetween('created_at', $values);
757+
}
758+
759+
/**
760+
* @return iterable<string, array{mixed}>
761+
*/
762+
public static function provideWhereBetweenInvalidValuesThrowInvalidArgumentException(): iterable
763+
{
764+
return [
765+
'null' => [null],
766+
'not array' => ['not array'],
767+
'empty array' => [[]],
768+
'one value' => [['2026-01-01']],
769+
'three values' => [
770+
['2026-01-01', '2026-01-31', '2026-02-28'],
771+
],
772+
];
773+
}
774+
628775
public function testWhereIn(): void
629776
{
630777
$builder = $this->db->table('jobs');

user_guide_src/source/changelogs/v4.8.0.rst

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

220+
- Added ``whereBetween()``, ``orWhereBetween()``, ``whereNotBetween()``, and ``orWhereNotBetween()`` to Query Builder. See :ref:`query-builder-where-between`.
220221
- Added ``whereColumn()`` and ``orWhereColumn()`` to compare one column to another column while protecting identifiers by default. See :ref:`query-builder-where-column`.
221222
- Added ``whereExists()``, ``orWhereExists()``, ``whereNotExists()``, and ``orWhereNotExists()`` to add ``EXISTS`` and ``NOT EXISTS`` subquery conditions. See :ref:`query-builder-where-exists`.
222223
- Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations.

0 commit comments

Comments
 (0)