|
20 | 20 | use CodeIgniter\Exceptions\InvalidArgumentException; |
21 | 21 | use CodeIgniter\Traits\ConditionalTrait; |
22 | 22 | use Config\Feature; |
| 23 | +use DateTimeInterface; |
23 | 24 | use TypeError; |
24 | 25 |
|
25 | 26 | /** |
@@ -775,6 +776,246 @@ public function orWhere($key, $value = null, ?bool $escape = null) |
775 | 776 | return $this->whereHaving('QBWhere', $key, $value, 'OR ', $escape); |
776 | 777 | } |
777 | 778 |
|
| 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 | + |
778 | 1019 | /** |
779 | 1020 | * Generates a WHERE clause that compares two columns. |
780 | 1021 | * |
@@ -3836,6 +4077,10 @@ private function compileWhereHavingCondition(array|RawSql|string $condition): Ra |
3836 | 4077 | return $this->compileBetweenComparison($condition); |
3837 | 4078 | } |
3838 | 4079 |
|
| 4080 | + if (($condition['datePartComparison'] ?? false) === true) { |
| 4081 | + return $this->compileDatePartComparison($condition); |
| 4082 | + } |
| 4083 | + |
3839 | 4084 | if ($condition['escape'] === false) { |
3840 | 4085 | return $condition['condition']; |
3841 | 4086 | } |
@@ -3927,6 +4172,56 @@ private function compileBetweenComparison(array $condition): string |
3927 | 4172 | return $condition['condition'] . $condition['key'] . $condition['not'] . ' BETWEEN :' . $condition['lowerBind'] . ': AND :' . $condition['upperBind'] . ':'; |
3928 | 4173 | } |
3929 | 4174 |
|
| 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 | + |
3930 | 4225 | /** |
3931 | 4226 | * Escapes identifiers in GROUP BY statements at execution time. |
3932 | 4227 | * |
|
0 commit comments