Merge pull request #17027 from demeritcowboy/nl-be-upgrade
[civicrm-core.git] / Civi / Api4 / Query / Api4SelectQuery.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 namespace Civi\Api4\Query;
13
14 use Civi\API\SelectQuery;
15 use Civi\Api4\Event\Events;
16 use Civi\Api4\Event\PostSelectQueryEvent;
17 use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
18 use Civi\Api4\Service\Schema\Joinable\Joinable;
19 use Civi\Api4\Utils\FormattingUtil;
20 use Civi\Api4\Utils\CoreUtil;
21 use Civi\Api4\Utils\SelectUtil;
22 use CRM_Core_DAO_AllCoreTables as AllCoreTables;
23
24 /**
25 * A query `node` may be in one of three formats:
26 *
27 * * leaf: [$fieldName, $operator, $criteria]
28 * * negated: ['NOT', $node]
29 * * branch: ['OR|NOT', [$node, $node, ...]]
30 *
31 * Leaf operators are one of:
32 *
33 * * '=', '<=', '>=', '>', '<', 'LIKE', "<>", "!=",
34 * * "NOT LIKE", 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN',
35 * * 'IS NOT NULL', or 'IS NULL'.
36 */
37 class Api4SelectQuery extends SelectQuery {
38
39 /**
40 * @var int
41 */
42 protected $apiVersion = 4;
43
44 /**
45 * @var \Civi\Api4\Service\Schema\Joinable\Joinable[]
46 * The joinable tables that have been joined so far
47 */
48 protected $joinedTables = [];
49
50 /**
51 * @var array
52 */
53 protected $selectAliases = [];
54
55 /**
56 * If set to an array, this will start collecting debug info.
57 *
58 * @var null|array
59 */
60 public $debugOutput = NULL;
61
62 /**
63 * @var array
64 */
65 public $groupBy = [];
66
67 /**
68 * @var array
69 */
70 public $having = [];
71
72 /**
73 * @param \Civi\Api4\Generic\DAOGetAction $apiGet
74 */
75 public function __construct($apiGet) {
76 $this->entity = $apiGet->getEntityName();
77 $this->checkPermissions = $apiGet->getCheckPermissions();
78 $this->select = $apiGet->getSelect();
79 $this->where = $apiGet->getWhere();
80 $this->groupBy = $apiGet->getGroupBy();
81 $this->orderBy = $apiGet->getOrderBy();
82 $this->limit = $apiGet->getLimit();
83 $this->offset = $apiGet->getOffset();
84 $this->having = $apiGet->getHaving();
85 if ($apiGet->getDebug()) {
86 $this->debugOutput =& $apiGet->_debugOutput;
87 }
88 $baoName = CoreUtil::getBAOFromApiName($this->entity);
89 $this->entityFieldNames = array_column($baoName::fields(), 'name');
90 foreach ($apiGet->entityFields() as $path => $field) {
91 $field['sql_name'] = '`' . self::MAIN_TABLE_ALIAS . '`.`' . $field['column_name'] . '`';
92 $this->addSpecField($path, $field);
93 }
94
95 $this->constructQueryObject($baoName);
96
97 // Add ACLs first to avoid redundant subclauses
98 $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
99 }
100
101 /**
102 * Builds final sql statement after all params are set.
103 *
104 * @return string
105 * @throws \API_Exception
106 * @throws \CRM_Core_Exception
107 * @throws \Civi\API\Exception\UnauthorizedException
108 */
109 public function getSql() {
110 $this->buildSelectClause();
111 $this->buildWhereClause();
112 $this->buildOrderBy();
113 $this->buildLimit();
114 $this->buildGroupBy();
115 $this->buildHavingClause();
116 return $this->query->toSQL();
117 }
118
119 /**
120 * Why walk when you can
121 *
122 * @return array|int
123 */
124 public function run() {
125 $results = [];
126 $sql = $this->getSql();
127 if (is_array($this->debugOutput)) {
128 $this->debugOutput['sql'][] = $sql;
129 }
130 $query = \CRM_Core_DAO::executeQuery($sql);
131 $i = 0;
132 while ($query->fetch()) {
133 $id = $query->id ?? $i++;
134 if (in_array('row_count', $this->select)) {
135 $results[]['row_count'] = (int) $query->c;
136 break;
137 }
138 $results[$id] = [];
139 foreach ($this->selectAliases as $alias => $expr) {
140 $returnName = $alias;
141 $alias = str_replace('.', '_', $alias);
142 $results[$id][$returnName] = property_exists($query, $alias) ? $query->$alias : NULL;
143 }
144 }
145 $event = new PostSelectQueryEvent($results, $this);
146 \Civi::dispatcher()->dispatch(Events::POST_SELECT_QUERY, $event);
147
148 return $event->getResults();
149 }
150
151 protected function buildSelectClause() {
152 // An empty select is the same as *
153 if (empty($this->select)) {
154 $this->select = $this->entityFieldNames;
155 }
156 elseif (in_array('row_count', $this->select)) {
157 $this->query->select("COUNT(*) AS `c`");
158 return;
159 }
160 else {
161 // Always select ID (unless we're doing groupBy).
162 if (!$this->groupBy) {
163 $this->select = array_merge(['id'], $this->select);
164 }
165
166 // Expand wildcards in joins (the api wrapper already expanded non-joined wildcards)
167 $wildFields = array_filter($this->select, function($item) {
168 return strpos($item, '*') !== FALSE && strpos($item, '.') !== FALSE && strpos($item, '(') === FALSE && strpos($item, ' ') === FALSE;
169 });
170 foreach ($wildFields as $item) {
171 $pos = array_search($item, array_values($this->select));
172 $this->joinFK($item);
173 $matches = SelectUtil::getMatchingFields($item, array_keys($this->apiFieldSpec));
174 array_splice($this->select, $pos, 1, $matches);
175 }
176 $this->select = array_unique($this->select);
177 }
178 foreach ($this->select as $item) {
179 $expr = SqlExpression::convert($item, TRUE);
180 $valid = TRUE;
181 foreach ($expr->getFields() as $fieldName) {
182 $field = $this->getField($fieldName);
183 // Remove expressions with unknown fields without raising an error
184 if (!$field) {
185 $this->select = array_diff($this->select, [$item]);
186 if (is_array($this->debugOutput)) {
187 $this->debugOutput['undefined_fields'][] = $fieldName;
188 }
189 $valid = FALSE;
190 }
191 elseif ($field['is_many']) {
192 $valid = FALSE;
193 }
194 }
195 if ($valid) {
196 $alias = $expr->getAlias();
197 $this->selectAliases[$alias] = $expr->getExpr();
198 $this->query->select($expr->render($this->apiFieldSpec) . " AS `$alias`");
199 }
200 }
201 }
202
203 /**
204 * @inheritDoc
205 */
206 protected function buildWhereClause() {
207 foreach ($this->where as $clause) {
208 $this->query->where($this->treeWalkClauses($clause, 'WHERE'));
209 }
210 }
211
212 /**
213 * Build HAVING clause.
214 *
215 * Every expression referenced must also be in the SELECT clause.
216 */
217 protected function buildHavingClause() {
218 foreach ($this->having as $clause) {
219 $this->query->having($this->treeWalkClauses($clause, 'HAVING'));
220 }
221 }
222
223 /**
224 * @inheritDoc
225 */
226 protected function buildOrderBy() {
227 foreach ($this->orderBy as $item => $dir) {
228 if ($dir !== 'ASC' && $dir !== 'DESC') {
229 throw new \API_Exception("Invalid sort direction. Cannot order by $item $dir");
230 }
231 $expr = SqlExpression::convert($item);
232 foreach ($expr->getFields() as $fieldName) {
233 $this->getField($fieldName, TRUE);
234 }
235 $this->query->orderBy($expr->render($this->apiFieldSpec) . " $dir");
236 }
237 }
238
239 /**
240 * @throws \CRM_Core_Exception
241 */
242 protected function buildLimit() {
243 if (!empty($this->limit) || !empty($this->offset)) {
244 $this->query->limit($this->limit, $this->offset);
245 }
246 }
247
248 /**
249 * Adds GROUP BY clause to query
250 */
251 protected function buildGroupBy() {
252 foreach ($this->groupBy as $item) {
253 $expr = SqlExpression::convert($item);
254 foreach ($expr->getFields() as $fieldName) {
255 $this->getField($fieldName, TRUE);
256 }
257 $this->query->groupBy($expr->render($this->apiFieldSpec));
258 }
259 }
260
261 /**
262 * Recursively validate and transform a branch or leaf clause array to SQL.
263 *
264 * @param array $clause
265 * @param string $type
266 * WHERE|HAVING
267 * @return string SQL where clause
268 *
269 * @throws \API_Exception
270 * @uses composeClause() to generate the SQL etc.
271 */
272 protected function treeWalkClauses($clause, $type) {
273 switch ($clause[0]) {
274 case 'OR':
275 case 'AND':
276 // handle branches
277 if (count($clause[1]) === 1) {
278 // a single set so AND|OR is immaterial
279 return $this->treeWalkClauses($clause[1][0], $type);
280 }
281 else {
282 $sql_subclauses = [];
283 foreach ($clause[1] as $subclause) {
284 $sql_subclauses[] = $this->treeWalkClauses($subclause, $type);
285 }
286 return '(' . implode("\n" . $clause[0], $sql_subclauses) . ')';
287 }
288
289 case 'NOT':
290 // If we get a group of clauses with no operator, assume AND
291 if (!is_string($clause[1][0])) {
292 $clause[1] = ['AND', $clause[1]];
293 }
294 return 'NOT (' . $this->treeWalkClauses($clause[1], $type) . ')';
295
296 default:
297 return $this->composeClause($clause, $type);
298 }
299 }
300
301 /**
302 * Validate and transform a leaf clause array to SQL.
303 * @param array $clause [$fieldName, $operator, $criteria]
304 * @param string $type
305 * WHERE|HAVING
306 * @return string SQL
307 * @throws \API_Exception
308 * @throws \Exception
309 */
310 protected function composeClause(array $clause, string $type) {
311 // Pad array for unary operators
312 list($expr, $operator, $value) = array_pad($clause, 3, NULL);
313
314 // For WHERE clause, expr must be the name of a field.
315 if ($type === 'WHERE') {
316 $field = $this->getField($expr, TRUE);
317 FormattingUtil::formatInputValue($value, $field, $this->getEntity());
318 $fieldAlias = $field['sql_name'];
319 }
320 // For HAVING, expr must be an item in the SELECT clause
321 else {
322 if (isset($this->selectAliases[$expr])) {
323 $fieldAlias = $expr;
324 }
325 elseif (in_array($expr, $this->selectAliases)) {
326 $fieldAlias = array_search($expr, $this->selectAliases);
327 }
328 else {
329 throw new \API_Exception("Invalid expression in $type clause: '$expr'. Must use a value from SELECT clause.");
330 }
331 }
332
333 $sql_clause = \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
334 if ($sql_clause === NULL) {
335 throw new \API_Exception("Invalid value in $type clause for '$expr'");
336 }
337 return $sql_clause;
338 }
339
340 /**
341 * @inheritDoc
342 */
343 protected function getFields() {
344 return $this->apiFieldSpec;
345 }
346
347 /**
348 * Fetch a field from the getFields list
349 *
350 * @param string $fieldName
351 * @param bool $strict
352 * In strict mode, this will throw an exception if the field doesn't exist
353 *
354 * @return string|null
355 * @throws \API_Exception
356 */
357 public function getField($fieldName, $strict = FALSE) {
358 // Perform join if field not yet available - this will add it to apiFieldSpec
359 if (!isset($this->apiFieldSpec[$fieldName]) && strpos($fieldName, '.')) {
360 $this->joinFK($fieldName);
361 }
362 $field = $this->apiFieldSpec[$fieldName] ?? NULL;
363 if ($strict && !$field) {
364 throw new \API_Exception("Invalid field '$fieldName'");
365 }
366 return $field;
367 }
368
369 /**
370 * Joins a path and adds all fields in the joined eneity to apiFieldSpec
371 *
372 * @param $key
373 * @throws \API_Exception
374 * @throws \Exception
375 */
376 protected function joinFK($key) {
377 if (isset($this->apiFieldSpec[$key])) {
378 return;
379 }
380
381 $pathArray = explode('.', $key);
382
383 /** @var \Civi\Api4\Service\Schema\Joiner $joiner */
384 $joiner = \Civi::container()->get('joiner');
385 // 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.
386 array_pop($pathArray);
387 $pathString = implode('.', $pathArray);
388
389 if (!$joiner->canJoin($this, $pathString)) {
390 return;
391 }
392
393 $joinPath = $joiner->join($this, $pathString);
394
395 $isMany = FALSE;
396 foreach ($joinPath as $joinable) {
397 if ($joinable->getJoinType() === Joinable::JOIN_TYPE_ONE_TO_MANY) {
398 $isMany = TRUE;
399 }
400 }
401
402 /** @var \Civi\Api4\Service\Schema\Joinable\Joinable $lastLink */
403 $lastLink = array_pop($joinPath);
404
405 // Custom field names are already prefixed
406 $isCustom = $lastLink instanceof CustomGroupJoinable;
407 if ($isCustom) {
408 array_pop($pathArray);
409 }
410 $prefix = $pathArray ? implode('.', $pathArray) . '.' : '';
411 // Cache field info for retrieval by $this->getField()
412 $joinEntity = $lastLink->getEntity();
413 foreach ($lastLink->getEntityFields() as $fieldObject) {
414 $fieldArray = ['entity' => $joinEntity] + $fieldObject->toArray();
415 $fieldArray['sql_name'] = '`' . $lastLink->getAlias() . '`.`' . $fieldArray['column_name'] . '`';
416 $fieldArray['is_custom'] = $isCustom;
417 $fieldArray['is_join'] = TRUE;
418 $fieldArray['is_many'] = $isMany;
419 $this->addSpecField($prefix . $fieldArray['name'], $fieldArray);
420 }
421 }
422
423 /**
424 * @param \Civi\Api4\Service\Schema\Joinable\Joinable $joinable
425 *
426 * @return $this
427 */
428 public function addJoinedTable(Joinable $joinable) {
429 $this->joinedTables[] = $joinable;
430
431 return $this;
432 }
433
434 /**
435 * @return FALSE|string
436 */
437 public function getFrom() {
438 return AllCoreTables::getTableForClass(AllCoreTables::getFullName($this->entity));
439 }
440
441 /**
442 * @return string
443 */
444 public function getEntity() {
445 return $this->entity;
446 }
447
448 /**
449 * @return array
450 */
451 public function getSelect() {
452 return $this->select;
453 }
454
455 /**
456 * @return array
457 */
458 public function getWhere() {
459 return $this->where;
460 }
461
462 /**
463 * @return array
464 */
465 public function getOrderBy() {
466 return $this->orderBy;
467 }
468
469 /**
470 * @return mixed
471 */
472 public function getLimit() {
473 return $this->limit;
474 }
475
476 /**
477 * @return mixed
478 */
479 public function getOffset() {
480 return $this->offset;
481 }
482
483 /**
484 * @return array
485 */
486 public function getSelectFields() {
487 return $this->selectFields;
488 }
489
490 /**
491 * @return bool
492 */
493 public function isFillUniqueFields() {
494 return $this->isFillUniqueFields;
495 }
496
497 /**
498 * @return \CRM_Utils_SQL_Select
499 */
500 public function getQuery() {
501 return $this->query;
502 }
503
504 /**
505 * @return array
506 */
507 public function getJoins() {
508 return $this->joins;
509 }
510
511 /**
512 * @return array
513 */
514 public function getApiFieldSpec() {
515 return $this->apiFieldSpec;
516 }
517
518 /**
519 * @return array
520 */
521 public function getEntityFieldNames() {
522 return $this->entityFieldNames;
523 }
524
525 /**
526 * @return array
527 */
528 public function getAclFields() {
529 return $this->aclFields;
530 }
531
532 /**
533 * @return bool|string
534 */
535 public function getCheckPermissions() {
536 return $this->checkPermissions;
537 }
538
539 /**
540 * @return int
541 */
542 public function getApiVersion() {
543 return $this->apiVersion;
544 }
545
546 /**
547 * @return \Civi\Api4\Service\Schema\Joinable\Joinable[]
548 */
549 public function getJoinedTables() {
550 return $this->joinedTables;
551 }
552
553 /**
554 * @return \Civi\Api4\Service\Schema\Joinable\Joinable
555 */
556 public function getJoinedTable($alias) {
557 foreach ($this->joinedTables as $join) {
558 if ($join->getAlias() == $alias) {
559 return $join;
560 }
561 }
562 }
563
564 /**
565 * Get table name on basis of entity
566 *
567 * @param string $baoName
568 *
569 * @return void
570 */
571 public function constructQueryObject($baoName) {
572 if (strstr($this->entity, 'Custom_')) {
573 $this->query = \CRM_Utils_SQL_Select::from(CoreUtil::getCustomTableByName(str_replace('Custom_', '', $this->entity)) . ' ' . self::MAIN_TABLE_ALIAS);
574 $this->entityFieldNames = array_keys($this->apiFieldSpec);
575 }
576 else {
577 $bao = new $baoName();
578 $this->query = \CRM_Utils_SQL_Select::from($bao->tableName() . ' ' . self::MAIN_TABLE_ALIAS);
579 }
580 }
581
582 /**
583 * Checks if a field either belongs to the main entity or is joinable 1-to-1.
584 *
585 * Used to determine if a field can be added to the SELECT of the main query,
586 * or if it must be fetched post-query.
587 *
588 * @param string $fieldPath
589 * @return bool
590 */
591 public function isOneToOneField(string $fieldPath) {
592 return strpos($fieldPath, '.') === FALSE || !array_filter($this->getPathJoinTypes($fieldPath));
593 }
594
595 /**
596 * Separates a string like 'emails.location_type.label' into an array, where
597 * each value in the array tells whether it is 1-1 or 1-n join type
598 *
599 * @param string $pathString
600 * Dot separated path to the field
601 *
602 * @return array
603 * Index is table alias and value is boolean whether is 1-to-many join
604 */
605 public function getPathJoinTypes($pathString) {
606 $pathParts = explode('.', $pathString);
607 // remove field
608 array_pop($pathParts);
609 $path = [];
610 $query = $this;
611 $isMultipleChecker = function($alias) use ($query) {
612 foreach ($query->getJoinedTables() as $table) {
613 if ($table->getAlias() === $alias) {
614 return $table->getJoinType() === Joinable::JOIN_TYPE_ONE_TO_MANY;
615 }
616 }
617 return FALSE;
618 };
619
620 foreach ($pathParts as $part) {
621 $path[$part] = $isMultipleChecker($part);
622 }
623
624 return $path;
625 }
626
627 /**
628 * @param $path
629 * @param $field
630 */
631 private function addSpecField($path, $field) {
632 // Only add field to spec if we have permission
633 if ($this->checkPermissions && !empty($field['permission']) && !\CRM_Core_Permission::check($field['permission'])) {
634 $this->apiFieldSpec[$path] = FALSE;
635 return;
636 }
637 $defaults = [];
638 $defaults['is_custom'] = $defaults['is_join'] = $defaults['is_many'] = FALSE;
639 $field += $defaults;
640 $this->apiFieldSpec[$path] = $field;
641 }
642
643 }