Merge pull request #20321 from mattwire/loggedincontactid
[civicrm-core.git] / Civi / API / SelectQuery.php
CommitLineData
e47bcddb
CW
1<?php
2/*
3 +--------------------------------------------------------------------+
41498ac5 4 | Copyright CiviCRM LLC. All rights reserved. |
e47bcddb 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 |
e47bcddb
CW
9 +--------------------------------------------------------------------+
10 */
11namespace Civi\API;
34f3bbd9 12
ab039e22 13use Civi\API\Exception\UnauthorizedException;
2d743fc6 14use CRM_Core_Exception;
e47bcddb
CW
15
16/**
17 * Query builder for civicrm_api_basic_get.
18 *
19 * Fetches an entity based on specified params for the "where" clause,
20 * return properties for the "select" clause,
21 * as well as limit and order.
22 *
23 * Automatically joins on custom fields to return or filter by them.
24 *
25 * Supports an additional sql fragment which the calling api can provide.
26 *
27 * @package Civi\API
28 */
8bcc0d86 29abstract class SelectQuery {
e47bcddb 30
6c051493
CW
31 const
32 MAX_JOINS = 4,
33 MAIN_TABLE_ALIAS = 'a';
5b46e216 34
e47bcddb
CW
35 /**
36 * @var string
37 */
38 protected $entity;
c64f69d9
CW
39 public $select = [];
40 public $where = [];
41 public $orderBy = [];
8bcc0d86
CW
42 public $limit;
43 public $offset;
e47bcddb
CW
44 /**
45 * @var array
46 */
c64f69d9 47 protected $selectFields = [];
e47bcddb
CW
48 /**
49 * @var bool
50 */
8bcc0d86 51 public $isFillUniqueFields = FALSE;
e47bcddb
CW
52 /**
53 * @var \CRM_Utils_SQL_Select
54 */
55 protected $query;
2cfe873c
CW
56 /**
57 * @var array
58 */
c64f69d9 59 protected $joins = [];
e47bcddb
CW
60 /**
61 * @var array
62 */
63 protected $apiFieldSpec;
64 /**
65 * @var array
66 */
5e327f37 67 protected $entityFieldNames = [];
b53bcc5d
CW
68 /**
69 * @var array
70 */
c64f69d9 71 protected $aclFields = [];
d343069c
CW
72 /**
73 * @var string|bool
74 */
8e8bf584 75 protected $checkPermissions;
8bcc0d86
CW
76
77 protected $apiVersion;
e47bcddb
CW
78
79 /**
8bcc0d86 80 * @param string $entity
8e8bf584 81 * @param bool $checkPermissions
2d743fc6
EM
82 *
83 * @throws \CRM_Core_Exception
e47bcddb 84 */
8e8bf584 85 public function __construct($entity, $checkPermissions) {
8bcc0d86
CW
86 $this->entity = $entity;
87 require_once 'api/v3/utils.php';
88 $baoName = _civicrm_api3_get_BAO($entity);
2d743fc6
EM
89 if (!$baoName) {
90 throw new CRM_Core_Exception('Invalid entity: ' . \CRM_Utils_Type::validate($entity, 'Alphanumeric'));
91 }
6c051493 92 $bao = new $baoName();
e47bcddb 93
388eb91b 94 $this->entityFieldNames = array_column($baoName::fields(), 'name');
8bcc0d86 95 $this->apiFieldSpec = $this->getFields();
e47bcddb 96
6c051493 97 $this->query = \CRM_Utils_SQL_Select::from($bao->tableName() . ' ' . self::MAIN_TABLE_ALIAS);
b53bcc5d 98
6c051493 99 // Add ACLs first to avoid redundant subclauses
8e8bf584 100 $this->checkPermissions = $checkPermissions;
6c051493 101 $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
e47bcddb
CW
102 }
103
d343069c
CW
104 /**
105 * Build & execute the query and return results array
106 *
066c4638 107 * @return array|int
d343069c
CW
108 * @throws \API_Exception
109 * @throws \CRM_Core_Exception
110 * @throws \Exception
111 */
e47bcddb 112 public function run() {
8bcc0d86 113 $this->buildSelectFields();
e47bcddb 114
8bcc0d86 115 $this->buildWhereClause();
258c92c6 116 if (in_array('count_rows', $this->select)) {
8bcc0d86 117 $this->query->select("count(*) as c");
e47bcddb 118 }
8bcc0d86
CW
119 else {
120 foreach ($this->selectFields as $column => $alias) {
69aae315 121 $this->query->select("$column as `$alias`");
e47bcddb 122 }
8bcc0d86
CW
123 // Order by
124 $this->buildOrderBy();
e47bcddb
CW
125 }
126
530c3791 127 // Limit
8bcc0d86
CW
128 if (!empty($this->limit) || !empty($this->offset)) {
129 $this->query->limit($this->limit, $this->offset);
e47bcddb
CW
130 }
131
c64f69d9 132 $result_entities = [];
e47bcddb
CW
133 $result_dao = \CRM_Core_DAO::executeQuery($this->query->toSQL());
134
135 while ($result_dao->fetch()) {
258c92c6 136 if (in_array('count_rows', $this->select)) {
e47bcddb
CW
137 return (int) $result_dao->c;
138 }
c64f69d9 139 $result_entities[$result_dao->id] = [];
8bcc0d86 140 foreach ($this->selectFields as $column => $alias) {
69aae315
CW
141 $returnName = $alias;
142 $alias = str_replace('.', '_', $alias);
e47bcddb 143 if (property_exists($result_dao, $alias) && $result_dao->$alias != NULL) {
69aae315 144 $result_entities[$result_dao->id][$returnName] = $result_dao->$alias;
e47bcddb
CW
145 }
146 // Backward compatibility on fields names.
984f31ae
CW
147 if ($this->isFillUniqueFields && !empty($this->apiFieldSpec[$alias]['uniqueName'])) {
148 $result_entities[$result_dao->id][$this->apiFieldSpec[$alias]['uniqueName']] = $result_dao->$alias;
e47bcddb
CW
149 }
150 foreach ($this->apiFieldSpec as $returnName => $spec) {
151 if (empty($result_entities[$result_dao->id][$returnName]) && !empty($result_entities[$result_dao->id][$spec['name']])) {
152 $result_entities[$result_dao->id][$returnName] = $result_entities[$result_dao->id][$spec['name']];
153 }
154 }
155 };
156 }
e47bcddb
CW
157 return $result_entities;
158 }
159
160 /**
161 * @param \CRM_Utils_SQL_Select $sqlFragment
4b350175 162 * @return SelectQuery
e47bcddb
CW
163 */
164 public function merge($sqlFragment) {
165 $this->query->merge($sqlFragment);
166 return $this;
167 }
69aae315
CW
168
169 /**
170 * Joins onto an fk field
171 *
172 * Adds one or more joins to the query to make this field available for use in a clause.
173 *
d343069c
CW
174 * Enforces permissions at the api level and by appending the acl clause for that entity to the join.
175 *
69aae315 176 * @param $fkFieldName
2cfe873c
CW
177 * @param $side
178 *
69aae315
CW
179 * @return array|null
180 * Returns the table and field name for adding this field to a SELECT or WHERE clause
d343069c 181 * @throws \API_Exception
ab039e22 182 * @throws \Civi\API\Exception\UnauthorizedException
69aae315 183 */
8bcc0d86 184 protected function addFkField($fkFieldName, $side) {
69aae315
CW
185 $stack = explode('.', $fkFieldName);
186 if (count($stack) < 2) {
187 return NULL;
188 }
6c051493 189 $prev = self::MAIN_TABLE_ALIAS;
69aae315
CW
190 foreach ($stack as $depth => $fieldName) {
191 // Setup variables then skip the first level
192 if (!$depth) {
193 $fk = $fieldName;
194 // We only join on core fields
195 // @TODO: Custom contact ref fields could be supported too
196 if (!in_array($fk, $this->entityFieldNames)) {
197 return NULL;
198 }
199 $fkField = &$this->apiFieldSpec[$fk];
200 continue;
201 }
4f7a32d8 202 // More than 4 joins deep seems excessive - DOS attack?
5b46e216 203 if ($depth > self::MAX_JOINS) {
3924e596 204 throw new UnauthorizedException("Maximum number of joins exceeded in parameter $fkFieldName");
4f7a32d8 205 }
466fce54
CW
206 $subStack = array_slice($stack, 0, $depth);
207 $this->getJoinInfo($fkField, $subStack);
2cfe873c 208 if (!isset($fkField['FKApiName']) || !isset($fkField['FKClassName'])) {
ab039e22 209 // Join doesn't exist - might be another param with a dot in it for some reason, we'll just ignore it.
69aae315
CW
210 return NULL;
211 }
a762b380 212 // Ensure we have permission to access the other api
b53bcc5d 213 if (!$this->checkPermissionToJoin($fkField['FKApiName'], $subStack)) {
ab039e22 214 throw new UnauthorizedException("Authorization failed to join onto {$fkField['FKApiName']} api in parameter $fkFieldName");
a762b380 215 }
69aae315
CW
216 if (!isset($fkField['FKApiSpec'])) {
217 $fkField['FKApiSpec'] = \_civicrm_api_get_fields($fkField['FKApiName']);
218 }
9e10fb6b 219 $fieldInfo = $fkField['FKApiSpec'][$fieldName] ?? NULL;
69aae315 220
9c846e15
CW
221 $keyColumn = \CRM_Utils_Array::value('FKKeyColumn', $fkField, 'id');
222 if (!$fieldInfo || !isset($fkField['FKApiSpec'][$keyColumn])) {
ab039e22 223 // Join doesn't exist - might be another param with a dot in it for some reason, we'll just ignore it.
69aae315
CW
224 return NULL;
225 }
3493febb
CW
226
227 // Skip if we don't have permission to access this field
228 if ($this->checkPermissions && !empty($fieldInfo['permission']) && !\CRM_Core_Permission::check($fieldInfo['permission'])) {
229 return NULL;
230 }
231
69aae315 232 $fkTable = \CRM_Core_DAO_AllCoreTables::getTableForClass($fkField['FKClassName']);
b53bcc5d 233 $tableAlias = implode('_to_', $subStack) . "_to_$fkTable";
69aae315 234
d343069c 235 // Add acl condition
2cfe873c 236 $joinCondition = array_merge(
c64f69d9 237 ["$prev.$fk = $tableAlias.$keyColumn"],
2cfe873c
CW
238 $this->getAclClause($tableAlias, \_civicrm_api3_get_BAO($fkField['FKApiName']), $subStack)
239 );
d343069c 240
9c846e15
CW
241 if (!empty($fkField['FKCondition'])) {
242 $joinCondition[] = str_replace($fkTable, $tableAlias, $fkField['FKCondition']);
243 }
244
2cfe873c 245 $this->join($side, $fkTable, $tableAlias, $joinCondition);
69aae315
CW
246
247 if (strpos($fieldName, 'custom_') === 0) {
2d743fc6 248 [$tableAlias, $fieldName] = $this->addCustomField($fieldInfo, $side, $tableAlias);
69aae315
CW
249 }
250
251 // Get ready to recurse to the next level
252 $fk = $fieldName;
253 $fkField = &$fkField['FKApiSpec'][$fieldName];
254 $prev = $tableAlias;
255 }
c64f69d9 256 return [$tableAlias, $fieldName];
69aae315
CW
257 }
258
466fce54 259 /**
9c846e15 260 * Get join info for dynamically-joined fields (e.g. "entity_id", "option_group")
466fce54
CW
261 *
262 * @param $fkField
263 * @param $stack
264 */
265 protected function getJoinInfo(&$fkField, $stack) {
266 if ($fkField['name'] == 'entity_id') {
267 $entityTableParam = substr(implode('.', $stack), 0, -2) . 'table';
9e10fb6b 268 $entityTable = $this->where[$entityTableParam] ?? NULL;
466fce54
CW
269 if ($entityTable && is_string($entityTable) && \CRM_Core_DAO_AllCoreTables::getClassForTable($entityTable)) {
270 $fkField['FKClassName'] = \CRM_Core_DAO_AllCoreTables::getClassForTable($entityTable);
271 $fkField['FKApiName'] = \CRM_Core_DAO_AllCoreTables::getBriefName($fkField['FKClassName']);
272 }
273 }
9c846e15
CW
274 if (!empty($fkField['pseudoconstant']['optionGroupName'])) {
275 $fkField['FKClassName'] = 'CRM_Core_DAO_OptionValue';
276 $fkField['FKApiName'] = 'OptionValue';
277 $fkField['FKKeyColumn'] = 'value';
278 $fkField['FKCondition'] = "civicrm_option_value.option_group_id = (SELECT id FROM civicrm_option_group WHERE name = '{$fkField['pseudoconstant']['optionGroupName']}')";
279 }
466fce54
CW
280 }
281
69aae315
CW
282 /**
283 * Joins onto a custom field
284 *
285 * Adds a join to the query to make this field available for use in a clause.
286 *
287 * @param array $customField
2cfe873c 288 * @param string $side
69aae315
CW
289 * @param string $baseTable
290 * @return array
291 * Returns the table and field name for adding this field to a SELECT or WHERE clause
292 */
8bcc0d86 293 protected function addCustomField($customField, $side, $baseTable = self::MAIN_TABLE_ALIAS) {
69aae315
CW
294 $tableName = $customField["table_name"];
295 $columnName = $customField["column_name"];
296 $tableAlias = "{$baseTable}_to_$tableName";
c64f69d9
CW
297 $this->join($side, $tableName, $tableAlias, ["`$tableAlias`.entity_id = `$baseTable`.id"]);
298 return [$tableAlias, $columnName];
69aae315
CW
299 }
300
301 /**
302 * Fetch a field from the getFields list
303 *
69aae315
CW
304 * @param string $fieldName
305 * @return array|null
306 */
8bcc0d86 307 abstract protected function getField($fieldName);
69aae315 308
48488ea3 309 /**
2fa03859
CW
310 * Perform input validation on params that use the join syntax
311 *
312 * Arguably this should be done at the api wrapper level, but doing it here provides a bit more consistency
313 * in that api permissions to perform the join are checked first.
48488ea3
CW
314 *
315 * @param $fieldName
316 * @param $value
317 * @throws \Exception
318 */
8bcc0d86 319 protected function validateNestedInput($fieldName, &$value) {
48488ea3
CW
320 $stack = explode('.', $fieldName);
321 $spec = $this->apiFieldSpec;
322 $fieldName = array_pop($stack);
323 foreach ($stack as $depth => $name) {
324 $entity = $spec[$name]['FKApiName'];
325 $spec = $spec[$name]['FKApiSpec'];
326 }
c64f69d9 327 $params = [$fieldName => $value];
2fa03859
CW
328 \_civicrm_api3_validate_fields($entity, 'get', $params, $spec);
329 $value = $params[$fieldName];
48488ea3
CW
330 }
331
a762b380
CW
332 /**
333 * Check permission to join onto another api entity
334 *
335 * @param string $entity
336 * @param array $fieldStack
337 * The stack of fields leading up to this join
338 * @return bool
339 */
8bcc0d86 340 protected function checkPermissionToJoin($entity, $fieldStack) {
d343069c 341 if (!$this->checkPermissions) {
a762b380
CW
342 return TRUE;
343 }
344 // Build an array of params that relate to the joined entity
c64f69d9 345 $params = [
a762b380 346 'version' => 3,
c64f69d9 347 'return' => [],
d343069c 348 'check_permissions' => $this->checkPermissions,
c64f69d9 349 ];
a762b380
CW
350 $prefix = implode('.', $fieldStack) . '.';
351 $len = strlen($prefix);
8bcc0d86 352 foreach ($this->select as $key => $ret) {
a762b380
CW
353 if (strpos($key, $prefix) === 0) {
354 $params['return'][substr($key, $len)] = $ret;
355 }
356 }
8bcc0d86 357 foreach ($this->where as $key => $param) {
a762b380
CW
358 if (strpos($key, $prefix) === 0) {
359 $params[substr($key, $len)] = $param;
360 }
361 }
362
363 return \Civi::service('civi_api_kernel')->runAuthorize($entity, 'get', $params);
364 }
365
188fd46b 366 /**
d343069c
CW
367 * Get acl clause for an entity
368 *
369 * @param string $tableAlias
16f5a13d 370 * @param \CRM_Core_DAO|string $baoName
b53bcc5d 371 * @param array $stack
2cfe873c 372 * @return array
188fd46b 373 */
77fdabbf 374 public function getAclClause($tableAlias, $baoName, $stack = []) {
d343069c 375 if (!$this->checkPermissions) {
c64f69d9 376 return [];
d343069c 377 }
b53bcc5d
CW
378 // Prevent (most) redundant acl sub clauses if they have already been applied to the main entity.
379 // FIXME: Currently this only works 1 level deep, but tracking through multiple joins would increase complexity
380 // and just doing it for the first join takes care of most acl clause deduping.
381 if (count($stack) === 1 && in_array($stack[0], $this->aclFields)) {
c64f69d9 382 return [];
b53bcc5d 383 }
20e41014 384 $clauses = $baoName::getSelectWhereClause($tableAlias);
6c051493
CW
385 if (!$stack) {
386 // Track field clauses added to the main entity
387 $this->aclFields = array_keys($clauses);
b53bcc5d 388 }
2cfe873c 389 return array_filter($clauses);
188fd46b
CW
390 }
391
530c3791
CW
392 /**
393 * Orders the query by one or more fields
394 *
530c3791
CW
395 * @throws \API_Exception
396 * @throws \Civi\API\Exception\UnauthorizedException
397 */
8bcc0d86 398 protected function buildOrderBy() {
8bcc0d86 399 $sortParams = is_string($this->orderBy) ? explode(',', $this->orderBy) : (array) $this->orderBy;
4c6cc364
CW
400 foreach ($sortParams as $index => $item) {
401 $item = trim($item);
402 if ($item == '(1)') {
403 continue;
404 }
405 $words = preg_split("/[\s]+/", $item);
530c3791
CW
406 if ($words) {
407 // Direction defaults to ASC unless DESC is specified
408 $direction = strtoupper(\CRM_Utils_Array::value(1, $words, '')) == 'DESC' ? ' DESC' : '';
409 $field = $this->getField($words[0]);
410 if ($field) {
4c6cc364 411 $this->query->orderBy(self::MAIN_TABLE_ALIAS . '.' . $field['name'] . $direction, NULL, $index);
530c3791
CW
412 }
413 elseif (strpos($words[0], '.')) {
2cfe873c 414 $join = $this->addFkField($words[0], 'LEFT');
530c3791 415 if ($join) {
4c6cc364 416 $this->query->orderBy("`{$join[0]}`.`{$join[1]}`$direction", NULL, $index);
530c3791
CW
417 }
418 }
419 else {
420 throw new \API_Exception("Unknown field specified for sort. Cannot order by '$item'");
421 }
422 }
423 }
530c3791
CW
424 }
425
2cfe873c
CW
426 /**
427 * @param string $side
428 * @param string $tableName
429 * @param string $tableAlias
430 * @param array $conditions
431 */
432 public function join($side, $tableName, $tableAlias, $conditions) {
433 // INNER JOINs take precedence over LEFT JOINs
434 if ($side != 'LEFT' || !isset($this->joins[$tableAlias])) {
435 $this->joins[$tableAlias] = $side;
436 $this->query->join($tableAlias, "$side JOIN `$tableName` `$tableAlias` ON " . implode(' AND ', $conditions));
437 }
438 }
439
8bcc0d86
CW
440 /**
441 * Populate where clauses
442 *
443 * @throws \Civi\API\Exception\UnauthorizedException
444 * @throws \Exception
445 */
446 abstract protected function buildWhereClause();
447
448 /**
449 * Populate $this->selectFields
450 *
451 * @throws \Civi\API\Exception\UnauthorizedException
452 */
453 protected function buildSelectFields() {
454 $return_all_fields = (empty($this->select) || !is_array($this->select));
455 $return = $return_all_fields ? $this->entityFieldNames : $this->select;
456 if ($return_all_fields || in_array('custom', $this->select)) {
457 foreach (array_keys($this->apiFieldSpec) as $fieldName) {
458 if (strpos($fieldName, 'custom_') === 0) {
459 $return[] = $fieldName;
460 }
461 }
462 }
463
523c222f 464 // Always select the ID if the table has one.
465 if (array_key_exists('id', $this->apiFieldSpec)) {
466 $this->selectFields[self::MAIN_TABLE_ALIAS . ".id"] = "id";
467 }
8bcc0d86
CW
468
469 // core return fields
470 foreach ($return as $fieldName) {
471 $field = $this->getField($fieldName);
472 if ($field && in_array($field['name'], $this->entityFieldNames)) {
473 $this->selectFields[self::MAIN_TABLE_ALIAS . ".{$field['name']}"] = $field['name'];
474 }
475 elseif (strpos($fieldName, '.')) {
476 $fkField = $this->addFkField($fieldName, 'LEFT');
477 if ($fkField) {
478 $this->selectFields[implode('.', $fkField)] = $fieldName;
479 }
480 }
481 elseif ($field && strpos($fieldName, 'custom_') === 0) {
2d743fc6 482 [$table_name, $column_name] = $this->addCustomField($field, 'LEFT');
8bcc0d86
CW
483
484 if ($field['data_type'] != 'ContactReference') {
485 // 'ordinary' custom field. We will select the value as custom_XX.
486 $this->selectFields["$table_name.$column_name"] = $fieldName;
487 }
488 else {
489 // contact reference custom field. The ID will be stored in custom_XX_id.
490 // custom_XX will contain the sort name of the contact.
491 $this->query->join("c_$fieldName", "LEFT JOIN civicrm_contact c_$fieldName ON c_$fieldName.id = `$table_name`.`$column_name`");
492 $this->selectFields["$table_name.$column_name"] = $fieldName . "_id";
493 // We will call the contact table for the join c_XX.
494 $this->selectFields["c_$fieldName.sort_name"] = $fieldName;
495 }
496 }
497 }
498 }
499
500 /**
501 * Load entity fields
502 * @return array
503 */
504 abstract protected function getFields();
505
e47bcddb 506}