diff --git a/Civi/Api4/ActivityContact.php b/Civi/Api4/ActivityContact.php index 3c306ef13976..e0d39cfb2797 100644 --- a/Civi/Api4/ActivityContact.php +++ b/Civi/Api4/ActivityContact.php @@ -29,6 +29,6 @@ * @see \Civi\Api4\Activity * @package Civi\Api4 */ -class ActivityContact extends Generic\DAOEntity { +class ActivityContact extends Generic\BridgeEntity { } diff --git a/Civi/Api4/DashboardContact.php b/Civi/Api4/DashboardContact.php index 4a4bdc732cee..313327204109 100644 --- a/Civi/Api4/DashboardContact.php +++ b/Civi/Api4/DashboardContact.php @@ -26,6 +26,6 @@ * @see \Civi\Api4\Dashboard * @package Civi\Api4 */ -class DashboardContact extends Generic\DAOEntity { +class DashboardContact extends Generic\BridgeEntity { } diff --git a/Civi/Api4/Entity.php b/Civi/Api4/Entity.php index cdb0ef6f7a89..49f39859ecc6 100644 --- a/Civi/Api4/Entity.php +++ b/Civi/Api4/Entity.php @@ -52,6 +52,11 @@ public static function getFields($checkPermissions = TRUE) { 'name' => 'title', 'description' => 'Localized title', ], + [ + 'name' => 'type', + 'description' => 'Base class for this entity', + 'options' => ['DAOEntity' => 'DAOEntity', 'BasicEntity' => 'BasicEntity', 'BridgeEntity' => 'BridgeEntity', 'AbstractEntity' => 'AbstractEntity'], + ], [ 'name' => 'description', 'description' => 'Description from docblock', diff --git a/Civi/Api4/EntityTag.php b/Civi/Api4/EntityTag.php index e2a48276a019..aeefb3d747e2 100644 --- a/Civi/Api4/EntityTag.php +++ b/Civi/Api4/EntityTag.php @@ -25,6 +25,6 @@ * * @package Civi\Api4 */ -class EntityTag extends Generic\DAOEntity { +class EntityTag extends Generic\BridgeEntity { } diff --git a/Civi/Api4/Generic/AbstractEntity.php b/Civi/Api4/Generic/AbstractEntity.php index eaeb511fb298..f2d0d26d0873 100644 --- a/Civi/Api4/Generic/AbstractEntity.php +++ b/Civi/Api4/Generic/AbstractEntity.php @@ -80,7 +80,7 @@ public static function permissions() { * @return string */ protected static function getEntityName() { - return substr(static::class, strrpos(static::class, '\\') + 1); + return self::stripNamespace(static::class); } /** @@ -126,6 +126,7 @@ public static function getInfo() { $info = [ 'name' => static::getEntityName(), 'title' => static::getEntityTitle(), + 'type' => self::stripNamespace(get_parent_class(static::class)), ]; $reflection = new \ReflectionClass(static::class); $info += ReflectionUtils::getCodeDocs($reflection, NULL, ['entity' => $info['name']]); @@ -133,4 +134,14 @@ public static function getInfo() { return $info; } + /** + * Remove namespace prefix from a class name + * + * @param string $className + * @return string + */ + private static function stripNamespace($className) { + return substr($className, strrpos($className, '\\') + 1); + } + } diff --git a/Civi/Api4/Generic/BridgeEntity.php b/Civi/Api4/Generic/BridgeEntity.php new file mode 100644 index 000000000000..32faea1aa588 --- /dev/null +++ b/Civi/Api4/Generic/BridgeEntity.php @@ -0,0 +1,21 @@ +join[] = $conditions; return $this; diff --git a/Civi/Api4/GroupContact.php b/Civi/Api4/GroupContact.php index 98e0eb626896..d5935967a639 100644 --- a/Civi/Api4/GroupContact.php +++ b/Civi/Api4/GroupContact.php @@ -28,7 +28,7 @@ * * @package Civi\Api4 */ -class GroupContact extends Generic\DAOEntity { +class GroupContact extends Generic\BridgeEntity { /** * @param bool $checkPermissions diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php index b9bedfa86bbc..ea8cad5f3fe2 100644 --- a/Civi/Api4/Query/Api4SelectQuery.php +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -439,7 +439,7 @@ public function getAclClause($tableAlias, $baoName, $stack = []) { // Prevent (most) redundant acl sub clauses if they have already been applied to the main entity. // FIXME: Currently this only works 1 level deep, but tracking through multiple joins would increase complexity // and just doing it for the first join takes care of most acl clause deduping. - if (count($stack) === 1 && in_array($stack[0], $this->aclFields)) { + if (count($stack) === 1 && in_array($stack[0], $this->aclFields, TRUE)) { return []; } $clauses = $baoName::getSelectWhereClause($tableAlias); @@ -494,12 +494,18 @@ private function addExplicitJoins() { // First item in the array is a boolean indicating if the join is required (aka INNER or LEFT). // The rest are join conditions. $side = array_shift($join) ? 'INNER' : 'LEFT'; + // Add all fields from joined entity to spec $joinEntityGet = \Civi\API\Request::create($entity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()]); foreach ($joinEntityGet->entityFields() as $field) { $field['sql_name'] = '`' . $alias . '`.`' . $field['column_name'] . '`'; $this->addSpecField($alias . '.' . $field['name'], $field); } - $conditions = $this->getJoinConditions($entity, $alias); + if (!empty($join[0]) && is_string($join[0]) && \CRM_Utils_Rule::alphanumeric($join[0])) { + $conditions = $this->getBridgeJoin($join, $entity, $alias); + } + else { + $conditions = $this->getJoinConditions($join, $entity, $alias); + } foreach (array_filter($join) as $clause) { $conditions[] = $this->treeWalkClauses($clause, 'ON'); } @@ -511,35 +517,133 @@ private function addExplicitJoins() { /** * Supply conditions for an explicit join. * - * @param $entity - * @param $alias + * @param array $joinTree + * @param string $joinEntity + * @param string $alias * @return array */ - private function getJoinConditions($entity, $alias) { + private function getJoinConditions($joinTree, $joinEntity, $alias) { $conditions = []; // getAclClause() expects a stack of 1-to-1 join fields to help it dedupe, but this is more flexible, // so unless this is a direct 1-to-1 join with the main entity, we'll just hack it // with a padded empty stack to bypass its deduping. $stack = [NULL, NULL]; - foreach ($this->apiFieldSpec as $name => $field) { - if ($field['entity'] !== $entity && $field['fk_entity'] === $entity) { - $conditions[] = $this->treeWalkClauses([$name, '=', "$alias.id"], 'ON'); + // If we're not explicitly referencing the joinEntity ID in the ON clause, search for a default + $explicitId = array_filter($joinTree, function($clause) use ($alias) { + list($sideA, $op, $sideB) = array_pad((array) $clause, 3, NULL); + return $op === '=' && ($sideA === "$alias.id" || $sideB === "$alias.id"); + }); + if (!$explicitId) { + foreach ($this->apiFieldSpec as $name => $field) { + if ($field['entity'] !== $joinEntity && $field['fk_entity'] === $joinEntity) { + $conditions[] = $this->treeWalkClauses([$name, '=', "$alias.id"], 'ON'); + } + elseif (strpos($name, "$alias.") === 0 && substr_count($name, '.') === 1 && $field['fk_entity'] === $this->getEntity()) { + $conditions[] = $this->treeWalkClauses([$name, '=', 'id'], 'ON'); + $stack = ['id']; + } } - elseif (strpos($name, "$alias.") === 0 && substr_count($name, '.') === 1 && $field['fk_entity'] === $this->getEntity()) { - $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) { + $stack = [NULL, NULL]; + $conditions = []; } } - // 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) { - $stack = [NULL, NULL]; - $conditions = []; - } - $baoName = CoreUtil::getBAOFromApiName($entity); + $baoName = CoreUtil::getBAOFromApiName($joinEntity); $acls = array_values($this->getAclClause($alias, $baoName, $stack)); return array_merge($acls, $conditions); } + /** + * Join onto a BridgeEntity table + * + * @param array $joinTree + * @param string $joinEntity + * @param string $alias + * @return array + * @throws \API_Exception + */ + protected function getBridgeJoin(&$joinTree, $joinEntity, $alias) { + $bridgeEntity = array_shift($joinTree); + if (!is_a('\Civi\Api4\\' . $bridgeEntity, '\Civi\Api4\Generic\BridgeEntity', TRUE)) { + throw new \API_Exception("Illegal bridge entity specified: " . $bridgeEntity); + } + $bridgeAlias = $alias . '_via_' . strtolower($bridgeEntity); + $bridgeTable = CoreUtil::getTableName($bridgeEntity); + $joinTable = CoreUtil::getTableName($joinEntity); + $bridgeEntityGet = \Civi\API\Request::create($bridgeEntity, 'get', ['version' => 4, 'checkPermissions' => $this->getCheckPermissions()]); + $fkToJoinField = $fkToBaseField = NULL; + // Find the bridge field that links to the joinEntity (either an explicit FK or an entity_id/entity_table combo) + foreach ($bridgeEntityGet->entityFields() as $name => $field) { + if ($field['fk_entity'] === $joinEntity || (!$fkToJoinField && $name === 'entity_id')) { + $fkToJoinField = $name; + } + } + // Get list of entities allowed for entity_table + if (array_key_exists('entity_id', $bridgeEntityGet->entityFields())) { + $entityTables = (array) civicrm_api4($bridgeEntity, 'getFields', [ + 'checkPermissions' => FALSE, + 'where' => [['name', '=', 'entity_table']], + 'loadOptions' => TRUE, + ], ['options'])->first(); + } + // If bridge field to joinEntity is entity_id, validate entity_table is allowed + if (!$fkToJoinField || ($fkToJoinField === 'entity_id' && !array_key_exists($joinTable, $entityTables))) { + throw new \API_Exception("Unable to join $bridgeEntity to $joinEntity"); + } + // Create link between bridge entity and join entity + $joinConditions = [ + "`$bridgeAlias`.`$fkToJoinField` = `$alias`.`id`", + ]; + if ($fkToJoinField === 'entity_id') { + $joinConditions[] = "`$bridgeAlias`.`entity_table` = '$joinTable'"; + } + // Register fields from the bridge entity as if they belong to the join entity + foreach ($bridgeEntityGet->entityFields() as $name => $field) { + if ($name == 'id' || $name == $fkToJoinField || ($name == 'entity_table' && $fkToJoinField == 'entity_id')) { + continue; + } + if ($field['fk_entity'] || (!$fkToBaseField && $name == 'entity_id')) { + $fkToBaseField = $name; + } + // Note these fields get a sql alias pointing to the bridge entity, but an api alias pretending they belong to the join entity + $field['sql_name'] = '`' . $bridgeAlias . '`.`' . $field['column_name'] . '`'; + $this->addSpecField($alias . '.' . $field['name'], $field); + } + // Move conditions for the bridge join out of the joinTree + $bridgeConditions = []; + $joinTree = array_filter($joinTree, function($clause) use ($fkToBaseField, $alias, $bridgeAlias, &$bridgeConditions) { + list($sideA, $op, $sideB) = array_pad((array) $clause, 3, NULL); + if ($op === '=' && $sideB && ($sideA === "$alias.$fkToBaseField" || $sideB === "$alias.$fkToBaseField")) { + $expr = $sideA === "$alias.$fkToBaseField" ? $sideB : $sideA; + $bridgeConditions[] = "`$bridgeAlias`.`$fkToBaseField` = " . $this->getExpression($expr)->render($this->apiFieldSpec); + return FALSE; + } + elseif ($op === '=' && $fkToBaseField == 'entity_id' && ($sideA === "$alias.entity_table" || $sideB === "$alias.entity_table")) { + $expr = $sideA === "$alias.entity_table" ? $sideB : $sideA; + $bridgeConditions[] = "`$bridgeAlias`.`entity_table` = " . $this->getExpression($expr)->render($this->apiFieldSpec); + return FALSE; + } + return TRUE; + }); + // If no bridge conditions were specified, link it to the base entity + if (!$bridgeConditions) { + $bridgeConditions[] = "`$bridgeAlias`.`$fkToBaseField` = a.id"; + if ($fkToBaseField == 'entity_id') { + if (!array_key_exists($this->getFrom(), $entityTables)) { + throw new \API_Exception("Unable to join $bridgeEntity to " . $this->getEntity()); + } + $bridgeConditions[] = "`$bridgeAlias`.`entity_table` = '" . $this->getFrom() . "'"; + } + } + + $this->join('LEFT', $bridgeTable, $bridgeAlias, $bridgeConditions); + + $baoName = CoreUtil::getBAOFromApiName($joinEntity); + $acls = array_values($this->getAclClause($alias, $baoName, [NULL, NULL])); + return array_merge($acls, $joinConditions); + } + /** * Joins a path and adds all fields in the joined entity to apiFieldSpec * diff --git a/Civi/Api4/UFMatch.php b/Civi/Api4/UFMatch.php index de7500ee02b9..6cc1c4ee5907 100644 --- a/Civi/Api4/UFMatch.php +++ b/Civi/Api4/UFMatch.php @@ -24,6 +24,6 @@ * * @package Civi\Api4 */ -class UFMatch extends Generic\DAOEntity { +class UFMatch extends Generic\BridgeEntity { } diff --git a/ang/api4Explorer/Explorer.html b/ang/api4Explorer/Explorer.html index 5b4797868360..b32196dfd55c 100644 --- a/ang/api4Explorer/Explorer.html +++ b/ang/api4Explorer/Explorer.html @@ -70,10 +70,15 @@