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