3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
12 namespace Civi\Api4\Query
;
15 * Base class for SqlColumn, SqlString, SqlBool, and SqlFunction classes.
17 * These are used to validate and format sql expressions in Api4 select queries.
19 * @package Civi\Api4\Query
21 abstract class SqlExpression
{
26 protected $fields = [];
29 * The SELECT alias (if null it will be calculated by getAlias)
35 * The raw expression, minus the alias.
41 * Whether or not pseudoconstant suffixes should be evaluated during output.
44 * @see \Civi\Api4\Utils\FormattingUtil::formatOutputValues
46 public $supportsExpansion = FALSE;
49 * Data type output by this expression
53 protected static $dataType;
56 * SqlFunction constructor.
58 * @param string|null $alias
60 public function __construct(string $expr, $alias = NULL) {
62 $this->alias
= $alias;
66 abstract protected function initialize();
69 * Converts a string to a SqlExpression object.
71 * E.g. the expression "SUM(foo)" would return a SqlFunctionSUM object.
73 * @param string $expression
74 * @param bool $parseAlias
75 * @param array $mustBe
76 * @return SqlExpression
77 * @throws \API_Exception
79 public static function convert(string $expression, $parseAlias = FALSE, $mustBe = []) {
80 $as = $parseAlias ?
strrpos($expression, ' AS ') : FALSE;
81 $expr = $as ?
substr($expression, 0, $as) : $expression;
82 $alias = $as ? \CRM_Utils_String
::munge(substr($expression, $as +
4), '_', 256) : NULL;
83 $bracketPos = strpos($expr, '(');
84 $firstChar = substr($expr, 0, 1);
85 $lastChar = substr($expr, -1);
86 // Statement surrounded by brackets is an equation
87 if ($firstChar === '(' && $lastChar === ')') {
88 $className = 'SqlEquation';
90 // If there are brackets but not the first character, we have a function
91 elseif ($bracketPos && $lastChar === ')') {
92 $fnName = substr($expr, 0, $bracketPos);
93 if ($fnName !== strtoupper($fnName)) {
94 throw new \
API_Exception('Sql function must be uppercase.');
96 $className = 'SqlFunction' . $fnName;
99 elseif ($firstChar === $lastChar && in_array($firstChar, ['"', "'"], TRUE)) {
100 $className = 'SqlString';
102 elseif ($expr === 'NULL') {
103 $className = 'SqlNull';
105 elseif ($expr === '*') {
106 $className = 'SqlWild';
108 elseif (is_numeric($expr)) {
109 $className = 'SqlNumber';
111 // If none of the above, assume it's a field name
113 $className = 'SqlField';
115 $className = __NAMESPACE__
. '\\' . $className;
116 if (!class_exists($className)) {
117 throw new \
API_Exception('Unable to parse sql expression: ' . $expression);
119 $sqlExpression = new $className($expr, $alias);
121 foreach ($mustBe as $must) {
122 if (is_a($sqlExpression, __NAMESPACE__
. '\\' . $must)) {
123 return $sqlExpression;
126 throw new \
API_Exception('Illegal sql expression.');
128 return $sqlExpression;
132 * Returns the field names of all sql columns that are arguments to this expression.
136 public function getFields(): array {
137 return $this->fields
;
141 * Renders expression to a sql string, replacing field names with column names.
143 * @param array $fieldList
146 abstract public function render(array $fieldList): string;
151 public function getExpr(): string {
156 * Returns the alias to use for SELECT AS.
160 public function getAlias(): string {
161 return $this->alias ??
$this->fields
[0] ?? \CRM_Utils_String
::munge($this->expr
, '_', 256);
165 * Returns the name of this sql expression class.
169 public function getType(): string {
170 $className = get_class($this);
171 return substr($className, strrpos($className, '\\') +
1);
177 abstract public static function getTitle(): string;
180 * @return string|NULL
182 public static function getDataType():?
string {
183 return static::$dataType;
187 * Shift a keyword off the beginning of the argument string and return it.
189 * @param array $keywords
190 * Whitelist of keywords
194 protected function captureKeyword($keywords, &$arg) {
195 foreach ($keywords as $key) {
196 // Match keyword followed by a space or eol
197 if (strpos($arg, $key . ' ') === 0 ||
rtrim($arg) === $key) {
198 $arg = ltrim(substr($arg, strlen($key)));
206 * Shifts 0 or more expressions off the argument string and returns them
209 * @param array $mustBe
211 * @return SqlExpression[]
212 * @throws \API_Exception
214 protected function captureExpressions(string &$arg, array $mustBe, int $max) {
218 $item = $this->captureExpression($arg);
219 $arg = ltrim(substr($arg, strlen($item)));
220 $expr = self
::convert($item, FALSE, $mustBe);
221 $this->fields
= array_merge($this->fields
, $expr->getFields());
223 // Keep going if we have a comma indicating another expression follows
224 if (count($captured) < $max && substr($arg, 0, 1) === ',') {
225 $arg = ltrim(substr($arg, 1));
235 * Scans the beginning of a string for an expression; stops when it hits delimiter
240 protected function captureExpression($arg) {
241 $isEscaped = $quote = NULL;
243 $quotes = ['"', "'"];
247 $enclosures = array_fill_keys($brackets, 0);
248 foreach (str_split($arg) as $char) {
249 if (!$isEscaped && in_array($char, $quotes, TRUE)) {
250 // Open quotes - we'll ignore everything inside
255 elseif ($char === $quote) {
260 // Delineates end of expression
261 if (($char == ',' ||
$char == ' ') && !array_filter($enclosures)) {
264 // Open brackets - we'll ignore delineators inside
265 if (isset($enclosures[$char])) {
266 $enclosures[$char]++
;
269 if (isset($brackets[$char]) && $enclosures[$brackets[$char]]) {
270 $enclosures[$brackets[$char]]--;
274 // We are escaping the next char if this is a backslash not preceded by an odd number of backslashes
275 $isEscaped = $char === '\\' && ((strlen($item) - strlen(rtrim($item, '\\'))) %
2);