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