diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 29af09d0f2f6..c88cdb9a71ef 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -19,6 +19,7 @@ use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Traits\ConditionalTrait; use Config\Feature; +use DateTimeInterface; use TypeError; /** @@ -774,6 +775,246 @@ public function orWhere($key, $value = null, ?bool $escape = null) return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape); } + /** + * Generates a WHERE clause that compares the date portion of a field. + * + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function whereDate(string $key, $value, ?bool $escape = null): static + { + return $this->whereDatePart('date', $key, $value, 'AND ', $escape); + } + + /** + * Generates an OR WHERE clause that compares the date portion of a field. + * + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orWhereDate(string $key, $value, ?bool $escape = null): static + { + return $this->whereDatePart('date', $key, $value, 'OR ', $escape); + } + + /** + * Generates a WHERE clause that compares the year portion of a field. + * + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function whereYear(string $key, $value, ?bool $escape = null): static + { + return $this->whereDatePart('year', $key, $value, 'AND ', $escape); + } + + /** + * Generates an OR WHERE clause that compares the year portion of a field. + * + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orWhereYear(string $key, $value, ?bool $escape = null): static + { + return $this->whereDatePart('year', $key, $value, 'OR ', $escape); + } + + /** + * Generates a WHERE clause that compares the month portion of a field. + * + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function whereMonth(string $key, $value, ?bool $escape = null): static + { + return $this->whereDatePart('month', $key, $value, 'AND ', $escape); + } + + /** + * Generates an OR WHERE clause that compares the month portion of a field. + * + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orWhereMonth(string $key, $value, ?bool $escape = null): static + { + return $this->whereDatePart('month', $key, $value, 'OR ', $escape); + } + + /** + * Generates a WHERE clause that compares the day portion of a field. + * + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function whereDay(string $key, $value, ?bool $escape = null): static + { + return $this->whereDatePart('day', $key, $value, 'AND ', $escape); + } + + /** + * Generates an OR WHERE clause that compares the day portion of a field. + * + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function orWhereDay(string $key, $value, ?bool $escape = null): static + { + return $this->whereDatePart('day', $key, $value, 'OR ', $escape); + } + + /** + * @used-by whereDate() + * @used-by orWhereDate() + * @used-by whereYear() + * @used-by orWhereYear() + * @used-by whereMonth() + * @used-by orWhereMonth() + * @used-by whereDay() + * @used-by orWhereDay() + * + * @param 'date'|'day'|'month'|'year' $part + * @param non-empty-string $key Field name, optionally with comparison operator + * @param mixed $value + * + * @return $this + * + * @throws InvalidArgumentException + */ + private function whereDatePart(string $part, string $key, $value, string $type = 'AND ', ?bool $escape = null): static + { + [$key, $operator] = $this->parseDatePartKey($key); + + if ($key === '') { + throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function'])); + } + + $escape ??= $this->db->protectIdentifiers; + + if (is_array($value) || $this->isSubquery($value)) { + throw new InvalidArgumentException(sprintf('%s() does not accept array or subquery values', debug_backtrace(0, 2)[1]['function'])); + } + + if ($value === null) { + $nullOperator = match ($operator) { + '=' => 'IS NULL', + '!=', '<>' => 'IS NOT NULL', + default => throw new InvalidArgumentException(sprintf('%s() supports null values only with =, !=, or <> operators', debug_backtrace(0, 2)[1]['function'])), + }; + + $this->addWhereHavingCondition('QBWhere', [ + 'condition' => '', + 'datePartComparison' => true, + 'escape' => $escape, + 'key' => $key, + 'nullComparison' => true, + 'nullOperator' => $nullOperator, + 'part' => $part, + ], $type); + + return $this; + } + + $value = $this->normalizeDatePartValue($part, $value); + $bind = $this->setBind($key, $value, $escape); + + $this->addWhereHavingCondition('QBWhere', [ + 'condition' => '', + 'datePartComparison' => true, + 'escape' => $escape, + 'key' => $key, + 'operator' => $operator, + 'part' => $part, + 'rawValue' => $value instanceof RawSql, + 'valueBind' => $bind, + ], $type); + + return $this; + } + + /** + * Extracts the comparison operator from date helper keys. + * + * @return array{string, string} + * + * @throws InvalidArgumentException + */ + private function parseDatePartKey(string $key): array + { + $key = trim($key); + + if ( + preg_match('/\s+(?:IS(?:\s+NOT)?(?:\s+NULL)?|(?:NOT\s+)?(?:LIKE|IN|BETWEEN|EXISTS|REGEXP|RLIKE))(?:\s+.*|\s*\(.*\))?$/i', $key) === 1 + || preg_match('/\s*<=>\s*$/', $key) === 1 + ) { + throw new InvalidArgumentException(sprintf('%s() supports only comparison operators: =, !=, <>, <, >, <=, >=', debug_backtrace(0, 3)[2]['function'])); + } + + if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $key, $match) === 1) { + return [rtrim(substr($key, 0, -strlen($match[0]))), trim($match[1])]; + } + + return [$key, '=']; + } + + /** + * Converts DateTime values into deterministic date helper comparison values. + * + * @param 'date'|'day'|'month'|'year' $part + * @param mixed $value + * + * @return mixed + */ + private function normalizeDatePartValue(string $part, $value) + { + if ($value instanceof DateTimeInterface) { + return match ($part) { + 'date' => $value->format('Y-m-d'), + 'year' => (int) $value->format('Y'), + 'month' => (int) $value->format('m'), + 'day' => (int) $value->format('d'), + }; + } + + if (! $value instanceof RawSql && $part !== 'date' && (is_int($value) || (is_string($value) && preg_match('/^-?\d+$/', $value) === 1))) { + return (int) $value; + } + + return $value; + } + /** * Generates a WHERE clause that compares two columns. * @@ -3763,6 +4004,10 @@ private function compileWhereHavingCondition(array|RawSql|string $condition): Ra return $this->compileBetweenComparison($condition); } + if (($condition['datePartComparison'] ?? false) === true) { + return $this->compileDatePartComparison($condition); + } + if ($condition['escape'] === false) { return $condition['condition']; } @@ -3854,6 +4099,56 @@ private function compileBetweenComparison(array $condition): string return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':'; } + /** + * @used-by compileWhereHavingCondition() + * + * @param array{condition: string, datePartComparison: true, escape: bool, key: string, nullComparison?: true, nullOperator?: string, operator?: string, part: 'date'|'day'|'month'|'year', rawValue?: bool, valueBind?: string} $condition + */ + private function compileDatePartComparison(array $condition): string + { + if ($condition['escape']) { + $condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true); + } + + $expression = $this->compileDatePartExpression($condition['part'], $condition['key']); + + if (($condition['nullComparison'] ?? false) === true) { + return $condition['condition'] + . $expression + . ' ' . $condition['nullOperator']; + } + + return $condition['condition'] + . $expression + . ' ' . $condition['operator'] . ' ' + . $this->compileDatePartValue($condition['part'], $condition['valueBind'], $condition['rawValue']); + } + + /** + * Compiles a driver-specific SQL expression for Query Builder date helpers. + * + * @param 'date'|'day'|'month'|'year' $part + */ + protected function compileDatePartExpression(string $part, string $field): string + { + return match ($part) { + 'date' => 'CAST(' . $field . ' AS DATE)', + 'year' => 'EXTRACT(YEAR FROM ' . $field . ')', + 'month' => 'EXTRACT(MONTH FROM ' . $field . ')', + 'day' => 'EXTRACT(DAY FROM ' . $field . ')', + }; + } + + /** + * Compiles the value side for Query Builder date helpers. + * + * @param 'date'|'day'|'month'|'year' $part + */ + protected function compileDatePartValue(string $part, string $bind, bool $rawValue): string + { + return ':' . $bind . ':'; + } + /** * Escapes identifiers in GROUP BY statements at execution time. * diff --git a/system/Database/MySQLi/Builder.php b/system/Database/MySQLi/Builder.php index 975bb8b24823..6ed43eda13cb 100644 --- a/system/Database/MySQLi/Builder.php +++ b/system/Database/MySQLi/Builder.php @@ -76,6 +76,21 @@ protected function compileLockForUpdate(): string return parent::compileLockForUpdate(); } + /** + * Compiles a driver-specific SQL expression for Query Builder date helpers. + * + * @param 'date'|'day'|'month'|'year' $part + */ + protected function compileDatePartExpression(string $part, string $field): string + { + return match ($part) { + 'date' => 'DATE(' . $field . ')', + 'year' => 'YEAR(' . $field . ')', + 'month' => 'MONTH(' . $field . ')', + 'day' => 'DAY(' . $field . ')', + }; + } + /** * Generates a platform-specific batch update string from the supplied data */ diff --git a/system/Database/OCI8/Builder.php b/system/Database/OCI8/Builder.php index 54eafe6098de..bcbc085f0d3b 100644 --- a/system/Database/OCI8/Builder.php +++ b/system/Database/OCI8/Builder.php @@ -147,6 +147,32 @@ protected function _truncate(string $table): string return 'TRUNCATE TABLE ' . $table; } + /** + * Compiles a driver-specific SQL expression for Query Builder date helpers. + * + * @param 'date'|'day'|'month'|'year' $part + */ + protected function compileDatePartExpression(string $part, string $field): string + { + if ($part === 'date') { + return 'TRUNC(' . $field . ')'; + } + + return 'EXTRACT(' . strtoupper($part) . ' FROM ' . $field . ')'; + } + + /** + * Compiles the value side for Query Builder date helpers. + * + * @param 'date'|'day'|'month'|'year' $part + */ + protected function compileDatePartValue(string $part, string $bind, bool $rawValue): string + { + $value = parent::compileDatePartValue($part, $bind, $rawValue); + + return $part === 'date' && ! $rawValue ? "TO_DATE({$value}, 'YYYY-MM-DD')" : $value; + } + /** * Compiles a delete string and runs the query * diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index 823d9bac2b63..a7effb347d42 100644 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -110,6 +110,20 @@ protected function compileJoinTable(string $table, bool $escape): string return $this->getFullName($table); } + /** + * Compiles a driver-specific SQL expression for Query Builder date helpers. + * + * @param 'date'|'day'|'month'|'year' $part + */ + protected function compileDatePartExpression(string $part, string $field): string + { + if ($part === 'date') { + return 'CAST(' . $field . ' AS DATE)'; + } + + return 'DATEPART(' . strtoupper($part) . ', ' . $field . ')'; + } + /** * Generates a platform-specific insert string from the supplied data * diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php index a0403f8827d7..d49bd490e0a9 100644 --- a/system/Database/SQLite3/Builder.php +++ b/system/Database/SQLite3/Builder.php @@ -75,6 +75,33 @@ protected function compileExplain(string $sql): string return 'EXPLAIN QUERY PLAN ' . $sql; } + /** + * Compiles a driver-specific SQL expression for Query Builder date helpers. + * + * @param 'date'|'day'|'month'|'year' $part + */ + protected function compileDatePartExpression(string $part, string $field): string + { + return match ($part) { + 'date' => 'DATE(' . $field . ')', + 'year' => "CAST(STRFTIME('%Y', " . $field . ') AS INTEGER)', + 'month' => "CAST(STRFTIME('%m', " . $field . ') AS INTEGER)', + 'day' => "CAST(STRFTIME('%d', " . $field . ') AS INTEGER)', + }; + } + + /** + * Compiles the value side for Query Builder date helpers. + * + * @param 'date'|'day'|'month'|'year' $part + */ + protected function compileDatePartValue(string $part, string $bind, bool $rawValue): string + { + $value = parent::compileDatePartValue($part, $bind, $rawValue); + + return $part === 'date' || $rawValue ? $value : 'CAST(' . $value . ' AS INTEGER)'; + } + /** * Replace statement * diff --git a/system/Model.php b/system/Model.php index f6b125260398..65df64753412 100644 --- a/system/Model.php +++ b/system/Model.php @@ -80,11 +80,15 @@ * @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 orWhereDate(string $key, $value, ?bool $escape = null) + * @method $this orWhereDay(string $key, $value, ?bool $escape = null) * @method $this orWhereExists($subquery) * @method $this orWhereIn(?string $key = null, $values = null, ?bool $escape = null) + * @method $this orWhereMonth(string $key, $value, ?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 orWhereYear(string $key, $value, ?bool $escape = null) * @method $this select($select = '*', ?bool $escape = null) * @method $this selectAvg(string $select = '', string $alias = '') * @method $this selectCount(string $select = '', string $alias = '') @@ -96,11 +100,15 @@ * @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 whereDate(string $key, $value, ?bool $escape = null) + * @method $this whereDay(string $key, $value, ?bool $escape = null) * @method $this whereExists($subquery) * @method $this whereIn(?string $key = null, $values = null, ?bool $escape = null) + * @method $this whereMonth(string $key, $value, ?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) + * @method $this whereYear(string $key, $value, ?bool $escape = null) * * @phpstan-method $this when($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null) * @phpstan-method $this whenNot($condition, callable(BaseBuilder, mixed): mixed $callback, (callable(BaseBuilder): mixed)|null $defaultCallback = null) diff --git a/tests/system/Database/Builder/WhereTest.php b/tests/system/Database/Builder/WhereTest.php index 484bbe154fe9..3d6a558597b9 100644 --- a/tests/system/Database/Builder/WhereTest.php +++ b/tests/system/Database/Builder/WhereTest.php @@ -15,7 +15,12 @@ use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\MySQLi\Builder as MySQLiBuilder; +use CodeIgniter\Database\OCI8\Builder as OCI8Builder; +use CodeIgniter\Database\Postgre\Builder as PostgreBuilder; use CodeIgniter\Database\RawSql; +use CodeIgniter\Database\SQLite3\Builder as SQLite3Builder; +use CodeIgniter\Database\SQLSRV\Builder as SQLSRVBuilder; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockConnection; @@ -672,6 +677,276 @@ public function testWhereExistsSameBaseBuilderObject(): void $builder->whereExists($builder); } + #[DataProvider('provideWhereDatePartMethods')] + public function testWhereDatePartMethods(string $method, string $expression, int|string $value): void + { + $builder = $this->db->table('jobs'); + + $builder->{$method}('created_at', $value); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE ' . $expression . ' = ' . (is_int($value) ? $value : "'{$value}'"); + $expectedBinds = [ + 'created_at' => [ + $value, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + /** + * @return iterable + */ + public static function provideWhereDatePartMethods(): iterable + { + return [ + 'date' => ['whereDate', 'CAST("created_at" AS DATE)', '2026-01-31'], + 'year' => ['whereYear', 'EXTRACT(YEAR FROM "created_at")', 2026], + 'month' => ['whereMonth', 'EXTRACT(MONTH FROM "created_at")', 1], + 'day' => ['whereDay', 'EXTRACT(DAY FROM "created_at")', 31], + ]; + } + + public function testWhereDatePartWithOperator(): void + { + $builder = $this->db->table('jobs'); + + $builder->whereDate('created_at >=', '2026-01-01'); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE CAST("created_at" AS DATE) >= \'2026-01-01\''; + $expectedBinds = [ + 'created_at' => [ + '2026-01-01', + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testWhereDatePartWithNull(): void + { + $builder = $this->db->table('jobs'); + + $builder->whereDate('created_at', null) + ->orWhereDate('deleted_at !=', null); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE CAST("created_at" AS DATE) IS NULL OR CAST("deleted_at" AS DATE) IS NOT NULL'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame([], $builder->getBinds()); + } + + public function testWhereDatePartWithInvalidNullOperatorThrowInvalidArgumentException(): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('jobs'); + $builder->whereDate('created_at >', null); + } + + #[DataProvider('provideWhereDatePartInvalidValueThrowInvalidArgumentException')] + public function testWhereDatePartInvalidValueThrowInvalidArgumentException(mixed $value): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('jobs'); + $builder->whereDate('created_at', $value); + } + + /** + * @return iterable + */ + public static function provideWhereDatePartInvalidValueThrowInvalidArgumentException(): iterable + { + return [ + 'array' => [['2026-01-01', '2026-01-31']], + 'subquery' => [static fn (BaseBuilder $builder): BaseBuilder => $builder], + ]; + } + + public function testOrWhereDatePart(): void + { + $builder = $this->db->table('jobs'); + + $builder->where('active', 1) + ->orWhereYear('created_at', 2026); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE "active" = 1 OR EXTRACT(YEAR FROM "created_at") = 2026'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testWhereDatePartWithGroupedConditions(): void + { + $builder = $this->db->table('jobs'); + + $builder->groupStart() + ->whereYear('created_at', 2026) + ->orWhereMonth('created_at', 5) + ->groupEnd() + ->where('active', 1); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE ( EXTRACT(YEAR FROM "created_at") = 2026 OR EXTRACT(MONTH FROM "created_at") = 5 ) AND "active" = 1'; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testWhereDatePartWithAliasBeforeFrom(): void + { + $builder = $this->db->newQuery(); + + $builder->whereDate('u.created_at', '2026-01-31') + ->from('users u'); + + $expectedSQL = 'SELECT * FROM "users" "u" WHERE CAST("u"."created_at" AS DATE) = \'2026-01-31\''; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + public function testWhereDatePartWithDateTimeValue(): void + { + $builder = $this->db->table('jobs'); + + $builder->whereDate('created_at', new DateTime('2026-05-31 12:34:56')) + ->whereMonth('created_at', new DateTime('2026-05-31 12:34:56')); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE CAST("created_at" AS DATE) = \'2026-05-31\' AND EXTRACT(MONTH FROM "created_at") = 5'; + $expectedBinds = [ + 'created_at' => [ + '2026-05-31', + true, + ], + 'created_at.1' => [ + 5, + true, + ], + ]; + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertSame($expectedBinds, $builder->getBinds()); + } + + public function testWhereDatePartNoEscape(): void + { + $builder = $this->db->table('jobs'); + + $builder->whereDate('DATE_ADD(created_at, INTERVAL 1 DAY)', new RawSql("'2026-01-31'"), escape: false); + + $expectedSQL = 'SELECT * FROM "jobs" WHERE CAST(DATE_ADD(created_at, INTERVAL 1 DAY) AS DATE) = \'2026-01-31\''; + $binds = $builder->getBinds(); + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + $this->assertInstanceOf(RawSql::class, $binds['DATE_ADD(created_at, INTERVAL 1 DAY)'][0]); + $this->assertFalse($binds['DATE_ADD(created_at, INTERVAL 1 DAY)'][1]); + } + + /** + * @param class-string $builderClass + * @param array $params + */ + #[DataProvider('provideWhereDatePartDriverCompilation')] + public function testWhereDatePartDriverCompilation(string $builderClass, array $params, string $method, int|string $value, string $expectedSQL): void + { + $builder = new $builderClass('jobs', new MockConnection($params)); + + $builder->{$method}('created_at', $value); + + $this->assertSame($expectedSQL, str_replace("\n", ' ', $builder->getCompiledSelect())); + } + + /** + * @return iterable, array, string, int|string, string}> + */ + public static function provideWhereDatePartDriverCompilation(): iterable + { + return [ + 'mysql date' => [ + MySQLiBuilder::class, + ['DBDriver' => 'MySQLi', 'escapeChar' => '`'], + 'whereDate', + '2026-01-31', + 'SELECT * FROM `jobs` WHERE DATE(`created_at`) = \'2026-01-31\'', + ], + 'postgres year' => [ + PostgreBuilder::class, + ['DBDriver' => 'Postgre'], + 'whereYear', + 2026, + 'SELECT * FROM "jobs" WHERE EXTRACT(YEAR FROM "created_at") = 2026', + ], + 'sqlsrv month' => [ + SQLSRVBuilder::class, + ['DBDriver' => 'SQLSRV', 'database' => 'test', 'schema' => 'dbo'], + 'whereMonth', + 5, + 'SELECT * FROM "test"."dbo"."jobs" WHERE DATEPART(MONTH, "created_at") = 5', + ], + 'sqlite day' => [ + SQLite3Builder::class, + ['DBDriver' => 'SQLite3', 'escapeChar' => '`'], + 'whereDay', + 31, + "SELECT * FROM `jobs` WHERE CAST(STRFTIME('%d', `created_at`) AS INTEGER) = CAST(31 AS INTEGER)", + ], + 'oci8 date' => [ + OCI8Builder::class, + ['DBDriver' => 'OCI8'], + 'whereDate', + '2026-01-31', + 'SELECT * FROM "jobs" WHERE TRUNC("created_at") = TO_DATE(\'2026-01-31\', \'YYYY-MM-DD\')', + ], + ]; + } + + #[DataProvider('provideWhereDatePartInvalidKeyThrowInvalidArgumentException')] + public function testWhereDatePartInvalidKeyThrowInvalidArgumentException(string $key): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('jobs'); + $builder->whereDate($key, '2026-01-31'); + } + + /** + * @return iterable + */ + public static function provideWhereDatePartInvalidKeyThrowInvalidArgumentException(): iterable + { + return [ + 'empty string' => [''], + ]; + } + + #[DataProvider('provideWhereDatePartUnsupportedOperatorThrowInvalidArgumentException')] + public function testWhereDatePartUnsupportedOperatorThrowInvalidArgumentException(string $key): void + { + $this->expectException(InvalidArgumentException::class); + + $builder = $this->db->table('jobs'); + $builder->whereDate($key, '2026-01-31'); + } + + /** + * @return iterable + */ + public static function provideWhereDatePartUnsupportedOperatorThrowInvalidArgumentException(): iterable + { + return [ + 'between' => ['created_at BETWEEN'], + 'in' => ['created_at IN'], + 'is' => ['created_at IS'], + 'is null' => ['created_at IS NULL'], + 'is distinct from' => ['created_at IS DISTINCT FROM'], + 'like' => ['created_at LIKE'], + 'regexp' => ['created_at REGEXP'], + 'spaceship' => ['created_at <=>'], + ]; + } + #[DataProvider('provideWhereBetweenMethods')] public function testWhereBetweenMethods(string $method, string $sql): void { diff --git a/tests/system/Database/Live/WhereTest.php b/tests/system/Database/Live/WhereTest.php index 8513144356cf..be1d96e9d04a 100644 --- a/tests/system/Database/Live/WhereTest.php +++ b/tests/system/Database/Live/WhereTest.php @@ -69,6 +69,27 @@ public function testWhereCustomString(): void $this->assertSame('Musician', $job->name); } + public function testWhereDatePartHelpers(): void + { + $this->db->table('user')->insert([ + 'name' => 'Date Helper', + 'email' => 'date-helper@example.com', + 'country' => 'US', + 'created_at' => '2026-05-31 12:34:56', + ]); + + $users = $this->db->table('user') + ->whereDate('created_at', '2026-05-31') + ->whereYear('created_at', 2026) + ->whereMonth('created_at', 5) + ->whereDay('created_at', 31) + ->get() + ->getResult(); + + $this->assertCount(1, $users); + $this->assertSame('Date Helper', $users[0]->name); + } + public function testOrWhere(): void { $jobs = $this->db->table('job') diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 359448aa83b5..12268fed9344 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -223,6 +223,7 @@ 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 ``whereDate()``, ``orWhereDate()``, ``whereDay()``, ``orWhereDay()``, ``whereMonth()``, ``orWhereMonth()``, ``whereYear()``, and ``orWhereYear()`` to Query Builder. See :ref:`query-builder-where-date`. - 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. - Added ``lockForUpdate()`` to add pessimistic write locks to ``SELECT`` queries on supported drivers. See :ref:`query-builder-lock-for-update`. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 0144c28eeb65..a0ff008ad65a 100644 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -458,6 +458,41 @@ $builder->orWhereNotBetween() This method is identical to ``whereNotBetween()``, except that multiple instances are joined by **OR**. +.. _query-builder-where-date: + +$builder->whereDate() +--------------------- + +.. versionadded:: 4.8.0 + +Generates a **WHERE** clause that compares only the date portion of a field: + +.. literalinclude:: query_builder/130.php + +The ``whereYear()``, ``whereMonth()``, and ``whereDay()`` methods compare the +corresponding date part. The ``orWhereDate()``, ``orWhereYear()``, +``orWhereMonth()``, and ``orWhereDay()`` methods are identical, except that +multiple instances are joined by **OR**. + +You can include a supported operator at the end of the field name. Supported +operators are ``=``, ``!=``, ``<>``, ``<``, ``>``, ``<=``, and ``>=``. If none +of these operators is detected, ``=`` is used. Passing ``null`` with ``=`` uses +``IS NULL``; passing ``null`` with ``!=`` or ``<>`` uses ``IS NOT NULL``. + +By default, values are bound and escaped automatically. ``DateTimeInterface`` +values are converted to ISO date or numeric date-part values before binding. +Field names are protected by default. The ``$escape`` parameter controls both +value escaping and identifier protection. Array values and subqueries are not +accepted. + +.. 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. + +.. warning:: These helpers may apply SQL functions or casts to the field. For + large indexed timestamp columns, an explicit range condition such as + ``whereBetween()`` can be more efficient. + .. _query-builder-where-exists: $builder->whereExists() @@ -1876,6 +1911,86 @@ Class Reference Generates a ``WHERE`` field ``NOT BETWEEN`` minimum and maximum value SQL query, joined with ``OR`` if appropriate. + .. php:method:: whereDate($key, $value[, $escape = null]) + + :param string $key: Name of field to examine, optionally with comparison operator + :param mixed $value: Date value or ``null`` to compare + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares the date portion of a field, joined with ``AND`` if appropriate. + + .. php:method:: orWhereDate($key, $value[, $escape = null]) + + :param string $key: Name of field to examine, optionally with comparison operator + :param mixed $value: Date value or ``null`` to compare + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares the date portion of a field, joined with ``OR`` if appropriate. + + .. php:method:: whereYear($key, $value[, $escape = null]) + + :param string $key: Name of field to examine, optionally with comparison operator + :param mixed $value: Year value or ``null`` to compare + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares the year portion of a field, joined with ``AND`` if appropriate. + + .. php:method:: orWhereYear($key, $value[, $escape = null]) + + :param string $key: Name of field to examine, optionally with comparison operator + :param mixed $value: Year value or ``null`` to compare + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares the year portion of a field, joined with ``OR`` if appropriate. + + .. php:method:: whereMonth($key, $value[, $escape = null]) + + :param string $key: Name of field to examine, optionally with comparison operator + :param mixed $value: Month value or ``null`` to compare + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares the month portion of a field, joined with ``AND`` if appropriate. + + .. php:method:: orWhereMonth($key, $value[, $escape = null]) + + :param string $key: Name of field to examine, optionally with comparison operator + :param mixed $value: Month value or ``null`` to compare + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares the month portion of a field, joined with ``OR`` if appropriate. + + .. php:method:: whereDay($key, $value[, $escape = null]) + + :param string $key: Name of field to examine, optionally with comparison operator + :param mixed $value: Day value or ``null`` to compare + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares the day portion of a field, joined with ``AND`` if appropriate. + + .. php:method:: orWhereDay($key, $value[, $escape = null]) + + :param string $key: Name of field to examine, optionally with comparison operator + :param mixed $value: Day value or ``null`` to compare + :param bool $escape: Whether to escape values and protect identifiers + :returns: ``BaseBuilder`` instance (method chaining) + :rtype: ``BaseBuilder`` + + Generates a ``WHERE`` clause that compares the day portion of a field, 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/130.php b/user_guide_src/source/database/query_builder/130.php new file mode 100644 index 000000000000..b6ebf52a0ba8 --- /dev/null +++ b/user_guide_src/source/database/query_builder/130.php @@ -0,0 +1,9 @@ +whereDate('created_at', '2026-01-31'); +$builder->whereYear('created_at', 2026); +$builder->whereMonth('created_at', 1); +$builder->whereDay('created_at', 31); + +// You can include a comparison operator at the end of the field name. +$builder->whereDate('created_at >=', '2026-01-01');