APIv4 - Fix explicit joins to custom entities
authorColeman Watts <coleman@civicrm.org>
Fri, 22 May 2020 20:45:11 +0000 (16:45 -0400)
committerColeman Watts <coleman@civicrm.org>
Mon, 25 May 2020 22:41:44 +0000 (18:41 -0400)
CRM/Core/BAO/CustomValue.php
Civi/API/SelectQuery.php
Civi/Api4/Action/Entity/GetLinks.php
Civi/Api4/Generic/Traits/CustomValueActionTrait.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Service/Schema/SchemaMapBuilder.php
Civi/Api4/Service/Spec/SpecGatherer.php
Civi/Api4/Utils/CoreUtil.php
tests/phpunit/api/v3/ACLPermissionTest.php

index 8cbe6b3d08a89a8cc2d71619c09a9c48c76a6bf0..3a64cdb29d3b57c3f1a09e9120664b726c1433ae 100644 (file)
@@ -206,4 +206,16 @@ class CRM_Core_BAO_CustomValue extends CRM_Core_DAO {
     );
   }
 
+  /**
+   * ACL clause for an APIv4 custom pseudo-entity (aka multi-record custom group extending Contact).
+   * @return array
+   */
+  public function addSelectWhereClause() {
+    $clauses = [
+      'entity_id' => CRM_Utils_SQL::mergeSubquery('Contact'),
+    ];
+    CRM_Utils_Hook::selectWhereClause($this, $clauses);
+    return $clauses;
+  }
+
 }
index 8e91d0f68b98330bb7f2df160de05c4728ac3e45..97fac0a1333ea2c435a57780f5bc5e6432314114 100644 (file)
@@ -63,7 +63,7 @@ abstract class SelectQuery {
   /**
    * @var array
    */
-  protected $entityFieldNames;
+  protected $entityFieldNames = [];
   /**
    * @var array
    */
index a5c6c53fbd2e75a7fb6241b85cef65169ccdf63b..e841c566aca2ebb0d541681b29047add928aac4c 100644 (file)
@@ -33,7 +33,7 @@ class GetLinks extends \Civi\Api4\Generic\BasicGetAction {
     foreach ($schema->getTables() as $table) {
       $entity = CoreUtil::getApiNameFromTableName($table->getName());
       // Since this is an api function, exclude tables that don't have an api
-      if (class_exists('\Civi\Api4\\' . $entity)) {
+      if (strpos($entity, 'Custom_') === 0 || class_exists('\Civi\Api4\\' . $entity)) {
         $item = [
           'entity' => $entity,
           'table' => $table->getName(),
index 236cb3fc4ddc4fc6e958c5534a039aac58015f55..acf90cdcf5398ccfa862f3d9a4cc584104ecbbd6 100644 (file)
@@ -76,7 +76,7 @@ trait CustomValueActionTrait {
    * @inheritDoc
    */
   protected function deleteObjects($items) {
-    $customTable = CoreUtil::getCustomTableByName($this->getCustomGroup());
+    $customTable = CoreUtil::getTableName($this->getEntityName());
     $ids = [];
     foreach ($items as $item) {
       \CRM_Utils_Hook::pre('delete', $this->getEntityName(), $item['id'], \CRM_Core_DAO::$_nullArray);
index 80208522b4521c44199900dcd0a0689fee12f72a..9dffe7a734959810dd1e817973aa6aa884e7f8e6 100644 (file)
@@ -16,7 +16,6 @@ use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
 use Civi\Api4\Utils\FormattingUtil;
 use Civi\Api4\Utils\CoreUtil;
 use Civi\Api4\Utils\SelectUtil;
-use CRM_Core_DAO_AllCoreTables as AllCoreTables;
 
 /**
  * A query `node` may be in one of three formats:
@@ -81,14 +80,14 @@ class Api4SelectQuery extends SelectQuery {
     if ($apiGet->getDebug()) {
       $this->debugOutput =& $apiGet->_debugOutput;
     }
-    $baoName = CoreUtil::getBAOFromApiName($this->entity);
-    $this->entityFieldNames = array_column($baoName::fields(), 'name');
-    foreach ($apiGet->entityFields() as $path => $field) {
+    foreach ($apiGet->entityFields() as $field) {
+      $this->entityFieldNames[] = $field['name'];
       $field['sql_name'] = '`' . self::MAIN_TABLE_ALIAS . '`.`' . $field['column_name'] . '`';
-      $this->addSpecField($path, $field);
+      $this->addSpecField($field['name'], $field);
     }
 
-    $this->constructQueryObject($baoName);
+    $baoName = CoreUtil::getBAOFromApiName($this->entity);
+    $this->constructQueryObject();
 
     // Add ACLs first to avoid redundant subclauses
     $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
@@ -443,11 +442,11 @@ class Api4SelectQuery extends SelectQuery {
         $field['is_join'] = TRUE;
         $this->addSpecField($alias . '.' . $field['name'], $field);
       }
-      $conditions = [];
-      foreach (array_merge($join, $this->getJoinConditions($entity, $alias)) as $clause) {
+      $conditions = $this->getJoinConditions($entity, $alias);
+      foreach (array_filter($join) as $clause) {
         $conditions[] = $this->treeWalkClauses($clause, 'ON');
       }
-      $tableName = AllCoreTables::getTableForEntityName($entity);
+      $tableName = CoreUtil::getTableName($entity);
       $this->join($side, $tableName, $alias, $conditions);
     }
   }
@@ -467,18 +466,20 @@ class Api4SelectQuery extends SelectQuery {
     $stack = [NULL, NULL];
     foreach ($this->apiFieldSpec as $name => $field) {
       if ($field['entity'] !== $entity && $field['fk_entity'] === $entity) {
-        $conditions[] = [$name, '=', "$alias.id"];
-        $stack = [$name];
+        $conditions[] = $this->treeWalkClauses([$name, '=', "$alias.id"], 'ON');
       }
       elseif (strpos($name, "$alias.") === 0 && substr_count($name, '.') === 1 &&  $field['fk_entity'] === $this->entity) {
-        $conditions[] = [$name, '=', 'id'];
+        $conditions[] = $this->treeWalkClauses([$name, '=', 'id'], 'ON');
+        $stack = ['id'];
       }
     }
     // Hmm, if we came up with > 1 condition, then it's ambiguous how it should be joined so we won't return anything but the generic ACLs
     if (count($conditions) > 1) {
-      return $this->getAclClause($alias, AllCoreTables::getFullName($entity), [NULL, NULL]);
+      $stack = [NULL, NULL];
+      $conditions = [];
     }
-    $acls = $this->getAclClause($alias, AllCoreTables::getFullName($entity), $stack);
+    $baoName = CoreUtil::getBAOFromApiName($entity);
+    $acls = array_values($this->getAclClause($alias, $baoName, $stack));
     return array_merge($acls, $conditions);
   }
 
@@ -531,7 +532,7 @@ class Api4SelectQuery extends SelectQuery {
    * @return FALSE|string
    */
   public function getFrom() {
-    return AllCoreTables::getTableForClass(AllCoreTables::getFullName($this->entity));
+    return CoreUtil::getTableName($this->entity);
   }
 
   /**
@@ -635,19 +636,11 @@ class Api4SelectQuery extends SelectQuery {
   /**
    * Get table name on basis of entity
    *
-   * @param string $baoName
-   *
    * @return void
    */
-  public function constructQueryObject($baoName) {
-    if (strstr($this->entity, 'Custom_')) {
-      $this->query = \CRM_Utils_SQL_Select::from(CoreUtil::getCustomTableByName(str_replace('Custom_', '', $this->entity)) . ' ' . self::MAIN_TABLE_ALIAS);
-      $this->entityFieldNames = array_keys($this->apiFieldSpec);
-    }
-    else {
-      $bao = new $baoName();
-      $this->query = \CRM_Utils_SQL_Select::from($bao->tableName() . ' ' . self::MAIN_TABLE_ALIAS);
-    }
+  public function constructQueryObject() {
+    $tableName = CoreUtil::getTableName($this->entity);
+    $this->query = \CRM_Utils_SQL_Select::from($tableName . ' ' . self::MAIN_TABLE_ALIAS);
   }
 
   /**
index 684784f54fc3a97802897a523148ac7ebdf97e56..b6393755e0ae6bf0c85a760737b0de05f0399a4c 100644 (file)
@@ -110,10 +110,13 @@ class SchemaMapBuilder {
       foreach ($table->getTableLinks() as $link) {
         $target = $map->getTableByName($link->getTargetTable());
         $tableName = $link->getBaseTable();
-        $plural = str_replace('civicrm_', '', $this->getPlural($tableName));
-        $joinable = new Joinable($tableName, $link->getBaseColumn(), $plural);
-        $joinable->setJoinType($joinable::JOIN_TYPE_ONE_TO_MANY);
-        $target->addTableLink($link->getTargetColumn(), $joinable);
+        // Exclude custom field tables
+        if (strpos($link->getTargetTable(), 'civicrm_value_') !== 0) {
+          $plural = str_replace('civicrm_', '', $this->getPlural($tableName));
+          $joinable = new Joinable($tableName, $link->getBaseColumn(), $plural);
+          $joinable->setJoinType($joinable::JOIN_TYPE_ONE_TO_MANY);
+          $target->addTableLink($link->getTargetColumn(), $joinable);
+        }
       }
     }
   }
@@ -178,6 +181,12 @@ class SchemaMapBuilder {
       $links[$alias]['tableName'] = $tableName;
       $links[$alias]['isMultiple'] = !empty($fieldData->is_multiple);
       $links[$alias]['columns'][$fieldData->name] = $fieldData->column_name;
+
+      // Add backreference
+      if (!empty($fieldData->is_multiple)) {
+        $joinable = new Joinable($baseTable->getName(), 'id', AllCoreTables::convertEntityNameToLower($entity));
+        $customTable->addTableLink('entity_id', $joinable);
+      }
     }
 
     foreach ($links as $alias => $link) {
index 798779a8798978ce138a8a601a6fbce079f75e81..22d730be53e29a909a3362ffa362a73e23736586 100644 (file)
@@ -138,6 +138,7 @@ class SpecGatherer {
    */
   private function getCustomGroupFields($customGroup, RequestSpec $specification) {
     $customFields = CustomField::get()
+      ->setCheckPermissions(FALSE)
       ->addWhere('custom_group.name', '=', $customGroup)
       ->setSelect(['custom_group.name', 'custom_group.table_name', '*'])
       ->execute();
index 9055a63149667070edbfdaf59b87b8c5787bd9f2..1f4c7766a4b44f9a925d79e81b5a55082785c544 100644 (file)
@@ -21,7 +21,6 @@
 
 namespace Civi\Api4\Utils;
 
-use Civi\Api4\CustomGroup;
 use CRM_Core_DAO_AllCoreTables as AllCoreTables;
 
 require_once 'api/v3/utils.php';
@@ -39,34 +38,39 @@ class CoreUtil {
    */
   public static function getBAOFromApiName($entityName) {
     if ($entityName === 'CustomValue' || strpos($entityName, 'Custom_') === 0) {
-      return 'CRM_Contact_BAO_Contact';
+      return 'CRM_Core_BAO_CustomValue';
     }
     return \_civicrm_api3_get_BAO($entityName);
   }
 
   /**
-   * Get table name of given Custom group
+   * Get table name of given entity
    *
-   * @param string $customGroupName
+   * @param string $entityName
    *
    * @return string
    */
-  public static function getCustomTableByName($customGroupName) {
-    return CustomGroup::get()
-      ->addSelect('table_name')
-      ->addWhere('name', '=', $customGroupName)
-      ->execute()
-      ->first()['table_name'];
+  public static function getTableName($entityName) {
+    if (strpos($entityName, 'Custom_') === 0) {
+      $customGroup = substr($entityName, 7);
+      return \CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomGroup', $customGroup, 'table_name', 'name');
+    }
+    return AllCoreTables::getTableForEntityName($entityName);
   }
 
   /**
    * Given a sql table name, return the name of the api entity.
    *
    * @param $tableName
-   * @return string
+   * @return string|NULL
    */
   public static function getApiNameFromTableName($tableName) {
-    return AllCoreTables::getBriefName(AllCoreTables::getClassForTable($tableName));
+    $entityName = AllCoreTables::getBriefName(AllCoreTables::getClassForTable($tableName));
+    if (!$entityName) {
+      $customGroup = \CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomGroup', $tableName, 'name', 'table_name');
+      $entityName = $customGroup ? "Custom_$customGroup" : NULL;
+    }
+    return $entityName;
   }
 
 }
index c5db7e2ecd1709affa845637b12be0bf656f2393..3035ac86687d00b75335a68cf3ccc6a05c3731f6 100644 (file)
@@ -9,6 +9,11 @@
  +--------------------------------------------------------------------+
  */
 
+use Civi\Api4\Contact;
+use Civi\Api4\CustomField;
+use Civi\Api4\CustomGroup;
+use Civi\Api4\CustomValue;
+
 /**
  * This class is intended to test ACL permission using the multisite module
  *
@@ -1001,4 +1006,64 @@ class api_v3_ACLPermissionTest extends CiviUnitTestCase {
     $this->assertFalse(isset($result['values'][$tag2][$createdFirstName]));
   }
 
+  public function testApi4CustomEntityACL() {
+    $group = uniqid('mg');
+    $textField = uniqid('tx');
+
+    CustomGroup::create()
+      ->setCheckPermissions(FALSE)
+      ->addValue('name', $group)
+      ->addValue('extends', 'Contact')
+      ->addValue('is_multiple', TRUE)
+      ->addChain('field', CustomField::create()
+        ->addValue('label', $textField)
+        ->addValue('custom_group_id', '$id')
+        ->addValue('html_type', 'Text')
+        ->addValue('data_type', 'String')
+      )
+      ->execute();
+
+    $this->createLoggedInUser();
+    $c1 = $this->individualCreate(['first_name' => 'C1']);
+    $c2 = $this->individualCreate(['first_name' => 'C2', 'is_deleted' => 1], 1);
+
+    CustomValue::save($group)->setCheckPermissions(FALSE)
+      ->addRecord(['entity_id' => $c1, $textField => '1'])
+      ->addRecord(['entity_id' => $c2, $textField => '2'])
+      ->execute();
+
+    $this->setPermissions(['access CiviCRM', 'view debug output']);
+    $this->hookClass->setHook('civicrm_aclWhereClause', [$this, 'aclWhereHookAllResults']);
+
+    // Without "access deleted contacts" we won't see C2
+    $vals = CustomValue::get($group)->setDebug(TRUE)->execute();
+    $this->assertCount(1, $vals);
+    $this->assertEquals($c1, $vals[0]['entity_id']);
+
+    $this->setPermissions(['access CiviCRM', 'access deleted contacts', 'view debug output']);
+    $this->hookClass->setHook('civicrm_aclWhereClause', [$this, 'aclWhereHookAllResults']);
+    $this->cleanupCachedPermissions();
+
+    $vals = CustomValue::get($group)->execute();
+    $this->assertCount(2, $vals);
+
+    $this->allowedContactId = $c2;
+    $this->hookClass->setHook('civicrm_aclWhereClause', [$this, 'aclWhereOnlyOne']);
+    $this->cleanupCachedPermissions();
+
+    $vals = CustomValue::get($group)->addSelect('*', 'contact.first_name')->execute();
+    $this->assertCount(1, $vals);
+    $this->assertEquals($c2, $vals[0]['entity_id']);
+    $this->assertEquals('C2', $vals[0]['contact.first_name']);
+
+    $vals = Contact::get()
+      ->addJoin('Custom_' . $group . ' AS cf')
+      ->addSelect('first_name', 'cf.' . $textField)
+      ->addWhere('is_deleted', '=', TRUE)
+      ->execute();
+    $this->assertCount(1, $vals);
+    $this->assertEquals('C2', $vals[0]['first_name']);
+    $this->assertEquals('2', $vals[0]['cf.' . $textField]);
+  }
+
 }