Skip to content

Commit e2e6106

Browse files
committed
feat(database): add query builder date helpers
- Add whereDate(), whereYear(), whereMonth(), and whereDay() - Add OR variants for date-part WHERE clauses - Compile date-part expressions per database driver - Document supported operators, null handling, escaping, and index caveats - Add builder and live database coverage Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent f889329 commit e2e6106

11 files changed

Lines changed: 806 additions & 0 deletions

File tree

system/Database/BaseBuilder.php

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use CodeIgniter\Exceptions\InvalidArgumentException;
2121
use CodeIgniter\Traits\ConditionalTrait;
2222
use Config\Feature;
23+
use DateTimeInterface;
2324
use TypeError;
2425

2526
/**
@@ -775,6 +776,246 @@ public function orWhere($key, $value = null, ?bool $escape = null)
775776
return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape);
776777
}
777778

779+
/**
780+
* Generates a WHERE clause that compares the date portion of a field.
781+
*
782+
* @param non-empty-string $key Field name, optionally with comparison operator
783+
* @param mixed $value
784+
*
785+
* @return $this
786+
*
787+
* @throws InvalidArgumentException
788+
*/
789+
public function whereDate(string $key, $value, ?bool $escape = null): static
790+
{
791+
return $this->whereDatePart('date', $key, $value, 'AND ', $escape);
792+
}
793+
794+
/**
795+
* Generates an OR WHERE clause that compares the date portion of a field.
796+
*
797+
* @param non-empty-string $key Field name, optionally with comparison operator
798+
* @param mixed $value
799+
*
800+
* @return $this
801+
*
802+
* @throws InvalidArgumentException
803+
*/
804+
public function orWhereDate(string $key, $value, ?bool $escape = null): static
805+
{
806+
return $this->whereDatePart('date', $key, $value, 'OR ', $escape);
807+
}
808+
809+
/**
810+
* Generates a WHERE clause that compares the year portion of a field.
811+
*
812+
* @param non-empty-string $key Field name, optionally with comparison operator
813+
* @param mixed $value
814+
*
815+
* @return $this
816+
*
817+
* @throws InvalidArgumentException
818+
*/
819+
public function whereYear(string $key, $value, ?bool $escape = null): static
820+
{
821+
return $this->whereDatePart('year', $key, $value, 'AND ', $escape);
822+
}
823+
824+
/**
825+
* Generates an OR WHERE clause that compares the year portion of a field.
826+
*
827+
* @param non-empty-string $key Field name, optionally with comparison operator
828+
* @param mixed $value
829+
*
830+
* @return $this
831+
*
832+
* @throws InvalidArgumentException
833+
*/
834+
public function orWhereYear(string $key, $value, ?bool $escape = null): static
835+
{
836+
return $this->whereDatePart('year', $key, $value, 'OR ', $escape);
837+
}
838+
839+
/**
840+
* Generates a WHERE clause that compares the month portion of a field.
841+
*
842+
* @param non-empty-string $key Field name, optionally with comparison operator
843+
* @param mixed $value
844+
*
845+
* @return $this
846+
*
847+
* @throws InvalidArgumentException
848+
*/
849+
public function whereMonth(string $key, $value, ?bool $escape = null): static
850+
{
851+
return $this->whereDatePart('month', $key, $value, 'AND ', $escape);
852+
}
853+
854+
/**
855+
* Generates an OR WHERE clause that compares the month portion of a field.
856+
*
857+
* @param non-empty-string $key Field name, optionally with comparison operator
858+
* @param mixed $value
859+
*
860+
* @return $this
861+
*
862+
* @throws InvalidArgumentException
863+
*/
864+
public function orWhereMonth(string $key, $value, ?bool $escape = null): static
865+
{
866+
return $this->whereDatePart('month', $key, $value, 'OR ', $escape);
867+
}
868+
869+
/**
870+
* Generates a WHERE clause that compares the day portion of a field.
871+
*
872+
* @param non-empty-string $key Field name, optionally with comparison operator
873+
* @param mixed $value
874+
*
875+
* @return $this
876+
*
877+
* @throws InvalidArgumentException
878+
*/
879+
public function whereDay(string $key, $value, ?bool $escape = null): static
880+
{
881+
return $this->whereDatePart('day', $key, $value, 'AND ', $escape);
882+
}
883+
884+
/**
885+
* Generates an OR WHERE clause that compares the day portion of a field.
886+
*
887+
* @param non-empty-string $key Field name, optionally with comparison operator
888+
* @param mixed $value
889+
*
890+
* @return $this
891+
*
892+
* @throws InvalidArgumentException
893+
*/
894+
public function orWhereDay(string $key, $value, ?bool $escape = null): static
895+
{
896+
return $this->whereDatePart('day', $key, $value, 'OR ', $escape);
897+
}
898+
899+
/**
900+
* @used-by whereDate()
901+
* @used-by orWhereDate()
902+
* @used-by whereYear()
903+
* @used-by orWhereYear()
904+
* @used-by whereMonth()
905+
* @used-by orWhereMonth()
906+
* @used-by whereDay()
907+
* @used-by orWhereDay()
908+
*
909+
* @param 'date'|'day'|'month'|'year' $part
910+
* @param non-empty-string $key Field name, optionally with comparison operator
911+
* @param mixed $value
912+
*
913+
* @return $this
914+
*
915+
* @throws InvalidArgumentException
916+
*/
917+
private function whereDatePart(string $part, string $key, $value, string $type = 'AND ', ?bool $escape = null): static
918+
{
919+
[$key, $operator] = $this->parseDatePartKey($key);
920+
921+
if ($key === '') {
922+
throw new InvalidArgumentException(sprintf('%s() expects $key to be a non-empty string', debug_backtrace(0, 2)[1]['function']));
923+
}
924+
925+
$escape ??= $this->db->protectIdentifiers;
926+
927+
if (is_array($value) || $this->isSubquery($value)) {
928+
throw new InvalidArgumentException(sprintf('%s() does not accept array or subquery values', debug_backtrace(0, 2)[1]['function']));
929+
}
930+
931+
if ($value === null) {
932+
$nullOperator = match ($operator) {
933+
'=' => 'IS NULL',
934+
'!=', '<>' => 'IS NOT NULL',
935+
default => throw new InvalidArgumentException(sprintf('%s() supports null values only with =, !=, or <> operators', debug_backtrace(0, 2)[1]['function'])),
936+
};
937+
938+
$this->addWhereHavingCondition('QBWhere', [
939+
'condition' => '',
940+
'datePartComparison' => true,
941+
'escape' => $escape,
942+
'key' => $key,
943+
'nullComparison' => true,
944+
'nullOperator' => $nullOperator,
945+
'part' => $part,
946+
], $type);
947+
948+
return $this;
949+
}
950+
951+
$value = $this->normalizeDatePartValue($part, $value);
952+
$bind = $this->setBind($key, $value, $escape);
953+
954+
$this->addWhereHavingCondition('QBWhere', [
955+
'condition' => '',
956+
'datePartComparison' => true,
957+
'escape' => $escape,
958+
'key' => $key,
959+
'operator' => $operator,
960+
'part' => $part,
961+
'rawValue' => $value instanceof RawSql,
962+
'valueBind' => $bind,
963+
], $type);
964+
965+
return $this;
966+
}
967+
968+
/**
969+
* Extracts the comparison operator from date helper keys.
970+
*
971+
* @return array{string, string}
972+
*
973+
* @throws InvalidArgumentException
974+
*/
975+
private function parseDatePartKey(string $key): array
976+
{
977+
$key = trim($key);
978+
979+
if (
980+
preg_match('/\s+(?:IS(?:\s+NOT)?(?:\s+NULL)?|(?:NOT\s+)?(?:LIKE|IN|BETWEEN|EXISTS|REGEXP|RLIKE))(?:\s+.*|\s*\(.*\))?$/i', $key) === 1
981+
|| preg_match('/\s*<=>\s*$/', $key) === 1
982+
) {
983+
throw new InvalidArgumentException(sprintf('%s() supports only comparison operators: =, !=, <>, <, >, <=, >=', debug_backtrace(0, 3)[2]['function']));
984+
}
985+
986+
if (preg_match('/\s*(!=|<>|<=|>=|=|<|>)\s*$/', $key, $match) === 1) {
987+
return [rtrim(substr($key, 0, -strlen($match[0]))), trim($match[1])];
988+
}
989+
990+
return [$key, '='];
991+
}
992+
993+
/**
994+
* Converts DateTime values into deterministic date helper comparison values.
995+
*
996+
* @param 'date'|'day'|'month'|'year' $part
997+
* @param mixed $value
998+
*
999+
* @return mixed
1000+
*/
1001+
private function normalizeDatePartValue(string $part, $value)
1002+
{
1003+
if ($value instanceof DateTimeInterface) {
1004+
return match ($part) {
1005+
'date' => $value->format('Y-m-d'),
1006+
'year' => (int) $value->format('Y'),
1007+
'month' => (int) $value->format('m'),
1008+
'day' => (int) $value->format('d'),
1009+
};
1010+
}
1011+
1012+
if (! $value instanceof RawSql && $part !== 'date' && (is_int($value) || (is_string($value) && preg_match('/^-?\d+$/', $value) === 1))) {
1013+
return (int) $value;
1014+
}
1015+
1016+
return $value;
1017+
}
1018+
7781019
/**
7791020
* Generates a WHERE clause that compares two columns.
7801021
*
@@ -3836,6 +4077,10 @@ private function compileWhereHavingCondition(array|RawSql|string $condition): Ra
38364077
return $this->compileBetweenComparison($condition);
38374078
}
38384079

4080+
if (($condition['datePartComparison'] ?? false) === true) {
4081+
return $this->compileDatePartComparison($condition);
4082+
}
4083+
38394084
if ($condition['escape'] === false) {
38404085
return $condition['condition'];
38414086
}
@@ -3927,6 +4172,56 @@ private function compileBetweenComparison(array $condition): string
39274172
return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':';
39284173
}
39294174

4175+
/**
4176+
* @used-by compileWhereHavingCondition()
4177+
*
4178+
* @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
4179+
*/
4180+
private function compileDatePartComparison(array $condition): string
4181+
{
4182+
if ($condition['escape']) {
4183+
$condition['key'] = $this->db->protectIdentifiers($condition['key'], false, true);
4184+
}
4185+
4186+
$expression = $this->compileDatePartExpression($condition['part'], $condition['key']);
4187+
4188+
if (($condition['nullComparison'] ?? false) === true) {
4189+
return $condition['condition']
4190+
. $expression
4191+
. ' ' . $condition['nullOperator'];
4192+
}
4193+
4194+
return $condition['condition']
4195+
. $expression
4196+
. ' ' . $condition['operator'] . ' '
4197+
. $this->compileDatePartValue($condition['part'], $condition['valueBind'], $condition['rawValue']);
4198+
}
4199+
4200+
/**
4201+
* Compiles a driver-specific SQL expression for Query Builder date helpers.
4202+
*
4203+
* @param 'date'|'day'|'month'|'year' $part
4204+
*/
4205+
protected function compileDatePartExpression(string $part, string $field): string
4206+
{
4207+
return match ($part) {
4208+
'date' => 'CAST(' . $field . ' AS DATE)',
4209+
'year' => 'EXTRACT(YEAR FROM ' . $field . ')',
4210+
'month' => 'EXTRACT(MONTH FROM ' . $field . ')',
4211+
'day' => 'EXTRACT(DAY FROM ' . $field . ')',
4212+
};
4213+
}
4214+
4215+
/**
4216+
* Compiles the value side for Query Builder date helpers.
4217+
*
4218+
* @param 'date'|'day'|'month'|'year' $part
4219+
*/
4220+
protected function compileDatePartValue(string $part, string $bind, bool $rawValue): string
4221+
{
4222+
return ':' . $bind . ':';
4223+
}
4224+
39304225
/**
39314226
* Escapes identifiers in GROUP BY statements at execution time.
39324227
*

system/Database/MySQLi/Builder.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ protected function compileLockForUpdate(): string
7676
return parent::compileLockForUpdate();
7777
}
7878

79+
/**
80+
* Compiles a driver-specific SQL expression for Query Builder date helpers.
81+
*
82+
* @param 'date'|'day'|'month'|'year' $part
83+
*/
84+
protected function compileDatePartExpression(string $part, string $field): string
85+
{
86+
return match ($part) {
87+
'date' => 'DATE(' . $field . ')',
88+
'year' => 'YEAR(' . $field . ')',
89+
'month' => 'MONTH(' . $field . ')',
90+
'day' => 'DAY(' . $field . ')',
91+
};
92+
}
93+
7994
/**
8095
* Generates a platform-specific batch update string from the supplied data
8196
*/

system/Database/OCI8/Builder.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,32 @@ protected function _truncate(string $table): string
147147
return 'TRUNCATE TABLE ' . $table;
148148
}
149149

150+
/**
151+
* Compiles a driver-specific SQL expression for Query Builder date helpers.
152+
*
153+
* @param 'date'|'day'|'month'|'year' $part
154+
*/
155+
protected function compileDatePartExpression(string $part, string $field): string
156+
{
157+
if ($part === 'date') {
158+
return 'TRUNC(' . $field . ')';
159+
}
160+
161+
return 'EXTRACT(' . strtoupper($part) . ' FROM ' . $field . ')';
162+
}
163+
164+
/**
165+
* Compiles the value side for Query Builder date helpers.
166+
*
167+
* @param 'date'|'day'|'month'|'year' $part
168+
*/
169+
protected function compileDatePartValue(string $part, string $bind, bool $rawValue): string
170+
{
171+
$value = parent::compileDatePartValue($part, $bind, $rawValue);
172+
173+
return $part === 'date' && ! $rawValue ? "TO_DATE({$value}, 'YYYY-MM-DD')" : $value;
174+
}
175+
150176
/**
151177
* Compiles a delete string and runs the query
152178
*

system/Database/SQLSRV/Builder.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,20 @@ protected function compileJoinTable(string $table, bool $escape): string
110110
return $this->getFullName($table);
111111
}
112112

113+
/**
114+
* Compiles a driver-specific SQL expression for Query Builder date helpers.
115+
*
116+
* @param 'date'|'day'|'month'|'year' $part
117+
*/
118+
protected function compileDatePartExpression(string $part, string $field): string
119+
{
120+
if ($part === 'date') {
121+
return 'CAST(' . $field . ' AS DATE)';
122+
}
123+
124+
return 'DATEPART(' . strtoupper($part) . ', ' . $field . ')';
125+
}
126+
113127
/**
114128
* Generates a platform-specific insert string from the supplied data
115129
*

0 commit comments

Comments
 (0)