Commit | Line | Data |
---|---|---|
f0acec37 CW |
1 | <?php |
2 | /* | |
3 | +--------------------------------------------------------------------+ | |
4 | | Copyright CiviCRM LLC. All rights reserved. | | |
5 | | | | |
6 | | This work is published under the GNU AGPLv3 license with some | | |
7 | | permitted exceptions and without any warranty. For full license | | |
8 | | and copyright information, see https://civicrm.org/licensing | | |
9 | +--------------------------------------------------------------------+ | |
10 | */ | |
11 | ||
12 | namespace Civi\Api4\Query; | |
13 | ||
14 | /** | |
15 | * Base class for all Sql functions. | |
16 | * | |
17 | * @package Civi\Api4\Query | |
18 | */ | |
19 | abstract class SqlFunction extends SqlExpression { | |
20 | ||
7ce7b1cd CW |
21 | /** |
22 | * @var array[] | |
23 | */ | |
f0acec37 CW |
24 | protected $args = []; |
25 | ||
e7f6def6 CW |
26 | /** |
27 | * Used for categorizing functions in the UI | |
28 | * | |
29 | * @var string | |
30 | */ | |
31 | protected static $category; | |
32 | ||
b0aa3463 CW |
33 | /** |
34 | * Data type output by this function | |
35 | * | |
36 | * @var string | |
37 | */ | |
38 | protected static $dataType; | |
39 | ||
e7f6def6 CW |
40 | const CATEGORY_AGGREGATE = 'aggregate', |
41 | CATEGORY_COMPARISON = 'comparison', | |
42 | CATEGORY_DATE = 'date', | |
43 | CATEGORY_MATH = 'math', | |
44 | CATEGORY_STRING = 'string'; | |
45 | ||
f0acec37 CW |
46 | /** |
47 | * Parse the argument string into an array of function arguments | |
48 | */ | |
49 | protected function initialize() { | |
50 | $arg = trim(substr($this->expr, strpos($this->expr, '(') + 1, -1)); | |
7ce7b1cd | 51 | foreach ($this->getParams() as $idx => $param) { |
1b6a82ee CW |
52 | $prefix = NULL; |
53 | if ($param['prefix']) { | |
54 | $prefix = $this->captureKeyword([$param['prefix']], $arg); | |
55 | // Supply api_default | |
56 | if (!$prefix && isset($param['api_default'])) { | |
57 | $this->args[$idx] = [ | |
58 | 'prefix' => $param['api_default']['prefix'] ?? [$param['prefix']], | |
59 | 'expr' => array_map([parent::class, 'convert'], $param['api_default']['expr']), | |
60 | 'suffix' => $param['api_default']['suffix'] ?? [], | |
61 | ]; | |
62 | continue; | |
63 | } | |
64 | if (!$prefix && !$param['optional']) { | |
65 | throw new \API_Exception("Missing {$param['prefix']} for SQL function " . static::getName()); | |
66 | } | |
67 | } | |
68 | elseif ($param['flag_before']) { | |
69 | $prefix = $this->captureKeyword(array_keys($param['flag_before']), $arg); | |
70 | } | |
7ce7b1cd | 71 | $this->args[$idx] = [ |
1b6a82ee | 72 | 'prefix' => (array) $prefix, |
7ce7b1cd | 73 | 'expr' => [], |
1b6a82ee | 74 | 'suffix' => [], |
7ce7b1cd | 75 | ]; |
1b6a82ee | 76 | if ($param['max_expr'] && (!$param['prefix'] || $param['prefix'] === $prefix)) { |
fa7465e4 CW |
77 | $exprs = $this->captureExpressions($arg, $param['must_be'], $param['cant_be']); |
78 | if (count($exprs) < $param['min_expr'] || count($exprs) > $param['max_expr']) { | |
79 | throw new \API_Exception('Incorrect number of arguments for SQL function ' . static::getName()); | |
80 | } | |
81 | $this->args[$idx]['expr'] = $exprs; | |
1b6a82ee CW |
82 | |
83 | $this->args[$idx]['suffix'] = (array) $this->captureKeyword(array_keys($param['flag_after']), $arg); | |
f0acec37 CW |
84 | } |
85 | } | |
86 | } | |
87 | ||
b0aa3463 CW |
88 | /** |
89 | * Change $dataType according to output of function | |
90 | * | |
91 | * @see \Civi\Api4\Utils\FormattingUtil::formatOutputValues | |
92 | * @param string $value | |
93 | * @param string $dataType | |
94 | * @return string | |
95 | */ | |
96 | public function formatOutputValue($value, &$dataType) { | |
97 | if (static::$dataType) { | |
98 | $dataType = static::$dataType; | |
99 | } | |
100 | return $value; | |
101 | } | |
102 | ||
f0acec37 | 103 | /** |
7ce7b1cd | 104 | * Shift a keyword off the beginning of the argument string and return it. |
f0acec37 CW |
105 | * |
106 | * @param array $keywords | |
107 | * Whitelist of keywords | |
108 | * @param string $arg | |
109 | * @return mixed|null | |
110 | */ | |
111 | private function captureKeyword($keywords, &$arg) { | |
1b6a82ee | 112 | foreach ($keywords as $key) { |
f0acec37 | 113 | if (strpos($arg, $key . ' ') === 0) { |
f0acec37 CW |
114 | $arg = ltrim(substr($arg, strlen($key))); |
115 | return $key; | |
116 | } | |
117 | } | |
118 | return NULL; | |
119 | } | |
120 | ||
121 | /** | |
7ce7b1cd | 122 | * Shifts 0 or more expressions off the argument string and returns them |
f0acec37 CW |
123 | * |
124 | * @param string $arg | |
f0acec37 CW |
125 | * @param array $mustBe |
126 | * @param array $cantBe | |
7ce7b1cd | 127 | * @return array |
f0acec37 CW |
128 | * @throws \API_Exception |
129 | */ | |
fa7465e4 | 130 | private function captureExpressions(&$arg, $mustBe, $cantBe) { |
7ce7b1cd | 131 | $captured = []; |
f0acec37 CW |
132 | $arg = ltrim($arg); |
133 | while ($arg) { | |
134 | $item = $this->captureExpression($arg); | |
135 | $arg = ltrim(substr($arg, strlen($item))); | |
136 | $expr = SqlExpression::convert($item, FALSE, $mustBe, $cantBe); | |
137 | $this->fields = array_merge($this->fields, $expr->getFields()); | |
7ce7b1cd | 138 | $captured[] = $expr; |
f0acec37 | 139 | // Keep going if we have a comma indicating another expression follows |
fa7465e4 | 140 | if (substr($arg, 0, 1) === ',') { |
f0acec37 CW |
141 | $arg = ltrim(substr($arg, 1)); |
142 | } | |
143 | else { | |
7ce7b1cd | 144 | break; |
f0acec37 CW |
145 | } |
146 | } | |
7ce7b1cd | 147 | return $captured; |
f0acec37 CW |
148 | } |
149 | ||
150 | /** | |
151 | * Scans the beginning of a string for an expression; stops when it hits delimiter | |
152 | * | |
153 | * @param $arg | |
154 | * @return string | |
155 | */ | |
156 | private function captureExpression($arg) { | |
157 | $chars = str_split($arg); | |
158 | $isEscaped = $quote = NULL; | |
159 | $item = ''; | |
160 | $quotes = ['"', "'"]; | |
161 | $brackets = [ | |
162 | ')' => '(', | |
163 | ]; | |
164 | $enclosures = array_fill_keys($brackets, 0); | |
165 | foreach ($chars as $index => $char) { | |
166 | if (!$isEscaped && in_array($char, $quotes, TRUE)) { | |
167 | // Open quotes - we'll ignore everything inside | |
168 | if (!$quote) { | |
169 | $quote = $char; | |
170 | } | |
171 | // Close quotes | |
172 | elseif ($char === $quote) { | |
173 | $quote = NULL; | |
174 | } | |
175 | } | |
176 | if (!$quote) { | |
177 | // Delineates end of expression | |
178 | if (($char == ',' || $char == ' ') && !array_filter($enclosures)) { | |
179 | return $item; | |
180 | } | |
181 | // Open brackets - we'll ignore delineators inside | |
182 | if (isset($enclosures[$char])) { | |
183 | $enclosures[$char]++; | |
184 | } | |
185 | // Close brackets | |
186 | if (isset($brackets[$char]) && $enclosures[$brackets[$char]]) { | |
187 | $enclosures[$brackets[$char]]--; | |
188 | } | |
189 | } | |
190 | $item .= $char; | |
191 | // We are escaping the next char if this is a backslash not preceded by an odd number of backslashes | |
192 | $isEscaped = $char === '\\' && ((strlen($item) - strlen(rtrim($item, '\\'))) % 2); | |
193 | } | |
194 | return $item; | |
195 | } | |
196 | ||
7ce7b1cd CW |
197 | /** |
198 | * Render the expression for insertion into the sql query | |
199 | * | |
200 | * @param array $fieldList | |
201 | * @return string | |
202 | */ | |
f0acec37 | 203 | public function render(array $fieldList): string { |
7ce7b1cd CW |
204 | $output = ''; |
205 | $params = $this->getParams(); | |
f0acec37 | 206 | foreach ($this->args as $index => $arg) { |
7ce7b1cd CW |
207 | $rendered = $this->renderArg($arg, $params[$index], $fieldList); |
208 | if (strlen($rendered)) { | |
209 | $output .= (strlen($output) ? ' ' : '') . $rendered; | |
f0acec37 | 210 | } |
7ce7b1cd CW |
211 | } |
212 | return $this->getName() . '(' . $output . ')'; | |
213 | } | |
214 | ||
215 | /** | |
216 | * @param array $arg | |
217 | * @param array $param | |
218 | * @param array $fieldList | |
219 | * @return string | |
220 | */ | |
221 | private function renderArg($arg, $param, $fieldList): string { | |
1b6a82ee | 222 | $rendered = implode(' ', $arg['prefix']); |
7ce7b1cd CW |
223 | foreach ($arg['expr'] ?? [] as $idx => $expr) { |
224 | if (strlen($rendered) || $idx) { | |
225 | $rendered .= $idx ? ', ' : ' '; | |
f0acec37 | 226 | } |
7ce7b1cd CW |
227 | $rendered .= $expr->render($fieldList); |
228 | } | |
1b6a82ee CW |
229 | if ($arg['suffix']) { |
230 | $rendered .= (strlen($rendered) ? ' ' : '') . implode(' ', $arg['suffix']); | |
f0acec37 | 231 | } |
7ce7b1cd | 232 | return $rendered; |
f0acec37 CW |
233 | } |
234 | ||
235 | /** | |
236 | * @inheritDoc | |
237 | */ | |
238 | public function getAlias(): string { | |
239 | return $this->alias ?? $this->getName() . ':' . implode('_', $this->fields); | |
240 | } | |
241 | ||
242 | /** | |
243 | * Get the name of this sql function. | |
244 | * @return string | |
245 | */ | |
246 | public static function getName(): string { | |
247 | $className = static::class; | |
248 | return substr($className, strrpos($className, 'SqlFunction') + 11); | |
249 | } | |
250 | ||
251 | /** | |
252 | * Get the param metadata for this sql function. | |
253 | * @return array | |
254 | */ | |
f19a0f00 | 255 | final public static function getParams(): array { |
f0acec37 | 256 | $params = []; |
f19a0f00 | 257 | foreach (static::params() as $param) { |
f0acec37 CW |
258 | // Merge in defaults to ensure each param has these properties |
259 | $params[] = $param + [ | |
1b6a82ee | 260 | 'prefix' => NULL, |
fa7465e4 CW |
261 | 'min_expr' => 1, |
262 | 'max_expr' => 1, | |
1b6a82ee CW |
263 | 'flag_before' => [], |
264 | 'flag_after' => [], | |
f0acec37 CW |
265 | 'optional' => FALSE, |
266 | 'must_be' => [], | |
267 | 'cant_be' => ['SqlWild'], | |
7ce7b1cd | 268 | 'api_default' => NULL, |
f0acec37 CW |
269 | ]; |
270 | } | |
271 | return $params; | |
272 | } | |
273 | ||
f19a0f00 CW |
274 | abstract protected static function params(): array; |
275 | ||
7ce7b1cd CW |
276 | /** |
277 | * Get the arguments passed to this sql function instance. | |
278 | * @return array[] | |
279 | */ | |
280 | public function getArgs(): array { | |
281 | return $this->args; | |
282 | } | |
283 | ||
e7f6def6 CW |
284 | /** |
285 | * @return string | |
286 | */ | |
287 | public static function getCategory(): string { | |
288 | return static::$category; | |
289 | } | |
290 | ||
b0aa3463 CW |
291 | /** |
292 | * @return string|NULL | |
293 | */ | |
294 | public static function getDataType():? string { | |
295 | return static::$dataType; | |
296 | } | |
297 | ||
9cae8a07 CW |
298 | /** |
299 | * @return string | |
300 | */ | |
301 | abstract public static function getTitle(): string; | |
302 | ||
f0acec37 | 303 | } |