Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dev/event#37 Add CONTAINS operator for APIv4 & Search #18285

Merged
merged 3 commits into from
Aug 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CRM/Api4/Page/Api4Explorer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

use Civi\Api4\Service\Schema\Joinable\Joinable;
use Civi\Api4\Utils\CoreUtil;

/**
*
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion CRM/Core/DAO.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
6 changes: 4 additions & 2 deletions Civi/Api4/Generic/AbstractQueryAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
*
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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];
Expand Down
3 changes: 2 additions & 1 deletion Civi/Api4/Generic/DAOGetAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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];
Expand Down
9 changes: 9 additions & 0 deletions Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
36 changes: 30 additions & 6 deletions Civi/Api4/Query/Api4SelectQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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');
}

Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -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;
}
}

Expand Down
9 changes: 9 additions & 0 deletions Civi/Api4/Utils/CoreUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,13 @@ public static function getApiNameFromTableName($tableName) {
return $entityName;
}

/**
* @return string[]
*/
public static function getOperators() {
$operators = \CRM_Core_DAO::acceptedSQLOperators();
$operators[] = 'CONTAINS';
return $operators;
}

}
25 changes: 24 additions & 1 deletion ext/search/CRM/Search/Page/Ang.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function run() {

// Add client-side vars for the search UI
$vars = [
'operators' => \CRM_Core_DAO::acceptedSQLOperators(),
'operators' => CRM_Utils_Array::makeNonAssociative($this->getOperators()),
'schema' => $this->schema,
'links' => $this->getLinks(),
'loadOptions' => $this->loadOptions,
Expand All @@ -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
*/
Expand Down
3 changes: 3 additions & 0 deletions ext/search/ang/search/crmSearch.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,9 @@
else if (type === 'Money') {
return CRM.formatMoney(value);
}
if (_.isArray(value)) {
return value.join(', ');
}
return value;
}

Expand Down
2 changes: 1 addition & 1 deletion ext/search/ang/search/crmSearchClause.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
</div>
<div ng-if="!$ctrl.conjunctions[clause[0]]" class="api4-input-group">
<input class="form-control" ng-model="clause[0]" crm-ui-select="{data: data.fields, allowClear: true, placeholder: 'Field'}" />
<select class="form-control api4-operator" ng-model="clause[1]" ng-options="o for o in $ctrl.operators" ></select>
<select class="form-control api4-operator" ng-model="clause[1]" ng-options="o.key as o.value for o in $ctrl.operators" ></select>
<input class="form-control" ng-model="clause[2]" crm-search-value="{field: clause[0], op: clause[1], format: data.format}" />
</div>
<fieldset class="clearfix" ng-if="$ctrl.conjunctions[clause[0]]" crm-search-clause="{format: data.format, clauses: clause[1], op: clause[0], fields: data.fields, groupParent: data.clauses, groupIndex: index}">
Expand Down
4 changes: 2 additions & 2 deletions ext/search/ang/search/crmSearchValue.directive.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'], 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');
Expand Down
2 changes: 1 addition & 1 deletion ext/search/css/search.css
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
}

#bootstrap-theme.crm-search .api4-operator {
width: 90px;
width: 110px;
}

#bootstrap-theme.crm-search .api4-add-where-group-menu {
Expand Down
43 changes: 43 additions & 0 deletions tests/phpunit/api/v4/Action/BasicActionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
26 changes: 26 additions & 0 deletions tests/phpunit/api/v4/Action/PseudoconstantTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Civi\Api4\Email;
use Civi\Api4\EntityTag;
use Civi\Api4\OptionValue;
use Civi\Api4\Participant;
use Civi\Api4\Tag;

/**
Expand Down Expand Up @@ -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);
}

}