SearchKit - Support filters in new Run action
[civicrm-core.git] / Civi / Api4 / Query / Api4SelectQuery.php
CommitLineData
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
12namespace Civi\Api4\Query;
13
19b53e5b 14use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
19b53e5b
C
15use Civi\Api4\Utils\FormattingUtil;
16use Civi\Api4\Utils\CoreUtil;
39e0f675 17use 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
32class 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}