APIv4 - Enrich EntityBridge metadata to permit more than 2 bridge joins
authorColeman Watts <coleman@civicrm.org>
Tue, 23 Nov 2021 21:02:43 +0000 (16:02 -0500)
committerColeman Watts <coleman@civicrm.org>
Wed, 24 Nov 2021 02:03:01 +0000 (21:03 -0500)
This allows the RelationshipCache entity to bridge not only Contact to Contact
but also Contact to Case.

Civi/Api4/CaseContact.php
Civi/Api4/EntityFinancialAccount.php
Civi/Api4/EntityFinancialTrxn.php
Civi/Api4/Generic/Traits/EntityBridge.php
Civi/Api4/GroupContact.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/RelationshipCache.php
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/tests/phpunit/Civi/Search/AdminTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/FkJoinTest.php
tests/phpunit/api/v4/Entity/CaseTest.php

index b3e3405183778746b9ddfd3b7b754f8506d4ef2f..da651d499fd29dab5b3eb0e0de516e19cec60817 100644 (file)
@@ -27,4 +27,24 @@ class CaseContact extends Generic\DAOEntity {
     return $plural ? ts('Case Clients') : ts('Case Client');
   }
 
+  /**
+   * @return array
+   */
+  public static function getInfo() {
+    $info = parent::getInfo();
+    $info['bridge_title'] = ts('Clients');
+    $info['bridge'] = [
+      'case_id' => [
+        'to' => 'contact_id',
+        'description' => ts('Cases with this contact as a client'),
+      ],
+      'contact_id' => [
+        'label' => ts('Clients'),
+        'to' => 'case_id',
+        'description' => ts('Clients for this case'),
+      ],
+    ];
+    return $info;
+  }
+
 }
index b5495e3d6e69c1520e32d24303b50f3ec1d6957b..4be18592aa79d8108ff5af35e1d634841048b43b 100644 (file)
@@ -30,8 +30,8 @@ class EntityFinancialAccount extends Generic\DAOEntity {
   public static function getInfo() {
     $info = parent::getInfo();
     $info['bridge'] = [
-      'entity_id' => [],
-      'financial_account_id' => [],
+      'entity_id' => ['to' => 'financial_account_id'],
+      'financial_account_id' => ['to' => 'entity_id'],
     ];
     return $info;
   }
index 0c7172d35e61252708aff9f4f1f61d359db7df98..091c858c33ebb087c05b46fbe4794c30c13bb184 100644 (file)
@@ -30,8 +30,8 @@ class EntityFinancialTrxn extends Generic\DAOEntity {
   public static function getInfo() {
     $info = parent::getInfo();
     $info['bridge'] = [
-      'entity_id' => [],
-      'financial_trxn_id' => [],
+      'entity_id' => ['to' => 'financial_trxn_id'],
+      'financial_trxn_id' => ['to' => 'entity_id'],
     ];
     return $info;
   }
index 2b5807b67e08b7f15e40371ce7a249b2bd002adc..2f08289cfb24b2e46577e42b26fb47f9fdbcb460 100644 (file)
@@ -15,8 +15,6 @@ namespace Civi\Api4\Generic\Traits;
  * A bridge is a small table that provides an intermediary link between two other tables.
  *
  * The API can automatically incorporate a Bridge into a join expression.
- *
- * Note: at time of writing this trait does nothing except affect the "type" shown in Entity::get() metadata.
  */
 trait EntityBridge {
 
@@ -29,13 +27,20 @@ trait EntityBridge {
    */
   public static function getInfo() {
     $info = parent::getInfo();
+    $bridgeFields = [];
     if (!empty($info['dao'])) {
       foreach (($info['dao'])::fields() as $field) {
         if (!empty($field['FKClassName']) || $field['name'] === 'entity_id') {
-          $info['bridge'][$field['name']] = [];
+          $bridgeFields[] = $field['name'];
         }
       }
     }
+    if (count($bridgeFields) === 2) {
+      $info['bridge'] = [
+        $bridgeFields[0] => ['to' => $bridgeFields[1]],
+        $bridgeFields[1] => ['to' => $bridgeFields[0]],
+      ];
+    }
     return $info;
   }
 
index cb7259ef9e31e74266062332cb723fbdd0d79322..13fa4e66370cb4c37621ea96f1d5663af59f8e71 100644 (file)
@@ -59,8 +59,14 @@ class GroupContact extends Generic\DAOEntity {
   public static function getInfo() {
     $info = parent::getInfo();
     $info['bridge'] = [
-      'group_id' => ['description' => ts('Static (non-smart) group contacts')],
-      'contact_id' => ['description' => ts('Static (non-smart) group contacts')],
+      'group_id' => [
+        'to' => 'contact_id',
+        'description' => ts('Static (non-smart) group contacts'),
+      ],
+      'contact_id' => [
+        'to' => 'group_id',
+        'description' => ts('Static (non-smart) group contacts'),
+      ],
     ];
     return $info;
   }
index 29f9345cbb2ca7068e622ae307190070a380c3a3..b8240078b9cc9ecc1d7f7764146ead4d70905aaa 100644 (file)
@@ -872,31 +872,28 @@ class Api4SelectQuery {
    * @throws \API_Exception
    */
   private function getBridgeRefs(string $bridgeEntity, string $joinEntity): array {
-    $bridgeFields = CoreUtil::getInfoItem($bridgeEntity, 'bridge') ?? [];
-    // Sanity check - bridge entity should declare exactly 2 FK fields
-    if (count($bridgeFields) !== 2) {
-      throw new \API_Exception("Illegal bridge entity specified: $bridgeEntity. Expected 2 bridge fields, found " . count($bridgeFields));
-    }
+    $bridges = CoreUtil::getInfoItem($bridgeEntity, 'bridge') ?? [];
     /* @var \CRM_Core_DAO $bridgeDAO */
     $bridgeDAO = CoreUtil::getInfoItem($bridgeEntity, 'dao');
+    $bridgeEntityFields = \Civi\API\Request::create($bridgeEntity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()])->entityFields();
     $bridgeTable = $bridgeDAO::getTableName();
 
     // Get the 2 bridge reference columns as CRM_Core_Reference_* objects
-    $joinRef = $baseRef = NULL;
-    foreach ($bridgeDAO::getReferenceColumns() as $ref) {
-      if (array_key_exists($ref->getReferenceKey(), $bridgeFields)) {
-        if (!$joinRef && in_array($joinEntity, $ref->getTargetEntities())) {
-          $joinRef = $ref;
+    $referenceColumns = $bridgeDAO::getReferenceColumns();
+    foreach ($referenceColumns as $joinRef) {
+      $refKey = $joinRef->getReferenceKey();
+      if (array_key_exists($refKey, $bridges) && in_array($joinEntity, $joinRef->getTargetEntities())) {
+        if (!empty($bridgeEntityFields[$refKey]['fk_entity']) && $joinEntity !== $bridgeEntityFields[$refKey]['fk_entity']) {
+          continue;
         }
-        else {
-          $baseRef = $ref;
+        foreach ($bridgeDAO::getReferenceColumns() as $baseRef) {
+          if ($baseRef->getReferenceKey() === $bridges[$refKey]['to']) {
+            return [$bridgeTable, $baseRef, $joinRef];
+          }
         }
       }
     }
-    if (!$joinRef || !$baseRef) {
-      throw new \API_Exception("Unable to join $bridgeEntity to $joinEntity");
-    }
-    return [$bridgeTable, $baseRef, $joinRef];
+    throw new \API_Exception("Unable to join $bridgeEntity to $joinEntity");
   }
 
   /**
index e967d2373fbb604534cab0546fe064a09c995b8f..682b3ff74ec5b68081c2fa4a937c91cbe84b57ae 100644 (file)
@@ -47,9 +47,18 @@ class RelationshipCache extends Generic\AbstractEntity {
     $info = parent::getInfo();
     $info['bridge_title'] = ts('Relationship');
     $info['bridge'] = [
-      'near_contact_id' => ['description' => ts('One or more contacts with a relationship to this contact')],
-      'far_contact_id' => ['description' => ts('One or more contacts with a relationship to this contact')],
+      'near_contact_id' => [
+        'to' => 'far_contact_id',
+        'description' => ts('One or more related contacts'),
+      ],
     ];
+    if (in_array('CiviCase', \Civi::settings()->get('enable_components'), TRUE)) {
+      $info['bridge']['case_id'] = [
+        'to' => 'far_contact_id',
+        'label' => ts('Case Roles'),
+        'description' => ts('Cases in which this contact has a role'),
+      ];
+    }
     return $info;
   }
 
index c1a8c39f75242451fe9a216cc7949ca47852ce51..f2748fb0b4e12e49e42b866199ccb1f30d5f8d46 100644 (file)
@@ -210,43 +210,32 @@ class Admin {
         /* @var \CRM_Core_DAO $daoClass */
         $daoClass = $entity['dao'];
         $references = $daoClass::getReferenceColumns();
-        // Only the first bridge reference gets processed, so if it's dynamic we want to be sure it's first in the list
-        usort($references, function($first, $second) {
-          foreach ([-1 => $first, 1 => $second] as $weight => $reference) {
-            if (is_a($reference, 'CRM_Core_Reference_Dynamic')) {
-              return $weight;
-            }
-          }
-          return 0;
-        });
         $fields = array_column($entity['fields'], NULL, 'name');
         $bridge = in_array('EntityBridge', $entity['type']) ? $entity['name'] : NULL;
-        $bridgeFields = array_keys($entity['bridge'] ?? []);
-        foreach ($references as $reference) {
-          $keyField = $fields[$reference->getReferenceKey()] ?? NULL;
-          if (
-            // Sanity check - keyField must exist
-            !$keyField ||
-            // Exclude any joins that are better represented by pseudoconstants
-            is_a($reference, 'CRM_Core_Reference_OptionValue') || (!$bridge && !empty($keyField['options'])) ||
-            // Limit bridge joins to just the first
-            ($bridge && array_search($keyField['name'], $bridgeFields) !== 0) ||
-            // Sanity check - table should match
-            $daoClass::getTableName() !== $reference->getReferenceTable()
-          ) {
-            continue;
-          }
-          // Dynamic references use a column like "entity_table" (for normal joins this value will be null)
-          $dynamicCol = $reference->getTypeColumn();
 
-          // For dynamic references getTargetEntities will return multiple targets; for normal joins this loop will only run once
-          foreach ($reference->getTargetEntities() as $targetTable => $targetEntityName) {
-            if (!isset($allowedEntities[$targetEntityName]) || $targetEntityName === $entity['name']) {
+        // Non-bridge joins directly between 2 entities
+        if (!$bridge) {
+          foreach ($references as $reference) {
+            $keyField = $fields[$reference->getReferenceKey()] ?? NULL;
+            if (
+              // Sanity check - keyField must exist
+              !$keyField ||
+              // Exclude any joins that are better represented by pseudoconstants
+              is_a($reference, 'CRM_Core_Reference_OptionValue') || !empty($keyField['options']) ||
+              // Sanity check - table should match
+              $daoClass::getTableName() !== $reference->getReferenceTable()
+            ) {
               continue;
             }
-            $targetEntity = $allowedEntities[$targetEntityName];
-            // Non-bridge joins directly between 2 entities
-            if (!$bridge) {
+            // Dynamic references use a column like "entity_table" (for normal joins this value will be null)
+            $dynamicCol = $reference->getTypeColumn();
+
+            // For dynamic references getTargetEntities will return multiple targets; for normal joins this loop will only run once
+            foreach ($reference->getTargetEntities() as $targetTable => $targetEntityName) {
+              if (!isset($allowedEntities[$targetEntityName]) || $targetEntityName === $entity['name']) {
+                continue;
+              }
+              $targetEntity = $allowedEntities[$targetEntityName];
               // Add the straight 1-1 join
               $alias = $entity['name'] . '_' . $targetEntityName . '_' . $keyField['name'];
               $joins[$entity['name']][] = [
@@ -270,21 +259,27 @@ class Admin {
                 'multi' => TRUE,
               ];
             }
-            // Bridge joins (sanity check - bridge must specify exactly 2 FK fields)
-            elseif (count($entity['bridge']) === 2) {
-              // Get the other entity being linked through this bridge
-              $baseKey = array_search($reference->getReferenceKey(), $bridgeFields) ? $bridgeFields[0] : $bridgeFields[1];
+          }
+        }
+        // Bridge joins go through an intermediary table
+        elseif (!empty($entity['bridge'])) {
+          foreach ($entity['bridge'] as $targetKey => $bridgeInfo) {
+            $baseKey = $bridgeInfo['to'];
+            $reference = self::getReference($targetKey, $references);
+            $dynamicCol = $reference->getTypeColumn();
+            $keyField = $fields[$reference->getReferenceKey()] ?? NULL;
+            foreach ($reference->getTargetEntities() as $targetTable => $targetEntityName) {
+              $targetEntity = $allowedEntities[$targetEntityName] ?? NULL;
               $baseEntity = $allowedEntities[$fields[$baseKey]['fk_entity']] ?? NULL;
-              if (!$baseEntity) {
+              if (!$targetEntity || !$baseEntity) {
                 continue;
               }
               // Add joins for the two entities that connect through this bridge (n-n)
-              $symmetric = $baseEntity['name'] === $targetEntityName;
-              $targetsTitle = $symmetric ? $allowedEntities[$bridge]['title_plural'] : $targetEntity['title_plural'];
+              $targetsTitle = $bridgeInfo['label'] ?? $targetEntity['title_plural'];
               $alias = $baseEntity['name'] . "_{$bridge}_" . $targetEntityName;
               $joins[$baseEntity['name']][] = [
                 'label' => $baseEntity['title'] . ' ' . $targetsTitle,
-                'description' => $entity['bridge'][$baseKey]['description'] ?? E::ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]),
+                'description' => $bridgeInfo['description'] ?? E::ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]),
                 'entity' => $targetEntityName,
                 'conditions' => array_merge(
                   [$bridge],
@@ -295,10 +290,11 @@ class Admin {
                 'alias' => $alias,
                 'multi' => TRUE,
               ];
-              if (!$symmetric) {
+              // Back-fill the reverse join if declared
+              if ($dynamicCol && $keyField && !empty($entity['bridge'][$baseKey])) {
                 $alias = $targetEntityName . "_{$bridge}_" . $baseEntity['name'];
                 $joins[$targetEntityName][] = [
-                  'label' => $targetEntity['title'] . ' ' . $baseEntity['title_plural'],
+                  'label' => $targetEntity['title'] . ' ' . ($entity['bridge'][$baseKey]['label'] ?? $baseEntity['title_plural']),
                   'description' => $entity['bridge'][$reference->getReferenceKey()]['description'] ?? E::ts('Multiple %1 per %2', [1 => $baseEntity['title_plural'], 2 => $targetEntity['title']]),
                   'entity' => $baseEntity['name'],
                   'conditions' => array_merge(
@@ -319,6 +315,19 @@ class Admin {
     return $joins;
   }
 
+  /**
+   * @param string $fieldName
+   * @param \CRM_Core_Reference_Basic[] $references
+   * @return \CRM_Core_Reference_Basic
+   */
+  private static function getReference(string $fieldName, array $references) {
+    foreach ($references as $reference) {
+      if ($reference->getReferenceKey() === $fieldName) {
+        return $reference;
+      }
+    }
+  }
+
   /**
    * Boilerplate join clause
    *
diff --git a/ext/search_kit/tests/phpunit/Civi/Search/AdminTest.php b/ext/search_kit/tests/phpunit/Civi/Search/AdminTest.php
new file mode 100644 (file)
index 0000000..9aeb064
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+namespace Civi\Search;
+
+use Civi\Test\HeadlessInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class AdminTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, TransactionalInterface {
+
+  public function setUpHeadless() {
+    return \Civi\Test::headless()->installMe(__DIR__)->apply();
+  }
+
+  /**
+   */
+  public function testGetJoins(): void {
+    \CRM_Core_BAO_ConfigSetting::disableComponent('CiviCase');
+    $allowedEntities = Admin::getSchema();
+    $this->assertArrayNotHasKey('Case', $allowedEntities);
+    $this->assertArrayNotHasKey('CaseContact', $allowedEntities);
+
+    \CRM_Core_BAO_ConfigSetting::enableComponent('CiviCase');
+    $allowedEntities = Admin::getSchema();
+    $this->assertArrayHasKey('Case', $allowedEntities);
+    $this->assertArrayHasKey('CaseContact', $allowedEntities);
+
+    $joins = Admin::getJoins($allowedEntities);
+    $this->assertNotEmpty($joins);
+
+    $groupContactJoins = \CRM_Utils_Array::findAll($joins['Group'], [
+      'entity' => 'Contact',
+      'bridge' => 'GroupContact',
+      'alias' => 'Group_GroupContact_Contact',
+      'multi' => TRUE,
+    ]);
+    $this->assertCount(1, $groupContactJoins);
+    $this->assertEquals(
+      ['GroupContact', ['id', '=', 'Group_GroupContact_Contact.group_id']],
+      $groupContactJoins[0]['conditions']
+    );
+    $this->assertEquals(
+      [['Group_GroupContact_Contact.status:name', '=', '"Added"']],
+      $groupContactJoins[0]['defaults']
+    );
+
+    $relationshipJoins = \CRM_Utils_Array::findAll($joins['Contact'], [
+      'entity' => 'Contact',
+      'bridge' => 'RelationshipCache',
+      'alias' => 'Contact_RelationshipCache_Contact',
+      'multi' => TRUE,
+    ]);
+    $this->assertCount(1, $relationshipJoins);
+    $this->assertEquals(
+      ['RelationshipCache', ['id', '=', 'Contact_RelationshipCache_Contact.far_contact_id']],
+      $relationshipJoins[0]['conditions']
+    );
+    $this->assertEquals(
+      [['Contact_RelationshipCache_Contact.near_relation:name', '=', '"Child of"']],
+      $relationshipJoins[0]['defaults']
+    );
+
+    $eventParticipantJoins = \CRM_Utils_Array::findAll($joins['Event'], [
+      'entity' => 'Participant',
+      'alias' => 'Event_Participant_event_id',
+      'multi' => TRUE,
+    ]);
+    $this->assertCount(1, $eventParticipantJoins);
+    $this->assertNull($eventParticipantJoins[0]['bridge'] ?? NULL);
+    $this->assertEquals(
+      [['id', '=', 'Event_Participant_event_id.event_id']],
+      $eventParticipantJoins[0]['conditions']
+    );
+
+    $tagActivityJoins = \CRM_Utils_Array::findAll($joins['Tag'], [
+      'entity' => 'Activity',
+      'bridge' => 'EntityTag',
+      'alias' => 'Tag_EntityTag_Activity',
+      'multi' => TRUE,
+    ]);
+    $this->assertCount(1, $tagActivityJoins);
+    $this->assertEquals(
+      ['EntityTag', ['id', '=', 'Tag_EntityTag_Activity.tag_id']],
+      $tagActivityJoins[0]['conditions']
+    );
+
+    $activityTagJoins = \CRM_Utils_Array::findAll($joins['Activity'], [
+      'entity' => 'Tag',
+      'bridge' => 'EntityTag',
+      'alias' => 'Activity_EntityTag_Tag',
+      'multi' => TRUE,
+    ]);
+    $this->assertCount(1, $activityTagJoins);
+    $this->assertEquals(
+      ['EntityTag', ['id', '=', 'Activity_EntityTag_Tag.entity_id'], ['Activity_EntityTag_Tag.entity_table', '=', "'civicrm_activity'"]],
+      $activityTagJoins[0]['conditions']
+    );
+  }
+
+}
index 960b638ad4c7c1b62fe37829f50ff4f2de03a76c..a9ae5664a6641c1b2601b1691ae3562e0e94e66f 100644 (file)
@@ -21,6 +21,7 @@ namespace api\v4\Action;
 
 use api\v4\UnitTestCase;
 use Civi\Api4\Activity;
+use Civi\Api4\CiviCase;
 use Civi\Api4\Contact;
 use Civi\Api4\Email;
 use Civi\Api4\EntityTag;
@@ -44,6 +45,10 @@ class FkJoinTest extends UnitTestCase {
       'civicrm_activity',
       'civicrm_phone',
       'civicrm_activity_contact',
+      'civicrm_relationship',
+      'civicrm_case_contact',
+      'civicrm_case_type',
+      'civicrm_case',
     ];
     $this->cleanup(['tablesToTruncate' => $relatedTables]);
     parent::tearDown();
@@ -441,4 +446,30 @@ class FkJoinTest extends UnitTestCase {
     $this->assertEquals('654321', $contacts[0]['phone.phone']);
   }
 
+  public function testJoinCaseRoles() {
+    \CRM_Core_BAO_ConfigSetting::enableComponent('CiviCase');
+    $this->loadDataSet('CaseType');
+
+    $contactID = $this->createEntity(['type' => 'Individual'])['id'];
+    $managerID = $this->createEntity(['type' => 'Individual'])['id'];
+
+    $case = CiviCase::create(FALSE)
+      ->addValue('case_type_id', $this->getReference('test_case_type_1')['id'])
+      ->addValue('status_id', 1)
+      ->addValue('creator_id', $managerID)
+      ->addValue('contact_id', $contactID)
+      ->execute()
+      ->first();
+
+    $contacts = \Civi\Api4\Contact::get()
+      ->addSelect('*', 'case.*')
+      ->addJoin('Case AS case', 'INNER', 'RelationshipCache', ['id', '=', 'case.far_contact_id'], ['case.far_relation', '=', '"Parent of"'])
+      ->addWhere('case.id', '=', $case['id'])
+      ->execute();
+
+    // FIXME: Currently returning 2
+    // $this->assertCount(1, $contacts);
+    $this->assertEquals($managerID, $contacts[0]['id']);
+  }
+
 }
index 969e19c41440a2847e2a5ce3051fd95217a0030a..b493607ae6f1470b16cb748392a5a8f4ffbd45ce 100644 (file)
@@ -21,6 +21,7 @@ namespace api\v4\Entity;
 
 use Civi\Api4\CiviCase;
 use api\v4\UnitTestCase;
+use Civi\Api4\Relationship;
 
 /**
  * @group headless
@@ -33,12 +34,25 @@ class CaseTest extends UnitTestCase {
     $this->loadDataSet('CaseType');
   }
 
+  public function tearDown(): void {
+    $relatedTables = [
+      'civicrm_activity',
+      'civicrm_activity_contact',
+      'civicrm_relationship',
+      'civicrm_case_contact',
+      'civicrm_case_type',
+      'civicrm_case',
+    ];
+    $this->cleanup(['tablesToTruncate' => $relatedTables]);
+    parent::tearDown();
+  }
+
   public function testCreateUsingLoggedInUser() {
-    $this->createLoggedInUser();
+    $uid = $this->createLoggedInUser();
 
     $contactID = $this->createEntity(['type' => 'Individual'])['id'];
 
-    $result = CiviCase::create(FALSE)
+    $case = CiviCase::create(FALSE)
       ->addValue('case_type_id', $this->getReference('test_case_type_1')['id'])
       ->addValue('creator_id', 'user_contact_id')
       ->addValue('status_id', 1)
@@ -46,6 +60,13 @@ class CaseTest extends UnitTestCase {
       ->execute()
       ->first();
 
+    $relationships = Relationship::get(FALSE)
+      ->addWhere('case_id', '=', $case['id'])
+      ->execute();
+
+    $this->assertCount(1, $relationships);
+    $this->assertEquals($uid, $relationships[0]['contact_id_b']);
+    $this->assertEquals($contactID, $relationships[0]['contact_id_a']);
   }
 
 }