Skip to content

Commit

Permalink
Merge pull request #17808 from colemanw/bridge
Browse files Browse the repository at this point in the history
APIv4 - Specify BridgeEntities to assist with joins
  • Loading branch information
eileenmcnaughton authored Jul 20, 2020
2 parents ade6fb6 + 90908aa commit d2302f9
Show file tree
Hide file tree
Showing 14 changed files with 262 additions and 32 deletions.
2 changes: 1 addition & 1 deletion Civi/Api4/ActivityContact.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@
* @see \Civi\Api4\Activity
* @package Civi\Api4
*/
class ActivityContact extends Generic\DAOEntity {
class ActivityContact extends Generic\BridgeEntity {

}
2 changes: 1 addition & 1 deletion Civi/Api4/DashboardContact.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@
* @see \Civi\Api4\Dashboard
* @package Civi\Api4
*/
class DashboardContact extends Generic\DAOEntity {
class DashboardContact extends Generic\BridgeEntity {

}
5 changes: 5 additions & 0 deletions Civi/Api4/Entity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion Civi/Api4/EntityTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@
*
* @package Civi\Api4
*/
class EntityTag extends Generic\DAOEntity {
class EntityTag extends Generic\BridgeEntity {

}
13 changes: 12 additions & 1 deletion Civi/Api4/Generic/AbstractEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -126,11 +126,22 @@ 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']]);
unset($info['package'], $info['method']);
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);
}

}
21 changes: 21 additions & 0 deletions Civi/Api4/Generic/BridgeEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

namespace Civi\Api4\Generic;

/**
* A bridge is a small table that provides an intermediary link between two other tables.
*
* The API can automatically incorporate a bridgeEntity into a join expression.
*/
class BridgeEntity extends DAOEntity {

}
20 changes: 19 additions & 1 deletion Civi/Api4/Generic/DAOGetAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,21 @@ class DAOGetAction extends AbstractGetAction {
/**
* Joins to other entities.
*
* Each join is an array of properties:
*
* ```
* [Entity, Required, Bridge, [field, op, value]...]
* ```
*
* - `Entity`: the name of the api entity to join onto.
* - `Required`: `TRUE` for an `INNER JOIN`, `FALSE` for a `LEFT JOIN`.
* - `Bridge` (optional): Name of a BridgeEntity to incorporate into the join.
* - `[field, op, value]...`: zero or more conditions for the ON clause, using the same nested format as WHERE and HAVING
* but with the difference that "value" is interpreted as an expression (e.g. can be the name of a field).
* Enclose literal values with quotes.
*
* @var array
* @see \Civi\Api4\Generic\BridgeEntity
*/
protected $join = [];

Expand Down Expand Up @@ -141,10 +155,14 @@ public function addHaving(string $expr, string $op, $value = NULL) {
/**
* @param string $entity
* @param bool $required
* @param string $bridge
* @param array ...$conditions
* @return DAOGetAction
*/
public function addJoin(string $entity, bool $required = FALSE, ...$conditions): DAOGetAction {
public function addJoin(string $entity, bool $required = FALSE, $bridge = NULL, ...$conditions): DAOGetAction {
if ($bridge) {
array_unshift($conditions, $bridge);
}
array_unshift($conditions, $entity, $required);
$this->join[] = $conditions;
return $this;
Expand Down
2 changes: 1 addition & 1 deletion Civi/Api4/GroupContact.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
*
* @package Civi\Api4
*/
class GroupContact extends Generic\DAOEntity {
class GroupContact extends Generic\BridgeEntity {

/**
* @param bool $checkPermissions
Expand Down
138 changes: 121 additions & 17 deletions Civi/Api4/Query/Api4SelectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
}
Expand All @@ -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
*
Expand Down
2 changes: 1 addition & 1 deletion Civi/Api4/UFMatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@
*
* @package Civi\Api4
*/
class UFMatch extends Generic\DAOEntity {
class UFMatch extends Generic\BridgeEntity {

}
7 changes: 6 additions & 1 deletion ang/api4Explorer/Explorer.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,15 @@ <h1 crm-page-title>
<div class="api4-input form-inline">
<i class="crm-i fa-arrows"></i>
<input class="form-control twenty" type="text" ng-model="params.join[$index][0]" ng-model-options="{updateOn: 'blur'}" ng-change="$ctrl.buildFieldList()"/>
<label>{{:: ts('Required:') }}</label>
<select class="form-control" ng-model="params.join[$index][1]" ng-options="o.k as o.v for o in ::joinTypes" ></select>
<label>{{:: ts('Using:') }}</label>
<select class="form-control" ng-model="params.join[$index][2]" ng-options="e.name as e.name for e in ::bridgeEntities" ng-change="$ctrl.buildFieldList()">
<option value="">{{:: ts('- none -') }}</option>
</select>
<a href class="crm-hover-button" title="Clear" ng-click="clearParam('join', $index)"><i class="crm-i fa-times"></i></a>
</div>
<fieldset class="api4-clause-fieldset" crm-api4-clause="{skip: 2, clauses: params.join[$index], op: 'AND', label: 'On', fields: fieldsAndJoins, format: 'plain'}">
<fieldset class="api4-clause-fieldset" crm-api4-clause="{skip: 3, clauses: params.join[$index], op: 'AND', label: 'On', fields: fieldsAndJoins, format: 'plain'}">
</fieldset>
</fieldset>
</div>
Expand Down
3 changes: 2 additions & 1 deletion ang/api4Explorer/Explorer.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
$scope.loading = false;
$scope.controls = {};
$scope.langs = ['php', 'js', 'ang', 'cli'];
$scope.joinTypes = [{k: false, v: ts('Optional')}, {k: true, v: ts('Required')}];
$scope.joinTypes = [{k: false, v: 'FALSE (LEFT JOIN)'}, {k: true, v: 'TRUE (INNER JOIN)'}];
$scope.bridgeEntities = _.filter(schema, {type: 'BridgeEntity'});
$scope.code = {
php: [
{name: 'oop', label: ts('OOP Style'), code: ''},
Expand Down
4 changes: 0 additions & 4 deletions css/api4-explorer.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,6 @@
margin-bottom: 10px;
}

#bootstrap-theme.api4-explorer-page .api4-input.form-inline > label {
margin-right: 12px;
}

#bootstrap-theme.api4-explorer-page .explorer-help-panel .panel-body {
word-break: break-word;
}
Expand Down
Loading

0 comments on commit d2302f9

Please sign in to comment.