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