From 9d2afe25e2566c1fede83ba9e61fd74f49cd86dc Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Fri, 28 Aug 2020 13:07:08 -0400 Subject: [PATCH 1/3] APIv4 - Move list of accepted query operators to CoreUtil --- CRM/Api4/Page/Api4Explorer.php | 3 ++- CRM/Core/DAO.php | 2 +- Civi/Api4/Generic/AbstractQueryAction.php | 6 ++++-- Civi/Api4/Generic/DAOGetAction.php | 3 ++- Civi/Api4/Query/Api4SelectQuery.php | 2 +- Civi/Api4/Utils/CoreUtil.php | 7 +++++++ ext/search/CRM/Search/Page/Ang.php | 2 +- 7 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CRM/Api4/Page/Api4Explorer.php b/CRM/Api4/Page/Api4Explorer.php index 3daba0733836..6de079bb8f15 100644 --- a/CRM/Api4/Page/Api4Explorer.php +++ b/CRM/Api4/Page/Api4Explorer.php @@ -11,6 +11,7 @@ */ use Civi\Api4\Service\Schema\Joinable\Joinable; +use Civi\Api4\Utils\CoreUtil; /** * @@ -30,7 +31,7 @@ public function run() { }); } $vars = [ - 'operators' => \CRM_Core_DAO::acceptedSQLOperators(), + 'operators' => CoreUtil::getOperators(), 'basePath' => Civi::resources()->getUrl('civicrm'), 'schema' => (array) \Civi\Api4\Entity::get()->setChain(['fields' => ['$name', 'getFields']])->execute(), 'links' => $entityLinks, diff --git a/CRM/Core/DAO.php b/CRM/Core/DAO.php index 4c33f8002ec3..d62fba1401e7 100644 --- a/CRM/Core/DAO.php +++ b/CRM/Core/DAO.php @@ -2830,7 +2830,7 @@ public static function createSQLFilter($fieldName, $filter, $type = NULL, $alias /** * @see http://issues.civicrm.org/jira/browse/CRM-9150 * support for other syntaxes is discussed in ticket but being put off for now - * @return array + * @return string[] */ public static function acceptedSQLOperators() { return [ diff --git a/Civi/Api4/Generic/AbstractQueryAction.php b/Civi/Api4/Generic/AbstractQueryAction.php index ed7ac9bf374d..85ebcfb5edd5 100644 --- a/Civi/Api4/Generic/AbstractQueryAction.php +++ b/Civi/Api4/Generic/AbstractQueryAction.php @@ -19,6 +19,8 @@ namespace Civi\Api4\Generic; +use Civi\Api4\Utils\CoreUtil; + /** * Base class for all actions that need to fetch records (`Get`, `Update`, `Delete`, etc.). * @@ -86,7 +88,7 @@ abstract class AbstractQueryAction extends AbstractAction { * @throws \API_Exception */ public function addWhere(string $fieldName, string $op, $value = NULL) { - if (!in_array($op, \CRM_Core_DAO::acceptedSQLOperators())) { + if (!in_array($op, CoreUtil::getOperators())) { throw new \API_Exception('Unsupported operator'); } $this->where[] = [$fieldName, $op, $value]; @@ -145,7 +147,7 @@ protected function whereClauseToString($whereClause = NULL, $op = 'AND') { } return $output . '(' . $this->whereClauseToString($whereClause, $op) . ')'; } - elseif (isset($whereClause[1]) && in_array($whereClause[1], \CRM_Core_DAO::acceptedSQLOperators())) { + elseif (isset($whereClause[1]) && in_array($whereClause[1], CoreUtil::getOperators())) { $output = $whereClause[0] . ' ' . $whereClause[1] . ' '; if (isset($whereClause[2])) { $output .= is_array($whereClause[2]) ? '[' . implode(', ', $whereClause[2]) . ']' : $whereClause[2]; diff --git a/Civi/Api4/Generic/DAOGetAction.php b/Civi/Api4/Generic/DAOGetAction.php index d6a3f10eea2a..852a333ccff0 100644 --- a/Civi/Api4/Generic/DAOGetAction.php +++ b/Civi/Api4/Generic/DAOGetAction.php @@ -20,6 +20,7 @@ namespace Civi\Api4\Generic; use Civi\Api4\Query\Api4SelectQuery; +use Civi\Api4\Utils\CoreUtil; /** * Retrieve $ENTITIES based on criteria specified in the `where` parameter. @@ -154,7 +155,7 @@ public function addGroupBy(string $field) { * @throws \API_Exception */ public function addHaving(string $expr, string $op, $value = NULL) { - if (!in_array($op, \CRM_Core_DAO::acceptedSQLOperators())) { + if (!in_array($op, CoreUtil::getOperators())) { throw new \API_Exception('Unsupported operator'); } $this->having[] = [$expr, $op, $value]; diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php index 5721e919688e..6198b93603c2 100644 --- a/Civi/Api4/Query/Api4SelectQuery.php +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -362,7 +362,7 @@ protected function treeWalkClauses($clause, $type) { protected function composeClause(array $clause, string $type) { // Pad array for unary operators list($expr, $operator, $value) = array_pad($clause, 3, NULL); - if (!in_array($operator, \CRM_Core_DAO::acceptedSQLOperators(), TRUE)) { + if (!in_array($operator, CoreUtil::getOperators(), TRUE)) { throw new \API_Exception('Illegal operator'); } diff --git a/Civi/Api4/Utils/CoreUtil.php b/Civi/Api4/Utils/CoreUtil.php index 8d06ce96789b..9bc7ec9b6a42 100644 --- a/Civi/Api4/Utils/CoreUtil.php +++ b/Civi/Api4/Utils/CoreUtil.php @@ -71,4 +71,11 @@ public static function getApiNameFromTableName($tableName) { return $entityName; } + /** + * @return string[] + */ + public static function getOperators() { + return \CRM_Core_DAO::acceptedSQLOperators(); + } + } diff --git a/ext/search/CRM/Search/Page/Ang.php b/ext/search/CRM/Search/Page/Ang.php index 069dc0ee973f..936f92edd619 100644 --- a/ext/search/CRM/Search/Page/Ang.php +++ b/ext/search/CRM/Search/Page/Ang.php @@ -32,7 +32,7 @@ public function run() { // Add client-side vars for the search UI $vars = [ - 'operators' => \CRM_Core_DAO::acceptedSQLOperators(), + 'operators' => \Civi\Api4\Utils\CoreUtil::getOperators(), 'schema' => $this->schema, 'links' => $this->getLinks(), 'loadOptions' => $this->loadOptions, From 39deabd60329563c9808673f3c30d25883960f2e Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Fri, 28 Aug 2020 20:56:16 -0400 Subject: [PATCH 2/3] APIv4 - Add CONTAINS operator and add to search extension --- .../Generic/Traits/ArrayQueryActionTrait.php | 9 ++++ Civi/Api4/Query/Api4SelectQuery.php | 34 ++++++++++++--- Civi/Api4/Utils/CoreUtil.php | 4 +- ext/search/ang/search/crmSearch.component.js | 3 ++ .../ang/search/crmSearchValue.directive.js | 2 +- .../api/v4/Action/BasicActionsTest.php | 43 +++++++++++++++++++ .../api/v4/Action/PseudoconstantTest.php | 26 +++++++++++ 7 files changed, 114 insertions(+), 7 deletions(-) diff --git a/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php b/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php index 4ae6096c5039..fd515b9a4b71 100644 --- a/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php +++ b/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php @@ -158,6 +158,15 @@ private function filterCompare($row, $condition) { case 'NOT IN': return !in_array($value, $expected); + case 'CONTAINS': + if (is_array($value)) { + return in_array($expected, $value); + } + elseif (is_string($value) || is_numeric($value)) { + return strpos((string) $value, (string) $expected) !== FALSE; + } + return $value == $expected; + default: throw new NotImplementedException("Unsupported operator: '$operator' cannot be used with array data"); } diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php index 6198b93603c2..e4f8ceb187fc 100644 --- a/Civi/Api4/Query/Api4SelectQuery.php +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -26,8 +26,8 @@ * Leaf operators are one of: * * * '=', '<=', '>=', '>', '<', 'LIKE', "<>", "!=", - * * "NOT LIKE", 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', - * * 'IS NOT NULL', or 'IS NULL'. + * * 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', + * * 'IS NOT NULL', or 'IS NULL', 'CONTAINS'. */ class Api4SelectQuery { @@ -379,7 +379,8 @@ protected function composeClause(array $clause, string $type) { $fieldAlias = $expr; // Attempt to format if this is a real field if (isset($this->apiFieldSpec[$expr])) { - FormattingUtil::formatInputValue($value, $expr, $this->apiFieldSpec[$expr]); + $field = $this->getField($expr); + FormattingUtil::formatInputValue($value, $expr, $field); } } // Expr references a non-field expression like a function; convert to alias @@ -392,7 +393,8 @@ protected function composeClause(array $clause, string $type) { foreach ($this->selectAliases as $selectAlias => $selectExpr) { list($selectField) = explode(':', $selectAlias); if ($selectAlias === $selectExpr && $fieldName === $selectField && isset($this->apiFieldSpec[$fieldName])) { - FormattingUtil::formatInputValue($value, $expr, $this->apiFieldSpec[$fieldName]); + $field = $this->getField($fieldName); + FormattingUtil::formatInputValue($value, $expr, $field); $fieldAlias = $selectAlias; break; } @@ -415,7 +417,29 @@ protected function composeClause(array $clause, string $type) { return sprintf('%s %s %s', $fieldAlias, $operator, $valExpr->render($this->apiFieldSpec)); } elseif ($fieldName) { - FormattingUtil::formatInputValue($value, $fieldName, $this->apiFieldSpec[$fieldName]); + $field = $this->getField($fieldName); + FormattingUtil::formatInputValue($value, $fieldName, $field); + } + } + + if ($operator === 'CONTAINS') { + switch ($field['serialize'] ?? NULL) { + case \CRM_Core_DAO::SERIALIZE_JSON: + $operator = 'LIKE'; + $value = '%"' . $value . '"%'; + // FIXME: Use this instead of the above hack once MIN_INSTALL_MYSQL_VER is bumped to 5.7. + // return sprintf('JSON_SEARCH(%s, "one", "%s") IS NOT NULL', $fieldAlias, \CRM_Core_DAO::escapeString($value)); + break; + + case \CRM_Core_DAO::SERIALIZE_SEPARATOR_BOOKEND: + $operator = 'LIKE'; + $value = '%' . \CRM_Core_DAO::VALUE_SEPARATOR . $value . \CRM_Core_DAO::VALUE_SEPARATOR . '%'; + break; + + default: + $operator = 'LIKE'; + $value = '%' . $value . '%'; + break; } } diff --git a/Civi/Api4/Utils/CoreUtil.php b/Civi/Api4/Utils/CoreUtil.php index 9bc7ec9b6a42..b9090c97dec2 100644 --- a/Civi/Api4/Utils/CoreUtil.php +++ b/Civi/Api4/Utils/CoreUtil.php @@ -75,7 +75,9 @@ public static function getApiNameFromTableName($tableName) { * @return string[] */ public static function getOperators() { - return \CRM_Core_DAO::acceptedSQLOperators(); + $operators = \CRM_Core_DAO::acceptedSQLOperators(); + $operators[] = 'CONTAINS'; + return $operators; } } diff --git a/ext/search/ang/search/crmSearch.component.js b/ext/search/ang/search/crmSearch.component.js index e2d8c564d15e..51f2532bc49a 100644 --- a/ext/search/ang/search/crmSearch.component.js +++ b/ext/search/ang/search/crmSearch.component.js @@ -340,6 +340,9 @@ else if (type === 'Money') { return CRM.formatMoney(value); } + if (_.isArray(value)) { + return value.join(', '); + } return value; } diff --git a/ext/search/ang/search/crmSearchValue.directive.js b/ext/search/ang/search/crmSearchValue.directive.js index 0fb160088402..ad93d2b8788c 100644 --- a/ext/search/ang/search/crmSearchValue.directive.js +++ b/ext/search/ang/search/crmSearchValue.directive.js @@ -39,7 +39,7 @@ if (_.includes(['=', '!=', '<>', '>', '>=', '<', '<='], op)) { $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false}); } - } else if (_.includes(['=', '!=', '<>', 'IN', 'NOT IN'], op) && (field.fk_entity || field.options || dataType === 'Boolean')) { + } else if (_.includes(['=', '!=', '<>', 'IN', 'NOT IN', 'CONTAINS'], op) && (field.fk_entity || field.options || dataType === 'Boolean')) { if (field.options) { if (field.options === true) { $el.addClass('loading'); diff --git a/tests/phpunit/api/v4/Action/BasicActionsTest.php b/tests/phpunit/api/v4/Action/BasicActionsTest.php index 99d704a18f99..8df90eb87782 100644 --- a/tests/phpunit/api/v4/Action/BasicActionsTest.php +++ b/tests/phpunit/api/v4/Action/BasicActionsTest.php @@ -255,6 +255,49 @@ public function testWildcardSelect() { $this->assertEquals(['shape', 'size', 'weight'], array_keys($result)); } + public function testContainsOperator() { + MockBasicEntity::delete()->addWhere('id', '>', 0)->execute(); + + $records = [ + ['group' => 'one', 'fruit:name' => ['apple', 'pear'], 'weight' => 11], + ['group' => 'two', 'fruit:name' => ['pear', 'banana'], 'weight' => 12], + ]; + MockBasicEntity::save()->setRecords($records)->execute(); + + $result = MockBasicEntity::get() + ->addWhere('fruit:name', 'CONTAINS', 'apple') + ->execute(); + $this->assertCount(1, $result); + $this->assertEquals('one', $result->first()['group']); + + $result = MockBasicEntity::get() + ->addWhere('fruit:name', 'CONTAINS', 'pear') + ->execute(); + $this->assertCount(2, $result); + + $result = MockBasicEntity::get() + ->addWhere('group', 'CONTAINS', 'o') + ->execute(); + $this->assertCount(2, $result); + + $result = MockBasicEntity::get() + ->addWhere('weight', 'CONTAINS', 1) + ->execute(); + $this->assertCount(2, $result); + + $result = MockBasicEntity::get() + ->addWhere('fruit:label', 'CONTAINS', 'Banana') + ->execute(); + $this->assertCount(1, $result); + $this->assertEquals('two', $result->first()['group']); + + $result = MockBasicEntity::get() + ->addWhere('weight', 'CONTAINS', 2) + ->execute(); + $this->assertCount(1, $result); + $this->assertEquals('two', $result->first()['group']); + } + public function testPseudoconstantMatch() { MockBasicEntity::delete()->addWhere('id', '>', 0)->execute(); diff --git a/tests/phpunit/api/v4/Action/PseudoconstantTest.php b/tests/phpunit/api/v4/Action/PseudoconstantTest.php index 9f7e363941de..248073ab8d42 100644 --- a/tests/phpunit/api/v4/Action/PseudoconstantTest.php +++ b/tests/phpunit/api/v4/Action/PseudoconstantTest.php @@ -27,6 +27,7 @@ use Civi\Api4\Email; use Civi\Api4\EntityTag; use Civi\Api4\OptionValue; +use Civi\Api4\Participant; use Civi\Api4\Tag; /** @@ -273,4 +274,29 @@ public function testTagOptions() { $this->assertEquals($tag, $options[$tag]['label']); } + public function testParticipantRole() { + $event = $this->createEntity(['type' => 'Event']); + $contact = $this->createEntity(['type' => 'Individual']); + $participant = Participant::create() + ->addValue('contact_id', $contact['id']) + ->addValue('event_id', $event['id']) + ->addValue('role_id:label', ['Attendee', 'Volunteer']) + ->execute()->first(); + + $search1 = Participant::get() + ->addSelect('role_id', 'role_id:label') + ->addWhere('role_id:label', 'CONTAINS', 'Volunteer') + ->addOrderBy('id') + ->execute()->last(); + + $this->assertEquals(['Attendee', 'Volunteer'], $search1['role_id:label']); + $this->assertEquals(['1', '2'], $search1['role_id']); + + $search2 = Participant::get() + ->addWhere('role_id:label', 'CONTAINS', 'Host') + ->execute()->indexBy('id'); + + $this->assertArrayNotHasKey($participant['id'], (array) $search2); + } + } From 7156e4b0e8ca27491dce7784b55c0dc39a4f1d25 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Sat, 29 Aug 2020 19:19:06 -0400 Subject: [PATCH 3/3] Search ext - Show translated labels for operators --- ext/search/CRM/Search/Page/Ang.php | 25 ++++++++++++++++++- ext/search/ang/search/crmSearchClause.html | 2 +- .../ang/search/crmSearchValue.directive.js | 4 +-- ext/search/css/search.css | 2 +- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/ext/search/CRM/Search/Page/Ang.php b/ext/search/CRM/Search/Page/Ang.php index 936f92edd619..b6a03e2bbf76 100644 --- a/ext/search/CRM/Search/Page/Ang.php +++ b/ext/search/CRM/Search/Page/Ang.php @@ -32,7 +32,7 @@ public function run() { // Add client-side vars for the search UI $vars = [ - 'operators' => \Civi\Api4\Utils\CoreUtil::getOperators(), + 'operators' => CRM_Utils_Array::makeNonAssociative($this->getOperators()), 'schema' => $this->schema, 'links' => $this->getLinks(), 'loadOptions' => $this->loadOptions, @@ -55,6 +55,29 @@ public function run() { parent::run(); } + /** + * @return string[] + */ + private function getOperators() { + return [ + '=' => '=', + '!=' => '≠', + '>' => '>', + '<' => '<', + '>=' => '≥', + '<=' => '≤', + 'CONTAINS' => ts('Contains'), + 'IN' => ts('Is In'), + 'NOT IN' => ts('Not In'), + 'LIKE' => ts('Is Like'), + 'NOT LIKE' => ts('Not Like'), + 'BETWEEN' => ts('Is Between'), + 'NOT BETWEEN' => ts('Not Between'), + 'IS NULL' => ts('Is Null'), + 'IS NOT NULL' => ts('Not Null'), + ]; + } + /** * Populates $this->schema & $this->allowedEntities */ diff --git a/ext/search/ang/search/crmSearchClause.html b/ext/search/ang/search/crmSearchClause.html index 1a0567f9cf76..97ef49621fad 100644 --- a/ext/search/ang/search/crmSearchClause.html +++ b/ext/search/ang/search/crmSearchClause.html @@ -16,7 +16,7 @@
- +
diff --git a/ext/search/ang/search/crmSearchValue.directive.js b/ext/search/ang/search/crmSearchValue.directive.js index ad93d2b8788c..9e39cf1585b5 100644 --- a/ext/search/ang/search/crmSearchValue.directive.js +++ b/ext/search/ang/search/crmSearchValue.directive.js @@ -36,10 +36,10 @@ return; } if (inputType === 'Date') { - if (_.includes(['=', '!=', '<>', '>', '>=', '<', '<='], op)) { + if (_.includes(['=', '!=', '>', '>=', '<', '<='], op)) { $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false}); } - } else if (_.includes(['=', '!=', '<>', 'IN', 'NOT IN', 'CONTAINS'], op) && (field.fk_entity || field.options || dataType === 'Boolean')) { + } else if (_.includes(['=', '!=', 'IN', 'NOT IN', 'CONTAINS'], op) && (field.fk_entity || field.options || dataType === 'Boolean')) { if (field.options) { if (field.options === true) { $el.addClass('loading'); diff --git a/ext/search/css/search.css b/ext/search/css/search.css index 70b329b56324..554f1f724b6e 100644 --- a/ext/search/css/search.css +++ b/ext/search/css/search.css @@ -133,7 +133,7 @@ } #bootstrap-theme.crm-search .api4-operator { - width: 90px; + width: 110px; } #bootstrap-theme.crm-search .api4-add-where-group-menu {