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