Skip to content

Commit

Permalink
SearchKit - Add API filter for contacts in groups and smart groups
Browse files Browse the repository at this point in the history
Adds 'type' property to API getFields to distinguish regular fields
from custom fields, extra fields and filters.

Implements `Contact.groups` as a filter, which internally adds a temp-table
and incorporates it into the query.
  • Loading branch information
colemanw committed Jun 6, 2021
1 parent f96e498 commit a1415a0
Show file tree
Hide file tree
Showing 23 changed files with 406 additions and 95 deletions.
14 changes: 1 addition & 13 deletions Civi/Api4/Action/CustomValue/GetFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,8 @@ protected function getRecords() {
$fields = $this->_itemsToGet('name');
/** @var \Civi\Api4\Service\Spec\SpecGatherer $gatherer */
$gatherer = \Civi::container()->get('spec_gatherer');
$spec = $gatherer->getSpec('Custom_' . $this->getCustomGroup(), $this->getAction(), $this->includeCustom, $this->values);
$spec = $gatherer->getSpec('Custom_' . $this->getCustomGroup(), $this->getAction(), TRUE, $this->values);
return $this->specToArray($spec->getFields($fields));
}

/**
* @inheritDoc
*/
public function getParamInfo($param = NULL) {
$info = parent::getParamInfo($param);
if (!$param) {
// This param is meaningless here.
unset($info['includeCustom']);
}
return $info;
}

}
6 changes: 5 additions & 1 deletion Civi/Api4/Generic/AbstractAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -433,11 +433,15 @@ public function getPermissions() {
*/
public function entityFields() {
if (!$this->_entityFields) {
$allowedTypes = ['Field', 'Filter', 'Extra'];
if (method_exists($this, 'getCustomGroup')) {
$allowedTypes[] = 'Custom';
}
$getFields = \Civi\API\Request::create($this->getEntityName(), 'getFields', [
'version' => 4,
'checkPermissions' => $this->checkPermissions,
'action' => $this->getActionName(),
'includeCustom' => FALSE,
'where' => [['type', 'IN', $allowedTypes]],
]);
$result = new Result();
// Pass TRUE for the private $isInternal param
Expand Down
34 changes: 22 additions & 12 deletions Civi/Api4/Generic/BasicGetFieldsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ class BasicGetFieldsAction extends BasicGetAction {
*/
protected $values = [];

/**
* @var bool
* @deprecated
*/
protected $includeCustom;

/**
* To implement getFields for your own entity:
*
Expand Down Expand Up @@ -207,18 +213,6 @@ public function addValue(string $fieldName, $value) {
return $this;
}

/**
* @param bool $includeCustom
* @return $this
*/
public function setIncludeCustom(bool $includeCustom) {
// Be forgiving if the param doesn't exist and don't throw an exception
if (property_exists($this, 'includeCustom')) {
$this->includeCustom = $includeCustom;
}
return $this;
}

/**
* Helper function to retrieve options from an option group (for non-DAO entities).
*
Expand Down Expand Up @@ -259,6 +253,17 @@ public function fields() {
'data_type' => 'String',
'description' => ts('Explanation of the purpose of the field'),
],
[
'name' => 'type',
'data_type' => 'String',
'default_value' => 'Field',
'options' => [
'Field' => ts('Primary Field'),
'Custom' => ts('Custom Field'),
'Filter' => ts('Search Filter'),
'Extra' => ts('Extra API Field'),
],
],
[
'name' => 'default_value',
'data_type' => 'String',
Expand All @@ -277,6 +282,11 @@ public function fields() {
'data_type' => 'Array',
'default_value' => FALSE,
],
[
'name' => 'operators',
'data_type' => 'Array',
'description' => 'If set, limits the operators that can be used on this field for "get" actions.',
],
[
'name' => 'data_type',
'default_value' => 'String',
Expand Down
25 changes: 14 additions & 11 deletions Civi/Api4/Generic/DAOGetFieldsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,25 @@
*/
class DAOGetFieldsAction extends BasicGetFieldsAction {

/**
* Include custom fields for this entity, or only core fields?
*
* @var bool
*/
protected $includeCustom = TRUE;

/**
* Get fields for a DAO-based entity.
*
* @return array
*/
protected function getRecords() {
$fieldsToGet = $this->_itemsToGet('name');
$typesToGet = $this->_itemsToGet('type');
/** @var \Civi\Api4\Service\Spec\SpecGatherer $gatherer */
$gatherer = \Civi::container()->get('spec_gatherer');
// Any fields name with a dot in it is either custom or an implicit join
if ($fieldsToGet) {
$this->includeCustom = strpos(implode('', $fieldsToGet), '.') !== FALSE;
$includeCustom = TRUE;
if ($typesToGet) {
$includeCustom = in_array('Custom', $typesToGet, TRUE);
}
$spec = $gatherer->getSpec($this->getEntityName(), $this->getAction(), $this->includeCustom, $this->values);
elseif ($fieldsToGet) {
// Any fields name with a dot in it is either custom or an implicit join
$includeCustom = strpos(implode('', $fieldsToGet), '.') !== FALSE;
}
$spec = $gatherer->getSpec($this->getEntityName(), $this->getAction(), $includeCustom, $this->values);
$fields = $this->specToArray($spec->getFields($fieldsToGet));
foreach ($fieldsToGet ?? [] as $fieldName) {
if (empty($fields[$fieldName]) && strpos($fieldName, '.') !== FALSE) {
Expand Down Expand Up @@ -122,6 +120,11 @@ public function fields() {
'name' => 'custom_group_id',
'data_type' => 'Integer',
];
$fields[] = [
'name' => 'sql_filters',
'data_type' => 'Array',
'@internal' => TRUE,
];
return $fields;
}

Expand Down
62 changes: 44 additions & 18 deletions Civi/Api4/Query/Api4SelectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,6 @@ class Api4SelectQuery {
*/
protected $apiFieldSpec;

/**
* @var array
*/
protected $entityFieldNames = [];

/**
* @var array
*/
Expand Down Expand Up @@ -97,7 +92,6 @@ public function __construct($apiGet) {

// Build field lists
foreach ($this->api->entityFields() as $field) {
$this->entityFieldNames[] = $field['name'];
$field['sql_name'] = '`' . self::MAIN_TABLE_ALIAS . '`.`' . $field['column_name'] . '`';
$this->addSpecField($field['name'], $field);
}
Expand Down Expand Up @@ -205,7 +199,7 @@ protected function buildSelectClause($select = NULL) {
$select = array_diff($select ?? $this->getSelect(), ['row_count']);
// An empty select is the same as *
if (empty($select)) {
$select = $this->entityFieldNames;
$select = $this->selectMatchingFields('*');
}
else {
if ($this->forceSelectId) {
Expand Down Expand Up @@ -236,7 +230,7 @@ protected function buildSelectClause($select = NULL) {
// If the joined_entity.id isn't in the fieldspec already, autoJoinFK will attempt to add the entity.
$idField = substr($wildField, 0, strrpos($wildField, '.')) . '.id';
$this->autoJoinFK($idField);
$matches = SelectUtil::getMatchingFields($wildField, array_keys($this->apiFieldSpec));
$matches = $this->selectMatchingFields($wildField);
array_splice($select, $pos, 1, $matches);
}
$select = array_unique($select);
Expand All @@ -247,7 +241,7 @@ protected function buildSelectClause($select = NULL) {
foreach ($expr->getFields() as $fieldName) {
$field = $this->getField($fieldName);
// Remove expressions with unknown fields without raising an error
if (!$field) {
if (!$field || !in_array($field['type'], ['Field', 'Custom'], TRUE)) {
$select = array_diff($select, [$item]);
$this->debug('undefined_fields', $fieldName);
$valid = FALSE;
Expand All @@ -264,6 +258,20 @@ protected function buildSelectClause($select = NULL) {
}
}

/**
* Get all fields for SELECT clause matching a wildcard pattern
*
* @param $pattern
* @return array
*/
private function selectMatchingFields($pattern) {
// Only core & custom fields can be selected
$availableFields = array_filter($this->apiFieldSpec, function($field) {
return in_array($field['type'], ['Field', 'Custom'], TRUE);
});
return SelectUtil::getMatchingFields($pattern, array_keys($availableFields));
}

/**
* Add WHERE clause to query
*/
Expand Down Expand Up @@ -352,12 +360,13 @@ protected function buildGroupBy() {
* @param array $clause
* @param string $type
* WHERE|HAVING|ON
* @param int $depth
* @return string SQL where clause
*
* @throws \API_Exception
* @uses composeClause() to generate the SQL etc.
*/
protected function treeWalkClauses($clause, $type) {
protected function treeWalkClauses($clause, $type, $depth = 0) {
// Skip empty leaf.
if (in_array($clause[0], ['AND', 'OR', 'NOT']) && empty($clause[1])) {
return '';
Expand All @@ -368,12 +377,12 @@ protected function treeWalkClauses($clause, $type) {
// handle branches
if (count($clause[1]) === 1) {
// a single set so AND|OR is immaterial
return $this->treeWalkClauses($clause[1][0], $type);
return $this->treeWalkClauses($clause[1][0], $type, $depth + 1);
}
else {
$sql_subclauses = [];
foreach ($clause[1] as $subclause) {
$sql_subclauses[] = $this->treeWalkClauses($subclause, $type);
$sql_subclauses[] = $this->treeWalkClauses($subclause, $type, $depth + 1);
}
return '(' . implode("\n" . $clause[0], $sql_subclauses) . ')';
}
Expand All @@ -383,10 +392,10 @@ protected function treeWalkClauses($clause, $type) {
if (!is_string($clause[1][0])) {
$clause[1] = ['AND', $clause[1]];
}
return 'NOT (' . $this->treeWalkClauses($clause[1], $type) . ')';
return 'NOT (' . $this->treeWalkClauses($clause[1], $type, $depth + 1) . ')';

default:
return $this->composeClause($clause, $type);
return $this->composeClause($clause, $type, $depth);
}
}

Expand All @@ -395,11 +404,13 @@ protected function treeWalkClauses($clause, $type) {
* @param array $clause [$fieldName, $operator, $criteria]
* @param string $type
* WHERE|HAVING|ON
* @param int $depth
* @return string SQL
* @throws \API_Exception
* @throws \Exception
*/
protected function composeClause(array $clause, string $type) {
protected function composeClause(array $clause, string $type, int $depth) {
$field = NULL;
// Pad array for unary operators
[$expr, $operator, $value] = array_pad($clause, 3, NULL);
if (!in_array($operator, CoreUtil::getOperators(), TRUE)) {
Expand Down Expand Up @@ -454,7 +465,7 @@ protected function composeClause(array $clause, string $type) {
if ($fieldName && $valExpr->getType() === 'SqlString') {
$value = $valExpr->getExpr();
FormattingUtil::formatInputValue($value, $fieldName, $this->apiFieldSpec[$fieldName], $operator);
return $this->createSQLClause($fieldAlias, $operator, $value, $this->apiFieldSpec[$fieldName]);
return $this->createSQLClause($fieldAlias, $operator, $value, $this->apiFieldSpec[$fieldName], $depth);
}
else {
$value = $valExpr->render($this->apiFieldSpec);
Expand All @@ -467,7 +478,7 @@ protected function composeClause(array $clause, string $type) {
}
}

$sqlClause = $this->createSQLClause($fieldAlias, $operator, $value, $field ?? NULL);
$sqlClause = $this->createSQLClause($fieldAlias, $operator, $value, $field, $depth);
if ($sqlClause === NULL) {
throw new \API_Exception("Invalid value in $type clause for '$expr'");
}
Expand All @@ -479,10 +490,25 @@ protected function composeClause(array $clause, string $type) {
* @param string $operator
* @param mixed $value
* @param array|null $field
* @param int $depth
* @return array|string|NULL
* @throws \Exception
*/
protected function createSQLClause($fieldAlias, $operator, $value, $field) {
protected function createSQLClause($fieldAlias, $operator, $value, $field, int $depth) {
if (!empty($field['operators']) && !in_array($operator, $field['operators'], TRUE)) {
throw new \API_Exception('Illegal operator for ' . $field['name']);
}
// Some fields use a callback to generate their sql
if (!empty($field['sql_filters'])) {
$sql = [];
foreach ($field['sql_filters'] as $filter) {
$clause = is_callable($filter) ? $filter($fieldAlias, $operator, $value, $this, $depth) : NULL;
if ($clause) {
$sql[] = $clause;
}
}
return $sql ? implode(' AND ', $sql) : NULL;
}
if ($operator === 'CONTAINS') {
switch ($field['serialize'] ?? NULL) {
case \CRM_Core_DAO::SERIALIZE_JSON:
Expand Down
5 changes: 5 additions & 0 deletions Civi/Api4/Service/Spec/CustomFieldSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ class CustomFieldSpec extends FieldSpec {
*/
public $customGroup;

/**
* @var string
*/
public $type = 'Custom';

/**
* @inheritDoc
*/
Expand Down
Loading

0 comments on commit a1415a0

Please sign in to comment.