Merge pull request #17953 from civicrm/5.28
[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', "<>", "!=",
29 * * "NOT LIKE", 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN',
30 * * 'IS NOT NULL', or 'IS NULL'.
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
19b53e5b 79 /**
3c7c8fa6
CW
80 * @param \Civi\Api4\Generic\DAOGetAction $apiGet
81 */
82 public function __construct($apiGet) {
88409aaf 83 $this->api = $apiGet;
651c4c95 84
88409aaf 85 // Always select ID of main table unless grouping by something else
651c4c95
CW
86 $this->forceSelectId = !$this->getGroupBy() || $this->getGroupBy() === ['id'];
87
88 // Build field lists
89 foreach ($this->api->entityFields() as $field) {
5e327f37 90 $this->entityFieldNames[] = $field['name'];
9b06167d 91 $field['sql_name'] = '`' . self::MAIN_TABLE_ALIAS . '`.`' . $field['column_name'] . '`';
5e327f37 92 $this->addSpecField($field['name'], $field);
a689294c 93 }
19b53e5b 94
651c4c95
CW
95 $tableName = CoreUtil::getTableName($this->getEntity());
96 $this->query = \CRM_Utils_SQL_Select::from($tableName . ' ' . self::MAIN_TABLE_ALIAS);
19b53e5b
C
97
98 // Add ACLs first to avoid redundant subclauses
651c4c95 99 $baoName = CoreUtil::getBAOFromApiName($this->getEntity());
19b53e5b
C
100 $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
101 }
102
103 /**
651c4c95 104 * Builds main final sql statement after initialization.
19b53e5b 105 *
4e97c268
CW
106 * @return string
107 * @throws \API_Exception
108 * @throws \CRM_Core_Exception
19b53e5b 109 */
4e97c268 110 public function getSql() {
88409aaf
CW
111 // Add explicit joins. Other joins implied by dot notation may be added later
112 $this->addExplicitJoins();
a689294c 113 $this->buildSelectClause();
19b53e5b 114 $this->buildWhereClause();
a689294c
CW
115 $this->buildOrderBy();
116 $this->buildLimit();
fba513f6 117 $this->buildGroupBy();
c9e3ae2e 118 $this->buildHavingClause();
4e97c268
CW
119 return $this->query->toSQL();
120 }
19b53e5b 121
4e97c268
CW
122 /**
123 * Why walk when you can
124 *
651c4c95 125 * @return array
4e97c268
CW
126 */
127 public function run() {
19b53e5b 128 $results = [];
4e97c268 129 $sql = $this->getSql();
88409aaf 130 $this->debug('sql', $sql);
19b53e5b 131 $query = \CRM_Core_DAO::executeQuery($sql);
19b53e5b 132 while ($query->fetch()) {
334bebdd 133 $result = [];
c9e3ae2e 134 foreach ($this->selectAliases as $alias => $expr) {
19b53e5b 135 $returnName = $alias;
3176b04c 136 $alias = str_replace('.', '_', $alias);
334bebdd 137 $result[$returnName] = property_exists($query, $alias) ? $query->$alias : NULL;
fba513f6 138 }
334bebdd 139 $results[] = $result;
19b53e5b 140 }
88409aaf 141 FormattingUtil::formatOutputValues($results, $this->apiFieldSpec, $this->getEntity());
334bebdd 142 return $results;
19b53e5b
C
143 }
144
88409aaf 145 /**
651c4c95 146 * @return int
88409aaf
CW
147 * @throws \API_Exception
148 */
651c4c95
CW
149 public function getCount() {
150 $this->addExplicitJoins();
151 $this->buildWhereClause();
152 // If no having or groupBy, we only need to select count
153 if (!$this->getHaving() && !$this->getGroupBy()) {
154 $this->query->select('COUNT(*) AS `c`');
155 $sql = $this->query->toSQL();
156 }
157 // Use a subquery to count groups from GROUP BY or results filtered by HAVING
158 else {
159 // With no HAVING, just select the last field grouped by
160 if (!$this->getHaving()) {
161 $select = array_slice($this->getGroupBy(), -1);
162 }
163 $this->buildSelectClause($select ?? NULL);
164 $this->buildHavingClause();
165 $this->buildGroupBy();
166 $subquery = $this->query->toSQL();
167 $sql = "SELECT count(*) AS `c` FROM ( $subquery ) AS rows";
168 }
169 $this->debug('sql', $sql);
170 return (int) \CRM_Core_DAO::singleValueQuery($sql);
171 }
172
173 /**
174 * @param array $select
175 * Array of select expressions; defaults to $this->getSelect
176 * @throws \API_Exception
177 */
178 protected function buildSelectClause($select = NULL) {
179 // Use default if select not provided, exclude row_count which is handled elsewhere
180 $select = array_diff($select ?? $this->getSelect(), ['row_count']);
9b06167d 181 // An empty select is the same as *
88409aaf
CW
182 if (empty($select)) {
183 $select = $this->entityFieldNames;
19b53e5b 184 }
a689294c 185 else {
48102254 186 if ($this->forceSelectId) {
88409aaf 187 $select = array_merge(['id'], $select);
fba513f6 188 }
a689294c
CW
189
190 // Expand wildcards in joins (the api wrapper already expanded non-joined wildcards)
88409aaf 191 $wildFields = array_filter($select, function($item) {
3176b04c 192 return strpos($item, '*') !== FALSE && strpos($item, '.') !== FALSE && strpos($item, '(') === FALSE && strpos($item, ' ') === FALSE;
a689294c
CW
193 });
194 foreach ($wildFields as $item) {
88409aaf 195 $pos = array_search($item, array_values($select));
334bebdd 196 $this->autoJoinFK($item);
a689294c 197 $matches = SelectUtil::getMatchingFields($item, array_keys($this->apiFieldSpec));
88409aaf 198 array_splice($select, $pos, 1, $matches);
a689294c 199 }
88409aaf 200 $select = array_unique($select);
a689294c 201 }
88409aaf 202 foreach ($select as $item) {
3176b04c
CW
203 $expr = SqlExpression::convert($item, TRUE);
204 $valid = TRUE;
205 foreach ($expr->getFields() as $fieldName) {
206 $field = $this->getField($fieldName);
207 // Remove expressions with unknown fields without raising an error
208 if (!$field) {
88409aaf
CW
209 $select = array_diff($select, [$item]);
210 $this->debug('undefined_fields', $fieldName);
3176b04c
CW
211 $valid = FALSE;
212 }
19b53e5b 213 }
3176b04c 214 if ($valid) {
c9e3ae2e 215 $alias = $expr->getAlias();
19fde02c
CW
216 if ($alias != $expr->getExpr() && isset($this->apiFieldSpec[$alias])) {
217 throw new \API_Exception('Cannot use existing field name as alias');
218 }
c9e3ae2e 219 $this->selectAliases[$alias] = $expr->getExpr();
3176b04c 220 $this->query->select($expr->render($this->apiFieldSpec) . " AS `$alias`");
9b06167d 221 }
19b53e5b
C
222 }
223 }
224
225 /**
88409aaf 226 * Add WHERE clause to query
19b53e5b
C
227 */
228 protected function buildWhereClause() {
88409aaf 229 foreach ($this->getWhere() as $clause) {
0c9bccf1
CW
230 $sql = $this->treeWalkClauses($clause, 'WHERE');
231 if ($sql) {
232 $this->query->where($sql);
233 }
c9e3ae2e
CW
234 }
235 }
236
237 /**
88409aaf 238 * Add HAVING clause to query
c9e3ae2e
CW
239 *
240 * Every expression referenced must also be in the SELECT clause.
241 */
242 protected function buildHavingClause() {
88409aaf 243 foreach ($this->getHaving() as $clause) {
c9e3ae2e 244 $this->query->having($this->treeWalkClauses($clause, 'HAVING'));
19b53e5b
C
245 }
246 }
247
248 /**
88409aaf 249 * Add ORDER BY to query
19b53e5b
C
250 */
251 protected function buildOrderBy() {
88409aaf 252 foreach ($this->getOrderBy() as $item => $dir) {
19b53e5b 253 if ($dir !== 'ASC' && $dir !== 'DESC') {
3176b04c
CW
254 throw new \API_Exception("Invalid sort direction. Cannot order by $item $dir");
255 }
a4499ec5
CW
256 $expr = $this->getExpression($item);
257 $column = $expr->render($this->apiFieldSpec);
258
259 // Use FIELD() function to sort on pseudoconstant values
260 $suffix = strstr($item, ':');
261 if ($suffix && $expr->getType() === 'SqlField') {
262 $field = $this->getField($item);
263 $options = FormattingUtil::getPseudoconstantList($field['entity'], $field['name'], substr($suffix, 1));
264 if ($options) {
265 asort($options);
266 $column = "FIELD($column,'" . implode("','", array_keys($options)) . "')";
267 }
268 }
269 $this->query->orderBy("$column $dir");
a689294c
CW
270 }
271 }
272
273 /**
88409aaf
CW
274 * Add LIMIT to query
275 *
a689294c
CW
276 * @throws \CRM_Core_Exception
277 */
278 protected function buildLimit() {
88409aaf 279 if ($this->getLimit() || $this->getOffset()) {
f8bf8e26 280 // If limit is 0, mysql will actually return 0 results. Instead set to maximum possible.
651c4c95 281 $this->query->limit($this->getLimit() ?: self::UNLIMITED, $this->getOffset());
19b53e5b
C
282 }
283 }
284
fba513f6 285 /**
88409aaf 286 * Add GROUP BY clause to query
fba513f6
CW
287 */
288 protected function buildGroupBy() {
88409aaf 289 foreach ($this->getGroupBy() as $item) {
a4499ec5 290 $this->query->groupBy($this->getExpression($item)->render($this->apiFieldSpec));
fba513f6
CW
291 }
292 }
293
19b53e5b
C
294 /**
295 * Recursively validate and transform a branch or leaf clause array to SQL.
296 *
297 * @param array $clause
c9e3ae2e 298 * @param string $type
16f5a13d 299 * WHERE|HAVING|ON
19b53e5b
C
300 * @return string SQL where clause
301 *
c9e3ae2e
CW
302 * @throws \API_Exception
303 * @uses composeClause() to generate the SQL etc.
19b53e5b 304 */
c9e3ae2e 305 protected function treeWalkClauses($clause, $type) {
0c9bccf1
CW
306 // Skip empty leaf.
307 if (in_array($clause[0], ['AND', 'OR', 'NOT']) && empty($clause[1])) {
308 return '';
309 }
19b53e5b
C
310 switch ($clause[0]) {
311 case 'OR':
312 case 'AND':
313 // handle branches
314 if (count($clause[1]) === 1) {
315 // a single set so AND|OR is immaterial
c9e3ae2e 316 return $this->treeWalkClauses($clause[1][0], $type);
19b53e5b
C
317 }
318 else {
319 $sql_subclauses = [];
320 foreach ($clause[1] as $subclause) {
c9e3ae2e 321 $sql_subclauses[] = $this->treeWalkClauses($subclause, $type);
19b53e5b
C
322 }
323 return '(' . implode("\n" . $clause[0], $sql_subclauses) . ')';
324 }
325
326 case 'NOT':
327 // If we get a group of clauses with no operator, assume AND
328 if (!is_string($clause[1][0])) {
329 $clause[1] = ['AND', $clause[1]];
330 }
c9e3ae2e 331 return 'NOT (' . $this->treeWalkClauses($clause[1], $type) . ')';
19b53e5b
C
332
333 default:
c9e3ae2e 334 return $this->composeClause($clause, $type);
19b53e5b
C
335 }
336 }
337
338 /**
339 * Validate and transform a leaf clause array to SQL.
340 * @param array $clause [$fieldName, $operator, $criteria]
c9e3ae2e 341 * @param string $type
16f5a13d 342 * WHERE|HAVING|ON
19b53e5b
C
343 * @return string SQL
344 * @throws \API_Exception
345 * @throws \Exception
346 */
c9e3ae2e 347 protected function composeClause(array $clause, string $type) {
19b53e5b 348 // Pad array for unary operators
c9e3ae2e 349 list($expr, $operator, $value) = array_pad($clause, 3, NULL);
16f5a13d
CW
350 if (!in_array($operator, \CRM_Core_DAO::acceptedSQLOperators(), TRUE)) {
351 throw new \API_Exception('Illegal operator');
352 }
19b53e5b 353
c9e3ae2e
CW
354 // For WHERE clause, expr must be the name of a field.
355 if ($type === 'WHERE') {
356 $field = $this->getField($expr, TRUE);
37d82abe 357 FormattingUtil::formatInputValue($value, $expr, $field);
c9e3ae2e
CW
358 $fieldAlias = $field['sql_name'];
359 }
360 // For HAVING, expr must be an item in the SELECT clause
16f5a13d 361 elseif ($type === 'HAVING') {
37d82abe 362 // Expr references a fieldName or alias
c9e3ae2e
CW
363 if (isset($this->selectAliases[$expr])) {
364 $fieldAlias = $expr;
37d82abe
CW
365 // Attempt to format if this is a real field
366 if (isset($this->apiFieldSpec[$expr])) {
367 FormattingUtil::formatInputValue($value, $expr, $this->apiFieldSpec[$expr]);
368 }
c9e3ae2e 369 }
37d82abe 370 // Expr references a non-field expression like a function; convert to alias
c9e3ae2e
CW
371 elseif (in_array($expr, $this->selectAliases)) {
372 $fieldAlias = array_search($expr, $this->selectAliases);
373 }
37d82abe 374 // If either the having or select field contains a pseudoconstant suffix, match and perform substitution
c9e3ae2e 375 else {
37d82abe
CW
376 list($fieldName) = explode(':', $expr);
377 foreach ($this->selectAliases as $selectAlias => $selectExpr) {
378 list($selectField) = explode(':', $selectAlias);
379 if ($selectAlias === $selectExpr && $fieldName === $selectField && isset($this->apiFieldSpec[$fieldName])) {
380 FormattingUtil::formatInputValue($value, $expr, $this->apiFieldSpec[$fieldName]);
381 $fieldAlias = $selectAlias;
382 break;
383 }
384 }
385 }
386 if (!isset($fieldAlias)) {
387 throw new \API_Exception("Invalid expression in HAVING clause: '$expr'. Must use a value from SELECT clause.");
c9e3ae2e 388 }
37d82abe 389 $fieldAlias = '`' . $fieldAlias . '`';
c9e3ae2e 390 }
16f5a13d
CW
391 elseif ($type === 'ON') {
392 $expr = $this->getExpression($expr);
393 $fieldName = count($expr->getFields()) === 1 ? $expr->getFields()[0] : NULL;
394 $fieldAlias = $expr->render($this->apiFieldSpec);
395 if (is_string($value)) {
396 $valExpr = $this->getExpression($value);
397 if ($fieldName && $valExpr->getType() === 'SqlString') {
398 FormattingUtil::formatInputValue($valExpr->expr, $fieldName, $this->apiFieldSpec[$fieldName]);
399 }
400 return sprintf('%s %s %s', $fieldAlias, $operator, $valExpr->render($this->apiFieldSpec));
401 }
402 elseif ($fieldName) {
403 FormattingUtil::formatInputValue($value, $fieldName, $this->apiFieldSpec[$fieldName]);
404 }
405 }
19b53e5b 406
c9e3ae2e 407 $sql_clause = \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
19b53e5b 408 if ($sql_clause === NULL) {
c9e3ae2e 409 throw new \API_Exception("Invalid value in $type clause for '$expr'");
19b53e5b
C
410 }
411 return $sql_clause;
412 }
413
16f5a13d
CW
414 /**
415 * @param string $expr
416 * @return SqlExpression
417 * @throws \API_Exception
418 */
419 protected function getExpression(string $expr) {
420 $sqlExpr = SqlExpression::convert($expr);
421 foreach ($sqlExpr->getFields() as $fieldName) {
422 $this->getField($fieldName, TRUE);
423 }
424 return $sqlExpr;
425 }
426
19b53e5b 427 /**
88409aaf
CW
428 * Get acl clause for an entity
429 *
430 * @param string $tableAlias
431 * @param \CRM_Core_DAO|string $baoName
432 * @param array $stack
433 * @return array
19b53e5b 434 */
88409aaf
CW
435 public function getAclClause($tableAlias, $baoName, $stack = []) {
436 if (!$this->getCheckPermissions()) {
437 return [];
438 }
439 // Prevent (most) redundant acl sub clauses if they have already been applied to the main entity.
440 // FIXME: Currently this only works 1 level deep, but tracking through multiple joins would increase complexity
441 // and just doing it for the first join takes care of most acl clause deduping.
90908aac 442 if (count($stack) === 1 && in_array($stack[0], $this->aclFields, TRUE)) {
88409aaf
CW
443 return [];
444 }
445 $clauses = $baoName::getSelectWhereClause($tableAlias);
446 if (!$stack) {
447 // Track field clauses added to the main entity
448 $this->aclFields = array_keys($clauses);
449 }
450 return array_filter($clauses);
19b53e5b
C
451 }
452
453 /**
454 * Fetch a field from the getFields list
455 *
961e974c 456 * @param string $expr
a689294c 457 * @param bool $strict
3176b04c 458 * In strict mode, this will throw an exception if the field doesn't exist
19b53e5b 459 *
88409aaf 460 * @return array|null
a689294c 461 * @throws \API_Exception
19b53e5b 462 */
961e974c
CW
463 public function getField($expr, $strict = FALSE) {
464 // If the expression contains a pseudoconstant filter like activity_type_id:label,
465 // strip it to look up the base field name, then add the field:filter key to apiFieldSpec
466 $col = strpos($expr, ':');
467 $fieldName = $col ? substr($expr, 0, $col) : $expr;
a689294c
CW
468 // Perform join if field not yet available - this will add it to apiFieldSpec
469 if (!isset($this->apiFieldSpec[$fieldName]) && strpos($fieldName, '.')) {
334bebdd 470 $this->autoJoinFK($fieldName);
a689294c
CW
471 }
472 $field = $this->apiFieldSpec[$fieldName] ?? NULL;
3176b04c 473 if ($strict && !$field) {
a689294c 474 throw new \API_Exception("Invalid field '$fieldName'");
19b53e5b 475 }
961e974c 476 $this->apiFieldSpec[$expr] = $field;
3176b04c 477 return $field;
19b53e5b
C
478 }
479
16f5a13d
CW
480 /**
481 * Join onto other entities as specified by the api call.
482 *
16f5a13d
CW
483 * @throws \API_Exception
484 * @throws \Civi\API\Exception\NotImplementedException
485 */
88409aaf
CW
486 private function addExplicitJoins() {
487 foreach ($this->getJoin() as $join) {
16f5a13d
CW
488 // First item in the array is the entity name
489 $entity = array_shift($join);
490 // Which might contain an alias. Split on the keyword "AS"
491 list($entity, $alias) = array_pad(explode(' AS ', $entity), 2, NULL);
492 // Ensure alias is a safe string, and supply default if not given
493 $alias = $alias ? \CRM_Utils_String::munge($alias) : strtolower($entity);
494 // First item in the array is a boolean indicating if the join is required (aka INNER or LEFT).
495 // The rest are join conditions.
496 $side = array_shift($join) ? 'INNER' : 'LEFT';
90908aac 497 // Add all fields from joined entity to spec
88409aaf 498 $joinEntityGet = \Civi\API\Request::create($entity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()]);
16f5a13d
CW
499 foreach ($joinEntityGet->entityFields() as $field) {
500 $field['sql_name'] = '`' . $alias . '`.`' . $field['column_name'] . '`';
16f5a13d
CW
501 $this->addSpecField($alias . '.' . $field['name'], $field);
502 }
90908aac
CW
503 if (!empty($join[0]) && is_string($join[0]) && \CRM_Utils_Rule::alphanumeric($join[0])) {
504 $conditions = $this->getBridgeJoin($join, $entity, $alias);
505 }
506 else {
507 $conditions = $this->getJoinConditions($join, $entity, $alias);
508 }
5e327f37 509 foreach (array_filter($join) as $clause) {
16f5a13d
CW
510 $conditions[] = $this->treeWalkClauses($clause, 'ON');
511 }
5e327f37 512 $tableName = CoreUtil::getTableName($entity);
16f5a13d
CW
513 $this->join($side, $tableName, $alias, $conditions);
514 }
515 }
516
517 /**
518 * Supply conditions for an explicit join.
519 *
90908aac
CW
520 * @param array $joinTree
521 * @param string $joinEntity
522 * @param string $alias
16f5a13d
CW
523 * @return array
524 */
90908aac 525 private function getJoinConditions($joinTree, $joinEntity, $alias) {
16f5a13d
CW
526 $conditions = [];
527 // getAclClause() expects a stack of 1-to-1 join fields to help it dedupe, but this is more flexible,
528 // so unless this is a direct 1-to-1 join with the main entity, we'll just hack it
529 // with a padded empty stack to bypass its deduping.
530 $stack = [NULL, NULL];
90908aac
CW
531 // If we're not explicitly referencing the joinEntity ID in the ON clause, search for a default
532 $explicitId = array_filter($joinTree, function($clause) use ($alias) {
533 list($sideA, $op, $sideB) = array_pad((array) $clause, 3, NULL);
534 return $op === '=' && ($sideA === "$alias.id" || $sideB === "$alias.id");
535 });
536 if (!$explicitId) {
537 foreach ($this->apiFieldSpec as $name => $field) {
538 if ($field['entity'] !== $joinEntity && $field['fk_entity'] === $joinEntity) {
539 $conditions[] = $this->treeWalkClauses([$name, '=', "$alias.id"], 'ON');
540 }
541 elseif (strpos($name, "$alias.") === 0 && substr_count($name, '.') === 1 && $field['fk_entity'] === $this->getEntity()) {
542 $conditions[] = $this->treeWalkClauses([$name, '=', 'id'], 'ON');
543 $stack = ['id'];
544 }
16f5a13d 545 }
90908aac
CW
546 // 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
547 if (count($conditions) > 1) {
548 $stack = [NULL, NULL];
549 $conditions = [];
16f5a13d
CW
550 }
551 }
90908aac 552 $baoName = CoreUtil::getBAOFromApiName($joinEntity);
5e327f37 553 $acls = array_values($this->getAclClause($alias, $baoName, $stack));
16f5a13d
CW
554 return array_merge($acls, $conditions);
555 }
556
90908aac
CW
557 /**
558 * Join onto a BridgeEntity table
559 *
560 * @param array $joinTree
561 * @param string $joinEntity
562 * @param string $alias
563 * @return array
564 * @throws \API_Exception
565 */
566 protected function getBridgeJoin(&$joinTree, $joinEntity, $alias) {
567 $bridgeEntity = array_shift($joinTree);
568 if (!is_a('\Civi\Api4\\' . $bridgeEntity, '\Civi\Api4\Generic\BridgeEntity', TRUE)) {
569 throw new \API_Exception("Illegal bridge entity specified: " . $bridgeEntity);
570 }
571 $bridgeAlias = $alias . '_via_' . strtolower($bridgeEntity);
572 $bridgeTable = CoreUtil::getTableName($bridgeEntity);
573 $joinTable = CoreUtil::getTableName($joinEntity);
574 $bridgeEntityGet = \Civi\API\Request::create($bridgeEntity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()]);
575 $fkToJoinField = $fkToBaseField = NULL;
576 // Find the bridge field that links to the joinEntity (either an explicit FK or an entity_id/entity_table combo)
577 foreach ($bridgeEntityGet->entityFields() as $name => $field) {
578 if ($field['fk_entity'] === $joinEntity || (!$fkToJoinField && $name === 'entity_id')) {
579 $fkToJoinField = $name;
580 }
581 }
582 // Get list of entities allowed for entity_table
583 if (array_key_exists('entity_id', $bridgeEntityGet->entityFields())) {
584 $entityTables = (array) civicrm_api4($bridgeEntity, 'getFields', [
585 'checkPermissions' => FALSE,
586 'where' => [['name', '=', 'entity_table']],
587 'loadOptions' => TRUE,
588 ], ['options'])->first();
589 }
590 // If bridge field to joinEntity is entity_id, validate entity_table is allowed
591 if (!$fkToJoinField || ($fkToJoinField === 'entity_id' && !array_key_exists($joinTable, $entityTables))) {
592 throw new \API_Exception("Unable to join $bridgeEntity to $joinEntity");
593 }
594 // Create link between bridge entity and join entity
595 $joinConditions = [
596 "`$bridgeAlias`.`$fkToJoinField` = `$alias`.`id`",
597 ];
598 if ($fkToJoinField === 'entity_id') {
599 $joinConditions[] = "`$bridgeAlias`.`entity_table` = '$joinTable'";
600 }
601 // Register fields from the bridge entity as if they belong to the join entity
602 foreach ($bridgeEntityGet->entityFields() as $name => $field) {
603 if ($name == 'id' || $name == $fkToJoinField || ($name == 'entity_table' && $fkToJoinField == 'entity_id')) {
604 continue;
605 }
606 if ($field['fk_entity'] || (!$fkToBaseField && $name == 'entity_id')) {
607 $fkToBaseField = $name;
608 }
609 // Note these fields get a sql alias pointing to the bridge entity, but an api alias pretending they belong to the join entity
610 $field['sql_name'] = '`' . $bridgeAlias . '`.`' . $field['column_name'] . '`';
611 $this->addSpecField($alias . '.' . $field['name'], $field);
612 }
613 // Move conditions for the bridge join out of the joinTree
614 $bridgeConditions = [];
615 $joinTree = array_filter($joinTree, function($clause) use ($fkToBaseField, $alias, $bridgeAlias, &$bridgeConditions) {
616 list($sideA, $op, $sideB) = array_pad((array) $clause, 3, NULL);
617 if ($op === '=' && $sideB && ($sideA === "$alias.$fkToBaseField" || $sideB === "$alias.$fkToBaseField")) {
618 $expr = $sideA === "$alias.$fkToBaseField" ? $sideB : $sideA;
619 $bridgeConditions[] = "`$bridgeAlias`.`$fkToBaseField` = " . $this->getExpression($expr)->render($this->apiFieldSpec);
620 return FALSE;
621 }
622 elseif ($op === '=' && $fkToBaseField == 'entity_id' && ($sideA === "$alias.entity_table" || $sideB === "$alias.entity_table")) {
623 $expr = $sideA === "$alias.entity_table" ? $sideB : $sideA;
624 $bridgeConditions[] = "`$bridgeAlias`.`entity_table` = " . $this->getExpression($expr)->render($this->apiFieldSpec);
625 return FALSE;
626 }
627 return TRUE;
628 });
629 // If no bridge conditions were specified, link it to the base entity
630 if (!$bridgeConditions) {
631 $bridgeConditions[] = "`$bridgeAlias`.`$fkToBaseField` = a.id";
632 if ($fkToBaseField == 'entity_id') {
633 if (!array_key_exists($this->getFrom(), $entityTables)) {
634 throw new \API_Exception("Unable to join $bridgeEntity to " . $this->getEntity());
635 }
636 $bridgeConditions[] = "`$bridgeAlias`.`entity_table` = '" . $this->getFrom() . "'";
637 }
638 }
639
640 $this->join('LEFT', $bridgeTable, $bridgeAlias, $bridgeConditions);
641
642 $baoName = CoreUtil::getBAOFromApiName($joinEntity);
643 $acls = array_values($this->getAclClause($alias, $baoName, [NULL, NULL]));
644 return array_merge($acls, $joinConditions);
645 }
646
19b53e5b 647 /**
334bebdd 648 * Joins a path and adds all fields in the joined entity to apiFieldSpec
a689294c 649 *
19b53e5b
C
650 * @param $key
651 * @throws \API_Exception
a689294c 652 * @throws \Exception
19b53e5b 653 */
334bebdd 654 protected function autoJoinFK($key) {
a689294c 655 if (isset($this->apiFieldSpec[$key])) {
9b06167d 656 return;
19b53e5b
C
657 }
658
a689294c
CW
659 $pathArray = explode('.', $key);
660
19b53e5b
C
661 /** @var \Civi\Api4\Service\Schema\Joiner $joiner */
662 $joiner = \Civi::container()->get('joiner');
a689294c
CW
663 // 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.
664 array_pop($pathArray);
19b53e5b
C
665 $pathString = implode('.', $pathArray);
666
334bebdd 667 if (!$joiner->canAutoJoin($this->getFrom(), $pathString)) {
9b06167d 668 return;
19b53e5b
C
669 }
670
671 $joinPath = $joiner->join($this, $pathString);
9b06167d 672
19b53e5b
C
673 $lastLink = array_pop($joinPath);
674
a689294c 675 // Custom field names are already prefixed
9b06167d
CW
676 $isCustom = $lastLink instanceof CustomGroupJoinable;
677 if ($isCustom) {
a689294c 678 array_pop($pathArray);
39e0f675 679 }
a689294c 680 $prefix = $pathArray ? implode('.', $pathArray) . '.' : '';
19b53e5b 681 // Cache field info for retrieval by $this->getField()
a689294c 682 foreach ($lastLink->getEntityFields() as $fieldObject) {
6fe7bdee 683 $fieldArray = $fieldObject->toArray();
a689294c 684 $fieldArray['sql_name'] = '`' . $lastLink->getAlias() . '`.`' . $fieldArray['column_name'] . '`';
9b06167d 685 $this->addSpecField($prefix . $fieldArray['name'], $fieldArray);
19b53e5b 686 }
19b53e5b
C
687 }
688
88409aaf
CW
689 /**
690 * @param string $side
691 * @param string $tableName
692 * @param string $tableAlias
693 * @param array $conditions
694 */
695 public function join($side, $tableName, $tableAlias, $conditions) {
696 // INNER JOINs take precedence over LEFT JOINs
697 if ($side != 'LEFT' || !isset($this->joins[$tableAlias])) {
698 $this->joins[$tableAlias] = $side;
699 $this->query->join($tableAlias, "$side JOIN `$tableName` `$tableAlias` ON " . implode(' AND ', $conditions));
700 }
701 }
702
19b53e5b
C
703 /**
704 * @return FALSE|string
705 */
706 public function getFrom() {
88409aaf 707 return CoreUtil::getTableName($this->getEntity());
19b53e5b
C
708 }
709
710 /**
711 * @return string
712 */
713 public function getEntity() {
88409aaf 714 return $this->api->getEntityName();
19b53e5b
C
715 }
716
717 /**
718 * @return array
719 */
720 public function getSelect() {
88409aaf 721 return $this->api->getSelect();
19b53e5b
C
722 }
723
724 /**
725 * @return array
726 */
727 public function getWhere() {
88409aaf 728 return $this->api->getWhere();
19b53e5b
C
729 }
730
731 /**
732 * @return array
733 */
88409aaf
CW
734 public function getHaving() {
735 return $this->api->getHaving();
19b53e5b
C
736 }
737
738 /**
739 * @return array
740 */
88409aaf
CW
741 public function getJoin() {
742 return $this->api->getJoin();
19b53e5b
C
743 }
744
19b53e5b 745 /**
88409aaf 746 * @return array
19b53e5b 747 */
88409aaf
CW
748 public function getGroupBy() {
749 return $this->api->getGroupBy();
19b53e5b
C
750 }
751
752 /**
753 * @return array
754 */
88409aaf
CW
755 public function getOrderBy() {
756 return $this->api->getOrderBy();
19b53e5b
C
757 }
758
759 /**
88409aaf 760 * @return mixed
19b53e5b 761 */
88409aaf
CW
762 public function getLimit() {
763 return $this->api->getLimit();
19b53e5b
C
764 }
765
766 /**
88409aaf 767 * @return mixed
19b53e5b 768 */
88409aaf
CW
769 public function getOffset() {
770 return $this->api->getOffset();
19b53e5b
C
771 }
772
773 /**
88409aaf 774 * @return \CRM_Utils_SQL_Select
19b53e5b 775 */
88409aaf
CW
776 public function getQuery() {
777 return $this->query;
19b53e5b
C
778 }
779
780 /**
781 * @return bool|string
782 */
783 public function getCheckPermissions() {
88409aaf 784 return $this->api->getCheckPermissions();
19b53e5b
C
785 }
786
9b06167d 787 /**
88409aaf
CW
788 * @param string $path
789 * @param array $field
9b06167d
CW
790 */
791 private function addSpecField($path, $field) {
792 // Only add field to spec if we have permission
88409aaf 793 if ($this->getCheckPermissions() && !empty($field['permission']) && !\CRM_Core_Permission::check($field['permission'])) {
9b06167d
CW
794 $this->apiFieldSpec[$path] = FALSE;
795 return;
796 }
9b06167d
CW
797 $this->apiFieldSpec[$path] = $field;
798 }
799
88409aaf
CW
800 /**
801 * Add something to the api's debug output if debugging is enabled
802 *
803 * @param $key
804 * @param $item
805 */
806 public function debug($key, $item) {
807 if ($this->api->getDebug()) {
808 $this->api->_debugOutput[$key][] = $item;
809 }
810 }
811
19b53e5b 812}