avoid crash with one column and blank lines
[civicrm-core.git] / Civi / Api4 / Query / Api4SelectQuery.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 use Civi\API\Exception\UnauthorizedException;
15 use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
16 use Civi\Api4\Utils\FormattingUtil;
17 use Civi\Api4\Utils\CoreUtil;
18 use Civi\Api4\Utils\SelectUtil;
19
20 /**
21 * A query `node` may be in one of three formats:
22 *
23 * * leaf: [$fieldName, $operator, $criteria]
24 * * negated: ['NOT', $node]
25 * * branch: ['OR|NOT', [$node, $node, ...]]
26 *
27 * Leaf operators are one of:
28 *
29 * * '=', '<=', '>=', '>', '<', 'LIKE', "<>", "!=",
30 * * 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN',
31 * * 'IS NOT NULL', or 'IS NULL', 'CONTAINS'.
32 */
33 class Api4SelectQuery {
34
35 const
36 MAIN_TABLE_ALIAS = 'a',
37 UNLIMITED = '18446744073709551615';
38
39 /**
40 * @var \CRM_Utils_SQL_Select
41 */
42 protected $query;
43
44 /**
45 * @var array
46 */
47 protected $joins = [];
48
49 /**
50 * Used to keep track of implicit join table aliases
51 * @var array
52 */
53 protected $joinTree = [];
54
55 /**
56 * Used to create a unique table alias for each implicit join
57 * @var int
58 */
59 protected $autoJoinSuffix = 0;
60
61 /**
62 * @var array[]
63 */
64 protected $apiFieldSpec;
65
66 /**
67 * @var array
68 */
69 protected $aclFields = [];
70
71 /**
72 * @var \Civi\Api4\Generic\DAOGetAction
73 */
74 private $api;
75
76 /**
77 * @var array
78 * [alias => expr][]
79 */
80 protected $selectAliases = [];
81
82 /**
83 * @var bool
84 */
85 public $forceSelectId = TRUE;
86
87 /**
88 * @var array
89 */
90 private $explicitJoins = [];
91
92 /**
93 * @var array
94 */
95 private $entityAccess = [];
96
97 /**
98 * @param \Civi\Api4\Generic\DAOGetAction $apiGet
99 */
100 public function __construct($apiGet) {
101 $this->api = $apiGet;
102
103 // Always select ID of main table unless grouping by something else
104 $keys = CoreUtil::getInfoItem($this->getEntity(), 'primary_key');
105 $this->forceSelectId = !$this->isAggregateQuery() || array_intersect($this->getGroupBy(), $keys);
106
107 // Build field lists
108 foreach ($this->api->entityFields() as $field) {
109 $field['sql_name'] = '`' . self::MAIN_TABLE_ALIAS . '`.`' . $field['column_name'] . '`';
110 $this->addSpecField($field['name'], $field);
111 }
112
113 $tableName = CoreUtil::getTableName($this->getEntity());
114 $this->query = \CRM_Utils_SQL_Select::from($tableName . ' ' . self::MAIN_TABLE_ALIAS);
115
116 $this->entityAccess[$this->getEntity()] = TRUE;
117
118 // Add ACLs first to avoid redundant subclauses
119 $baoName = CoreUtil::getBAOFromApiName($this->getEntity());
120 $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
121
122 // Add explicit joins. Other joins implied by dot notation may be added later
123 $this->addExplicitJoins();
124 }
125
126 protected function isAggregateQuery() {
127 if ($this->getGroupBy()) {
128 return TRUE;
129 }
130 foreach ($this->getSelect() as $sql) {
131 $classname = get_class(SqlExpression::convert($sql, TRUE));
132 if (method_exists($classname, 'getCategory') && $classname::getCategory() === SqlFunction::CATEGORY_AGGREGATE) {
133 return TRUE;
134 }
135 }
136 return FALSE;
137 }
138
139 /**
140 * Builds main final sql statement after initialization.
141 *
142 * @return string
143 * @throws \API_Exception
144 * @throws \CRM_Core_Exception
145 */
146 public function getSql() {
147 $this->buildSelectClause();
148 $this->buildWhereClause();
149 $this->buildOrderBy();
150 $this->buildLimit();
151 $this->buildGroupBy();
152 $this->buildHavingClause();
153 return $this->query->toSQL();
154 }
155
156 /**
157 * Why walk when you can
158 *
159 * @return array
160 */
161 public function run() {
162 $results = [];
163 $sql = $this->getSql();
164 $this->debug('sql', $sql);
165 $query = \CRM_Core_DAO::executeQuery($sql);
166 while ($query->fetch()) {
167 $result = [];
168 foreach ($this->selectAliases as $alias => $expr) {
169 $returnName = $alias;
170 $alias = str_replace('.', '_', $alias);
171 $result[$returnName] = property_exists($query, $alias) ? $query->$alias : NULL;
172 }
173 $results[] = $result;
174 }
175 FormattingUtil::formatOutputValues($results, $this->apiFieldSpec, $this->getEntity(), 'get', $this->selectAliases);
176 return $results;
177 }
178
179 /**
180 * @return int
181 * @throws \API_Exception
182 */
183 public function getCount() {
184 $this->buildWhereClause();
185 // If no having or groupBy, we only need to select count
186 if (!$this->getHaving() && !$this->getGroupBy()) {
187 $this->query->select('COUNT(*) AS `c`');
188 $sql = $this->query->toSQL();
189 }
190 // Use a subquery to count groups from GROUP BY or results filtered by HAVING
191 else {
192 // With no HAVING, just select the last field grouped by
193 if (!$this->getHaving()) {
194 $select = array_slice($this->getGroupBy(), -1);
195 }
196 $this->buildSelectClause($select ?? NULL);
197 $this->buildHavingClause();
198 $this->buildGroupBy();
199 $subquery = $this->query->toSQL();
200 $sql = "SELECT count(*) AS `c` FROM ( $subquery ) AS `rows`";
201 }
202 $this->debug('sql', $sql);
203 return (int) \CRM_Core_DAO::singleValueQuery($sql);
204 }
205
206 /**
207 * @param array $select
208 * Array of select expressions; defaults to $this->getSelect
209 * @throws \API_Exception
210 */
211 protected function buildSelectClause($select = NULL) {
212 // Use default if select not provided, exclude row_count which is handled elsewhere
213 $select = array_diff($select ?? $this->getSelect(), ['row_count']);
214 // An empty select is the same as *
215 if (empty($select)) {
216 $select = $this->selectMatchingFields('*');
217 }
218 else {
219 if ($this->forceSelectId) {
220 $keys = CoreUtil::getInfoItem($this->getEntity(), 'primary_key');
221 $select = array_merge($keys, $select);
222 }
223
224 // Expand the superstar 'custom.*' to select all fields in all custom groups
225 $customStar = array_search('custom.*', array_values($select), TRUE);
226 if ($customStar !== FALSE) {
227 $customGroups = civicrm_api4($this->getEntity(), 'getFields', [
228 'checkPermissions' => FALSE,
229 'where' => [['custom_group', 'IS NOT NULL']],
230 ], ['custom_group' => 'custom_group']);
231 $customSelect = [];
232 foreach ($customGroups as $groupName) {
233 $customSelect[] = "$groupName.*";
234 }
235 array_splice($select, $customStar, 1, $customSelect);
236 }
237
238 // Expand wildcards in joins (the api wrapper already expanded non-joined wildcards)
239 $wildFields = array_filter($select, function($item) {
240 return strpos($item, '*') !== FALSE && strpos($item, '.') !== FALSE && strpos($item, '(') === FALSE && strpos($item, ' ') === FALSE;
241 });
242
243 foreach ($wildFields as $wildField) {
244 $pos = array_search($wildField, array_values($select));
245 // If the joined_entity.id isn't in the fieldspec already, autoJoinFK will attempt to add the entity.
246 $fkField = substr($wildField, 0, strrpos($wildField, '.'));
247 $fkEntity = $this->getField($fkField)['fk_entity'] ?? NULL;
248 $id = $fkEntity ? CoreUtil::getInfoItem($fkEntity, 'primary_key')[0] : 'id';
249 $this->autoJoinFK($fkField . ".$id");
250 $matches = $this->selectMatchingFields($wildField);
251 array_splice($select, $pos, 1, $matches);
252 }
253 $select = array_unique($select);
254 }
255 foreach ($select as $item) {
256 $expr = SqlExpression::convert($item, TRUE);
257 $valid = TRUE;
258 foreach ($expr->getFields() as $fieldName) {
259 $field = $this->getField($fieldName);
260 // Remove expressions with unknown fields without raising an error
261 if (!$field || $field['type'] === 'Filter') {
262 $select = array_diff($select, [$item]);
263 $valid = FALSE;
264 }
265 }
266 if ($valid) {
267 $alias = $expr->getAlias();
268 if ($alias != $expr->getExpr() && isset($this->apiFieldSpec[$alias])) {
269 throw new \API_Exception('Cannot use existing field name as alias');
270 }
271 $this->selectAliases[$alias] = $expr->getExpr();
272 $this->query->select($expr->render($this->apiFieldSpec) . " AS `$alias`");
273 }
274 }
275 }
276
277 /**
278 * Get all fields for SELECT clause matching a wildcard pattern
279 *
280 * @param $pattern
281 * @return array
282 */
283 private function selectMatchingFields($pattern) {
284 // Only core & custom fields can be selected
285 $availableFields = array_filter($this->apiFieldSpec, function($field) {
286 return is_array($field) && in_array($field['type'], ['Field', 'Custom'], TRUE);
287 });
288 return SelectUtil::getMatchingFields($pattern, array_keys($availableFields));
289 }
290
291 /**
292 * Add WHERE clause to query
293 */
294 protected function buildWhereClause() {
295 foreach ($this->getWhere() as $clause) {
296 $sql = $this->treeWalkClauses($clause, 'WHERE');
297 if ($sql) {
298 $this->query->where($sql);
299 }
300 }
301 }
302
303 /**
304 * Add HAVING clause to query
305 *
306 * Every expression referenced must also be in the SELECT clause.
307 */
308 protected function buildHavingClause() {
309 foreach ($this->getHaving() as $clause) {
310 $sql = $this->treeWalkClauses($clause, 'HAVING');
311 if ($sql) {
312 $this->query->having($sql);
313 }
314 }
315 }
316
317 /**
318 * Add ORDER BY to query
319 */
320 protected function buildOrderBy() {
321 foreach ($this->getOrderBy() as $item => $dir) {
322 if ($dir !== 'ASC' && $dir !== 'DESC') {
323 throw new \API_Exception("Invalid sort direction. Cannot order by $item $dir");
324 }
325
326 try {
327 $expr = $this->getExpression($item);
328 $column = $expr->render($this->apiFieldSpec);
329
330 // Use FIELD() function to sort on pseudoconstant values
331 $suffix = strstr($item, ':');
332 if ($suffix && $expr->getType() === 'SqlField') {
333 $field = $this->getField($item);
334 $options = FormattingUtil::getPseudoconstantList($field, $item);
335 if ($options) {
336 asort($options);
337 $column = "FIELD($column,'" . implode("','", array_keys($options)) . "')";
338 }
339 }
340 }
341 // If the expression could not be rendered, it might be a field alias
342 catch (\API_Exception $e) {
343 // Silently ignore fields the user lacks permission to see
344 if (is_a($e, 'Civi\API\Exception\UnauthorizedException')) {
345 $this->debug('unauthorized_fields', $item);
346 continue;
347 }
348 if (!empty($this->selectAliases[$item])) {
349 $column = '`' . $item . '`';
350 }
351 else {
352 throw new \API_Exception("Invalid field '{$item}'");
353 }
354 }
355
356 $this->query->orderBy("$column $dir");
357 }
358 }
359
360 /**
361 * Add LIMIT to query
362 *
363 * @throws \CRM_Core_Exception
364 */
365 protected function buildLimit() {
366 if ($this->getLimit() || $this->getOffset()) {
367 // If limit is 0, mysql will actually return 0 results. Instead set to maximum possible.
368 $this->query->limit($this->getLimit() ?: self::UNLIMITED, $this->getOffset());
369 }
370 }
371
372 /**
373 * Add GROUP BY clause to query
374 */
375 protected function buildGroupBy() {
376 foreach ($this->getGroupBy() as $item) {
377 $this->query->groupBy($this->getExpression($item)->render($this->apiFieldSpec));
378 }
379 }
380
381 /**
382 * Recursively validate and transform a branch or leaf clause array to SQL.
383 *
384 * @param array $clause
385 * @param string $type
386 * WHERE|HAVING|ON
387 * @param int $depth
388 * @return string SQL where clause
389 *
390 * @throws \API_Exception
391 * @uses composeClause() to generate the SQL etc.
392 */
393 protected function treeWalkClauses($clause, $type, $depth = 0) {
394 // Skip empty leaf.
395 if (in_array($clause[0], ['AND', 'OR', 'NOT']) && empty($clause[1])) {
396 return '';
397 }
398 switch ($clause[0]) {
399 case 'OR':
400 case 'AND':
401 // handle branches
402 if (count($clause[1]) === 1) {
403 // a single set so AND|OR is immaterial
404 return $this->treeWalkClauses($clause[1][0], $type, $depth + 1);
405 }
406 else {
407 $sql_subclauses = [];
408 foreach ($clause[1] as $subclause) {
409 $sql_subclauses[] = $this->treeWalkClauses($subclause, $type, $depth + 1);
410 }
411 return '(' . implode("\n" . $clause[0] . ' ', $sql_subclauses) . ')';
412 }
413
414 case 'NOT':
415 // If we get a group of clauses with no operator, assume AND
416 if (!is_string($clause[1][0])) {
417 $clause[1] = ['AND', $clause[1]];
418 }
419 return 'NOT (' . $this->treeWalkClauses($clause[1], $type, $depth + 1) . ')';
420
421 default:
422 try {
423 return $this->composeClause($clause, $type, $depth);
424 }
425 // Silently ignore fields the user lacks permission to see
426 catch (UnauthorizedException $e) {
427 return '';
428 }
429 }
430 }
431
432 /**
433 * Validate and transform a leaf clause array to SQL.
434 * @param array $clause [$fieldName, $operator, $criteria]
435 * @param string $type
436 * WHERE|HAVING|ON
437 * @param int $depth
438 * @return string SQL
439 * @throws \API_Exception
440 * @throws \Exception
441 */
442 protected function composeClause(array $clause, string $type, int $depth) {
443 $field = NULL;
444 // Pad array for unary operators
445 [$expr, $operator, $value] = array_pad($clause, 3, NULL);
446 if (!in_array($operator, CoreUtil::getOperators(), TRUE)) {
447 throw new \API_Exception('Illegal operator');
448 }
449
450 // For WHERE clause, expr must be the name of a field.
451 if ($type === 'WHERE') {
452 $field = $this->getField($expr, TRUE);
453 FormattingUtil::formatInputValue($value, $expr, $field, $operator);
454 $fieldAlias = $this->getExpression($expr)->render($this->apiFieldSpec);
455 }
456 // For HAVING, expr must be an item in the SELECT clause
457 elseif ($type === 'HAVING') {
458 // Expr references a fieldName or alias
459 if (isset($this->selectAliases[$expr])) {
460 $fieldAlias = $expr;
461 // Attempt to format if this is a real field
462 if (isset($this->apiFieldSpec[$expr])) {
463 $field = $this->getField($expr);
464 FormattingUtil::formatInputValue($value, $expr, $field, $operator);
465 }
466 }
467 // Expr references a non-field expression like a function; convert to alias
468 elseif (in_array($expr, $this->selectAliases)) {
469 $fieldAlias = array_search($expr, $this->selectAliases);
470 }
471 // If either the having or select field contains a pseudoconstant suffix, match and perform substitution
472 else {
473 list($fieldName) = explode(':', $expr);
474 foreach ($this->selectAliases as $selectAlias => $selectExpr) {
475 list($selectField) = explode(':', $selectAlias);
476 if ($selectAlias === $selectExpr && $fieldName === $selectField && isset($this->apiFieldSpec[$fieldName])) {
477 $field = $this->getField($fieldName);
478 FormattingUtil::formatInputValue($value, $expr, $field, $operator);
479 $fieldAlias = $selectAlias;
480 break;
481 }
482 }
483 }
484 if (!isset($fieldAlias)) {
485 if (in_array($expr, $this->getSelect())) {
486 throw new UnauthorizedException("Unauthorized field '$expr'");
487 }
488 else {
489 throw new \API_Exception("Invalid expression in HAVING clause: '$expr'. Must use a value from SELECT clause.");
490 }
491 }
492 $fieldAlias = '`' . $fieldAlias . '`';
493 }
494 elseif ($type === 'ON') {
495 $expr = $this->getExpression($expr);
496 $fieldName = count($expr->getFields()) === 1 ? $expr->getFields()[0] : NULL;
497 $fieldAlias = $expr->render($this->apiFieldSpec);
498 if (is_string($value)) {
499 $valExpr = $this->getExpression($value);
500 if ($fieldName && $valExpr->getType() === 'SqlString') {
501 $value = $valExpr->getExpr();
502 FormattingUtil::formatInputValue($value, $fieldName, $this->apiFieldSpec[$fieldName], $operator);
503 return $this->createSQLClause($fieldAlias, $operator, $value, $this->apiFieldSpec[$fieldName], $depth);
504 }
505 else {
506 $value = $valExpr->render($this->apiFieldSpec);
507 return sprintf('%s %s %s', $fieldAlias, $operator, $value);
508 }
509 }
510 elseif ($fieldName) {
511 $field = $this->getField($fieldName);
512 FormattingUtil::formatInputValue($value, $fieldName, $field, $operator);
513 }
514 }
515
516 $sqlClause = $this->createSQLClause($fieldAlias, $operator, $value, $field, $depth);
517 if ($sqlClause === NULL) {
518 throw new \API_Exception("Invalid value in $type clause for '$expr'");
519 }
520 return $sqlClause;
521 }
522
523 /**
524 * @param string $fieldAlias
525 * @param string $operator
526 * @param mixed $value
527 * @param array|null $field
528 * @param int $depth
529 * @return array|string|NULL
530 * @throws \Exception
531 */
532 protected function createSQLClause($fieldAlias, $operator, $value, $field, int $depth) {
533 if (!empty($field['operators']) && !in_array($operator, $field['operators'], TRUE)) {
534 throw new \API_Exception('Illegal operator for ' . $field['name']);
535 }
536 // Some fields use a callback to generate their sql
537 if (!empty($field['sql_filters'])) {
538 $sql = [];
539 foreach ($field['sql_filters'] as $filter) {
540 $clause = is_callable($filter) ? $filter($field, $fieldAlias, $operator, $value, $this, $depth) : NULL;
541 if ($clause) {
542 $sql[] = $clause;
543 }
544 }
545 return $sql ? implode(' AND ', $sql) : NULL;
546 }
547 if ($operator === 'CONTAINS') {
548 switch ($field['serialize'] ?? NULL) {
549 case \CRM_Core_DAO::SERIALIZE_JSON:
550 $operator = 'LIKE';
551 $value = '%"' . $value . '"%';
552 // FIXME: Use this instead of the above hack once MIN_INSTALL_MYSQL_VER is bumped to 5.7.
553 // return sprintf('JSON_SEARCH(%s, "one", "%s") IS NOT NULL', $fieldAlias, \CRM_Core_DAO::escapeString($value));
554 break;
555
556 case \CRM_Core_DAO::SERIALIZE_SEPARATOR_BOOKEND:
557 $operator = 'LIKE';
558 $value = '%' . \CRM_Core_DAO::VALUE_SEPARATOR . $value . \CRM_Core_DAO::VALUE_SEPARATOR . '%';
559 break;
560
561 default:
562 $operator = 'LIKE';
563 $value = '%' . $value . '%';
564 break;
565 }
566 }
567
568 if ($operator === 'IS EMPTY' || $operator === 'IS NOT EMPTY') {
569 // If field is not a string or number, this will pass through and use IS NULL/IS NOT NULL
570 $operator = str_replace('EMPTY', 'NULL', $operator);
571 // For strings & numbers, create an OR grouping of empty value OR null
572 if (in_array($field['data_type'] ?? NULL, ['String', 'Integer', 'Float'], TRUE)) {
573 $emptyVal = $field['data_type'] === 'String' ? '""' : '0';
574 $isEmptyClause = $operator === 'IS NULL' ? "= $emptyVal OR" : "<> $emptyVal AND";
575 return "($fieldAlias $isEmptyClause $fieldAlias $operator)";
576 }
577 }
578 if (is_bool($value)) {
579 $value = (int) $value;
580 }
581
582 return \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
583 }
584
585 /**
586 * @param string $expr
587 * @return SqlExpression
588 * @throws \API_Exception
589 */
590 protected function getExpression(string $expr) {
591 $sqlExpr = SqlExpression::convert($expr);
592 foreach ($sqlExpr->getFields() as $fieldName) {
593 $this->getField($fieldName, TRUE);
594 }
595 return $sqlExpr;
596 }
597
598 /**
599 * Get acl clause for an entity
600 *
601 * @param string $tableAlias
602 * @param \CRM_Core_DAO|string $baoName
603 * @param array $stack
604 * @return array
605 */
606 public function getAclClause($tableAlias, $baoName, $stack = []) {
607 if (!$this->getCheckPermissions()) {
608 return [];
609 }
610 // Prevent (most) redundant acl sub clauses if they have already been applied to the main entity.
611 // FIXME: Currently this only works 1 level deep, but tracking through multiple joins would increase complexity
612 // and just doing it for the first join takes care of most acl clause deduping.
613 if (count($stack) === 1 && in_array(reset($stack), $this->aclFields, TRUE)) {
614 return [];
615 }
616 $clauses = $baoName::getSelectWhereClause($tableAlias);
617 if (!$stack) {
618 // Track field clauses added to the main entity
619 $this->aclFields = array_keys($clauses);
620 }
621 return array_filter($clauses);
622 }
623
624 /**
625 * Fetch a field from the getFields list
626 *
627 * @param string $expr
628 * @param bool $strict
629 * In strict mode, this will throw an exception if the field doesn't exist
630 *
631 * @return array|null
632 * @throws \API_Exception
633 */
634 public function getField($expr, $strict = FALSE) {
635 // If the expression contains a pseudoconstant filter like activity_type_id:label,
636 // strip it to look up the base field name, then add the field:filter key to apiFieldSpec
637 $col = strpos($expr, ':');
638 $fieldName = $col ? substr($expr, 0, $col) : $expr;
639 // Perform join if field not yet available - this will add it to apiFieldSpec
640 if (!isset($this->apiFieldSpec[$fieldName]) && strpos($fieldName, '.')) {
641 $this->autoJoinFK($fieldName);
642 }
643 $field = $this->apiFieldSpec[$fieldName] ?? NULL;
644 if (!$field) {
645 $this->debug($field === FALSE ? 'unauthorized_fields' : 'undefined_fields', $fieldName);
646 }
647 if ($strict && $field === NULL) {
648 throw new \API_Exception("Invalid field '$fieldName'");
649 }
650 if ($strict && $field === FALSE) {
651 throw new UnauthorizedException("Unauthorized field '$fieldName'");
652 }
653 if ($field) {
654 $this->apiFieldSpec[$expr] = $field;
655 }
656 return $field;
657 }
658
659 /**
660 * Check the "gatekeeper" permissions for performing "get" on a given entity.
661 *
662 * @param $entity
663 * @return bool
664 */
665 public function checkEntityAccess($entity) {
666 if (!$this->getCheckPermissions()) {
667 return TRUE;
668 }
669 if (!isset($this->entityAccess[$entity])) {
670 $this->entityAccess[$entity] = (bool) civicrm_api4($entity, 'getActions', [
671 'where' => [['name', '=', 'get']],
672 'select' => ['name'],
673 ])->first();
674 }
675 return $this->entityAccess[$entity];
676 }
677
678 /**
679 * Join onto other entities as specified by the api call.
680 *
681 * @throws \API_Exception
682 * @throws \Civi\API\Exception\NotImplementedException
683 */
684 private function addExplicitJoins() {
685 foreach ($this->getJoin() as $join) {
686 // First item in the array is the entity name
687 $entity = array_shift($join);
688 // Which might contain an alias. Split on the keyword "AS"
689 list($entity, $alias) = array_pad(explode(' AS ', $entity), 2, NULL);
690 // Ensure permissions
691 if (!$this->checkEntityAccess($entity)) {
692 continue;
693 }
694 // Ensure alias is a safe string, and supply default if not given
695 $alias = $alias ?: strtolower($entity);
696 if ($alias === self::MAIN_TABLE_ALIAS || !preg_match('/^[-\w]{1,256}$/', $alias)) {
697 throw new \API_Exception('Illegal join alias: "' . $alias . '"');
698 }
699 // First item in the array is a boolean indicating if the join is required (aka INNER or LEFT).
700 // The rest are join conditions.
701 $side = array_shift($join);
702 // If omitted, supply default (LEFT); and legacy support for boolean values
703 if (!is_string($side)) {
704 $side = $side ? 'INNER' : 'LEFT';
705 }
706 if (!in_array($side, ['INNER', 'LEFT', 'EXCLUDE'])) {
707 throw new \API_Exception("Illegal value for join side: '$side'.");
708 }
709 if ($side === 'EXCLUDE') {
710 $side = 'LEFT';
711 $this->api->addWhere("$alias.id", 'IS NULL');
712 }
713 // Add all fields from joined entity to spec
714 $joinEntityGet = \Civi\API\Request::create($entity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()]);
715 $joinEntityFields = $joinEntityGet->entityFields();
716 foreach ($joinEntityFields as $field) {
717 $field['sql_name'] = '`' . $alias . '`.`' . $field['column_name'] . '`';
718 $this->addSpecField($alias . '.' . $field['name'], $field);
719 }
720 $tableName = CoreUtil::getTableName($entity);
721 // Save join info to be retrieved by $this->getExplicitJoin()
722 $this->explicitJoins[$alias] = [
723 'entity' => $entity,
724 'table' => $tableName,
725 'bridge' => NULL,
726 ];
727 // If the first condition is a string, it's the name of a bridge entity
728 if (!empty($join[0]) && is_string($join[0]) && \CRM_Utils_Rule::alphanumeric($join[0])) {
729 $this->explicitJoins[$alias]['bridge'] = $join[0];
730 $this->addBridgeJoin($join, $entity, $alias, $side);
731 }
732 else {
733 $conditions = $this->getJoinConditions($join, $entity, $alias, $joinEntityFields);
734 foreach (array_filter($join) as $clause) {
735 $conditions[] = $this->treeWalkClauses($clause, 'ON');
736 }
737 $this->join($side, $tableName, $alias, $conditions);
738 }
739 }
740 }
741
742 /**
743 * Supply conditions for an explicit join.
744 *
745 * @param array $joinTree
746 * @param string $joinEntity
747 * @param string $alias
748 * @param array $joinEntityFields
749 * @return array
750 */
751 private function getJoinConditions($joinTree, $joinEntity, $alias, $joinEntityFields) {
752 $conditions = [];
753 // getAclClause() expects a stack of 1-to-1 join fields to help it dedupe, but this is more flexible,
754 // so unless this is a direct 1-to-1 join with the main entity, we'll just hack it
755 // with a padded empty stack to bypass its deduping.
756 $stack = [NULL, NULL];
757 // See if the ON clause already contains an FK reference to joinEntity
758 $explicitFK = array_filter($joinTree, function($clause) use ($alias, $joinEntityFields) {
759 list($sideA, $op, $sideB) = array_pad((array) $clause, 3, NULL);
760 if ($op !== '=' || !$sideB) {
761 return FALSE;
762 }
763 foreach ([$sideA, $sideB] as $expr) {
764 if ($expr === "$alias.id" || !empty($joinEntityFields[str_replace("$alias.", '', $expr)]['fk_entity'])) {
765 return TRUE;
766 }
767 }
768 return FALSE;
769 });
770 // If we're not explicitly referencing the ID (or some other FK field) of the joinEntity, search for a default
771 if (!$explicitFK) {
772 foreach ($this->apiFieldSpec as $name => $field) {
773 if (is_array($field) && $field['entity'] !== $joinEntity && $field['fk_entity'] === $joinEntity) {
774 $conditions[] = $this->treeWalkClauses([$name, '=', "$alias.id"], 'ON');
775 }
776 elseif (strpos($name, "$alias.") === 0 && substr_count($name, '.') === 1 && $field['fk_entity'] === $this->getEntity()) {
777 $conditions[] = $this->treeWalkClauses([$name, '=', 'id'], 'ON');
778 $stack = ['id'];
779 }
780 }
781 // 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
782 if (count($conditions) > 1) {
783 $stack = [NULL, NULL];
784 $conditions = [];
785 }
786 }
787 $baoName = CoreUtil::getBAOFromApiName($joinEntity);
788 $acls = array_values($this->getAclClause($alias, $baoName, $stack));
789 return array_merge($acls, $conditions);
790 }
791
792 /**
793 * Join via a Bridge table
794 *
795 * This creates a double-join in sql that appears to the API user like a single join.
796 *
797 * LEFT joins use a subquery so that the bridge + joined-entity can be treated like a single table.
798 *
799 * @param array $joinTree
800 * @param string $joinEntity
801 * @param string $alias
802 * @param string $side
803 * @throws \API_Exception
804 */
805 protected function addBridgeJoin($joinTree, $joinEntity, $alias, $side) {
806 $bridgeEntity = array_shift($joinTree);
807
808 // INNER joins require unique aliases, whereas left joins will be inside a subquery and short aliases are more readable
809 $bridgeAlias = $side === 'INNER' ? $alias . '_via_' . strtolower($bridgeEntity) : 'b';
810 $joinAlias = $side === 'INNER' ? $alias : 'c';
811
812 $joinTable = CoreUtil::getTableName($joinEntity);
813 [$bridgeTable, $baseRef, $joinRef] = $this->getBridgeRefs($bridgeEntity, $joinEntity);
814
815 $bridgeFields = $this->registerBridgeJoinFields($bridgeEntity, $joinRef, $baseRef, $alias, $bridgeAlias, $side);
816
817 $linkConditions = $this->getBridgeLinkConditions($bridgeAlias, $joinAlias, $joinTable, $joinRef);
818
819 $bridgeConditions = $this->getBridgeJoinConditions($joinTree, $baseRef, $alias, $bridgeAlias, $bridgeEntity, $side);
820
821 $acls = array_values($this->getAclClause($joinAlias, CoreUtil::getBAOFromApiName($joinEntity), [NULL, NULL]));
822
823 $joinConditions = [];
824 foreach (array_filter($joinTree) as $clause) {
825 $joinConditions[] = $this->treeWalkClauses($clause, 'ON');
826 }
827
828 // INNER joins are done with 2 joins
829 if ($side === 'INNER') {
830 $this->join('INNER', $bridgeTable, $bridgeAlias, $bridgeConditions);
831 $this->join('INNER', $joinTable, $alias, array_merge($linkConditions, $acls, $joinConditions));
832 }
833 // For LEFT joins, construct a subquery to link the bridge & join tables as one
834 else {
835 $joinEntityClass = CoreUtil::getApiClass($joinEntity);
836 foreach ($joinEntityClass::get($this->getCheckPermissions())->entityFields() as $name => $field) {
837 if ($field['type'] === 'Field') {
838 $bridgeFields[$field['column_name']] = '`' . $joinAlias . '`.`' . $field['column_name'] . '`';
839 }
840 }
841 $select = implode(',', $bridgeFields);
842 $joinConditions = array_merge($joinConditions, $bridgeConditions);
843 $innerConditions = array_merge($linkConditions, $acls);
844 $subquery = "SELECT $select FROM `$bridgeTable` `$bridgeAlias`, `$joinTable` `$joinAlias` WHERE " . implode(' AND ', $innerConditions);
845 $this->query->join($alias, "$side JOIN ($subquery) `$alias` ON " . implode(' AND ', $joinConditions));
846 }
847 }
848
849 /**
850 * Get the table name and 2 reference columns from a bridge entity
851 *
852 * @param string $bridgeEntity
853 * @param string $joinEntity
854 * @return array
855 * @throws \API_Exception
856 */
857 private function getBridgeRefs(string $bridgeEntity, string $joinEntity): array {
858 $bridgeFields = CoreUtil::getInfoItem($bridgeEntity, 'bridge') ?? [];
859 // Sanity check - bridge entity should declare exactly 2 FK fields
860 if (count($bridgeFields) !== 2) {
861 throw new \API_Exception("Illegal bridge entity specified: $bridgeEntity. Expected 2 bridge fields, found " . count($bridgeFields));
862 }
863 /* @var \CRM_Core_DAO $bridgeDAO */
864 $bridgeDAO = CoreUtil::getInfoItem($bridgeEntity, 'dao');
865 $bridgeTable = $bridgeDAO::getTableName();
866
867 // Get the 2 bridge reference columns as CRM_Core_Reference_* objects
868 $joinRef = $baseRef = NULL;
869 foreach ($bridgeDAO::getReferenceColumns() as $ref) {
870 if (array_key_exists($ref->getReferenceKey(), $bridgeFields)) {
871 if (!$joinRef && in_array($joinEntity, $ref->getTargetEntities())) {
872 $joinRef = $ref;
873 }
874 else {
875 $baseRef = $ref;
876 }
877 }
878 }
879 if (!$joinRef || !$baseRef) {
880 throw new \API_Exception("Unable to join $bridgeEntity to $joinEntity");
881 }
882 return [$bridgeTable, $baseRef, $joinRef];
883 }
884
885 /**
886 * Get the clause to link bridge entity with join entity
887 *
888 * @param string $bridgeAlias
889 * @param string $joinAlias
890 * @param string $joinTable
891 * @param $joinRef
892 * @return array
893 */
894 private function getBridgeLinkConditions(string $bridgeAlias, string $joinAlias, string $joinTable, $joinRef): array {
895 $linkConditions = [
896 "`$bridgeAlias`.`{$joinRef->getReferenceKey()}` = `$joinAlias`.`{$joinRef->getTargetKey()}`",
897 ];
898 // For dynamic references, also add the type column (e.g. `entity_table`)
899 if ($joinRef->getTypeColumn()) {
900 $linkConditions[] = "`$bridgeAlias`.`{$joinRef->getTypeColumn()}` = '$joinTable'";
901 }
902 return $linkConditions;
903 }
904
905 /**
906 * Register fields (other than bridge FK fields) from the bridge entity as if they belong to the join entity
907 *
908 * @param $bridgeEntity
909 * @param $joinRef
910 * @param $baseRef
911 * @param string $alias
912 * @param string $bridgeAlias
913 * @param string $side
914 * @return array
915 */
916 private function registerBridgeJoinFields($bridgeEntity, $joinRef, $baseRef, string $alias, string $bridgeAlias, string $side): array {
917 $fakeFields = [];
918 $bridgeFkFields = [$joinRef->getReferenceKey(), $joinRef->getTypeColumn(), $baseRef->getReferenceKey(), $baseRef->getTypeColumn()];
919 $bridgeEntityClass = CoreUtil::getApiClass($bridgeEntity);
920 foreach ($bridgeEntityClass::get($this->getCheckPermissions())->entityFields() as $name => $field) {
921 if ($name === 'id' || ($side === 'INNER' && in_array($name, $bridgeFkFields, TRUE))) {
922 continue;
923 }
924 // For INNER joins, these fields get a sql alias pointing to the bridge entity,
925 // but an api alias pretending they belong to the join entity.
926 $field['sql_name'] = '`' . ($side === 'LEFT' ? $alias : $bridgeAlias) . '`.`' . $field['column_name'] . '`';
927 $this->addSpecField($alias . '.' . $name, $field);
928 if ($field['type'] === 'Field') {
929 $fakeFields[$field['column_name']] = '`' . $bridgeAlias . '`.`' . $field['column_name'] . '`';
930 }
931 }
932 return $fakeFields;
933 }
934
935 /**
936 * Extract bridge join conditions from the joinTree if any, else supply default conditions for join to base entity
937 *
938 * @param array $joinTree
939 * @param $baseRef
940 * @param string $alias
941 * @param string $bridgeAlias
942 * @param string $bridgeEntity
943 * @param string $side
944 * @return string[]
945 * @throws \API_Exception
946 */
947 private function getBridgeJoinConditions(array &$joinTree, $baseRef, string $alias, string $bridgeAlias, string $bridgeEntity, string $side): array {
948 $bridgeConditions = [];
949 $bridgeAlias = $side === 'INNER' ? $bridgeAlias : $alias;
950 // Find explicit bridge join conditions and move them out of the joinTree
951 $joinTree = array_filter($joinTree, function ($clause) use ($baseRef, $alias, $bridgeAlias, &$bridgeConditions) {
952 list($sideA, $op, $sideB) = array_pad((array) $clause, 3, NULL);
953 // Skip AND/OR/NOT branches
954 if (!$sideB) {
955 return TRUE;
956 }
957 // If this condition makes an explicit link between the bridge and another entity
958 if ($op === '=' && $sideB && ($sideA === "$alias.{$baseRef->getReferenceKey()}" || $sideB === "$alias.{$baseRef->getReferenceKey()}")) {
959 $expr = $sideA === "$alias.{$baseRef->getReferenceKey()}" ? $sideB : $sideA;
960 $bridgeConditions[] = "`$bridgeAlias`.`{$baseRef->getReferenceKey()}` = " . $this->getExpression($expr)->render($this->apiFieldSpec);
961 return FALSE;
962 }
963 // Explicit link with dynamic "entity_table" column
964 elseif ($op === '=' && $baseRef->getTypeColumn() && ($sideA === "$alias.{$baseRef->getTypeColumn()}" || $sideB === "$alias.{$baseRef->getTypeColumn()}")) {
965 $expr = $sideA === "$alias.{$baseRef->getTypeColumn()}" ? $sideB : $sideA;
966 $bridgeConditions[] = "`$bridgeAlias`.`{$baseRef->getTypeColumn()}` = " . $this->getExpression($expr)->render($this->apiFieldSpec);
967 return FALSE;
968 }
969 return TRUE;
970 });
971 // If no bridge conditions were specified, link it to the base entity
972 if (!$bridgeConditions) {
973 if (!in_array($this->getEntity(), $baseRef->getTargetEntities())) {
974 throw new \API_Exception("Unable to join $bridgeEntity to " . $this->getEntity());
975 }
976 $bridgeConditions[] = "`$bridgeAlias`.`{$baseRef->getReferenceKey()}` = a.`{$baseRef->getTargetKey()}`";
977 if ($baseRef->getTypeColumn()) {
978 $bridgeConditions[] = "`$bridgeAlias`.`{$baseRef->getTypeColumn()}` = '" . $this->getFrom() . "'";
979 }
980 }
981 return $bridgeConditions;
982 }
983
984 /**
985 * Joins a path and adds all fields in the joined entity to apiFieldSpec
986 *
987 * @param $key
988 */
989 protected function autoJoinFK($key) {
990 if (isset($this->apiFieldSpec[$key])) {
991 return;
992 }
993 /** @var \Civi\Api4\Service\Schema\Joiner $joiner */
994 $joiner = \Civi::container()->get('joiner');
995
996 $pathArray = explode('.', $key);
997 // 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.
998 array_pop($pathArray);
999
1000 $baseTableAlias = $this::MAIN_TABLE_ALIAS;
1001
1002 // If the first item is the name of an explicit join, use it as the base & shift it off the path
1003 $explicitJoin = $this->getExplicitJoin($pathArray[0]);
1004 if ($explicitJoin) {
1005 $baseTableAlias = array_shift($pathArray);
1006 }
1007
1008 // Ensure joinTree array contains base table
1009 $this->joinTree[$baseTableAlias]['#table_alias'] = $baseTableAlias;
1010 $this->joinTree[$baseTableAlias]['#path'] = $explicitJoin ? $baseTableAlias . '.' : '';
1011 // During iteration this variable will refer to the current position in the tree
1012 $joinTreeNode =& $this->joinTree[$baseTableAlias];
1013
1014 try {
1015 $joinPath = $joiner->getPath($explicitJoin['table'] ?? $this->getFrom(), $pathArray);
1016 }
1017 catch (\API_Exception $e) {
1018 // Because the select clause silently ignores unknown fields, this function shouldn't throw exceptions
1019 return;
1020 }
1021
1022 foreach ($joinPath as $joinName => $link) {
1023 if (!isset($joinTreeNode[$joinName])) {
1024 $target = $link->getTargetTable();
1025 $tableAlias = $link->getAlias() . '_' . ++$this->autoJoinSuffix;
1026 $isCustom = $link instanceof CustomGroupJoinable;
1027
1028 $joinTreeNode[$joinName] = [
1029 '#table_alias' => $tableAlias,
1030 '#path' => $joinTreeNode['#path'] . $joinName . '.',
1031 ];
1032 $joinEntity = CoreUtil::getApiNameFromTableName($target);
1033
1034 if ($joinEntity && !$this->checkEntityAccess($joinEntity)) {
1035 return;
1036 }
1037 if ($this->getCheckPermissions() && $isCustom) {
1038 // Check access to custom group
1039 $groupId = \CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomGroup', $link->getTargetTable(), 'id', 'table_name');
1040 if (!\CRM_Core_BAO_CustomGroup::checkGroupAccess($groupId, \CRM_Core_Permission::VIEW)) {
1041 return;
1042 }
1043 }
1044 if ($link->isDeprecated()) {
1045 $deprecatedAlias = $link->getAlias();
1046 \CRM_Core_Error::deprecatedWarning("Deprecated join alias '$deprecatedAlias' used in APIv4 get. Should be changed to '{$deprecatedAlias}_id'");
1047 }
1048 $virtualField = $link->getSerialize();
1049
1050 // Cache field info for retrieval by $this->getField()
1051 foreach ($link->getEntityFields() as $fieldObject) {
1052 $fieldArray = $fieldObject->toArray();
1053 // Set sql name of field, using column name for real joins
1054 if (!$virtualField) {
1055 $fieldArray['sql_name'] = '`' . $tableAlias . '`.`' . $fieldArray['column_name'] . '`';
1056 }
1057 // For virtual joins on serialized fields, the callback function will need the sql name of the serialized field
1058 // @see self::renderSerializedJoin()
1059 else {
1060 $fieldArray['sql_name'] = '`' . $joinTreeNode['#table_alias'] . '`.`' . $link->getBaseColumn() . '`';
1061 }
1062 // Custom fields will already have the group name prefixed
1063 $fieldName = $isCustom ? explode('.', $fieldArray['name'])[1] : $fieldArray['name'];
1064 $this->addSpecField($joinTreeNode[$joinName]['#path'] . $fieldName, $fieldArray);
1065 }
1066
1067 // Serialized joins are rendered by this::renderSerializedJoin. Don't add their tables.
1068 if (!$virtualField) {
1069 $bao = $joinEntity ? CoreUtil::getBAOFromApiName($joinEntity) : NULL;
1070 $conditions = $link->getConditionsForJoin($joinTreeNode['#table_alias'], $tableAlias);
1071 if ($bao) {
1072 $conditions = array_merge($conditions, $this->getAclClause($tableAlias, $bao, $joinPath));
1073 }
1074 $this->join('LEFT', $target, $tableAlias, $conditions);
1075 }
1076
1077 }
1078 $joinTreeNode =& $joinTreeNode[$joinName];
1079 }
1080 }
1081
1082 /**
1083 * @param string $side
1084 * @param string $tableName
1085 * @param string $tableAlias
1086 * @param array $conditions
1087 */
1088 public function join($side, $tableName, $tableAlias, $conditions) {
1089 // INNER JOINs take precedence over LEFT JOINs
1090 if ($side != 'LEFT' || !isset($this->joins[$tableAlias])) {
1091 $this->joins[$tableAlias] = $side;
1092 $this->query->join($tableAlias, "$side JOIN `$tableName` `$tableAlias` ON " . implode(' AND ', $conditions));
1093 }
1094 }
1095
1096 /**
1097 * Performs a virtual join with a serialized field using FIND_IN_SET
1098 *
1099 * @param array $field
1100 * @return string
1101 */
1102 public static function renderSerializedJoin(array $field): string {
1103 $sep = \CRM_Core_DAO::VALUE_SEPARATOR;
1104 $id = CoreUtil::getInfoItem($field['entity'], 'primary_key')[0];
1105 $searchFn = "FIND_IN_SET(`{$field['table_name']}`.`$id`, REPLACE({$field['sql_name']}, '$sep', ','))";
1106 return "(
1107 SELECT GROUP_CONCAT(
1108 `{$field['column_name']}`
1109 ORDER BY $searchFn
1110 SEPARATOR '$sep'
1111 )
1112 FROM `{$field['table_name']}`
1113 WHERE $searchFn
1114 )";
1115 }
1116
1117 /**
1118 * @return FALSE|string
1119 */
1120 public function getFrom() {
1121 return CoreUtil::getTableName($this->getEntity());
1122 }
1123
1124 /**
1125 * @return string
1126 */
1127 public function getEntity() {
1128 return $this->api->getEntityName();
1129 }
1130
1131 /**
1132 * @return array
1133 */
1134 public function getSelect() {
1135 return $this->api->getSelect();
1136 }
1137
1138 /**
1139 * @return array
1140 */
1141 public function getWhere() {
1142 return $this->api->getWhere();
1143 }
1144
1145 /**
1146 * @return array
1147 */
1148 public function getHaving() {
1149 return $this->api->getHaving();
1150 }
1151
1152 /**
1153 * @return array
1154 */
1155 public function getJoin() {
1156 return $this->api->getJoin();
1157 }
1158
1159 /**
1160 * @return array
1161 */
1162 public function getGroupBy() {
1163 return $this->api->getGroupBy();
1164 }
1165
1166 /**
1167 * @return array
1168 */
1169 public function getOrderBy() {
1170 return $this->api->getOrderBy();
1171 }
1172
1173 /**
1174 * @return mixed
1175 */
1176 public function getLimit() {
1177 return $this->api->getLimit();
1178 }
1179
1180 /**
1181 * @return mixed
1182 */
1183 public function getOffset() {
1184 return $this->api->getOffset();
1185 }
1186
1187 /**
1188 * @return \CRM_Utils_SQL_Select
1189 */
1190 public function getQuery() {
1191 return $this->query;
1192 }
1193
1194 /**
1195 * @return bool|string
1196 */
1197 public function getCheckPermissions() {
1198 return $this->api->getCheckPermissions();
1199 }
1200
1201 /**
1202 * @param string $alias
1203 * @return array|NULL
1204 */
1205 public function getExplicitJoin($alias) {
1206 return $this->explicitJoins[$alias] ?? NULL;
1207 }
1208
1209 /**
1210 * @param string $path
1211 * @param array $field
1212 */
1213 private function addSpecField($path, $field) {
1214 // Only add field to spec if we have permission
1215 if ($this->getCheckPermissions() && !empty($field['permission']) && !\CRM_Core_Permission::check($field['permission'])) {
1216 $this->apiFieldSpec[$path] = FALSE;
1217 return;
1218 }
1219 $this->apiFieldSpec[$path] = $field;
1220 }
1221
1222 /**
1223 * Add something to the api's debug output if debugging is enabled
1224 *
1225 * @param $key
1226 * @param $item
1227 */
1228 public function debug($key, $item) {
1229 if ($this->api->getDebug()) {
1230 $this->api->_debugOutput[$key][] = $item;
1231 }
1232 }
1233
1234 }