Merge pull request #7535 from monishdeb/unitTestFixes
[civicrm-core.git] / Civi / API / SelectQuery.php
index 6871a6bf7a88244efe961af8f2d9a7e76e7733d7..9c9d837304d0ca4298045384c768c069f2c389c1 100644 (file)
@@ -25,6 +25,7 @@
  +--------------------------------------------------------------------+
  */
 namespace Civi\API;
+use Civi\API\Exception\UnauthorizedException;
 
 /**
  * Query builder for civicrm_api_basic_get.
@@ -41,6 +42,12 @@ namespace Civi\API;
  */
 class SelectQuery {
 
+  const MAX_JOINS = 4;
+
+  /**
+   * @var \CRM_Core_DAO
+   */
+  protected $bao;
   /**
    * @var string
    */
@@ -70,76 +77,69 @@ class SelectQuery {
    */
   protected $entityFieldNames;
   /**
-   * @var array
+   * @var string|bool
    */
-  protected $uniqueAliases = array();
+  protected $checkPermissions;
 
   /**
-   * @param string $dao_name
-   *   Name of DAO
+   * @param string $bao_name
+   *   Name of BAO
    * @param array $params
    *   As passed into api get function.
    * @param bool $isFillUniqueFields
    *   Do we need to ensure unique fields continue to be populated for this api? (backward compatibility).
    */
-  public function __construct($dao_name, $params, $isFillUniqueFields) {
-    /* @var \CRM_Core_DAO $dao */
-    $dao = new $dao_name();
-    $this->entity = _civicrm_api_get_entity_name_from_dao($dao);
+  public function __construct($bao_name, $params, $isFillUniqueFields) {
+    $this->bao = new $bao_name();
+    $this->entity = _civicrm_api_get_entity_name_from_dao($this->bao);
     $this->params = $params;
     $this->isFillUniqueFields = $isFillUniqueFields;
+    $this->checkPermissions = \CRM_Utils_Array::value('check_permissions', $this->params, FALSE);
     $this->options = _civicrm_api3_get_options_from_params($this->params);
 
+    $this->entityFieldNames = _civicrm_api3_field_names(_civicrm_api3_build_fields_array($this->bao));
+    // Call this function directly instead of using the api wrapper to force unique field names off
+    require_once 'api/v3/Generic.php';
+    $apiSpec = \civicrm_api3_generic_getfields(array('entity' => $this->entity, 'version' => 3, 'params' => array('action' => 'get')), FALSE);
+    $this->apiFieldSpec = $apiSpec['values'];
 
-    $this->entityFieldNames = _civicrm_api3_field_names(_civicrm_api3_build_fields_array($dao));
-    $this->apiFieldSpec = \CRM_Utils_Array::value('values', civicrm_api3($this->entity, 'getfields', array('action' => 'get')));
-    foreach ($this->apiFieldSpec as $getFieldKey => $getFieldSpec) {
-      if (in_array($getFieldSpec['name'], $this->entityFieldNames)) {
-        $this->uniqueAliases[$getFieldKey] = $getFieldSpec['name'];
-        $this->uniqueAliases[$getFieldSpec['name']] = $getFieldSpec['name'];
-        foreach (\CRM_Utils_Array::value('api.aliases', $getFieldSpec, array()) as $alias) {
-          $this->uniqueAliases[$alias] = $getFieldSpec['name'];
-        }
-      }
-    }
-
-    // Unset $this->params['options'] if they are api options (not options as a fieldname).
-    if (isset($this->params['options']) && !in_array('options', $this->uniqueAliases)) {
-      unset($this->params['options']);
-    }
-
-    $this->query = \CRM_Utils_SQL_Select::from($dao->tableName() . " a");
-    $dao->free();
+    $this->query = \CRM_Utils_SQL_Select::from($this->bao->tableName() . " a");
   }
 
+  /**
+   * Build & execute the query and return results array
+   *
+   * @return array
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \Exception
+   */
   public function run() {
-
-    // $select_fields maps column names to the field names of the result
-    // values.
-    $select_fields = array();
-
-    // array with elements array('column', 'operator', 'value');
-    $where_clauses = array();
-
-    // Tables we need to join with to retrieve the custom values.
-    $custom_value_tables = array();
-
-    // ID's of custom fields that refer to a contact.
-    $contact_reference_field_ids = array();
+    // $select_fields maps column names to the field names of the result values.
+    $select_fields = $custom_fields = array();
 
     // populate $select_fields
     $return_all_fields = (empty($this->options['return']) || !is_array($this->options['return']));
     $return = $return_all_fields ? array_fill_keys($this->entityFieldNames, 1) : $this->options['return'];
 
-    // default fields
+    // core return fields
     foreach ($return as $field_name => $include) {
-      if ($include && !empty($this->uniqueAliases[$field_name])) {
-        // 'a.' is an alias for the entity table.
-        $select_fields["a.{$this->uniqueAliases[$field_name]}"] = $this->uniqueAliases[$field_name];
+      if ($include) {
+        $field = $this->getField($field_name);
+        if ($field && in_array($field['name'], $this->entityFieldNames)) {
+          // 'a.' is an alias for the entity table.
+          $select_fields["a.{$field['name']}"] = $field['name'];
+        }
+        elseif ($include && strpos($field_name, '.')) {
+          $fkField = $this->addFkField($field_name);
+          if ($fkField) {
+            $select_fields[implode('.', $fkField)] = $field_name;
+          }
+        }
       }
     }
 
-    // process custom fields IF the params contain the word "custom"
+    // Do custom fields IF the params contain the word "custom" or we are returning *
     if ($return_all_fields || strpos(json_encode($this->params), 'custom')) {
       $custom_fields = _civicrm_api3_custom_fields_for_entity($this->entity);
       foreach ($custom_fields as $cf_id => $custom_field) {
@@ -149,21 +149,16 @@ class SelectQuery {
           // This is a tested format so we support it.
           !empty($this->options['return']['custom'])
         ) {
-          $table_name = $custom_field["table_name"];
-          $column_name = $custom_field["column_name"];
-          // remember that we will need to join the correct table.
-          if (!in_array($table_name, $custom_value_tables)) {
-            $custom_value_tables[] = $table_name;
-          }
+          list($table_name, $column_name) = $this->addCustomField($custom_field);
+
           if ($custom_field["data_type"] != "ContactReference") {
             // 'ordinary' custom field. We will select the value as custom_XX.
             $select_fields["$table_name.$column_name"] = $field_name;
           }
           else {
-            // contact reference custom field. The ID will be stored in
-            // custom_XX_id. custom_XX will contain the sort name of the
-            // contact.
-            $contact_reference_field_ids[] = $cf_id;
+            // contact reference custom field. The ID will be stored in custom_XX_id.
+            // custom_XX will contain the sort name of the contact.
+            $this->query->join("c_$cf_id", "LEFT JOIN civicrm_contact c_$cf_id ON c_$cf_id.id = `$table_name`.`$column_name`");
             $select_fields["$table_name.$column_name"] = $field_name . "_id";
             // We will call the contact table for the join c_XX.
             $select_fields["c_$cf_id.sort_name"] = $field_name;
@@ -171,12 +166,10 @@ class SelectQuery {
         }
       }
     }
-    if (!in_array("a.id", $select_fields)) {
-      // Always select the ID.
-      $select_fields["a.id"] = "id";
-    }
+    // Always select the ID.
+    $select_fields["a.id"] = "id";
 
-    // populate $where_clauses
+    // populate where_clauses
     foreach ($this->params as $key => $value) {
       $table_name = NULL;
       $column_name = NULL;
@@ -214,24 +207,29 @@ class SelectQuery {
           }
         }
       }
-
-      if (isset($this->apiFieldSpec[$key])) {
-        $key = $this->apiFieldSpec[$key]['name'];
+      // Ignore the "options" param if it is referring to api options and not a field in this entity
+      if (
+        $key === 'options' && is_array($value)
+        && !in_array(\CRM_Utils_Array::first(array_keys($value)), \CRM_Core_DAO::acceptedSQLOperators())
+      ) {
+        continue;
       }
-      if ($key == _civicrm_api_get_entity_name_from_camel($this->entity) . '_id') {
-        // The test contract enforces support of (eg) mailing_group_id if the entity is MailingGroup.
-        $key = 'id';
+      $field = $this->getField($key);
+      if ($field) {
+        $key = $field['name'];
       }
       if (in_array($key, $this->entityFieldNames)) {
         $table_name = 'a';
         $column_name = $key;
       }
       elseif (($cf_id = \CRM_Core_BAO_CustomField::getKeyID($key)) != FALSE) {
-        $table_name = $custom_fields[$cf_id]["table_name"];
-        $column_name = $custom_fields[$cf_id]["column_name"];
-
-        if (!in_array($table_name, $custom_value_tables)) {
-          $custom_value_tables[] = $table_name;
+        list($table_name, $column_name) = $this->addCustomField($custom_fields[$cf_id]);
+      }
+      elseif (strpos($key, '.')) {
+        $fkInfo = $this->addFkField($key);
+        if ($fkInfo) {
+          list($table_name, $column_name) = $fkInfo;
+          $this->validateNestedInput($key, $value);
         }
       }
       // I don't know why I had to specifically exclude 0 as a key - wouldn't the others have caught it?
@@ -242,7 +240,7 @@ class SelectQuery {
         continue;
       }
       if (!is_array($value)) {
-        $this->query->where(array("{$table_name}.{$column_name} = @value"), array(
+        $this->query->where(array("`$table_name`.`$column_name` = @value"), array(
           "@value" => $value,
         ));
       }
@@ -261,60 +259,15 @@ class SelectQuery {
       }
     }
 
-    $i = 0;
     if (!$this->options['is_count']) {
       foreach ($select_fields as $column => $alias) {
-        ++$i;
-        $this->query = $this->query->select("!column_$i as !alias_$i", array(
-          "!column_$i" => $column,
-          "!alias_$i" => $alias,
-        ));
+        $this->query->select("$column as `$alias`");
       }
     }
     else {
       $this->query->select("count(*) as c");
     }
 
-    // join with custom value tables
-    foreach ($custom_value_tables as $table_name) {
-      ++$i;
-      $this->query = $this->query->join(
-        "!table_name_$i",
-        "LEFT OUTER JOIN !table_name_$i ON !table_name_$i.entity_id = a.id",
-        array("!table_name_$i" => $table_name)
-      );
-    }
-
-    // join with contact for contact reference fields
-    foreach ($contact_reference_field_ids as $field_id) {
-      ++$i;
-      $this->query = $this->query->join(
-        "!contact_table_name$i",
-        "LEFT OUTER JOIN civicrm_contact !contact_table_name_$i ON !contact_table_name_$i.id = !values_table_name_$i.!column_name_$i",
-        array(
-          "!contact_table_name_$i" => "c_$field_id",
-          "!values_table_name_$i" => $custom_fields[$field_id]["table_name"],
-          "!column_name_$i" => $custom_fields[$field_id]["column_name"],
-        ));
-    };
-
-    foreach ($where_clauses as $clause) {
-      ++$i;
-      if (substr($clause[1], -4) == "NULL") {
-        $this->query->where("!columnName_$i !nullThing_$i", array(
-          "!columnName_$i" => $clause[0],
-          "!nullThing_$i" => $clause[1],
-        ));
-      }
-      else {
-        $this->query->where("!columnName_$i !operator_$i @value_$i", array(
-          "!columnName_$i" => $clause[0],
-          "!operator_$i" => $clause[1],
-          "@value_$i" => $clause[2],
-        ));
-      }
-    };
-
     // order by
     if (!empty($this->options['sort'])) {
       $sort_fields = array();
@@ -338,6 +291,10 @@ class SelectQuery {
       $this->query->limit($this->options['limit'], $this->options['offset']);
     }
 
+    // ACLs
+    $this->query->where($this->getAclClause('a'));
+    $this->bao->free();
+
     $result_entities = array();
     $result_dao = \CRM_Core_DAO::executeQuery($this->query->toSQL());
 
@@ -348,12 +305,14 @@ class SelectQuery {
       }
       $result_entities[$result_dao->id] = array();
       foreach ($select_fields as $column => $alias) {
+        $returnName = $alias;
+        $alias = str_replace('.', '_', $alias);
         if (property_exists($result_dao, $alias) && $result_dao->$alias != NULL) {
-          $result_entities[$result_dao->id][$alias] = $result_dao->$alias;
+          $result_entities[$result_dao->id][$returnName] = $result_dao->$alias;
         }
         // Backward compatibility on fields names.
-        if ($this->isFillUniqueFields && !empty($this->apiFieldSpec['values'][$column]['uniqueName'])) {
-          $result_entities[$result_dao->id][$this->apiFieldSpec['values'][$column]['uniqueName']] = $result_dao->$alias;
+        if ($this->isFillUniqueFields && !empty($this->apiFieldSpec[$alias]['uniqueName'])) {
+          $result_entities[$result_dao->id][$this->apiFieldSpec[$alias]['uniqueName']] = $result_dao->$alias;
         }
         foreach ($this->apiFieldSpec as $returnName => $spec) {
           if (empty($result_entities[$result_dao->id][$returnName]) && !empty($result_entities[$result_dao->id][$spec['name']])) {
@@ -374,4 +333,204 @@ class SelectQuery {
     $this->query->merge($sqlFragment);
     return $this;
   }
+
+  /**
+   * Joins onto an fk field
+   *
+   * Adds one or more joins to the query to make this field available for use in a clause.
+   *
+   * Enforces permissions at the api level and by appending the acl clause for that entity to the join.
+   *
+   * @param $fkFieldName
+   * @return array|null
+   *   Returns the table and field name for adding this field to a SELECT or WHERE clause
+   * @throws \API_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  private function addFkField($fkFieldName) {
+    $stack = explode('.', $fkFieldName);
+    if (count($stack) < 2) {
+      return NULL;
+    }
+    $prev = 'a';
+    foreach ($stack as $depth => $fieldName) {
+      // Setup variables then skip the first level
+      if (!$depth) {
+        $fk = $fieldName;
+        // We only join on core fields
+        // @TODO: Custom contact ref fields could be supported too
+        if (!in_array($fk, $this->entityFieldNames)) {
+          return NULL;
+        }
+        $fkField = &$this->apiFieldSpec[$fk];
+        continue;
+      }
+      // More than 4 joins deep seems excessive - DOS attack?
+      if ($depth > self::MAX_JOINS) {
+        throw new UnauthorizedException("Maximum number of joins exceeded for api.{$this->entity}.get in parameter $fkFieldName");
+      }
+      if (!isset($fkField['FKApiName']) && !isset($fkField['FKClassName'])) {
+        // Join doesn't exist - might be another param with a dot in it for some reason, we'll just ignore it.
+        return NULL;
+      }
+      // Ensure we have permission to access the other api
+      if (!$this->checkPermissionToJoin($fkField['FKApiName'], array_slice($stack, 0, $depth))) {
+        throw new UnauthorizedException("Authorization failed to join onto {$fkField['FKApiName']} api in parameter $fkFieldName");
+      }
+      if (!isset($fkField['FKApiSpec'])) {
+        $fkField['FKApiSpec'] = \_civicrm_api_get_fields($fkField['FKApiName']);
+      }
+      $fieldInfo = \CRM_Utils_Array::value($fieldName, $fkField['FKApiSpec']);
+
+      // FIXME: What if the foreign key is not the "id" column?
+      if (!$fieldInfo || !isset($fkField['FKApiSpec']['id'])) {
+        // Join doesn't exist - might be another param with a dot in it for some reason, we'll just ignore it.
+        return NULL;
+      }
+      $fkTable = \CRM_Core_DAO_AllCoreTables::getTableForClass($fkField['FKClassName']);
+      $tableAlias = implode('_to_', array_slice($stack, 0, $depth)) . "_to_$fkTable";
+      $joinClause = "LEFT JOIN $fkTable $tableAlias ON $prev.$fk = $tableAlias.id";
+
+      // Add acl condition
+      $joinCondition = $this->getAclClause($tableAlias, $fkField['FKClassName']);
+      if ($joinCondition !== NULL) {
+        $joinClause .= " AND $joinCondition";
+      }
+
+      $this->query->join($tableAlias, $joinClause);
+
+      if (strpos($fieldName, 'custom_') === 0) {
+        list($tableAlias, $fieldName) = $this->addCustomField($fieldInfo, $tableAlias);
+      }
+
+      // Get ready to recurse to the next level
+      $fk = $fieldName;
+      $fkField = &$fkField['FKApiSpec'][$fieldName];
+      $prev = $tableAlias;
+    }
+    return array($tableAlias, $fieldName);
+  }
+
+  /**
+   * Joins onto a custom field
+   *
+   * Adds a join to the query to make this field available for use in a clause.
+   *
+   * @param array $customField
+   * @param string $baseTable
+   * @return array
+   *   Returns the table and field name for adding this field to a SELECT or WHERE clause
+   */
+  private function addCustomField($customField, $baseTable = 'a') {
+    $tableName = $customField["table_name"];
+    $columnName = $customField["column_name"];
+    $tableAlias = "{$baseTable}_to_$tableName";
+    $this->query->join($tableAlias, "LEFT JOIN `$tableName` `$tableAlias` ON `$tableAlias`.entity_id = `$baseTable`.id");
+    return array($tableAlias, $columnName);
+  }
+
+  /**
+   * Fetch a field from the getFields list
+   *
+   * Searches by name, uniqueName, and api.aliases
+   *
+   * @param string $fieldName
+   * @return array|null
+   */
+  private function getField($fieldName) {
+    if (!$fieldName) {
+      return NULL;
+    }
+    if (isset($this->apiFieldSpec[$fieldName])) {
+      return $this->apiFieldSpec[$fieldName];
+    }
+    foreach ($this->apiFieldSpec as $field) {
+      if (
+        $fieldName == \CRM_Utils_Array::value('uniqueName', $field) ||
+        array_search($fieldName, \CRM_Utils_Array::value('api.aliases', $field, array())) !== FALSE
+      ) {
+        return $field;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Perform input validation on params that use the join syntax
+   *
+   * Arguably this should be done at the api wrapper level, but doing it here provides a bit more consistency
+   * in that api permissions to perform the join are checked first.
+   *
+   * @param $fieldName
+   * @param $value
+   * @throws \Exception
+   */
+  private function validateNestedInput($fieldName, &$value) {
+    $stack = explode('.', $fieldName);
+    $spec = $this->apiFieldSpec;
+    $fieldName = array_pop($stack);
+    foreach ($stack as $depth => $name) {
+      $entity = $spec[$name]['FKApiName'];
+      $spec = $spec[$name]['FKApiSpec'];
+    }
+    $params = array($fieldName => $value);
+    \_civicrm_api3_validate_fields($entity, 'get', $params, $spec);
+    $value = $params[$fieldName];
+  }
+
+  /**
+   * Check permission to join onto another api entity
+   *
+   * @param string $entity
+   * @param array $fieldStack
+   *   The stack of fields leading up to this join
+   * @return bool
+   */
+  private function checkPermissionToJoin($entity, $fieldStack) {
+    if (!$this->checkPermissions) {
+      return TRUE;
+    }
+    // Build an array of params that relate to the joined entity
+    $params = array(
+      'version' => 3,
+      'return' => array(),
+      'check_permissions' => $this->checkPermissions,
+    );
+    $prefix = implode('.', $fieldStack) . '.';
+    $len = strlen($prefix);
+    foreach ($this->options['return'] as $key => $ret) {
+      if (strpos($key, $prefix) === 0) {
+        $params['return'][substr($key, $len)] = $ret;
+      }
+    }
+    foreach ($this->params as $key => $param) {
+      if (strpos($key, $prefix) === 0) {
+        $params[substr($key, $len)] = $param;
+      }
+    }
+
+    return \Civi::service('civi_api_kernel')->runAuthorize($entity, 'get', $params);
+  }
+
+  /**
+   * Get acl clause for an entity
+   *
+   * @param string $tableAlias
+   * @param \CRM_Core_DAO $daoName
+   * @return null|string
+   */
+  private function getAclClause($tableAlias, $daoName = NULL) {
+    if (!$this->checkPermissions) {
+      return NULL;
+    }
+    if (!$daoName) {
+      $bao = $this->bao;
+    }
+    else {
+      $baoName = str_replace('_DAO_', '_BAO_', $daoName);
+      $bao = class_exists($baoName) ? new $baoName() : new $daoName();
+    }
+    return $bao->apiWhereClause($tableAlias);
+  }
+
 }