Skip to content

Commit

Permalink
APIv4 - Enrich EntityBridge metadata to permit more than 2 bridge joins
Browse files Browse the repository at this point in the history
This allows the RelationshipCache entity to bridge not only Contact to Contact
but also Contact to Case.
  • Loading branch information
colemanw committed Nov 24, 2021
1 parent 1e23ef1 commit 7d83e0a
Show file tree
Hide file tree
Showing 11 changed files with 270 additions and 71 deletions.
20 changes: 20 additions & 0 deletions Civi/Api4/CaseContact.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,24 @@ protected static function getEntityTitle($plural = FALSE) {
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;
}

}
4 changes: 2 additions & 2 deletions Civi/Api4/EntityFinancialAccount.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions Civi/Api4/EntityFinancialTrxn.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
11 changes: 8 additions & 3 deletions Civi/Api4/Generic/Traits/EntityBridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
* 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 {

Expand All @@ -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;
}

Expand Down
10 changes: 8 additions & 2 deletions Civi/Api4/GroupContact.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,14 @@ public static function update($checkPermissions = TRUE) {
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;
}
Expand Down
29 changes: 13 additions & 16 deletions Civi/Api4/Query/Api4SelectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -872,31 +872,28 @@ protected function addBridgeJoin($joinTree, $joinEntity, $alias, $side) {
* @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");
}

/**
Expand Down
13 changes: 11 additions & 2 deletions Civi/Api4/RelationshipCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,18 @@ public static function getInfo() {
$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;
}

Expand Down
93 changes: 51 additions & 42 deletions ext/search_kit/Civi/Search/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -210,43 +210,32 @@ public static function getJoins(array $allowedEntities) {
/* @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']][] = [
Expand All @@ -270,21 +259,27 @@ public static function getJoins(array $allowedEntities) {
'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],
Expand All @@ -295,10 +290,11 @@ public static function getJoins(array $allowedEntities) {
'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(
Expand All @@ -319,6 +315,19 @@ public static function getJoins(array $allowedEntities) {
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
*
Expand Down
101 changes: 101 additions & 0 deletions ext/search_kit/tests/phpunit/Civi/Search/AdminTest.php
Original file line number Diff line number Diff line change
@@ -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']
);
}

}
Loading

0 comments on commit 7d83e0a

Please sign in to comment.