CRM-19132 add html type to contribution_recur fields
[civicrm-core.git] / Civi / API / SelectQuery.php
CommitLineData
e47bcddb
CW
1<?php
2/*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.7 |
5 +--------------------------------------------------------------------+
fa938177 6 | Copyright CiviCRM LLC (c) 2004-2016 |
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;
ab039e22 28use Civi\API\Exception\UnauthorizedException;
e47bcddb
CW
29
30/**
31 * Query builder for civicrm_api_basic_get.
32 *
33 * Fetches an entity based on specified params for the "where" clause,
34 * return properties for the "select" clause,
35 * as well as limit and order.
36 *
37 * Automatically joins on custom fields to return or filter by them.
38 *
39 * Supports an additional sql fragment which the calling api can provide.
40 *
41 * @package Civi\API
42 */
43class SelectQuery {
44
6c051493
CW
45 const
46 MAX_JOINS = 4,
47 MAIN_TABLE_ALIAS = 'a';
5b46e216 48
e47bcddb
CW
49 /**
50 * @var string
51 */
52 protected $entity;
53 /**
54 * @var array
55 */
56 protected $params;
57 /**
58 * @var array
59 */
60 protected $options;
61 /**
62 * @var bool
63 */
64 protected $isFillUniqueFields;
65 /**
66 * @var \CRM_Utils_SQL_Select
67 */
68 protected $query;
2cfe873c
CW
69 /**
70 * @var array
71 */
72 private $joins = array();
e47bcddb
CW
73 /**
74 * @var array
75 */
76 protected $apiFieldSpec;
77 /**
78 * @var array
79 */
80 protected $entityFieldNames;
b53bcc5d
CW
81 /**
82 * @var array
83 */
84 protected $aclFields = array();
d343069c
CW
85 /**
86 * @var string|bool
87 */
88 protected $checkPermissions;
e47bcddb
CW
89
90 /**
6c051493 91 * @param string $baoName
d343069c 92 * Name of BAO
e47bcddb
CW
93 * @param array $params
94 * As passed into api get function.
95 * @param bool $isFillUniqueFields
96 * Do we need to ensure unique fields continue to be populated for this api? (backward compatibility).
97 */
6c051493
CW
98 public function __construct($baoName, $params, $isFillUniqueFields) {
99 $bao = new $baoName();
100 $this->entity = _civicrm_api_get_entity_name_from_dao($bao);
e47bcddb
CW
101 $this->params = $params;
102 $this->isFillUniqueFields = $isFillUniqueFields;
d343069c 103 $this->checkPermissions = \CRM_Utils_Array::value('check_permissions', $this->params, FALSE);
e47bcddb
CW
104 $this->options = _civicrm_api3_get_options_from_params($this->params);
105
6c051493 106 $this->entityFieldNames = _civicrm_api3_field_names(_civicrm_api3_build_fields_array($bao));
69aae315
CW
107 // Call this function directly instead of using the api wrapper to force unique field names off
108 require_once 'api/v3/Generic.php';
109 $apiSpec = \civicrm_api3_generic_getfields(array('entity' => $this->entity, 'version' => 3, 'params' => array('action' => 'get')), FALSE);
110 $this->apiFieldSpec = $apiSpec['values'];
e47bcddb 111
6c051493
CW
112 $this->query = \CRM_Utils_SQL_Select::from($bao->tableName() . ' ' . self::MAIN_TABLE_ALIAS);
113 $bao->free();
b53bcc5d 114
6c051493
CW
115 // Add ACLs first to avoid redundant subclauses
116 $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
e47bcddb
CW
117 }
118
d343069c
CW
119 /**
120 * Build & execute the query and return results array
121 *
122 * @return array
123 * @throws \API_Exception
124 * @throws \CRM_Core_Exception
125 * @throws \Exception
126 */
e47bcddb 127 public function run() {
69aae315
CW
128 // $select_fields maps column names to the field names of the result values.
129 $select_fields = $custom_fields = array();
e47bcddb
CW
130
131 // populate $select_fields
132 $return_all_fields = (empty($this->options['return']) || !is_array($this->options['return']));
133 $return = $return_all_fields ? array_fill_keys($this->entityFieldNames, 1) : $this->options['return'];
134
69aae315 135 // core return fields
e47bcddb 136 foreach ($return as $field_name => $include) {
69aae315
CW
137 if ($include) {
138 $field = $this->getField($field_name);
139 if ($field && in_array($field['name'], $this->entityFieldNames)) {
6c051493 140 $select_fields[self::MAIN_TABLE_ALIAS . ".{$field['name']}"] = $field['name'];
69aae315
CW
141 }
142 elseif ($include && strpos($field_name, '.')) {
2cfe873c 143 $fkField = $this->addFkField($field_name, 'LEFT');
69aae315
CW
144 if ($fkField) {
145 $select_fields[implode('.', $fkField)] = $field_name;
146 }
147 }
e47bcddb
CW
148 }
149 }
150
69aae315 151 // Do custom fields IF the params contain the word "custom" or we are returning *
e47bcddb
CW
152 if ($return_all_fields || strpos(json_encode($this->params), 'custom')) {
153 $custom_fields = _civicrm_api3_custom_fields_for_entity($this->entity);
154 foreach ($custom_fields as $cf_id => $custom_field) {
155 $field_name = "custom_$cf_id";
156 if ($return_all_fields || !empty($this->options['return'][$field_name])
157 ||
158 // This is a tested format so we support it.
159 !empty($this->options['return']['custom'])
160 ) {
2cfe873c 161 list($table_name, $column_name) = $this->addCustomField($custom_field, 'LEFT');
69aae315 162
e47bcddb
CW
163 if ($custom_field["data_type"] != "ContactReference") {
164 // 'ordinary' custom field. We will select the value as custom_XX.
165 $select_fields["$table_name.$column_name"] = $field_name;
166 }
167 else {
69aae315
CW
168 // contact reference custom field. The ID will be stored in custom_XX_id.
169 // custom_XX will contain the sort name of the contact.
170 $this->query->join("c_$cf_id", "LEFT JOIN civicrm_contact c_$cf_id ON c_$cf_id.id = `$table_name`.`$column_name`");
e47bcddb
CW
171 $select_fields["$table_name.$column_name"] = $field_name . "_id";
172 // We will call the contact table for the join c_XX.
173 $select_fields["c_$cf_id.sort_name"] = $field_name;
174 }
175 }
176 }
177 }
69aae315 178 // Always select the ID.
6c051493 179 $select_fields[self::MAIN_TABLE_ALIAS . ".id"] = "id";
e47bcddb 180
69aae315 181 // populate where_clauses
e47bcddb
CW
182 foreach ($this->params as $key => $value) {
183 $table_name = NULL;
184 $column_name = NULL;
185
186 if (substr($key, 0, 7) == 'filter.') {
187 // Legacy support for old filter syntax per the test contract.
188 // (Convert the style to the later one & then deal with them).
189 $filterArray = explode('.', $key);
190 $value = array($filterArray[1] => $value);
191 $key = 'filters';
192 }
193
194 // Legacy support for 'filter's construct.
195 if ($key == 'filters') {
196 foreach ($value as $filterKey => $filterValue) {
197 if (substr($filterKey, -4, 4) == 'high') {
198 $key = substr($filterKey, 0, -5);
199 $value = array('<=' => $filterValue);
200 }
201
202 if (substr($filterKey, -3, 3) == 'low') {
203 $key = substr($filterKey, 0, -4);
204 $value = array('>=' => $filterValue);
205 }
206
207 if ($filterKey == 'is_current' || $filterKey == 'isCurrent') {
6c051493 208 // Is current is almost worth creating as a 'sql filter' in the DAO function since several entities have the concept.
e47bcddb
CW
209 $todayStart = date('Ymd000000', strtotime('now'));
210 $todayEnd = date('Ymd235959', strtotime('now'));
6c051493
CW
211 $a = self::MAIN_TABLE_ALIAS;
212 $this->query->where("($a.start_date <= '$todayStart' OR $a.start_date IS NULL)
213 AND ($a.end_date >= '$todayEnd' OR $a.end_date IS NULL)
214 AND a.is_active = 1");
e47bcddb
CW
215 }
216 }
217 }
479bfd0f
CW
218 // Ignore the "options" param if it is referring to api options and not a field in this entity
219 if (
220 $key === 'options' && is_array($value)
221 && !in_array(\CRM_Utils_Array::first(array_keys($value)), \CRM_Core_DAO::acceptedSQLOperators())
222 ) {
223 continue;
224 }
69aae315
CW
225 $field = $this->getField($key);
226 if ($field) {
227 $key = $field['name'];
e47bcddb
CW
228 }
229 if (in_array($key, $this->entityFieldNames)) {
6c051493 230 $table_name = self::MAIN_TABLE_ALIAS;
e47bcddb
CW
231 $column_name = $key;
232 }
233 elseif (($cf_id = \CRM_Core_BAO_CustomField::getKeyID($key)) != FALSE) {
2cfe873c 234 list($table_name, $column_name) = $this->addCustomField($custom_fields[$cf_id], 'INNER');
69aae315
CW
235 }
236 elseif (strpos($key, '.')) {
2cfe873c 237 $fkInfo = $this->addFkField($key, 'INNER');
69aae315
CW
238 if ($fkInfo) {
239 list($table_name, $column_name) = $fkInfo;
48488ea3 240 $this->validateNestedInput($key, $value);
e47bcddb
CW
241 }
242 }
243 // I don't know why I had to specifically exclude 0 as a key - wouldn't the others have caught it?
244 // We normally silently ignore null values passed in - if people want IS_NULL they can use acceptedSqlOperator syntax.
245 if ((!$table_name) || empty($key) || is_null($value)) {
246 // No valid filter field. This might be a chained call or something.
247 // Just ignore this for the $where_clause.
248 continue;
249 }
250 if (!is_array($value)) {
69aae315 251 $this->query->where(array("`$table_name`.`$column_name` = @value"), array(
e47bcddb
CW
252 "@value" => $value,
253 ));
254 }
255 else {
256 // We expect only one element in the array, of the form
257 // "operator" => "rhs".
258 $operator = \CRM_Utils_Array::first(array_keys($value));
259 if (!in_array($operator, \CRM_Core_DAO::acceptedSQLOperators())) {
260 $this->query->where(array(
261 "{$table_name}.{$column_name} = @value"), array("@value" => $value)
262 );
263 }
264 else {
265 $this->query->where(\CRM_Core_DAO::createSQLFilter("{$table_name}.{$column_name}", $value));
266 }
267 }
268 }
269
e47bcddb
CW
270 if (!$this->options['is_count']) {
271 foreach ($select_fields as $column => $alias) {
69aae315 272 $this->query->select("$column as `$alias`");
e47bcddb
CW
273 }
274 }
275 else {
276 $this->query->select("count(*) as c");
277 }
278
530c3791 279 // Order by
e47bcddb 280 if (!empty($this->options['sort'])) {
530c3791 281 $this->orderBy($this->options['sort']);
e47bcddb
CW
282 }
283
530c3791 284 // Limit
e47bcddb
CW
285 if (!empty($this->options['limit']) || !empty($this->options['offset'])) {
286 $this->query->limit($this->options['limit'], $this->options['offset']);
287 }
288
289 $result_entities = array();
290 $result_dao = \CRM_Core_DAO::executeQuery($this->query->toSQL());
291
292 while ($result_dao->fetch()) {
293 if ($this->options['is_count']) {
294 $result_dao->free();
295 return (int) $result_dao->c;
296 }
297 $result_entities[$result_dao->id] = array();
298 foreach ($select_fields as $column => $alias) {
69aae315
CW
299 $returnName = $alias;
300 $alias = str_replace('.', '_', $alias);
e47bcddb 301 if (property_exists($result_dao, $alias) && $result_dao->$alias != NULL) {
69aae315 302 $result_entities[$result_dao->id][$returnName] = $result_dao->$alias;
e47bcddb
CW
303 }
304 // Backward compatibility on fields names.
984f31ae
CW
305 if ($this->isFillUniqueFields && !empty($this->apiFieldSpec[$alias]['uniqueName'])) {
306 $result_entities[$result_dao->id][$this->apiFieldSpec[$alias]['uniqueName']] = $result_dao->$alias;
e47bcddb
CW
307 }
308 foreach ($this->apiFieldSpec as $returnName => $spec) {
309 if (empty($result_entities[$result_dao->id][$returnName]) && !empty($result_entities[$result_dao->id][$spec['name']])) {
310 $result_entities[$result_dao->id][$returnName] = $result_entities[$result_dao->id][$spec['name']];
311 }
312 }
313 };
314 }
315 $result_dao->free();
316 return $result_entities;
317 }
318
319 /**
320 * @param \CRM_Utils_SQL_Select $sqlFragment
321 * @return $this
322 */
323 public function merge($sqlFragment) {
324 $this->query->merge($sqlFragment);
325 return $this;
326 }
69aae315
CW
327
328 /**
329 * Joins onto an fk field
330 *
331 * Adds one or more joins to the query to make this field available for use in a clause.
332 *
d343069c
CW
333 * Enforces permissions at the api level and by appending the acl clause for that entity to the join.
334 *
69aae315 335 * @param $fkFieldName
2cfe873c
CW
336 * @param $side
337 *
69aae315
CW
338 * @return array|null
339 * Returns the table and field name for adding this field to a SELECT or WHERE clause
d343069c 340 * @throws \API_Exception
ab039e22 341 * @throws \Civi\API\Exception\UnauthorizedException
69aae315 342 */
2cfe873c 343 private function addFkField($fkFieldName, $side) {
69aae315
CW
344 $stack = explode('.', $fkFieldName);
345 if (count($stack) < 2) {
346 return NULL;
347 }
6c051493 348 $prev = self::MAIN_TABLE_ALIAS;
69aae315
CW
349 foreach ($stack as $depth => $fieldName) {
350 // Setup variables then skip the first level
351 if (!$depth) {
352 $fk = $fieldName;
353 // We only join on core fields
354 // @TODO: Custom contact ref fields could be supported too
355 if (!in_array($fk, $this->entityFieldNames)) {
356 return NULL;
357 }
358 $fkField = &$this->apiFieldSpec[$fk];
359 continue;
360 }
4f7a32d8 361 // More than 4 joins deep seems excessive - DOS attack?
5b46e216 362 if ($depth > self::MAX_JOINS) {
3924e596 363 throw new UnauthorizedException("Maximum number of joins exceeded in parameter $fkFieldName");
4f7a32d8 364 }
2cfe873c 365 if (!isset($fkField['FKApiName']) || !isset($fkField['FKClassName'])) {
ab039e22 366 // Join doesn't exist - might be another param with a dot in it for some reason, we'll just ignore it.
69aae315
CW
367 return NULL;
368 }
b53bcc5d 369 $subStack = array_slice($stack, 0, $depth);
a762b380 370 // Ensure we have permission to access the other api
b53bcc5d 371 if (!$this->checkPermissionToJoin($fkField['FKApiName'], $subStack)) {
ab039e22 372 throw new UnauthorizedException("Authorization failed to join onto {$fkField['FKApiName']} api in parameter $fkFieldName");
a762b380 373 }
69aae315
CW
374 if (!isset($fkField['FKApiSpec'])) {
375 $fkField['FKApiSpec'] = \_civicrm_api_get_fields($fkField['FKApiName']);
376 }
377 $fieldInfo = \CRM_Utils_Array::value($fieldName, $fkField['FKApiSpec']);
378
379 // FIXME: What if the foreign key is not the "id" column?
380 if (!$fieldInfo || !isset($fkField['FKApiSpec']['id'])) {
ab039e22 381 // Join doesn't exist - might be another param with a dot in it for some reason, we'll just ignore it.
69aae315
CW
382 return NULL;
383 }
384 $fkTable = \CRM_Core_DAO_AllCoreTables::getTableForClass($fkField['FKClassName']);
b53bcc5d 385 $tableAlias = implode('_to_', $subStack) . "_to_$fkTable";
69aae315 386
d343069c 387 // Add acl condition
2cfe873c
CW
388 $joinCondition = array_merge(
389 array("$prev.$fk = $tableAlias.id"),
390 $this->getAclClause($tableAlias, \_civicrm_api3_get_BAO($fkField['FKApiName']), $subStack)
391 );
d343069c 392
2cfe873c 393 $this->join($side, $fkTable, $tableAlias, $joinCondition);
69aae315
CW
394
395 if (strpos($fieldName, 'custom_') === 0) {
2cfe873c 396 list($tableAlias, $fieldName) = $this->addCustomField($fieldInfo, $side, $tableAlias);
69aae315
CW
397 }
398
399 // Get ready to recurse to the next level
400 $fk = $fieldName;
401 $fkField = &$fkField['FKApiSpec'][$fieldName];
402 $prev = $tableAlias;
403 }
404 return array($tableAlias, $fieldName);
405 }
406
407 /**
408 * Joins onto a custom field
409 *
410 * Adds a join to the query to make this field available for use in a clause.
411 *
412 * @param array $customField
2cfe873c 413 * @param string $side
69aae315
CW
414 * @param string $baseTable
415 * @return array
416 * Returns the table and field name for adding this field to a SELECT or WHERE clause
417 */
2cfe873c 418 private function addCustomField($customField, $side, $baseTable = self::MAIN_TABLE_ALIAS) {
69aae315
CW
419 $tableName = $customField["table_name"];
420 $columnName = $customField["column_name"];
421 $tableAlias = "{$baseTable}_to_$tableName";
2cfe873c 422 $this->join($side, $tableName, $tableAlias, array("`$tableAlias`.entity_id = `$baseTable`.id"));
69aae315
CW
423 return array($tableAlias, $columnName);
424 }
425
426 /**
427 * Fetch a field from the getFields list
428 *
429 * Searches by name, uniqueName, and api.aliases
430 *
431 * @param string $fieldName
432 * @return array|null
433 */
434 private function getField($fieldName) {
435 if (!$fieldName) {
436 return NULL;
437 }
438 if (isset($this->apiFieldSpec[$fieldName])) {
439 return $this->apiFieldSpec[$fieldName];
440 }
441 foreach ($this->apiFieldSpec as $field) {
442 if (
443 $fieldName == \CRM_Utils_Array::value('uniqueName', $field) ||
444 array_search($fieldName, \CRM_Utils_Array::value('api.aliases', $field, array())) !== FALSE
445 ) {
446 return $field;
447 }
448 }
449 return NULL;
450 }
451
48488ea3 452 /**
2fa03859
CW
453 * Perform input validation on params that use the join syntax
454 *
455 * Arguably this should be done at the api wrapper level, but doing it here provides a bit more consistency
456 * in that api permissions to perform the join are checked first.
48488ea3
CW
457 *
458 * @param $fieldName
459 * @param $value
460 * @throws \Exception
461 */
462 private function validateNestedInput($fieldName, &$value) {
48488ea3
CW
463 $stack = explode('.', $fieldName);
464 $spec = $this->apiFieldSpec;
465 $fieldName = array_pop($stack);
466 foreach ($stack as $depth => $name) {
467 $entity = $spec[$name]['FKApiName'];
468 $spec = $spec[$name]['FKApiSpec'];
469 }
2fa03859
CW
470 $params = array($fieldName => $value);
471 \_civicrm_api3_validate_fields($entity, 'get', $params, $spec);
472 $value = $params[$fieldName];
48488ea3
CW
473 }
474
a762b380
CW
475 /**
476 * Check permission to join onto another api entity
477 *
478 * @param string $entity
479 * @param array $fieldStack
480 * The stack of fields leading up to this join
481 * @return bool
482 */
483 private function checkPermissionToJoin($entity, $fieldStack) {
d343069c 484 if (!$this->checkPermissions) {
a762b380
CW
485 return TRUE;
486 }
487 // Build an array of params that relate to the joined entity
488 $params = array(
489 'version' => 3,
490 'return' => array(),
d343069c 491 'check_permissions' => $this->checkPermissions,
a762b380
CW
492 );
493 $prefix = implode('.', $fieldStack) . '.';
494 $len = strlen($prefix);
495 foreach ($this->options['return'] as $key => $ret) {
496 if (strpos($key, $prefix) === 0) {
497 $params['return'][substr($key, $len)] = $ret;
498 }
499 }
500 foreach ($this->params as $key => $param) {
501 if (strpos($key, $prefix) === 0) {
502 $params[substr($key, $len)] = $param;
503 }
504 }
505
506 return \Civi::service('civi_api_kernel')->runAuthorize($entity, 'get', $params);
507 }
508
188fd46b 509 /**
d343069c
CW
510 * Get acl clause for an entity
511 *
512 * @param string $tableAlias
6c051493 513 * @param string $baoName
b53bcc5d 514 * @param array $stack
2cfe873c 515 * @return array
188fd46b 516 */
6c051493 517 private function getAclClause($tableAlias, $baoName, $stack = array()) {
d343069c 518 if (!$this->checkPermissions) {
2cfe873c 519 return array();
d343069c 520 }
b53bcc5d
CW
521 // Prevent (most) redundant acl sub clauses if they have already been applied to the main entity.
522 // FIXME: Currently this only works 1 level deep, but tracking through multiple joins would increase complexity
523 // and just doing it for the first join takes care of most acl clause deduping.
524 if (count($stack) === 1 && in_array($stack[0], $this->aclFields)) {
2cfe873c 525 return array();
b53bcc5d 526 }
20e41014 527 $clauses = $baoName::getSelectWhereClause($tableAlias);
6c051493
CW
528 if (!$stack) {
529 // Track field clauses added to the main entity
530 $this->aclFields = array_keys($clauses);
b53bcc5d 531 }
2cfe873c 532 return array_filter($clauses);
188fd46b
CW
533 }
534
530c3791
CW
535 /**
536 * Orders the query by one or more fields
537 *
538 * e.g.
539 * @code
540 * $this->orderBy(array('last_name DESC', 'birth_date'));
541 * @endcode
542 *
543 * @param string|array $sortParams
544 * @throws \API_Exception
545 * @throws \Civi\API\Exception\UnauthorizedException
546 */
547 public function orderBy($sortParams) {
548 $orderBy = array();
549 foreach (is_array($sortParams) ? $sortParams : explode(',', $sortParams) as $item) {
550 $words = preg_split("/[\s]+/", trim($item));
551 if ($words) {
552 // Direction defaults to ASC unless DESC is specified
553 $direction = strtoupper(\CRM_Utils_Array::value(1, $words, '')) == 'DESC' ? ' DESC' : '';
554 $field = $this->getField($words[0]);
555 if ($field) {
6c051493 556 $orderBy[] = self::MAIN_TABLE_ALIAS . '.' . $field['name'] . $direction;
530c3791
CW
557 }
558 elseif (strpos($words[0], '.')) {
2cfe873c 559 $join = $this->addFkField($words[0], 'LEFT');
530c3791
CW
560 if ($join) {
561 $orderBy[] = "`{$join[0]}`.`{$join[1]}`$direction";
562 }
563 }
564 else {
565 throw new \API_Exception("Unknown field specified for sort. Cannot order by '$item'");
566 }
567 }
568 }
569 $this->query->orderBy($orderBy);
570 }
571
2cfe873c
CW
572 /**
573 * @param string $side
574 * @param string $tableName
575 * @param string $tableAlias
576 * @param array $conditions
577 */
578 public function join($side, $tableName, $tableAlias, $conditions) {
579 // INNER JOINs take precedence over LEFT JOINs
580 if ($side != 'LEFT' || !isset($this->joins[$tableAlias])) {
581 $this->joins[$tableAlias] = $side;
582 $this->query->join($tableAlias, "$side JOIN `$tableName` `$tableAlias` ON " . implode(' AND ', $conditions));
583 }
584 }
585
e47bcddb 586}