Commit | Line | Data |
---|---|---|
19b53e5b C |
1 | <?php |
2 | /* | |
3 | +--------------------------------------------------------------------+ | |
41498ac5 | 4 | | Copyright CiviCRM LLC. All rights reserved. | |
19b53e5b | 5 | | | |
41498ac5 TO |
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 | | |
19b53e5b C |
9 | +--------------------------------------------------------------------+ |
10 | */ | |
11 | ||
12 | namespace Civi\Api4\Query; | |
13 | ||
19b53e5b | 14 | use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable; |
19b53e5b C |
15 | use Civi\Api4\Utils\FormattingUtil; |
16 | use Civi\Api4\Utils\CoreUtil; | |
39e0f675 | 17 | use Civi\Api4\Utils\SelectUtil; |
19b53e5b C |
18 | |
19 | /** | |
20 | * A query `node` may be in one of three formats: | |
21 | * | |
22 | * * leaf: [$fieldName, $operator, $criteria] | |
23 | * * negated: ['NOT', $node] | |
24 | * * branch: ['OR|NOT', [$node, $node, ...]] | |
25 | * | |
26 | * Leaf operators are one of: | |
27 | * | |
28 | * * '=', '<=', '>=', '>', '<', 'LIKE', "<>", "!=", | |
39deabd6 CW |
29 | * * 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', |
30 | * * 'IS NOT NULL', or 'IS NULL', 'CONTAINS'. | |
19b53e5b | 31 | */ |
88409aaf CW |
32 | class Api4SelectQuery { |
33 | ||
34 | const | |
651c4c95 CW |
35 | MAIN_TABLE_ALIAS = 'a', |
36 | UNLIMITED = '18446744073709551615'; | |
19b53e5b C |
37 | |
38 | /** | |
88409aaf | 39 | * @var \CRM_Utils_SQL_Select |
19b53e5b | 40 | */ |
88409aaf | 41 | protected $query; |
19b53e5b | 42 | |
3176b04c CW |
43 | /** |
44 | * @var array | |
45 | */ | |
88409aaf | 46 | protected $joins = []; |
3176b04c | 47 | |
b65fa6dc | 48 | /** |
88409aaf | 49 | * @var array[] |
b65fa6dc | 50 | */ |
88409aaf | 51 | protected $apiFieldSpec; |
b65fa6dc | 52 | |
fba513f6 CW |
53 | /** |
54 | * @var array | |
55 | */ | |
88409aaf | 56 | protected $entityFieldNames = []; |
fba513f6 | 57 | |
88409aaf CW |
58 | /** |
59 | * @var array | |
60 | */ | |
61 | protected $aclFields = []; | |
62 | ||
63 | /** | |
64 | * @var \Civi\Api4\Generic\DAOGetAction | |
65 | */ | |
66 | private $api; | |
48102254 | 67 | |
c9e3ae2e CW |
68 | /** |
69 | * @var array | |
88409aaf | 70 | * [alias => expr][] |
c9e3ae2e | 71 | */ |
88409aaf CW |
72 | protected $selectAliases = []; |
73 | ||
74 | /** | |
75 | * @var bool | |
76 | */ | |
77 | public $forceSelectId = TRUE; | |
c9e3ae2e | 78 | |
7de13161 CW |
79 | /** |
80 | * @var array | |
81 | */ | |
82 | private $explicitJoins = []; | |
83 | ||
19b53e5b | 84 | /** |
3c7c8fa6 CW |
85 | * @param \Civi\Api4\Generic\DAOGetAction $apiGet |
86 | */ | |
87 | public function __construct($apiGet) { | |
88409aaf | 88 | $this->api = $apiGet; |
651c4c95 | 89 | |
88409aaf | 90 | // Always select ID of main table unless grouping by something else |
651c4c95 CW |
91 | $this->forceSelectId = !$this->getGroupBy() || $this->getGroupBy() === ['id']; |
92 | ||
93 | // Build field lists | |
94 | foreach ($this->api->entityFields() as $field) { | |
5e327f37 | 95 | $this->entityFieldNames[] = $field['name']; |
9b06167d | 96 | $field['sql_name'] = '`' . self::MAIN_TABLE_ALIAS . '`.`' . $field['column_name'] . '`'; |
5e327f37 | 97 | $this->addSpecField($field['name'], $field); |
a689294c | 98 | } |
19b53e5b | 99 | |
651c4c95 CW |
100 | $tableName = CoreUtil::getTableName($this->getEntity()); |
101 | $this->query = \CRM_Utils_SQL_Select::from($tableName . ' ' . self::MAIN_TABLE_ALIAS); | |
19b53e5b C |
102 | |
103 | // Add ACLs first to avoid redundant subclauses | |
651c4c95 | 104 | $baoName = CoreUtil::getBAOFromApiName($this->getEntity()); |
19b53e5b C |
105 | $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName)); |
106 | } | |
107 | ||
108 | /** | |
651c4c95 | 109 | * Builds main final sql statement after initialization. |
19b53e5b | 110 | * |
4e97c268 CW |
111 | * @return string |
112 | * @throws \API_Exception | |
113 | * @throws \CRM_Core_Exception | |
19b53e5b | 114 | */ |
4e97c268 | 115 | public function getSql() { |
88409aaf CW |
116 | // Add explicit joins. Other joins implied by dot notation may be added later |
117 | $this->addExplicitJoins(); | |
a689294c | 118 | $this->buildSelectClause(); |
19b53e5b | 119 | $this->buildWhereClause(); |
a689294c CW |
120 | $this->buildOrderBy(); |
121 | $this->buildLimit(); | |
fba513f6 | 122 | $this->buildGroupBy(); |
c9e3ae2e | 123 | $this->buildHavingClause(); |
4e97c268 CW |
124 | return $this->query->toSQL(); |
125 | } | |
19b53e5b | 126 | |
4e97c268 CW |
127 | /** |
128 | * Why walk when you can | |
129 | * | |
651c4c95 | 130 | * @return array |
4e97c268 CW |
131 | */ |
132 | public function run() { | |
19b53e5b | 133 | $results = []; |
4e97c268 | 134 | $sql = $this->getSql(); |
88409aaf | 135 | $this->debug('sql', $sql); |
19b53e5b | 136 | $query = \CRM_Core_DAO::executeQuery($sql); |
19b53e5b | 137 | while ($query->fetch()) { |
334bebdd | 138 | $result = []; |
c9e3ae2e | 139 | foreach ($this->selectAliases as $alias => $expr) { |
19b53e5b | 140 | $returnName = $alias; |
3176b04c | 141 | $alias = str_replace('.', '_', $alias); |
334bebdd | 142 | $result[$returnName] = property_exists($query, $alias) ? $query->$alias : NULL; |
fba513f6 | 143 | } |
334bebdd | 144 | $results[] = $result; |
19b53e5b | 145 | } |
7ce7b1cd | 146 | FormattingUtil::formatOutputValues($results, $this->apiFieldSpec, $this->getEntity(), 'get', $this->selectAliases); |
334bebdd | 147 | return $results; |
19b53e5b C |
148 | } |
149 | ||
88409aaf | 150 | /** |
651c4c95 | 151 | * @return int |
88409aaf CW |
152 | * @throws \API_Exception |
153 | */ | |
651c4c95 CW |
154 | public function getCount() { |
155 | $this->addExplicitJoins(); | |
156 | $this->buildWhereClause(); | |
157 | // If no having or groupBy, we only need to select count | |
158 | if (!$this->getHaving() && !$this->getGroupBy()) { | |
159 | $this->query->select('COUNT(*) AS `c`'); | |
160 | $sql = $this->query->toSQL(); | |
161 | } | |
162 | // Use a subquery to count groups from GROUP BY or results filtered by HAVING | |
163 | else { | |
164 | // With no HAVING, just select the last field grouped by | |
165 | if (!$this->getHaving()) { | |
166 | $select = array_slice($this->getGroupBy(), -1); | |
167 | } | |
168 | $this->buildSelectClause($select ?? NULL); | |
169 | $this->buildHavingClause(); | |
170 | $this->buildGroupBy(); | |
171 | $subquery = $this->query->toSQL(); | |
b95c1876 | 172 | $sql = "SELECT count(*) AS `c` FROM ( $subquery ) AS `rows`"; |
651c4c95 CW |
173 | } |
174 | $this->debug('sql', $sql); | |
175 | return (int) \CRM_Core_DAO::singleValueQuery($sql); | |
176 | } | |
177 | ||
178 | /** | |
179 | * @param array $select | |
180 | * Array of select expressions; defaults to $this->getSelect | |
181 | * @throws \API_Exception | |
182 | */ | |
183 | protected function buildSelectClause($select = NULL) { | |
184 | // Use default if select not provided, exclude row_count which is handled elsewhere | |
185 | $select = array_diff($select ?? $this->getSelect(), ['row_count']); | |
9b06167d | 186 | // An empty select is the same as * |
88409aaf CW |
187 | if (empty($select)) { |
188 | $select = $this->entityFieldNames; | |
19b53e5b | 189 | } |
a689294c | 190 | else { |
48102254 | 191 | if ($this->forceSelectId) { |
88409aaf | 192 | $select = array_merge(['id'], $select); |
fba513f6 | 193 | } |
a689294c | 194 | |
2f69b203 CW |
195 | // Expand the superstar 'custom.*' to select all fields in all custom groups |
196 | $customStar = array_search('custom.*', array_values($select), TRUE); | |
197 | if ($customStar !== FALSE) { | |
198 | $customGroups = civicrm_api4($this->getEntity(), 'getFields', [ | |
199 | 'checkPermissions' => FALSE, | |
200 | 'where' => [['custom_group', 'IS NOT NULL']], | |
201 | ], ['custom_group' => 'custom_group']); | |
202 | $customSelect = []; | |
203 | foreach ($customGroups as $groupName) { | |
204 | $customSelect[] = "$groupName.*"; | |
205 | } | |
206 | array_splice($select, $customStar, 1, $customSelect); | |
207 | } | |
208 | ||
a689294c | 209 | // Expand wildcards in joins (the api wrapper already expanded non-joined wildcards) |
88409aaf | 210 | $wildFields = array_filter($select, function($item) { |
3176b04c | 211 | return strpos($item, '*') !== FALSE && strpos($item, '.') !== FALSE && strpos($item, '(') === FALSE && strpos($item, ' ') === FALSE; |
a689294c | 212 | }); |
2f69b203 | 213 | |
a44a3944 CW |
214 | foreach ($wildFields as $wildField) { |
215 | $pos = array_search($wildField, array_values($select)); | |
216 | // If the joined_entity.id isn't in the fieldspec already, autoJoinFK will attempt to add the entity. | |
217 | $idField = substr($wildField, 0, strrpos($wildField, '.')) . '.id'; | |
218 | $this->autoJoinFK($idField); | |
219 | $matches = SelectUtil::getMatchingFields($wildField, array_keys($this->apiFieldSpec)); | |
88409aaf | 220 | array_splice($select, $pos, 1, $matches); |
a689294c | 221 | } |
88409aaf | 222 | $select = array_unique($select); |
a689294c | 223 | } |
88409aaf | 224 | foreach ($select as $item) { |
3176b04c CW |
225 | $expr = SqlExpression::convert($item, TRUE); |
226 | $valid = TRUE; | |
227 | foreach ($expr->getFields() as $fieldName) { | |
228 | $field = $this->getField($fieldName); | |
229 | // Remove expressions with unknown fields without raising an error | |
230 | if (!$field) { | |
88409aaf CW |
231 | $select = array_diff($select, [$item]); |
232 | $this->debug('undefined_fields', $fieldName); | |
3176b04c CW |
233 | $valid = FALSE; |
234 | } | |
19b53e5b | 235 | } |
3176b04c | 236 | if ($valid) { |
c9e3ae2e | 237 | $alias = $expr->getAlias(); |
19fde02c CW |
238 | if ($alias != $expr->getExpr() && isset($this->apiFieldSpec[$alias])) { |
239 | throw new \API_Exception('Cannot use existing field name as alias'); | |
240 | } | |
c9e3ae2e | 241 | $this->selectAliases[$alias] = $expr->getExpr(); |
3176b04c | 242 | $this->query->select($expr->render($this->apiFieldSpec) . " AS `$alias`"); |
9b06167d | 243 | } |
19b53e5b C |
244 | } |
245 | } | |
246 | ||
247 | /** | |
88409aaf | 248 | * Add WHERE clause to query |
19b53e5b C |
249 | */ |
250 | protected function buildWhereClause() { | |
88409aaf | 251 | foreach ($this->getWhere() as $clause) { |
0c9bccf1 CW |
252 | $sql = $this->treeWalkClauses($clause, 'WHERE'); |
253 | if ($sql) { | |
254 | $this->query->where($sql); | |
255 | } | |
c9e3ae2e CW |
256 | } |
257 | } | |
258 | ||
259 | /** | |
88409aaf | 260 | * Add HAVING clause to query |
c9e3ae2e CW |
261 | * |
262 | * Every expression referenced must also be in the SELECT clause. | |
263 | */ | |
264 | protected function buildHavingClause() { | |
88409aaf | 265 | foreach ($this->getHaving() as $clause) { |
c9e3ae2e | 266 | $this->query->having($this->treeWalkClauses($clause, 'HAVING')); |
19b53e5b C |
267 | } |
268 | } | |
269 | ||
270 | /** | |
88409aaf | 271 | * Add ORDER BY to query |
19b53e5b C |
272 | */ |
273 | protected function buildOrderBy() { | |
88409aaf | 274 | foreach ($this->getOrderBy() as $item => $dir) { |
19b53e5b | 275 | if ($dir !== 'ASC' && $dir !== 'DESC') { |
3176b04c CW |
276 | throw new \API_Exception("Invalid sort direction. Cannot order by $item $dir"); |
277 | } | |
e62cd61b CW |
278 | |
279 | try { | |
280 | $expr = $this->getExpression($item); | |
281 | $column = $expr->render($this->apiFieldSpec); | |
282 | ||
283 | // Use FIELD() function to sort on pseudoconstant values | |
284 | $suffix = strstr($item, ':'); | |
285 | if ($suffix && $expr->getType() === 'SqlField') { | |
286 | $field = $this->getField($item); | |
287 | $options = FormattingUtil::getPseudoconstantList($field, substr($suffix, 1)); | |
288 | if ($options) { | |
289 | asort($options); | |
290 | $column = "FIELD($column,'" . implode("','", array_keys($options)) . "')"; | |
291 | } | |
292 | } | |
293 | } | |
294 | // If the expression could not be rendered, it might be a field alias | |
295 | catch (\API_Exception $e) { | |
296 | if (!empty($this->selectAliases[$item])) { | |
297 | $column = '`' . $item . '`'; | |
298 | } | |
299 | else { | |
300 | throw new \API_Exception("Invalid field '{$item}'"); | |
a4499ec5 CW |
301 | } |
302 | } | |
e62cd61b | 303 | |
a4499ec5 | 304 | $this->query->orderBy("$column $dir"); |
a689294c CW |
305 | } |
306 | } | |
307 | ||
308 | /** | |
88409aaf CW |
309 | * Add LIMIT to query |
310 | * | |
a689294c CW |
311 | * @throws \CRM_Core_Exception |
312 | */ | |
313 | protected function buildLimit() { | |
88409aaf | 314 | if ($this->getLimit() || $this->getOffset()) { |
f8bf8e26 | 315 | // If limit is 0, mysql will actually return 0 results. Instead set to maximum possible. |
651c4c95 | 316 | $this->query->limit($this->getLimit() ?: self::UNLIMITED, $this->getOffset()); |
19b53e5b C |
317 | } |
318 | } | |
319 | ||
fba513f6 | 320 | /** |
88409aaf | 321 | * Add GROUP BY clause to query |
fba513f6 CW |
322 | */ |
323 | protected function buildGroupBy() { | |
88409aaf | 324 | foreach ($this->getGroupBy() as $item) { |
a4499ec5 | 325 | $this->query->groupBy($this->getExpression($item)->render($this->apiFieldSpec)); |
fba513f6 CW |
326 | } |
327 | } | |
328 | ||
19b53e5b C |
329 | /** |
330 | * Recursively validate and transform a branch or leaf clause array to SQL. | |
331 | * | |
332 | * @param array $clause | |
c9e3ae2e | 333 | * @param string $type |
16f5a13d | 334 | * WHERE|HAVING|ON |
19b53e5b C |
335 | * @return string SQL where clause |
336 | * | |
c9e3ae2e CW |
337 | * @throws \API_Exception |
338 | * @uses composeClause() to generate the SQL etc. | |
19b53e5b | 339 | */ |
c9e3ae2e | 340 | protected function treeWalkClauses($clause, $type) { |
0c9bccf1 CW |
341 | // Skip empty leaf. |
342 | if (in_array($clause[0], ['AND', 'OR', 'NOT']) && empty($clause[1])) { | |
343 | return ''; | |
344 | } | |
19b53e5b C |
345 | switch ($clause[0]) { |
346 | case 'OR': | |
347 | case 'AND': | |
348 | // handle branches | |
349 | if (count($clause[1]) === 1) { | |
350 | // a single set so AND|OR is immaterial | |
c9e3ae2e | 351 | return $this->treeWalkClauses($clause[1][0], $type); |
19b53e5b C |
352 | } |
353 | else { | |
354 | $sql_subclauses = []; | |
355 | foreach ($clause[1] as $subclause) { | |
c9e3ae2e | 356 | $sql_subclauses[] = $this->treeWalkClauses($subclause, $type); |
19b53e5b C |
357 | } |
358 | return '(' . implode("\n" . $clause[0], $sql_subclauses) . ')'; | |
359 | } | |
360 | ||
361 | case 'NOT': | |
362 | // If we get a group of clauses with no operator, assume AND | |
363 | if (!is_string($clause[1][0])) { | |
364 | $clause[1] = ['AND', $clause[1]]; | |
365 | } | |
c9e3ae2e | 366 | return 'NOT (' . $this->treeWalkClauses($clause[1], $type) . ')'; |
19b53e5b C |
367 | |
368 | default: | |
c9e3ae2e | 369 | return $this->composeClause($clause, $type); |
19b53e5b C |
370 | } |
371 | } | |
372 | ||
373 | /** | |
374 | * Validate and transform a leaf clause array to SQL. | |
375 | * @param array $clause [$fieldName, $operator, $criteria] | |
c9e3ae2e | 376 | * @param string $type |
16f5a13d | 377 | * WHERE|HAVING|ON |
19b53e5b C |
378 | * @return string SQL |
379 | * @throws \API_Exception | |
380 | * @throws \Exception | |
381 | */ | |
c9e3ae2e | 382 | protected function composeClause(array $clause, string $type) { |
19b53e5b | 383 | // Pad array for unary operators |
c9e3ae2e | 384 | list($expr, $operator, $value) = array_pad($clause, 3, NULL); |
9d2afe25 | 385 | if (!in_array($operator, CoreUtil::getOperators(), TRUE)) { |
16f5a13d CW |
386 | throw new \API_Exception('Illegal operator'); |
387 | } | |
19b53e5b | 388 | |
c9e3ae2e CW |
389 | // For WHERE clause, expr must be the name of a field. |
390 | if ($type === 'WHERE') { | |
391 | $field = $this->getField($expr, TRUE); | |
3dd9e4a0 | 392 | FormattingUtil::formatInputValue($value, $expr, $field, $operator); |
c9e3ae2e CW |
393 | $fieldAlias = $field['sql_name']; |
394 | } | |
395 | // For HAVING, expr must be an item in the SELECT clause | |
16f5a13d | 396 | elseif ($type === 'HAVING') { |
37d82abe | 397 | // Expr references a fieldName or alias |
c9e3ae2e CW |
398 | if (isset($this->selectAliases[$expr])) { |
399 | $fieldAlias = $expr; | |
37d82abe CW |
400 | // Attempt to format if this is a real field |
401 | if (isset($this->apiFieldSpec[$expr])) { | |
39deabd6 | 402 | $field = $this->getField($expr); |
3dd9e4a0 | 403 | FormattingUtil::formatInputValue($value, $expr, $field, $operator); |
37d82abe | 404 | } |
c9e3ae2e | 405 | } |
37d82abe | 406 | // Expr references a non-field expression like a function; convert to alias |
c9e3ae2e CW |
407 | elseif (in_array($expr, $this->selectAliases)) { |
408 | $fieldAlias = array_search($expr, $this->selectAliases); | |
409 | } | |
37d82abe | 410 | // If either the having or select field contains a pseudoconstant suffix, match and perform substitution |
c9e3ae2e | 411 | else { |
37d82abe CW |
412 | list($fieldName) = explode(':', $expr); |
413 | foreach ($this->selectAliases as $selectAlias => $selectExpr) { | |
414 | list($selectField) = explode(':', $selectAlias); | |
415 | if ($selectAlias === $selectExpr && $fieldName === $selectField && isset($this->apiFieldSpec[$fieldName])) { | |
39deabd6 | 416 | $field = $this->getField($fieldName); |
3dd9e4a0 | 417 | FormattingUtil::formatInputValue($value, $expr, $field, $operator); |
37d82abe CW |
418 | $fieldAlias = $selectAlias; |
419 | break; | |
420 | } | |
421 | } | |
422 | } | |
423 | if (!isset($fieldAlias)) { | |
424 | throw new \API_Exception("Invalid expression in HAVING clause: '$expr'. Must use a value from SELECT clause."); | |
c9e3ae2e | 425 | } |
37d82abe | 426 | $fieldAlias = '`' . $fieldAlias . '`'; |
c9e3ae2e | 427 | } |
16f5a13d CW |
428 | elseif ($type === 'ON') { |
429 | $expr = $this->getExpression($expr); | |
430 | $fieldName = count($expr->getFields()) === 1 ? $expr->getFields()[0] : NULL; | |
431 | $fieldAlias = $expr->render($this->apiFieldSpec); | |
432 | if (is_string($value)) { | |
433 | $valExpr = $this->getExpression($value); | |
434 | if ($fieldName && $valExpr->getType() === 'SqlString') { | |
e062cce3 CW |
435 | $value = $valExpr->getExpr(); |
436 | FormattingUtil::formatInputValue($value, $fieldName, $this->apiFieldSpec[$fieldName], $operator); | |
437 | return \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]); | |
438 | } | |
439 | else { | |
440 | $value = $valExpr->render($this->apiFieldSpec); | |
441 | return sprintf('%s %s %s', $fieldAlias, $operator, $value); | |
16f5a13d | 442 | } |
16f5a13d CW |
443 | } |
444 | elseif ($fieldName) { | |
39deabd6 | 445 | $field = $this->getField($fieldName); |
3dd9e4a0 | 446 | FormattingUtil::formatInputValue($value, $fieldName, $field, $operator); |
39deabd6 CW |
447 | } |
448 | } | |
449 | ||
450 | if ($operator === 'CONTAINS') { | |
451 | switch ($field['serialize'] ?? NULL) { | |
452 | case \CRM_Core_DAO::SERIALIZE_JSON: | |
453 | $operator = 'LIKE'; | |
454 | $value = '%"' . $value . '"%'; | |
455 | // FIXME: Use this instead of the above hack once MIN_INSTALL_MYSQL_VER is bumped to 5.7. | |
456 | // return sprintf('JSON_SEARCH(%s, "one", "%s") IS NOT NULL', $fieldAlias, \CRM_Core_DAO::escapeString($value)); | |
457 | break; | |
458 | ||
459 | case \CRM_Core_DAO::SERIALIZE_SEPARATOR_BOOKEND: | |
460 | $operator = 'LIKE'; | |
461 | $value = '%' . \CRM_Core_DAO::VALUE_SEPARATOR . $value . \CRM_Core_DAO::VALUE_SEPARATOR . '%'; | |
462 | break; | |
463 | ||
464 | default: | |
465 | $operator = 'LIKE'; | |
466 | $value = '%' . $value . '%'; | |
467 | break; | |
16f5a13d CW |
468 | } |
469 | } | |
19b53e5b | 470 | |
c9e3ae2e | 471 | $sql_clause = \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]); |
19b53e5b | 472 | if ($sql_clause === NULL) { |
c9e3ae2e | 473 | throw new \API_Exception("Invalid value in $type clause for '$expr'"); |
19b53e5b C |
474 | } |
475 | return $sql_clause; | |
476 | } | |
477 | ||
16f5a13d CW |
478 | /** |
479 | * @param string $expr | |
480 | * @return SqlExpression | |
481 | * @throws \API_Exception | |
482 | */ | |
483 | protected function getExpression(string $expr) { | |
484 | $sqlExpr = SqlExpression::convert($expr); | |
485 | foreach ($sqlExpr->getFields() as $fieldName) { | |
486 | $this->getField($fieldName, TRUE); | |
487 | } | |
488 | return $sqlExpr; | |
489 | } | |
490 | ||
19b53e5b | 491 | /** |
88409aaf CW |
492 | * Get acl clause for an entity |
493 | * | |
494 | * @param string $tableAlias | |
495 | * @param \CRM_Core_DAO|string $baoName | |
496 | * @param array $stack | |
497 | * @return array | |
19b53e5b | 498 | */ |
88409aaf CW |
499 | public function getAclClause($tableAlias, $baoName, $stack = []) { |
500 | if (!$this->getCheckPermissions()) { | |
501 | return []; | |
502 | } | |
503 | // Prevent (most) redundant acl sub clauses if they have already been applied to the main entity. | |
504 | // FIXME: Currently this only works 1 level deep, but tracking through multiple joins would increase complexity | |
505 | // and just doing it for the first join takes care of most acl clause deduping. | |
90908aac | 506 | if (count($stack) === 1 && in_array($stack[0], $this->aclFields, TRUE)) { |
88409aaf CW |
507 | return []; |
508 | } | |
509 | $clauses = $baoName::getSelectWhereClause($tableAlias); | |
510 | if (!$stack) { | |
511 | // Track field clauses added to the main entity | |
512 | $this->aclFields = array_keys($clauses); | |
513 | } | |
514 | return array_filter($clauses); | |
19b53e5b C |
515 | } |
516 | ||
517 | /** | |
518 | * Fetch a field from the getFields list | |
519 | * | |
961e974c | 520 | * @param string $expr |
a689294c | 521 | * @param bool $strict |
3176b04c | 522 | * In strict mode, this will throw an exception if the field doesn't exist |
19b53e5b | 523 | * |
88409aaf | 524 | * @return array|null |
a689294c | 525 | * @throws \API_Exception |
19b53e5b | 526 | */ |
961e974c CW |
527 | public function getField($expr, $strict = FALSE) { |
528 | // If the expression contains a pseudoconstant filter like activity_type_id:label, | |
529 | // strip it to look up the base field name, then add the field:filter key to apiFieldSpec | |
530 | $col = strpos($expr, ':'); | |
531 | $fieldName = $col ? substr($expr, 0, $col) : $expr; | |
a689294c CW |
532 | // Perform join if field not yet available - this will add it to apiFieldSpec |
533 | if (!isset($this->apiFieldSpec[$fieldName]) && strpos($fieldName, '.')) { | |
334bebdd | 534 | $this->autoJoinFK($fieldName); |
a689294c CW |
535 | } |
536 | $field = $this->apiFieldSpec[$fieldName] ?? NULL; | |
3176b04c | 537 | if ($strict && !$field) { |
a689294c | 538 | throw new \API_Exception("Invalid field '$fieldName'"); |
19b53e5b | 539 | } |
d9aba0a4 CW |
540 | if ($field) { |
541 | $this->apiFieldSpec[$expr] = $field; | |
542 | } | |
3176b04c | 543 | return $field; |
19b53e5b C |
544 | } |
545 | ||
16f5a13d CW |
546 | /** |
547 | * Join onto other entities as specified by the api call. | |
548 | * | |
16f5a13d CW |
549 | * @throws \API_Exception |
550 | * @throws \Civi\API\Exception\NotImplementedException | |
551 | */ | |
88409aaf CW |
552 | private function addExplicitJoins() { |
553 | foreach ($this->getJoin() as $join) { | |
16f5a13d CW |
554 | // First item in the array is the entity name |
555 | $entity = array_shift($join); | |
556 | // Which might contain an alias. Split on the keyword "AS" | |
557 | list($entity, $alias) = array_pad(explode(' AS ', $entity), 2, NULL); | |
558 | // Ensure alias is a safe string, and supply default if not given | |
8f508853 | 559 | $alias = $alias ? \CRM_Utils_String::munge($alias, '_', 256) : strtolower($entity); |
16f5a13d CW |
560 | // First item in the array is a boolean indicating if the join is required (aka INNER or LEFT). |
561 | // The rest are join conditions. | |
562 | $side = array_shift($join) ? 'INNER' : 'LEFT'; | |
90908aac | 563 | // Add all fields from joined entity to spec |
88409aaf | 564 | $joinEntityGet = \Civi\API\Request::create($entity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()]); |
a5d7af60 CW |
565 | $joinEntityFields = $joinEntityGet->entityFields(); |
566 | foreach ($joinEntityFields as $field) { | |
16f5a13d | 567 | $field['sql_name'] = '`' . $alias . '`.`' . $field['column_name'] . '`'; |
16f5a13d CW |
568 | $this->addSpecField($alias . '.' . $field['name'], $field); |
569 | } | |
7de13161 CW |
570 | $tableName = CoreUtil::getTableName($entity); |
571 | // Save join info to be retrieved by $this->getExplicitJoin() | |
572 | $this->explicitJoins[$alias] = [ | |
573 | 'entity' => $entity, | |
574 | 'table' => $tableName, | |
575 | 'bridge' => NULL, | |
576 | ]; | |
577 | // If the first condition is a string, it's the name of a bridge entity | |
90908aac | 578 | if (!empty($join[0]) && is_string($join[0]) && \CRM_Utils_Rule::alphanumeric($join[0])) { |
7de13161 | 579 | $this->explicitJoins[$alias]['bridge'] = $join[0]; |
90908aac CW |
580 | $conditions = $this->getBridgeJoin($join, $entity, $alias); |
581 | } | |
582 | else { | |
a5d7af60 | 583 | $conditions = $this->getJoinConditions($join, $entity, $alias, $joinEntityFields); |
90908aac | 584 | } |
5e327f37 | 585 | foreach (array_filter($join) as $clause) { |
16f5a13d CW |
586 | $conditions[] = $this->treeWalkClauses($clause, 'ON'); |
587 | } | |
16f5a13d CW |
588 | $this->join($side, $tableName, $alias, $conditions); |
589 | } | |
590 | } | |
591 | ||
592 | /** | |
593 | * Supply conditions for an explicit join. | |
594 | * | |
90908aac CW |
595 | * @param array $joinTree |
596 | * @param string $joinEntity | |
597 | * @param string $alias | |
a5d7af60 | 598 | * @param array $joinEntityFields |
16f5a13d CW |
599 | * @return array |
600 | */ | |
a5d7af60 | 601 | private function getJoinConditions($joinTree, $joinEntity, $alias, $joinEntityFields) { |
16f5a13d CW |
602 | $conditions = []; |
603 | // getAclClause() expects a stack of 1-to-1 join fields to help it dedupe, but this is more flexible, | |
604 | // so unless this is a direct 1-to-1 join with the main entity, we'll just hack it | |
605 | // with a padded empty stack to bypass its deduping. | |
606 | $stack = [NULL, NULL]; | |
a5d7af60 CW |
607 | // See if the ON clause already contains an FK reference to joinEntity |
608 | $explicitFK = array_filter($joinTree, function($clause) use ($alias, $joinEntityFields) { | |
90908aac | 609 | list($sideA, $op, $sideB) = array_pad((array) $clause, 3, NULL); |
a5d7af60 CW |
610 | if ($op !== '=' || !$sideB) { |
611 | return FALSE; | |
612 | } | |
613 | foreach ([$sideA, $sideB] as $expr) { | |
614 | if ($expr === "$alias.id" || !empty($joinEntityFields["$alias.$expr"]['fk_entity'])) { | |
615 | return TRUE; | |
616 | } | |
617 | } | |
618 | return FALSE; | |
90908aac | 619 | }); |
a5d7af60 CW |
620 | // If we're not explicitly referencing the ID (or some other FK field) of the joinEntity, search for a default |
621 | if (!$explicitFK) { | |
90908aac CW |
622 | foreach ($this->apiFieldSpec as $name => $field) { |
623 | if ($field['entity'] !== $joinEntity && $field['fk_entity'] === $joinEntity) { | |
624 | $conditions[] = $this->treeWalkClauses([$name, '=', "$alias.id"], 'ON'); | |
625 | } | |
626 | elseif (strpos($name, "$alias.") === 0 && substr_count($name, '.') === 1 && $field['fk_entity'] === $this->getEntity()) { | |
627 | $conditions[] = $this->treeWalkClauses([$name, '=', 'id'], 'ON'); | |
628 | $stack = ['id']; | |
629 | } | |
16f5a13d | 630 | } |
90908aac CW |
631 | // Hmm, if we came up with > 1 condition, then it's ambiguous how it should be joined so we won't return anything but the generic ACLs |
632 | if (count($conditions) > 1) { | |
633 | $stack = [NULL, NULL]; | |
634 | $conditions = []; | |
16f5a13d CW |
635 | } |
636 | } | |
90908aac | 637 | $baoName = CoreUtil::getBAOFromApiName($joinEntity); |
5e327f37 | 638 | $acls = array_values($this->getAclClause($alias, $baoName, $stack)); |
16f5a13d CW |
639 | return array_merge($acls, $conditions); |
640 | } | |
641 | ||
90908aac | 642 | /** |
17019d49 CW |
643 | * Join via a Bridge table |
644 | * | |
645 | * This creates a double-join in sql that appears to the API user like a single join. | |
90908aac CW |
646 | * |
647 | * @param array $joinTree | |
648 | * @param string $joinEntity | |
649 | * @param string $alias | |
650 | * @return array | |
651 | * @throws \API_Exception | |
652 | */ | |
653 | protected function getBridgeJoin(&$joinTree, $joinEntity, $alias) { | |
654 | $bridgeEntity = array_shift($joinTree); | |
465bc32a CW |
655 | /* @var \Civi\Api4\Generic\DAOEntity $bridgeEntityClass */ |
656 | $bridgeEntityClass = '\Civi\Api4\\' . $bridgeEntity; | |
90908aac | 657 | $bridgeAlias = $alias . '_via_' . strtolower($bridgeEntity); |
17019d49 CW |
658 | $bridgeInfo = $bridgeEntityClass::getInfo(); |
659 | $bridgeFields = $bridgeInfo['bridge'] ?? []; | |
660 | // Sanity check - bridge entity should declare exactly 2 FK fields | |
661 | if (count($bridgeFields) !== 2) { | |
662 | throw new \API_Exception("Illegal bridge entity specified: $bridgeEntity. Expected 2 bridge fields, found " . count($bridgeFields)); | |
663 | } | |
664 | /* @var \CRM_Core_DAO $bridgeDAO */ | |
665 | $bridgeDAO = $bridgeInfo['dao']; | |
666 | $bridgeTable = $bridgeDAO::getTableName(); | |
667 | ||
90908aac | 668 | $joinTable = CoreUtil::getTableName($joinEntity); |
465bc32a | 669 | $bridgeEntityGet = $bridgeEntityClass::get($this->getCheckPermissions()); |
17019d49 CW |
670 | // Get the 2 bridge reference columns as CRM_Core_Reference_* objects |
671 | $joinRef = $baseRef = NULL; | |
672 | foreach ($bridgeDAO::getReferenceColumns() as $ref) { | |
673 | if (in_array($ref->getReferenceKey(), $bridgeFields)) { | |
674 | if (!$joinRef && in_array($joinEntity, $ref->getTargetEntities())) { | |
675 | $joinRef = $ref; | |
676 | } | |
677 | else { | |
678 | $baseRef = $ref; | |
679 | } | |
90908aac CW |
680 | } |
681 | } | |
17019d49 | 682 | if (!$joinRef || !$baseRef) { |
90908aac CW |
683 | throw new \API_Exception("Unable to join $bridgeEntity to $joinEntity"); |
684 | } | |
685 | // Create link between bridge entity and join entity | |
686 | $joinConditions = [ | |
17019d49 | 687 | "`$bridgeAlias`.`{$joinRef->getReferenceKey()}` = `$alias`.`{$joinRef->getTargetKey()}`", |
90908aac | 688 | ]; |
17019d49 CW |
689 | // For dynamic references, also add the type column (e.g. `entity_table`) |
690 | if ($joinRef->getTypeColumn()) { | |
691 | $joinConditions[] = "`$bridgeAlias`.`{$joinRef->getTypeColumn()}` = '$joinTable'"; | |
90908aac | 692 | } |
17019d49 CW |
693 | // Register fields (other than bridge FK fields) from the bridge entity as if they belong to the join entity |
694 | $fakeFields = []; | |
90908aac | 695 | foreach ($bridgeEntityGet->entityFields() as $name => $field) { |
17019d49 | 696 | if ($name === 'id' || $name === $joinRef->getReferenceKey() || $name === $joinRef->getTypeColumn() || $name === $baseRef->getReferenceKey() || $name === $baseRef->getTypeColumn()) { |
90908aac CW |
697 | continue; |
698 | } | |
90908aac CW |
699 | // Note these fields get a sql alias pointing to the bridge entity, but an api alias pretending they belong to the join entity |
700 | $field['sql_name'] = '`' . $bridgeAlias . '`.`' . $field['column_name'] . '`'; | |
701 | $this->addSpecField($alias . '.' . $field['name'], $field); | |
17019d49 | 702 | $fakeFields[] = $alias . '.' . $field['name']; |
90908aac CW |
703 | } |
704 | // Move conditions for the bridge join out of the joinTree | |
705 | $bridgeConditions = []; | |
17019d49 CW |
706 | $isExplicit = FALSE; |
707 | $joinTree = array_filter($joinTree, function($clause) use ($baseRef, $alias, $bridgeAlias, $fakeFields, &$bridgeConditions, &$isExplicit) { | |
90908aac | 708 | list($sideA, $op, $sideB) = array_pad((array) $clause, 3, NULL); |
17019d49 CW |
709 | // Skip AND/OR/NOT branches |
710 | if (!$sideB) { | |
711 | return TRUE; | |
712 | } | |
713 | // If this condition makes an explicit link between the bridge and another entity | |
714 | if ($op === '=' && $sideB && ($sideA === "$alias.{$baseRef->getReferenceKey()}" || $sideB === "$alias.{$baseRef->getReferenceKey()}")) { | |
715 | $expr = $sideA === "$alias.{$baseRef->getReferenceKey()}" ? $sideB : $sideA; | |
716 | $bridgeConditions[] = "`$bridgeAlias`.`{$baseRef->getReferenceKey()}` = " . $this->getExpression($expr)->render($this->apiFieldSpec); | |
717 | $isExplicit = TRUE; | |
90908aac CW |
718 | return FALSE; |
719 | } | |
17019d49 CW |
720 | // Explicit link with dynamic "entity_table" column |
721 | elseif ($op === '=' && $baseRef->getTypeColumn() && ($sideA === "$alias.{$baseRef->getTypeColumn()}" || $sideB === "$alias.{$baseRef->getTypeColumn()}")) { | |
722 | $expr = $sideA === "$alias.{$baseRef->getTypeColumn()}" ? $sideB : $sideA; | |
723 | $bridgeConditions[] = "`$bridgeAlias`.`{$baseRef->getTypeColumn()}` = " . $this->getExpression($expr)->render($this->apiFieldSpec); | |
724 | $isExplicit = TRUE; | |
90908aac CW |
725 | return FALSE; |
726 | } | |
17019d49 CW |
727 | // Other conditions that apply only to the bridge table should be |
728 | foreach ([$sideA, $sideB] as $expr) { | |
729 | if (is_string($expr) && in_array(explode(':', $expr)[0], $fakeFields)) { | |
730 | $bridgeConditions[] = $this->composeClause($clause, 'ON'); | |
731 | return FALSE; | |
732 | } | |
733 | } | |
90908aac CW |
734 | return TRUE; |
735 | }); | |
736 | // If no bridge conditions were specified, link it to the base entity | |
17019d49 CW |
737 | if (!$isExplicit) { |
738 | if (!in_array($this->getEntity(), $baseRef->getTargetEntities())) { | |
739 | throw new \API_Exception("Unable to join $bridgeEntity to " . $this->getEntity()); | |
740 | } | |
741 | $bridgeConditions[] = "`$bridgeAlias`.`{$baseRef->getReferenceKey()}` = a.`{$baseRef->getTargetKey()}`"; | |
742 | if ($baseRef->getTypeColumn()) { | |
743 | $bridgeConditions[] = "`$bridgeAlias`.`{$baseRef->getTypeColumn()}` = '" . $this->getFrom() . "'"; | |
90908aac CW |
744 | } |
745 | } | |
746 | ||
747 | $this->join('LEFT', $bridgeTable, $bridgeAlias, $bridgeConditions); | |
748 | ||
749 | $baoName = CoreUtil::getBAOFromApiName($joinEntity); | |
750 | $acls = array_values($this->getAclClause($alias, $baoName, [NULL, NULL])); | |
751 | return array_merge($acls, $joinConditions); | |
752 | } | |
753 | ||
19b53e5b | 754 | /** |
334bebdd | 755 | * Joins a path and adds all fields in the joined entity to apiFieldSpec |
a689294c | 756 | * |
19b53e5b C |
757 | * @param $key |
758 | * @throws \API_Exception | |
a689294c | 759 | * @throws \Exception |
19b53e5b | 760 | */ |
334bebdd | 761 | protected function autoJoinFK($key) { |
a689294c | 762 | if (isset($this->apiFieldSpec[$key])) { |
9b06167d | 763 | return; |
19b53e5b C |
764 | } |
765 | ||
a689294c CW |
766 | $pathArray = explode('.', $key); |
767 | ||
19b53e5b C |
768 | /** @var \Civi\Api4\Service\Schema\Joiner $joiner */ |
769 | $joiner = \Civi::container()->get('joiner'); | |
a689294c CW |
770 | // The last item in the path is the field name. We don't care about that; we'll add all fields from the joined entity. |
771 | array_pop($pathArray); | |
19b53e5b | 772 | |
7de13161 CW |
773 | try { |
774 | $joinPath = $joiner->autoJoin($this, $pathArray); | |
775 | } | |
776 | catch (\Exception $e) { | |
9b06167d | 777 | return; |
19b53e5b | 778 | } |
19b53e5b C |
779 | $lastLink = array_pop($joinPath); |
780 | ||
a689294c | 781 | // Custom field names are already prefixed |
9b06167d CW |
782 | $isCustom = $lastLink instanceof CustomGroupJoinable; |
783 | if ($isCustom) { | |
a689294c | 784 | array_pop($pathArray); |
39e0f675 | 785 | } |
a689294c | 786 | $prefix = $pathArray ? implode('.', $pathArray) . '.' : ''; |
19b53e5b | 787 | // Cache field info for retrieval by $this->getField() |
a689294c | 788 | foreach ($lastLink->getEntityFields() as $fieldObject) { |
6fe7bdee | 789 | $fieldArray = $fieldObject->toArray(); |
a689294c | 790 | $fieldArray['sql_name'] = '`' . $lastLink->getAlias() . '`.`' . $fieldArray['column_name'] . '`'; |
9b06167d | 791 | $this->addSpecField($prefix . $fieldArray['name'], $fieldArray); |
19b53e5b | 792 | } |
19b53e5b C |
793 | } |
794 | ||
88409aaf CW |
795 | /** |
796 | * @param string $side | |
797 | * @param string $tableName | |
798 | * @param string $tableAlias | |
799 | * @param array $conditions | |
800 | */ | |
801 | public function join($side, $tableName, $tableAlias, $conditions) { | |
802 | // INNER JOINs take precedence over LEFT JOINs | |
803 | if ($side != 'LEFT' || !isset($this->joins[$tableAlias])) { | |
804 | $this->joins[$tableAlias] = $side; | |
805 | $this->query->join($tableAlias, "$side JOIN `$tableName` `$tableAlias` ON " . implode(' AND ', $conditions)); | |
806 | } | |
807 | } | |
808 | ||
19b53e5b C |
809 | /** |
810 | * @return FALSE|string | |
811 | */ | |
812 | public function getFrom() { | |
88409aaf | 813 | return CoreUtil::getTableName($this->getEntity()); |
19b53e5b C |
814 | } |
815 | ||
816 | /** | |
817 | * @return string | |
818 | */ | |
819 | public function getEntity() { | |
88409aaf | 820 | return $this->api->getEntityName(); |
19b53e5b C |
821 | } |
822 | ||
823 | /** | |
824 | * @return array | |
825 | */ | |
826 | public function getSelect() { | |
88409aaf | 827 | return $this->api->getSelect(); |
19b53e5b C |
828 | } |
829 | ||
830 | /** | |
831 | * @return array | |
832 | */ | |
833 | public function getWhere() { | |
88409aaf | 834 | return $this->api->getWhere(); |
19b53e5b C |
835 | } |
836 | ||
837 | /** | |
838 | * @return array | |
839 | */ | |
88409aaf CW |
840 | public function getHaving() { |
841 | return $this->api->getHaving(); | |
19b53e5b C |
842 | } |
843 | ||
844 | /** | |
845 | * @return array | |
846 | */ | |
88409aaf CW |
847 | public function getJoin() { |
848 | return $this->api->getJoin(); | |
19b53e5b C |
849 | } |
850 | ||
19b53e5b | 851 | /** |
88409aaf | 852 | * @return array |
19b53e5b | 853 | */ |
88409aaf CW |
854 | public function getGroupBy() { |
855 | return $this->api->getGroupBy(); | |
19b53e5b C |
856 | } |
857 | ||
858 | /** | |
859 | * @return array | |
860 | */ | |
88409aaf CW |
861 | public function getOrderBy() { |
862 | return $this->api->getOrderBy(); | |
19b53e5b C |
863 | } |
864 | ||
865 | /** | |
88409aaf | 866 | * @return mixed |
19b53e5b | 867 | */ |
88409aaf CW |
868 | public function getLimit() { |
869 | return $this->api->getLimit(); | |
19b53e5b C |
870 | } |
871 | ||
872 | /** | |
88409aaf | 873 | * @return mixed |
19b53e5b | 874 | */ |
88409aaf CW |
875 | public function getOffset() { |
876 | return $this->api->getOffset(); | |
19b53e5b C |
877 | } |
878 | ||
879 | /** | |
88409aaf | 880 | * @return \CRM_Utils_SQL_Select |
19b53e5b | 881 | */ |
88409aaf CW |
882 | public function getQuery() { |
883 | return $this->query; | |
19b53e5b C |
884 | } |
885 | ||
886 | /** | |
887 | * @return bool|string | |
888 | */ | |
889 | public function getCheckPermissions() { | |
88409aaf | 890 | return $this->api->getCheckPermissions(); |
19b53e5b C |
891 | } |
892 | ||
7de13161 CW |
893 | /** |
894 | * @param string $alias | |
895 | * @return array|NULL | |
896 | */ | |
897 | public function getExplicitJoin($alias) { | |
898 | return $this->explicitJoins[$alias] ?? NULL; | |
899 | } | |
900 | ||
9b06167d | 901 | /** |
88409aaf CW |
902 | * @param string $path |
903 | * @param array $field | |
9b06167d CW |
904 | */ |
905 | private function addSpecField($path, $field) { | |
906 | // Only add field to spec if we have permission | |
88409aaf | 907 | if ($this->getCheckPermissions() && !empty($field['permission']) && !\CRM_Core_Permission::check($field['permission'])) { |
9b06167d CW |
908 | $this->apiFieldSpec[$path] = FALSE; |
909 | return; | |
910 | } | |
9b06167d CW |
911 | $this->apiFieldSpec[$path] = $field; |
912 | } | |
913 | ||
88409aaf CW |
914 | /** |
915 | * Add something to the api's debug output if debugging is enabled | |
916 | * | |
917 | * @param $key | |
918 | * @param $item | |
919 | */ | |
920 | public function debug($key, $item) { | |
921 | if ($this->api->getDebug()) { | |
922 | $this->api->_debugOutput[$key][] = $item; | |
923 | } | |
924 | } | |
925 | ||
19b53e5b | 926 | } |