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