Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions system/Database/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,112 @@ private function parseWhereColumnFirst(string $first): array
return [$first, '='];
}

/**
* Generates a WHERE field BETWEEN minimum AND maximum SQL query,
* joined with 'AND' if appropriate.
*
* @param array<array-key, mixed>|null $values The range values searched on
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function whereBetween(?string $key = null, $values = null, ?bool $escape = null): static
{
return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'AND ', $escape);
}

/**
* Generates a WHERE field BETWEEN minimum AND maximum SQL query,
* joined with 'OR' if appropriate.
*
* @param array<array-key, mixed>|null $values The range values searched on
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function orWhereBetween(?string $key = null, $values = null, ?bool $escape = null): static
{
return $this->whereBetweenHaving('QBWhere', $key, $values, false, 'OR ', $escape);
}

/**
* Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query,
* joined with 'AND' if appropriate.
*
* @param array<array-key, mixed>|null $values The range values searched on
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function whereNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
{
return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'AND ', $escape);
}

/**
* Generates a WHERE field NOT BETWEEN minimum AND maximum SQL query,
* joined with 'OR' if appropriate.
*
* @param array<array-key, mixed>|null $values The range values searched on
*
* @return $this
*
* @throws InvalidArgumentException
*/
public function orWhereNotBetween(?string $key = null, $values = null, ?bool $escape = null): static
{
return $this->whereBetweenHaving('QBWhere', $key, $values, true, 'OR ', $escape);
}

/**
* @used-by whereBetween()
* @used-by orWhereBetween()
* @used-by whereNotBetween()
* @used-by orWhereNotBetween()
*
* @param 'QBHaving'|'QBWhere' $qbKey
* @param non-empty-string|null $key
* @param array<array-key, mixed>|null $values The range values searched on
*
* @return $this
*
* @throws InvalidArgumentException
*/
protected function whereBetweenHaving(string $qbKey, ?string $key = null, $values = null, bool $not = false, string $type = 'AND ', ?bool $escape = null): static
{
if ($key === null || $key === '') {
throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
}

if (! is_array($values) || count($values) !== 2) {
throw new InvalidArgumentException(sprintf('%s() expects $values to be an array containing exactly two values', debug_backtrace(0, 2)[1]['function']));
}

$escape ??= $this->db->protectIdentifiers;
$values = array_values($values);
$ok = $key;

$lowerBind = $this->setBind($ok, $values[0], $escape);
$upperBind = $this->setBind($ok, $values[1], $escape);
$not = $not ? ' NOT' : '';
$prefix = $this->{$qbKey} === [] ? $this->groupGetType('') : $this->groupGetType($type);

$this->{$qbKey}[] = [
'betweenComparison' => true,
'condition' => $prefix,
'escape' => $escape,
'key' => $key,
'lowerBind' => $lowerBind,
'not' => $not,
'upperBind' => $upperBind,
];

return $this;
}

/**
* @used-by whereExists()
* @used-by orWhereExists()
Expand Down Expand Up @@ -3395,6 +3501,12 @@ protected function compileWhereHaving(string $qbKey): string
continue;
}

if (($qbkey['betweenComparison'] ?? false) === true) {
$qbkey = $this->compileBetweenComparison($qbkey);

continue;
}

if ($qbkey['escape'] === false) {
$qbkey = $qbkey['condition'];

Expand Down Expand Up @@ -3473,6 +3585,20 @@ private function compileColumnComparison(array $condition): string
return $condition['condition'] . $condition['first'] . ' ' . $condition['operator'] . ' ' . $condition['second'];
}

/**
* @used-by compileWhereHaving()
*
* @param array{betweenComparison: true, condition: string, escape: bool, key: string, lowerBind: string, not: string, upperBind: string} $condition
*/
private function compileBetweenComparison(array $condition): string
{
if ($condition['escape']) {
$condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true);
}

return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':';
}

/**
* Escapes identifiers in GROUP BY statements at execution time.
*
Expand Down
4 changes: 4 additions & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@
* @method $this orNotHavingLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
* @method $this orNotLike($field, string $match = '', string $side = 'both', ?bool $escape = null, bool $insensitiveSearch = false)
* @method $this orWhere($key, $value = null, ?bool $escape = null)
* @method $this orWhereBetween(?string $key = null, $values = null, ?bool $escape = null)
* @method $this orWhereColumn(string $first, string $second, ?bool $escape = null)
* @method $this orWhereExists($subquery)
* @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null)
* @method $this orWhereNotBetween(?string $key = null, $values = null, ?bool $escape = null)
* @method $this orWhereNotExists($subquery)
* @method $this orWhereNotIn(?string $key = null, $values = null, ?bool $escape = null)
* @method $this select($select = '*', ?bool $escape = null)
Expand All @@ -86,9 +88,11 @@
* @method $this when($condition, callable $callback, ?callable $defaultCallback = null)
* @method $this whenNot($condition, callable $callback, ?callable $defaultCallback = null)
* @method $this where($key, $value = null, ?bool $escape = null)
* @method $this whereBetween(?string $key = null, $values = null, ?bool $escape = null)
* @method $this whereColumn(string $first, string $second, ?bool $escape = null)
* @method $this whereExists($subquery)
* @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null)
* @method $this whereNotBetween(?string $key = null, $values = null, ?bool $escape = null)
* @method $this whereNotExists($subquery)
* @method $this whereNotIn(?string $key = null, $values = null, ?bool $escape = null)
*
Expand Down
22 changes: 22 additions & 0 deletions tests/system/Database/Builder/PrefixTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ public function testPrefixesSetOnTableNamesWithWhereColumnClause(): void
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testPrefixesSetOnTableNamesWithWhereBetweenClause(): void
{
$builder = $this->db->table('users');

$expectedSQL = 'SELECT * FROM "ci_users" WHERE "ci_users"."created_at" BETWEEN 1 AND 10';
$expectedBinds = [
'users.created_at' => [
1,
true,
],
'users.created_at.1' => [
10,
true,
],
];

$builder->whereBetween('users.created_at', [1, 10]);

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testPrefixWithSubquery(): void
{
$expected = <<<'NOWDOC'
Expand Down
147 changes: 147 additions & 0 deletions tests/system/Database/Builder/WhereTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,153 @@ public function testWhereExistsSameBaseBuilderObject(): void
$builder->whereExists($builder);
}

#[DataProvider('provideWhereBetweenMethods')]
public function testWhereBetweenMethods(string $method, string $sql): void
{
$builder = $this->db->table('jobs');

$builder->{$method}('created_at', ['2026-01-01', '2026-01-31']);

$expectedSQL = 'SELECT * FROM "jobs" WHERE "created_at" ' . $sql . " '2026-01-01' AND '2026-01-31'";
$expectedBinds = [
'created_at' => [
'2026-01-01',
true,
],
'created_at.1' => [
'2026-01-31',
true,
],
];

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
$this->assertSame($expectedBinds, $builder->getBinds());
}

/**
* @return iterable<string, array{string, string}>
*/
public static function provideWhereBetweenMethods(): iterable
{
return [
'between' => ['whereBetween', 'BETWEEN'],
'not between' => ['whereNotBetween', 'NOT BETWEEN'],
];
}

#[DataProvider('provideOrWhereBetweenMethods')]
public function testOrWhereBetweenMethods(string $method, string $sql): void
{
$builder = $this->db->table('jobs');

$builder->where('active', 1)
->{$method}('created_at', ['2026-01-01', '2026-01-31']);

$expectedSQL = 'SELECT * FROM "jobs" WHERE "active" = 1 OR "created_at" ' . $sql . " '2026-01-01' AND '2026-01-31'";

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
}

/**
* @return iterable<string, array{string, string}>
*/
public static function provideOrWhereBetweenMethods(): iterable
{
return [
'or between' => ['orWhereBetween', 'BETWEEN'],
'or not between' => ['orWhereNotBetween', 'NOT BETWEEN'],
];
}

public function testWhereBetweenWithGroupedConditions(): void
{
$builder = $this->db->table('jobs');

$builder->groupStart()
->whereBetween('created_at', ['2026-01-01', '2026-01-31'])
->orWhereNotBetween('updated_at', ['2026-02-01', '2026-02-28'])
->groupEnd()
->where('active', 1);

$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';

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
}

public function testWhereBetweenNoEscape(): void
{
$builder = $this->db->table('jobs');

$builder->whereBetween('DATE(created_at)', ['20260101', '20260131'], escape: false);

$expectedSQL = 'SELECT * FROM "jobs" WHERE DATE(created_at) BETWEEN 20260101 AND 20260131';
$expectedBinds = [
'DATE(created_at)' => [
'20260101',
false,
],
'DATE(created_at).1' => [
'20260131',
false,
],
];

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
$this->assertSame($expectedBinds, $builder->getBinds());
}

public function testWhereBetweenWithAliasBeforeFrom(): void
{
$builder = $this->db->newQuery();

$builder->whereBetween('u.created_at', ['2026-01-01', '2026-01-31'])
->from('users u');

$expectedSQL = 'SELECT * FROM "users" "u" WHERE "u"."created_at" BETWEEN \'2026-01-01\' AND \'2026-01-31\'';

$this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect()));
}

/**
* @param mixed $key
*/
#[DataProvider('provideWhereInvalidKeyThrowInvalidArgumentException')]
public function testWhereBetweenInvalidKeyThrowInvalidArgumentException($key): void
{
$this->expectException(InvalidArgumentException::class);

$builder = $this->db->table('jobs');
$builder->whereBetween($key, ['2026-01-01', '2026-01-31']);
}

/**
* @param mixed $values
*/
#[DataProvider('provideWhereBetweenInvalidValuesThrowInvalidArgumentException')]
public function testWhereBetweenInvalidValuesThrowInvalidArgumentException($values): void
{
$this->expectException(InvalidArgumentException::class);

$builder = $this->db->table('jobs');
$builder->whereBetween('created_at', $values);
}

/**
* @return iterable<string, array{mixed}>
*/
public static function provideWhereBetweenInvalidValuesThrowInvalidArgumentException(): iterable
{
return [
'null' => [null],
'not array' => ['not array'],
'empty array' => [[]],
'one value' => [['2026-01-01']],
'three values' => [
['2026-01-01', '2026-01-31', '2026-02-28'],
],
];
}

public function testWhereIn(): void
{
$builder = $this->db->table('jobs');
Expand Down
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ Database
Query Builder
-------------

- 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`.
- Added new ``incrementMany()`` and ``decrementMany()`` methods to ``CodeIgniter\Database\BaseBuilder`` for performing bulk increment/decrement operations.
Expand Down
Loading
Loading