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