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