Merge pull request #21045 from colemanw/fixSearchKitTaskPermissions
[civicrm-core.git] / Civi / Api4 / Query / SqlFunction.php
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
21 /**
22 * @var array[]
23 */
24 protected $args = [];
25
26 /**
27 * Used for categorizing functions in the UI
28 *
29 * @var string
30 */
31 protected static $category;
32
33 /**
34 * Data type output by this function
35 *
36 * @var string
37 */
38 protected static $dataType;
39
40 const CATEGORY_AGGREGATE = 'aggregate',
41 CATEGORY_COMPARISON = 'comparison',
42 CATEGORY_DATE = 'date',
43 CATEGORY_MATH = 'math',
44 CATEGORY_STRING = 'string';
45
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));
51 foreach ($this->getParams() as $idx => $param) {
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 }
71 $this->args[$idx] = [
72 'prefix' => (array) $prefix,
73 'expr' => [],
74 'suffix' => [],
75 ];
76 if ($param['max_expr'] && (!$param['prefix'] || $param['prefix'] === $prefix)) {
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;
82
83 $this->args[$idx]['suffix'] = (array) $this->captureKeyword(array_keys($param['flag_after']), $arg);
84 }
85 }
86 }
87
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
103 /**
104 * Shift a keyword off the beginning of the argument string and return it.
105 *
106 * @param array $keywords
107 * Whitelist of keywords
108 * @param string $arg
109 * @return mixed|null
110 */
111 private function captureKeyword($keywords, &$arg) {
112 foreach ($keywords as $key) {
113 if (strpos($arg, $key . ' ') === 0) {
114 $arg = ltrim(substr($arg, strlen($key)));
115 return $key;
116 }
117 }
118 return NULL;
119 }
120
121 /**
122 * Shifts 0 or more expressions off the argument string and returns them
123 *
124 * @param string $arg
125 * @param array $mustBe
126 * @param array $cantBe
127 * @return array
128 * @throws \API_Exception
129 */
130 private function captureExpressions(&$arg, $mustBe, $cantBe) {
131 $captured = [];
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());
138 $captured[] = $expr;
139 // Keep going if we have a comma indicating another expression follows
140 if (substr($arg, 0, 1) === ',') {
141 $arg = ltrim(substr($arg, 1));
142 }
143 else {
144 break;
145 }
146 }
147 return $captured;
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
197 /**
198 * Render the expression for insertion into the sql query
199 *
200 * @param array $fieldList
201 * @return string
202 */
203 public function render(array $fieldList): string {
204 $output = '';
205 $params = $this->getParams();
206 foreach ($this->args as $index => $arg) {
207 $rendered = $this->renderArg($arg, $params[$index], $fieldList);
208 if (strlen($rendered)) {
209 $output .= (strlen($output) ? ' ' : '') . $rendered;
210 }
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 {
222 $rendered = implode(' ', $arg['prefix']);
223 foreach ($arg['expr'] ?? [] as $idx => $expr) {
224 if (strlen($rendered) || $idx) {
225 $rendered .= $idx ? ', ' : ' ';
226 }
227 $rendered .= $expr->render($fieldList);
228 }
229 if ($arg['suffix']) {
230 $rendered .= (strlen($rendered) ? ' ' : '') . implode(' ', $arg['suffix']);
231 }
232 return $rendered;
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 */
255 final public static function getParams(): array {
256 $params = [];
257 foreach (static::params() as $param) {
258 // Merge in defaults to ensure each param has these properties
259 $params[] = $param + [
260 'prefix' => NULL,
261 'min_expr' => 1,
262 'max_expr' => 1,
263 'flag_before' => [],
264 'flag_after' => [],
265 'optional' => FALSE,
266 'must_be' => [],
267 'cant_be' => ['SqlWild'],
268 'api_default' => NULL,
269 ];
270 }
271 return $params;
272 }
273
274 abstract protected static function params(): array;
275
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
284 /**
285 * @return string
286 */
287 public static function getCategory(): string {
288 return static::$category;
289 }
290
291 /**
292 * @return string|NULL
293 */
294 public static function getDataType():? string {
295 return static::$dataType;
296 }
297
298 /**
299 * @return string
300 */
301 abstract public static function getTitle(): string;
302
303 }