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