From 63f3df59810097f69b0c9503cb725d95c43d43f1 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Tue, 23 Nov 2021 16:02:43 -0500 Subject: [PATCH] APIv4 - Enrich EntityBridge metadata to permit more than 2 bridge joins This allows the RelationshipCache entity to bridge not only Contact to Contact but also Contact to Case. --- Civi/Api4/CaseContact.php | 20 ++++ Civi/Api4/EntityFinancialAccount.php | 4 +- Civi/Api4/EntityFinancialTrxn.php | 4 +- Civi/Api4/Generic/Traits/EntityBridge.php | 11 +- Civi/Api4/GroupContact.php | 10 +- Civi/Api4/Query/Api4SelectQuery.php | 29 +++-- Civi/Api4/RelationshipCache.php | 13 ++- ext/search_kit/Civi/Search/Admin.php | 93 ++++++++-------- .../tests/phpunit/Civi/Search/AdminTest.php | 101 ++++++++++++++++++ tests/phpunit/api/v4/Action/FkJoinTest.php | 31 ++++++ tests/phpunit/api/v4/Entity/CaseTest.php | 25 ++++- 11 files changed, 270 insertions(+), 71 deletions(-) create mode 100644 ext/search_kit/tests/phpunit/Civi/Search/AdminTest.php diff --git a/Civi/Api4/CaseContact.php b/Civi/Api4/CaseContact.php index b3e3405183..da651d499f 100644 --- a/Civi/Api4/CaseContact.php +++ b/Civi/Api4/CaseContact.php @@ -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; + } + } diff --git a/Civi/Api4/EntityFinancialAccount.php b/Civi/Api4/EntityFinancialAccount.php index b5495e3d6e..4be18592aa 100644 --- a/Civi/Api4/EntityFinancialAccount.php +++ b/Civi/Api4/EntityFinancialAccount.php @@ -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; } diff --git a/Civi/Api4/EntityFinancialTrxn.php b/Civi/Api4/EntityFinancialTrxn.php index 0c7172d35e..091c858c33 100644 --- a/Civi/Api4/EntityFinancialTrxn.php +++ b/Civi/Api4/EntityFinancialTrxn.php @@ -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; } diff --git a/Civi/Api4/Generic/Traits/EntityBridge.php b/Civi/Api4/Generic/Traits/EntityBridge.php index 2b5807b67e..2f08289cfb 100644 --- a/Civi/Api4/Generic/Traits/EntityBridge.php +++ b/Civi/Api4/Generic/Traits/EntityBridge.php @@ -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; } diff --git a/Civi/Api4/GroupContact.php b/Civi/Api4/GroupContact.php index cb7259ef9e..13fa4e6637 100644 --- a/Civi/Api4/GroupContact.php +++ b/Civi/Api4/GroupContact.php @@ -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; } diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php index 29f9345cbb..b8240078b9 100644 --- a/Civi/Api4/Query/Api4SelectQuery.php +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -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"); } /** diff --git a/Civi/Api4/RelationshipCache.php b/Civi/Api4/RelationshipCache.php index e967d2373f..682b3ff74e 100644 --- a/Civi/Api4/RelationshipCache.php +++ b/Civi/Api4/RelationshipCache.php @@ -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; } diff --git a/ext/search_kit/Civi/Search/Admin.php b/ext/search_kit/Civi/Search/Admin.php index c1a8c39f75..f2748fb0b4 100644 --- a/ext/search_kit/Civi/Search/Admin.php +++ b/ext/search_kit/Civi/Search/Admin.php @@ -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 index 0000000000..9aeb06424c --- /dev/null +++ b/ext/search_kit/tests/phpunit/Civi/Search/AdminTest.php @@ -0,0 +1,101 @@ +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'] + ); + } + +} diff --git a/tests/phpunit/api/v4/Action/FkJoinTest.php b/tests/phpunit/api/v4/Action/FkJoinTest.php index 960b638ad4..a9ae5664a6 100644 --- a/tests/phpunit/api/v4/Action/FkJoinTest.php +++ b/tests/phpunit/api/v4/Action/FkJoinTest.php @@ -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']); + } + } diff --git a/tests/phpunit/api/v4/Entity/CaseTest.php b/tests/phpunit/api/v4/Entity/CaseTest.php index 969e19c414..b493607ae6 100644 --- a/tests/phpunit/api/v4/Entity/CaseTest.php +++ b/tests/phpunit/api/v4/Entity/CaseTest.php @@ -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']); } } -- 2.25.1