From a1415a02fd8278bb0e9e8f0b7f92681972f998f0 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Fri, 4 Jun 2021 16:46:05 -0400 Subject: [PATCH] SearchKit - Add API filter for contacts in groups and smart groups 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. --- Civi/Api4/Action/CustomValue/GetFields.php | 14 +-- Civi/Api4/Generic/AbstractAction.php | 6 +- Civi/Api4/Generic/BasicGetFieldsAction.php | 34 ++++-- Civi/Api4/Generic/DAOGetFieldsAction.php | 25 ++-- Civi/Api4/Query/Api4SelectQuery.php | 62 +++++++--- Civi/Api4/Service/Spec/CustomFieldSpec.php | 5 + Civi/Api4/Service/Spec/FieldSpec.php | 75 ++++++++++-- .../Spec/Provider/ContactGetSpecProvider.php | 102 ++++++++++++++++ .../Spec/Provider/CustomValueSpecProvider.php | 4 + Civi/Api4/Service/Spec/SpecFormatter.php | 2 + .../Civi/AfformAdmin/AfformAdminMeta.php | 1 - ext/search_kit/Civi/Search/Admin.php | 2 +- .../crmSearchAdmin.component.js | 12 +- .../crmSearchAdminDisplay.component.js | 2 +- .../crmSearchClause.component.js | 28 ++++- .../ang/crmSearchAdmin/crmSearchClause.html | 2 +- .../api/v4/Action/BasicCustomFieldTest.php | 2 +- .../phpunit/api/v4/Action/CustomValueTest.php | 7 +- .../api/v4/Action/GetExtraFieldsTest.php | 2 +- .../api/v4/Action/PseudoconstantTest.php | 1 - tests/phpunit/api/v4/Action/ResultTest.php | 2 +- .../phpunit/api/v4/Entity/ConformanceTest.php | 2 +- .../phpunit/api/v4/Entity/SavedSearchTest.php | 109 +++++++++++++++--- 23 files changed, 406 insertions(+), 95 deletions(-) create mode 100644 Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php diff --git a/Civi/Api4/Action/CustomValue/GetFields.php b/Civi/Api4/Action/CustomValue/GetFields.php index f685a3b312..63ab5c3e70 100644 --- a/Civi/Api4/Action/CustomValue/GetFields.php +++ b/Civi/Api4/Action/CustomValue/GetFields.php @@ -22,20 +22,8 @@ class GetFields extends \Civi\Api4\Generic\DAOGetFieldsAction { $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; - } - } diff --git a/Civi/Api4/Generic/AbstractAction.php b/Civi/Api4/Generic/AbstractAction.php index 4f1f72a9b3..d73a78cd5c 100644 --- a/Civi/Api4/Generic/AbstractAction.php +++ b/Civi/Api4/Generic/AbstractAction.php @@ -433,11 +433,15 @@ abstract class AbstractAction implements \ArrayAccess { */ 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 diff --git a/Civi/Api4/Generic/BasicGetFieldsAction.php b/Civi/Api4/Generic/BasicGetFieldsAction.php index 643ce11946..1261260fc0 100644 --- a/Civi/Api4/Generic/BasicGetFieldsAction.php +++ b/Civi/Api4/Generic/BasicGetFieldsAction.php @@ -68,6 +68,12 @@ class BasicGetFieldsAction extends BasicGetAction { */ protected $values = []; + /** + * @var bool + * @deprecated + */ + protected $includeCustom; + /** * To implement getFields for your own entity: * @@ -207,18 +213,6 @@ class BasicGetFieldsAction extends BasicGetAction { 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). * @@ -259,6 +253,17 @@ class BasicGetFieldsAction extends BasicGetAction { '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', @@ -277,6 +282,11 @@ class BasicGetFieldsAction extends BasicGetAction { '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', diff --git a/Civi/Api4/Generic/DAOGetFieldsAction.php b/Civi/Api4/Generic/DAOGetFieldsAction.php index 9672a99c86..a8a359b5d1 100644 --- a/Civi/Api4/Generic/DAOGetFieldsAction.php +++ b/Civi/Api4/Generic/DAOGetFieldsAction.php @@ -25,13 +25,6 @@ namespace Civi\Api4\Generic; */ 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. * @@ -39,13 +32,18 @@ class DAOGetFieldsAction extends BasicGetFieldsAction { */ 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) { @@ -122,6 +120,11 @@ class DAOGetFieldsAction extends BasicGetFieldsAction { 'name' => 'custom_group_id', 'data_type' => 'Integer', ]; + $fields[] = [ + 'name' => 'sql_filters', + 'data_type' => 'Array', + '@internal' => TRUE, + ]; return $fields; } diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php index 20c0f0ec64..27b232d8c4 100644 --- a/Civi/Api4/Query/Api4SelectQuery.php +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -50,11 +50,6 @@ class Api4SelectQuery { */ protected $apiFieldSpec; - /** - * @var array - */ - protected $entityFieldNames = []; - /** * @var array */ @@ -97,7 +92,6 @@ class Api4SelectQuery { // 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); } @@ -205,7 +199,7 @@ class Api4SelectQuery { $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) { @@ -236,7 +230,7 @@ class Api4SelectQuery { // 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); @@ -247,7 +241,7 @@ class Api4SelectQuery { 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; @@ -264,6 +258,20 @@ class Api4SelectQuery { } } + /** + * 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 */ @@ -352,12 +360,13 @@ class Api4SelectQuery { * @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 ''; @@ -368,12 +377,12 @@ class Api4SelectQuery { // 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) . ')'; } @@ -383,10 +392,10 @@ class Api4SelectQuery { 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); } } @@ -395,11 +404,13 @@ class Api4SelectQuery { * @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)) { @@ -454,7 +465,7 @@ class Api4SelectQuery { 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); @@ -467,7 +478,7 @@ class Api4SelectQuery { } } - $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'"); } @@ -479,10 +490,25 @@ class Api4SelectQuery { * @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: diff --git a/Civi/Api4/Service/Spec/CustomFieldSpec.php b/Civi/Api4/Service/Spec/CustomFieldSpec.php index 11fca39c52..c233a91581 100644 --- a/Civi/Api4/Service/Spec/CustomFieldSpec.php +++ b/Civi/Api4/Service/Spec/CustomFieldSpec.php @@ -30,6 +30,11 @@ class CustomFieldSpec extends FieldSpec { */ public $customGroup; + /** + * @var string + */ + public $type = 'Custom'; + /** * @inheritDoc */ diff --git a/Civi/Api4/Service/Spec/FieldSpec.php b/Civi/Api4/Service/Spec/FieldSpec.php index 54b582e25d..6bc6600955 100644 --- a/Civi/Api4/Service/Spec/FieldSpec.php +++ b/Civi/Api4/Service/Spec/FieldSpec.php @@ -40,6 +40,11 @@ class FieldSpec { */ public $title; + /** + * @var string + */ + public $type = 'Extra'; + /** * @var string */ @@ -90,6 +95,11 @@ class FieldSpec { */ public $inputAttrs = []; + /** + * @var string[] + */ + public $operators; + /** * @var string */ @@ -130,6 +140,12 @@ class FieldSpec { */ public $outputFormatters; + + /** + * @var callable[] + */ + public $sqlFilters; + /** * Aliases for the valid data types * @@ -148,7 +164,7 @@ class FieldSpec { */ public function __construct($name, $entity, $dataType = 'String') { $this->entity = $entity; - $this->name = $this->columnName = $name; + $this->name = $name; $this->setDataType($dataType); } @@ -390,6 +406,16 @@ class FieldSpec { return $this; } + /** + * @param string[] $operators + * @return $this + */ + public function setOperators($operators) { + $this->operators = $operators; + + return $this; + } + /** * @param callable[] $outputFormatters * @return $this @@ -413,6 +439,39 @@ class FieldSpec { return $this; } + /** + * @param callable[] $sqlFilters + * @return $this + */ + public function setSqlFilters($sqlFilters) { + $this->sqlFilters = $sqlFilters; + + return $this; + } + + /** + * @param callable $sqlFilter + * @return $this + */ + public function addSqlFilter($sqlFilter) { + if (!$this->sqlFilters) { + $this->sqlFilters = []; + } + $this->sqlFilters[] = $sqlFilter; + + return $this; + } + + /** + * @param string $type + * @return $this + */ + public function setType(string $type) { + $this->type = $type; + + return $this; + } + /** * @param bool $readonly * @return $this @@ -438,11 +497,11 @@ class FieldSpec { } /** - * @param string $customFieldColumnName + * @param string $tableName * @return $this */ - public function setTableName($customFieldColumnName) { - $this->tableName = $customFieldColumnName; + public function setTableName($tableName) { + $this->tableName = $tableName; return $this; } @@ -523,18 +582,18 @@ class FieldSpec { } /** - * @return string + * @return string|NULL */ - public function getColumnName() { + public function getColumnName(): ?string { return $this->columnName; } /** - * @param string $columnName + * @param string|null $columnName * * @return $this */ - public function setColumnName($columnName) { + public function setColumnName(?string $columnName) { $this->columnName = $columnName; return $this; } diff --git a/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php new file mode 100644 index 0000000000..8f3683a15f --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php @@ -0,0 +1,102 @@ +setLabel(ts('In Groups')) + ->setTitle(ts('Groups')) + ->setColumnName('id') + ->setDescription(ts('Groups (or sub-groups of groups) to which this contact belongs')) + ->setType('Filter') + ->setOperators(['IN', 'NOT IN']) + ->addSqlFilter([__CLASS__, 'getContactGroupSql']) + ->setOptionsCallback([__CLASS__, 'getGroupList']); + $spec->addFieldSpec($field); + } + + /** + * @param string $entity + * @param string $action + * + * @return bool + */ + public function applies($entity, $action) { + return $entity === 'Contact' && $action === 'get'; + } + + /** + * @param string $fieldAlias + * @param string $operator + * @param mixed $value + * @param \Civi\Api4\Query\Api4SelectQuery $query + * @param int $depth + * return string + */ + public static function getContactGroupSql(string $fieldAlias, string $operator, $value, Api4SelectQuery $query, int $depth): string { + $tempTable = \CRM_Utils_SQL_TempTable::build(); + $tempTable->createWithColumns('contact_id INT'); + $tableName = $tempTable->getName(); + \CRM_Contact_BAO_GroupContactCache::populateTemporaryTableWithContactsInGroups($value, $tableName); + // SQL optimization - use INNER JOIN if the base table is Contact & this clause is not nested + if ($fieldAlias === '`a`.`id`' && $operator === "IN" && !$depth) { + $query->getQuery()->join($tableName, "INNER JOIN `$tableName` ON $fieldAlias = `$tableName`.contact_id"); + return '1'; + } + // Else use IN or NOT IN (this filter only supports those 2 operators) + else { + return "$fieldAlias $operator (SELECT contact_id FROM `$tableName`)"; + } + } + + /** + * Callback function to build option lists groups pseudo-field. + * + * @param \Civi\Api4\Service\Spec\FieldSpec $spec + * @param array $values + * @param bool|array $returnFormat + * @param bool $checkPermissions + * @return array + */ + public static function getGroupList($spec, $values, $returnFormat, $checkPermissions) { + $groups = $checkPermissions ? \CRM_Core_PseudoConstant::group() : \CRM_Core_PseudoConstant::allGroup(NULL, FALSE); + $options = \CRM_Utils_Array::makeNonAssociative($groups, 'id', 'label'); + if ($options && is_array($returnFormat) && in_array('name', $returnFormat)) { + $groupIndex = array_flip(array_keys($groups)); + $dao = \CRM_Core_DAO::executeQuery('SELECT id, name FROM civicrm_group WHERE id IN (%1)', [ + 1 => [implode(',', array_keys($groups)), 'CommaSeparatedIntegers'], + ]); + while ($dao->fetch()) { + $options[$groupIndex[$dao->id]]['name'] = $dao->name; + } + } + return $options; + } + +} diff --git a/Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php b/Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php index 3e50a41faa..3cc363afc7 100644 --- a/Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php +++ b/Civi/Api4/Service/Spec/Provider/CustomValueSpecProvider.php @@ -31,11 +31,15 @@ class CustomValueSpecProvider implements Generic\SpecProviderInterface { $action = $spec->getAction(); if ($action !== 'create') { $idField = new FieldSpec('id', $spec->getEntity(), 'Integer'); + $idField->setType('Field'); + $idField->setColumnName('id'); $idField->setTitle(ts('Custom Value ID')); $idField->setReadonly(TRUE); $spec->addFieldSpec($idField); } $entityField = new FieldSpec('entity_id', $spec->getEntity(), 'Integer'); + $entityField->setType('Field'); + $entityField->setColumnName('entity_id'); $entityField->setTitle(ts('Entity ID')); $entityField->setRequired($action === 'create'); $entityField->setFkEntity('Contact'); diff --git a/Civi/Api4/Service/Spec/SpecFormatter.php b/Civi/Api4/Service/Spec/SpecFormatter.php index d498818a62..7a5aee1813 100644 --- a/Civi/Api4/Service/Spec/SpecFormatter.php +++ b/Civi/Api4/Service/Spec/SpecFormatter.php @@ -56,6 +56,8 @@ class SpecFormatter { else { $name = $data['name'] ?? NULL; $field = new FieldSpec($name, $entity, $dataTypeName); + $field->setType('Field'); + $field->setColumnName($name); $field->setRequired(!empty($data['required'])); $field->setTitle($data['title'] ?? NULL); $field->setLabel($data['html']['label'] ?? NULL); diff --git a/ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php b/ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php index 6608b5b736..06619a649c 100644 --- a/ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php +++ b/ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php @@ -87,7 +87,6 @@ class AfformAdminMeta { public static function getFields($entityName, $params = []) { $params += [ 'checkPermissions' => FALSE, - 'includeCustom' => TRUE, 'loadOptions' => ['id', 'label'], 'action' => 'create', 'select' => ['name', 'label', 'input_type', 'input_attrs', 'required', 'options', 'help_pre', 'help_post', 'serialize', 'data_type', 'fk_entity', 'readonly'], diff --git a/ext/search_kit/Civi/Search/Admin.php b/ext/search_kit/Civi/Search/Admin.php index 9d314a8118..367deb9a80 100644 --- a/ext/search_kit/Civi/Search/Admin.php +++ b/ext/search_kit/Civi/Search/Admin.php @@ -102,7 +102,7 @@ class Admin { } } $getFields = civicrm_api4($entity['name'], 'getFields', [ - 'select' => ['name', 'title', 'label', 'description', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly'], + 'select' => ['name', 'title', 'label', 'description', 'type', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly', 'operators'], 'where' => [['name', 'NOT IN', ['api_key', 'hash']]], 'orderBy' => ['label'], ]); diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js index a318ef8b2d..6bcb11213b 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js @@ -736,21 +736,21 @@ } $scope.fieldsForGroupBy = function() { - return {results: ctrl.getAllFields('', function(key) { + return {results: ctrl.getAllFields('', ['Field', 'Custom'], function(key) { return _.contains(ctrl.savedSearch.api_params.groupBy, key); }) }; }; $scope.fieldsForSelect = function() { - return {results: ctrl.getAllFields(':label', function(key) { + return {results: ctrl.getAllFields(':label', ['Field', 'Custom', 'Extra'], function(key) { return _.contains(ctrl.savedSearch.api_params.select, key); }) }; }; function getFieldsForJoin(joinEntity) { - return {results: ctrl.getAllFields(':name', null, joinEntity)}; + return {results: ctrl.getAllFields(':name', ['Field', 'Custom'], null, joinEntity)}; } $scope.fieldsForJoin = function(joinEntity) { @@ -789,7 +789,7 @@ }); } - this.getAllFields = function(suffix, disabledIf, topJoin) { + this.getAllFields = function(suffix, allowedTypes, disabledIf, topJoin) { disabledIf = disabledIf || _.noop; function formatFields(entityName, join) { var prefix = join ? join.alias + '.' : '', @@ -805,7 +805,9 @@ if (disabledIf(item.id)) { item.disabled = true; } - result.push(item); + if (!allowedTypes || _.includes(allowedTypes, field.type)) { + result.push(item); + } }); } diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js index e930f9097f..d6d673be87 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js @@ -344,7 +344,7 @@ results: [{ text: ts('Columns'), children: ctrl.crmSearchAdmin.getSelectFields(disabledIf) - }].concat(ctrl.crmSearchAdmin.getAllFields('', disabledIf)) + }].concat(ctrl.crmSearchAdmin.getAllFields('', ['Field', 'Custom'], disabledIf)) }; }; diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js index 9d6cbc6139..e6ad6ff14e 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js @@ -19,7 +19,7 @@ ctrl = this, meta = {}; this.conjunctions = {AND: ts('And'), OR: ts('Or'), NOT: ts('Not')}; - this.operators = CRM.crmSearchAdmin.operators; + this.operators = {}; this.sortOptions = { axis: 'y', connectWith: '.api4-clause-group-sortable', @@ -31,8 +31,30 @@ this.$onInit = function() { ctrl.hasParent = !!$element.attr('delete-group'); + _.each(ctrl.clauses, updateOperators); }; + this.getOperators = function(clause) { + var field = ctrl.getField(clause[0]); + if (!field || !field.operators) { + return CRM.crmSearchAdmin.operators; + } + var opKey = field.operators.join(); + if (!ctrl.operators[opKey]) { + ctrl.operators[opKey] = _.filter(CRM.crmSearchAdmin.operators, function(operator) { + return _.includes(field.operators, operator.key); + }); + } + return ctrl.operators[opKey]; + }; + + function updateOperators(clause) { + if (!clause[1] || !_.includes(_.pluck(ctrl.getOperators(clause), 'key'), clause[1])) { + clause[1] = ctrl.getOperators(clause)[0].key; + ctrl.changeClauseOperator(clause); + } + } + this.getField = function(expr) { if (!meta[expr]) { meta[expr] = searchMeta.parseExpr(expr); @@ -68,8 +90,10 @@ this.addClause = function() { $timeout(function() { if (ctrl.newClause) { + var newIndex = ctrl.clauses.length; ctrl.clauses.push([ctrl.newClause, '=', '']); ctrl.newClause = null; + updateOperators(ctrl.clauses[newIndex]); } }); }; @@ -82,6 +106,8 @@ this.changeClauseField = function(clause, index) { if (clause[0] === '') { ctrl.deleteRow(index); + } else { + updateOperators(clause); } }; diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html b/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html index 09363b721a..163e31f39d 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html @@ -16,7 +16,7 @@
- +
diff --git a/tests/phpunit/api/v4/Action/BasicCustomFieldTest.php b/tests/phpunit/api/v4/Action/BasicCustomFieldTest.php index 26e9cfa5a6..e85f816758 100644 --- a/tests/phpunit/api/v4/Action/BasicCustomFieldTest.php +++ b/tests/phpunit/api/v4/Action/BasicCustomFieldTest.php @@ -47,7 +47,7 @@ class BasicCustomFieldTest extends BaseCustomValueTest { // Individual fields should show up when contact_type = null|Individual but not other contact types $getFields = Contact::getFields(FALSE); - $this->assertContains('MyIndividualFields.FavColor', $getFields->execute()->column('name')); + $this->assertEquals('Custom', $getFields->execute()->indexBy('name')['MyIndividualFields.FavColor']['type']); $this->assertContains('MyIndividualFields.FavColor', $getFields->setValues(['contact_type' => 'Individual'])->execute()->column('name')); $this->assertNotContains('MyIndividualFields.FavColor', $getFields->setValues(['contact_type' => 'Household'])->execute()->column('name')); diff --git a/tests/phpunit/api/v4/Action/CustomValueTest.php b/tests/phpunit/api/v4/Action/CustomValueTest.php index 7a666f8a34..d2a0298720 100644 --- a/tests/phpunit/api/v4/Action/CustomValueTest.php +++ b/tests/phpunit/api/v4/Action/CustomValueTest.php @@ -84,6 +84,7 @@ class CustomValueTest extends BaseCustomValueTest { $expectedResult = [ [ 'custom_group' => $group, + 'type' => 'Custom', 'name' => $colorFieldName, 'title' => $colorFieldName, 'entity' => "Custom_$group", @@ -96,6 +97,7 @@ class CustomValueTest extends BaseCustomValueTest { ], [ 'custom_group' => $group, + 'type' => 'Custom', 'name' => $multiFieldName, 'title' => $multiFieldName, 'entity' => "Custom_$group", @@ -108,6 +110,7 @@ class CustomValueTest extends BaseCustomValueTest { ], [ 'custom_group' => $group, + 'type' => 'Custom', 'name' => $textFieldName, 'title' => $textFieldName, 'entity' => "Custom_$group", @@ -119,6 +122,7 @@ class CustomValueTest extends BaseCustomValueTest { ], [ 'name' => 'id', + 'type' => 'Field', 'title' => ts('Custom Value ID'), 'entity' => "Custom_$group", 'table_name' => $customGroup['table_name'], @@ -128,6 +132,7 @@ class CustomValueTest extends BaseCustomValueTest { ], [ 'name' => 'entity_id', + 'type' => 'Field', 'title' => ts('Entity ID'), 'table_name' => $customGroup['table_name'], 'column_name' => 'entity_id', @@ -139,7 +144,7 @@ class CustomValueTest extends BaseCustomValueTest { foreach ($expectedResult as $key => $field) { foreach ($field as $attr => $value) { - $this->assertEquals($expectedResult[$key][$attr], $fields[$key][$attr]); + $this->assertEquals($expectedResult[$key][$attr], $fields[$key][$attr], "$key $attr"); } } diff --git a/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php b/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php index af4fe7d3ee..50707591d4 100644 --- a/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php +++ b/tests/phpunit/api/v4/Action/GetExtraFieldsTest.php @@ -29,7 +29,7 @@ use Civi\Api4\Contact; class GetExtraFieldsTest extends UnitTestCase { public function testGetFieldsByContactType() { - $getFields = Contact::getFields(FALSE)->addSelect('name')->setIncludeCustom(FALSE); + $getFields = Contact::getFields(FALSE)->addSelect('name')->addWhere('type', '=', 'Field'); $baseFields = array_column(\CRM_Contact_BAO_Contact::fields(), 'name'); $returnedFields = $getFields->execute()->column('name'); diff --git a/tests/phpunit/api/v4/Action/PseudoconstantTest.php b/tests/phpunit/api/v4/Action/PseudoconstantTest.php index 248073ab8d..b5cbf6adb5 100644 --- a/tests/phpunit/api/v4/Action/PseudoconstantTest.php +++ b/tests/phpunit/api/v4/Action/PseudoconstantTest.php @@ -170,7 +170,6 @@ class PseudoconstantTest extends BaseCustomValueTest { $fields = Contact::getFields() ->setLoadOptions(array_keys($technicolor[0])) - ->setIncludeCustom(TRUE) ->execute() ->indexBy('name'); diff --git a/tests/phpunit/api/v4/Action/ResultTest.php b/tests/phpunit/api/v4/Action/ResultTest.php index fbd40aad7c..a6ecefae73 100644 --- a/tests/phpunit/api/v4/Action/ResultTest.php +++ b/tests/phpunit/api/v4/Action/ResultTest.php @@ -28,7 +28,7 @@ use api\v4\UnitTestCase; class ResultTest extends UnitTestCase { public function testJsonSerialize() { - $result = Contact::getFields(FALSE)->setIncludeCustom(FALSE)->execute(); + $result = Contact::getFields(FALSE)->addWhere('type', '=', 'Field')->execute(); $json = json_encode($result); $this->assertStringStartsWith('[{"', $json); $this->assertTrue(is_array(json_decode($json))); diff --git a/tests/phpunit/api/v4/Entity/ConformanceTest.php b/tests/phpunit/api/v4/Entity/ConformanceTest.php index 43af883e71..5ad31a132f 100644 --- a/tests/phpunit/api/v4/Entity/ConformanceTest.php +++ b/tests/phpunit/api/v4/Entity/ConformanceTest.php @@ -171,7 +171,7 @@ class ConformanceTest extends UnitTestCase { */ protected function checkFields($entityClass, $entity) { $fields = $entityClass::getFields(FALSE) - ->setIncludeCustom(FALSE) + ->addWhere('type', '=', 'Field') ->execute() ->indexBy('name'); diff --git a/tests/phpunit/api/v4/Entity/SavedSearchTest.php b/tests/phpunit/api/v4/Entity/SavedSearchTest.php index 859e89f2d4..789e502020 100644 --- a/tests/phpunit/api/v4/Entity/SavedSearchTest.php +++ b/tests/phpunit/api/v4/Entity/SavedSearchTest.php @@ -47,11 +47,17 @@ class SavedSearchTest extends UnitTestCase { ], ])->first(); - // Oops we don't have an api4 syntax yet for selecting contacts in a group. - $ins = civicrm_api3('Contact', 'get', ['group' => $savedSearch['group']['name'], 'options' => ['limit' => 0]]); - $this->assertEquals(1, count($ins['values'])); - $this->assertArrayHasKey($in['id'], $ins['values']); - $this->assertArrayNotHasKey($out['id'], $ins['values']); + $ins = civicrm_api4('Contact', 'get', [ + 'where' => [['groups', 'IN', [$savedSearch['group']['id']]]], + ])->indexBy('id'); + $this->assertCount(1, $ins); + $this->assertArrayHasKey($in['id'], (array) $ins); + + $outs = civicrm_api4('Contact', 'get', [ + 'where' => [['groups', 'NOT IN', [$savedSearch['group']['id']]]], + ])->indexBy('id'); + $this->assertArrayHasKey($out['id'], (array) $outs); + $this->assertArrayNotHasKey($in['id'], (array) $outs); } public function testEmailSmartGroup() { @@ -76,11 +82,17 @@ class SavedSearchTest extends UnitTestCase { ], ])->first(); - // Oops we don't have an api4 syntax yet for selecting contacts in a group. - $ins = civicrm_api3('Contact', 'get', ['group' => $savedSearch['group']['name'], 'options' => ['limit' => 0]]); - $this->assertEquals(1, count($ins['values'])); - $this->assertArrayHasKey($in['id'], $ins['values']); - $this->assertArrayNotHasKey($out['id'], $ins['values']); + $ins = civicrm_api4('Contact', 'get', [ + 'where' => [['groups', 'IN', [$savedSearch['group']['id']]]], + ])->indexBy('id'); + $this->assertCount(1, $ins); + $this->assertArrayHasKey($in['id'], (array) $ins); + + $outs = civicrm_api4('Contact', 'get', [ + 'where' => [['groups', 'NOT IN', [$savedSearch['group']['id']]]], + ])->indexBy('id'); + $this->assertArrayHasKey($out['id'], (array) $outs); + $this->assertArrayNotHasKey($in['id'], (array) $outs); } public function testSmartGroupWithHaving() { @@ -107,12 +119,77 @@ class SavedSearchTest extends UnitTestCase { ], ])->first(); - // Oops we don't have an api4 syntax yet for selecting contacts in a group. - $ins = civicrm_api3('Contact', 'get', ['group' => $savedSearch['group']['name'], 'options' => ['limit' => 0]]); - $this->assertCount(2, $ins['values']); - $this->assertArrayHasKey($in['id'], $ins['values']); - $this->assertArrayHasKey($in2['id'], $ins['values']); - $this->assertArrayNotHasKey($out['id'], $ins['values']); + $ins = civicrm_api4('Contact', 'get', [ + 'where' => [['groups', 'IN', [$savedSearch['group']['id']]]], + ])->indexBy('id'); + $this->assertCount(2, $ins); + $this->assertArrayHasKey($in['id'], (array) $ins); + $this->assertArrayHasKey($in2['id'], (array) $ins); + + $outs = civicrm_api4('Contact', 'get', [ + 'where' => [['groups', 'NOT IN', [$savedSearch['group']['id']]]], + ])->indexBy('id'); + $this->assertArrayHasKey($out['id'], (array) $outs); + $this->assertArrayNotHasKey($in['id'], (array) $outs); + $this->assertArrayNotHasKey($in2['id'], (array) $outs); + } + + public function testMultipleSmartGroups() { + $inGroup = $outGroup = []; + $inName = uniqid('inGroup'); + $outName = uniqid('outGroup'); + for ($i = 0; $i < 10; ++$i) { + $inGroup[] = Contact::create(FALSE) + ->setValues(['first_name' => "$i", 'last_name' => $inName]) + ->execute()->first()['id']; + $outGroup[] = Contact::create(FALSE) + ->setValues(['first_name' => "$i", 'last_name' => $outName]) + ->execute()->first()['id']; + } + + $savedSearchA = civicrm_api4('SavedSearch', 'create', [ + 'values' => [ + 'api_entity' => 'Contact', + 'api_params' => [ + 'version' => 4, + 'where' => [ + ['last_name', '=', $inName], + ], + ], + ], + 'chain' => [ + 'group' => ['Group', 'create', ['values' => ['title' => 'In A Test', 'saved_search_id' => '$id']], 0], + ], + ])->first(); + + $savedSearchB = civicrm_api4('SavedSearch', 'create', [ + 'values' => [ + 'api_entity' => 'Contact', + 'api_params' => [ + 'version' => 4, + 'where' => [ + ['last_name', 'IN', [$inName, $outName]], + ['first_name', '>', '4'], + ], + ], + ], + 'chain' => [ + 'group' => ['Group', 'create', ['values' => ['title' => 'In B Test', 'saved_search_id' => '$id']], 0], + ], + ])->first(); + + $bothGroups = civicrm_api4('Contact', 'get', [ + 'where' => [['groups:name', 'IN', [$savedSearchA['group']['name'], $savedSearchB['group']['name']]]], + ]); + $this->assertCount(15, $bothGroups); + + $aNotB = civicrm_api4('Contact', 'get', [ + 'where' => [ + ['groups:name', 'IN', [$savedSearchA['group']['name']]], + ['groups:name', 'NOT IN', [$savedSearchB['group']['name']]], + ], + ]); + $this->assertCount(5, $aNotB); } } -- 2.25.1