diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index b9d01063ffc1..e12ad26a466c 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -887,6 +887,111 @@ 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|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|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|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|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|null $values The range values searched on + * + * @return $this + * + * @throws InvalidArgumentException + */ + private 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); + + $lowerBind = $this->setBind($key, $values[0], $escape); + $upperBind = $this->setBind($key, $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() @@ -3395,6 +3500,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']; @@ -3473,6 +3584,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. * diff --git a/system/Model.php b/system/Model.php index ffa4889900a3..86d5da6d7095 100644 --- a/system/Model.php +++ b/system/Model.php @@ -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) @@ -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) * diff --git a/tests/system/Database/Builder/PrefixTest.php b/tests/system/Database/Builder/PrefixTest.php index a88de27c7a9b..44d405ce7643 100644 --- a/tests/system/Database/Builder/PrefixTest.php +++ b/tests/system/Database/Builder/PrefixTest.php @@ -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' diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index 398f40008745..a8a0586b798d 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -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 + */ + 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 + */ + 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 + */ + 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'); diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 36f95f466138..c228d7882d77 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 ``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. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index ead86baac6a9..d77deff5fbe8 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -395,6 +395,44 @@ $builder->orWhereColumn() This method is identical to ``whereColumn()``, except that multiple instances are joined by **OR**. +.. _query-builder-where-between: + +$builder->whereBetween() +------------------------ + +.. versionadded:: 4.8.0 + +Generates a **WHERE** field ``BETWEEN`` minimum and maximum value SQL query. +``BETWEEN`` includes both values: + +.. literalinclude:: query_builder/126.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 ``where()`` with :ref:`RawSql ` + and escape values manually. + +$builder->orWhereBetween() +-------------------------- + +This method is identical to ``whereBetween()``, except that multiple instances +are joined by **OR**. + +$builder->whereNotBetween() +--------------------------- + +This method is identical to ``whereBetween()``, except that it generates +``NOT BETWEEN``. + +$builder->orWhereNotBetween() +----------------------------- + +This method is identical to ``whereNotBetween()``, except that multiple +instances are joined by **OR**. + .. _query-builder-where-exists: $builder->whereExists() @@ -1679,6 +1717,46 @@ Class Reference If ``$first`` does not end with a supported operator, ``=`` is used as the comparison operator. Supported operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. + .. php:method:: whereBetween([$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 ``WHERE`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate. + + .. php:method:: orWhereBetween([$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 ``WHERE`` field ``BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + + .. php:method:: whereNotBetween([$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 ``WHERE`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``AND`` if appropriate. + + .. php:method:: orWhereNotBetween([$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 ``WHERE`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + .. php:method:: whereExists($subquery) :param BaseBuilder|Closure $subquery: The subquery to check for matching rows diff --git a/user_guide_src/source/database/query_builder/126.php b/user_guide_src/source/database/query_builder/126.php new file mode 100644 index 000000000000..4345abc6949f --- /dev/null +++ b/user_guide_src/source/database/query_builder/126.php @@ -0,0 +1,6 @@ +whereBetween('created_at', ['2026-01-01', '2026-01-31']); + +// Produces: +// WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'