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