Skip to content

Commit

Permalink
CIVICRM-2174 SearchKit, add case-sensitive pattern matching search op…
Browse files Browse the repository at this point in the history
…erator
  • Loading branch information
agileware-justin committed Oct 13, 2023
1 parent 8296d54 commit 6dd43f0
Show file tree
Hide file tree
Showing 10 changed files with 105 additions and 23 deletions.
8 changes: 6 additions & 2 deletions Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,12 @@ public static function filterCompare(array $row, array $condition, int $index =

case 'REGEXP':
case 'NOT REGEXP':
$pattern = '/' . str_replace('/', '\\/', $expected) . '/';
return !preg_match($pattern, $value) == ($operator != 'REGEXP');
case 'REGEXP BINARY':
case 'NOT REGEXP BINARY':
// Perform case-sensitive matching for BINARY operator, otherwise insensitive
$i = str_ends_with($operator, 'BINARY') ? '' : 'i';
$pattern = '/' . str_replace('/', '\\/', $expected) . "/$i";
return !preg_match($pattern, $value) == str_starts_with($operator, 'NOT');

case 'IN':
return in_array($value, $expected);
Expand Down
5 changes: 3 additions & 2 deletions Civi/Api4/Query/Api4Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
* * '=', '<=', '>=', '>', '<', 'LIKE', "<>", "!=",
* * 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN',
* * 'IS NOT NULL', 'IS NULL', 'CONTAINS', 'NOT CONTAINS',
* * 'IS EMPTY', 'IS NOT EMPTY', 'REGEXP', 'NOT REGEXP'.
* * 'IS EMPTY', 'IS NOT EMPTY', 'REGEXP', 'NOT REGEXP'
* * 'REGEXP BINARY', 'NOT REGEXP BINARY'
*/
abstract class Api4Query {

Expand Down Expand Up @@ -402,7 +403,7 @@ protected function createSQLClause($fieldAlias, $operator, $value, $field, int $
}
}

if ($operator == 'REGEXP' || $operator == 'NOT REGEXP') {
if ($operator == 'REGEXP' || $operator == 'NOT REGEXP' || $operator == 'REGEXP BINARY' || $operator == 'NOT REGEXP BINARY') {
return sprintf('%s %s "%s"', $fieldAlias, $operator, \CRM_Core_DAO::escapeString($value));
}

Expand Down
2 changes: 2 additions & 0 deletions Civi/Api4/Utils/CoreUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ public static function getOperators() {
$operators[] = 'IS NOT EMPTY';
$operators[] = 'REGEXP';
$operators[] = 'NOT REGEXP';
$operators[] = 'REGEXP BINARY';
$operators[] = 'NOT REGEXP BINARY';
return $operators;
}

Expand Down
2 changes: 2 additions & 0 deletions ext/afform/core/Civi/Afform/Utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ public static function getSearchOperators() {
'NOT LIKE' => E::ts('Not Like'),
'REGEXP' => E::ts('Matches Pattern'),
'NOT REGEXP' => E::ts("Doesn't Match Pattern"),
'REGEXP BINARY' => E::ts('Matches Pattern (case-sensitive)'),
'NOT REGEXP BINARY' => E::ts("Doesn't Match Pattern (case-sensitive)"),
];
}

Expand Down
2 changes: 2 additions & 0 deletions ext/search_kit/Civi/Search/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ public static function getOperators():array {
'NOT LIKE' => E::ts('Not Like'),
'REGEXP' => E::ts('Matches Pattern'),
'NOT REGEXP' => E::ts("Doesn't Match Pattern"),
'REGEXP BINARY' => E::ts('Matches Pattern (case-sensitive)'),
'NOT REGEXP BINARY' => E::ts("Doesn't Match Pattern (case-sensitive)"),
'BETWEEN' => E::ts('Is Between'),
'NOT BETWEEN' => E::ts('Not Between'),
'IS EMPTY' => E::ts('Is Empty'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
this.getTemplate = function() {
var field = ctrl.field || {};

if (_.includes(['LIKE', 'NOT LIKE', 'REGEXP', 'NOT REGEXP'], ctrl.op)) {
if (_.includes(['LIKE', 'NOT LIKE', 'REGEXP', 'NOT REGEXP', 'REGEXP BINARY', 'NOT REGEXP BINARY'], ctrl.op)) {
return '~/crmSearchTasks/crmSearchInput/text.html';
}

Expand Down
2 changes: 1 addition & 1 deletion ext/search_kit/css/crmSearchAdmin.css
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@
}

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

#bootstrap-theme.crm-search input[type=number] {
Expand Down
62 changes: 60 additions & 2 deletions tests/phpunit/api/v4/Action/ContactGetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,19 +215,77 @@ public function testRegexpOperators(): void {
->setValues(['first_name' => 'Jane', 'last_name' => $last_name])
->execute()->first();

$holly = Contact::create()
->setValues(['first_name' => 'holly', 'last_name' => $last_name])
->execute()->first();

$meg = Contact::create()
->setValues(['first_name' => 'meg', 'last_name' => $last_name])
->execute()->first();

$jess = Contact::create()
->setValues(['first_name' => 'jess', 'last_name' => $last_name])
->execute()->first();

$amy = Contact::create()
->setValues(['first_name' => 'amy', 'last_name' => $last_name])
->execute()->first();

$result = Contact::get(FALSE)
->addWhere('last_name', '=', $last_name)
->addWhere('first_name', 'REGEXP', '^A')
->execute()->indexBy('id');
$this->assertCount(2, $result);
$this->assertCount(3, $result);
$this->assertArrayHasKey($alice['id'], (array) $result);
$this->assertArrayHasKey($alex['id'], (array) $result);
$this->assertArrayHasKey($amy['id'], (array) $result);

$result = Contact::get(FALSE)
->addWhere('last_name', '=', $last_name)
->addWhere('first_name', 'NOT REGEXP', '^A')
->execute()->indexBy('id');
$this->assertCount(1, $result);
$this->assertCount(4, $result);
$this->assertArrayHasKey($jane['id'], (array) $result);
$this->assertArrayHasKey($holly['id'], (array) $result);
$this->assertArrayHasKey($meg['id'], (array) $result);
$this->assertArrayHasKey($jess['id'], (array) $result);

$result = Contact::get(FALSE)
->addWhere('last_name', '=', $last_name)
->addWhere('first_name', 'REGEXP BINARY', '^[A-Z]')
->execute()->indexBy('id');
$this->assertCount(3, $result);
$this->assertArrayHasKey($alice['id'], (array) $result);
$this->assertArrayHasKey($alex['id'], (array) $result);
$this->assertArrayHasKey($jane['id'], (array) $result);

$result = Contact::get(FALSE)
->addWhere('last_name', '=', $last_name)
->addWhere('first_name', 'REGEXP BINARY', '^[a-z]')
->execute()->indexBy('id');
$this->assertCount(4, $result);
$this->assertArrayHasKey($holly['id'], (array) $result);
$this->assertArrayHasKey($meg['id'], (array) $result);
$this->assertArrayHasKey($jess['id'], (array) $result);
$this->assertArrayHasKey($amy['id'], (array) $result);

$result = Contact::get(FALSE)
->addWhere('last_name', '=', $last_name)
->addWhere('first_name', 'NOT REGEXP BINARY', '^[A-Z]')
->execute()->indexBy('id');
$this->assertCount(4, $result);
$this->assertArrayHasKey($holly['id'], (array) $result);
$this->assertArrayHasKey($meg['id'], (array) $result);
$this->assertArrayHasKey($jess['id'], (array) $result);
$this->assertArrayHasKey($amy['id'], (array) $result);

$result = Contact::get(FALSE)
->addWhere('last_name', '=', $last_name)
->addWhere('first_name', 'NOT REGEXP BINARY', '^[a-z]')
->execute()->indexBy('id');
$this->assertCount(3, $result);
$this->assertArrayHasKey($alice['id'], (array) $result);
$this->assertArrayHasKey($alex['id'], (array) $result);
$this->assertArrayHasKey($jane['id'], (array) $result);
}

Expand Down
35 changes: 20 additions & 15 deletions tests/phpunit/api/v4/Action/GetFromArrayTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,26 +37,26 @@ public function testArrayGetWithLimit(): void {

// The object's count() method will account for all results, ignoring limit, while the array results are limited
$this->assertCount(2, (array) $result);
$this->assertCount(5, $result);
$this->assertCount(6, $result);
}

public function testArrayGetWithSort(): void {
$result = MockArrayEntity::get()
->addOrderBy('field1', 'DESC')
->execute();
$this->assertEquals([5, 4, 3, 2, 1], array_column((array) $result, 'field1'));
$this->assertEquals([6, 5, 4, 3, 2, 1], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addOrderBy('field5', 'DESC')
->addOrderBy('field2', 'ASC')
->execute();
$this->assertEquals([3, 2, 5, 4, 1], array_column((array) $result, 'field1'));
$this->assertEquals([3, 2, 5, 4, 1, 6], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addOrderBy('field3', 'ASC')
->addOrderBy('field2', 'ASC')
->execute();
$this->assertEquals([3, 1, 2, 5, 4], array_column((array) $result, 'field1'));
$this->assertEquals([3, 1, 2, 5, 4, 6], array_column((array) $result, 'field1'));
}

public function testArrayGetWithSelect(): void {
Expand Down Expand Up @@ -95,12 +95,12 @@ public function testArrayGetWithWhere(): void {
->addWhere('field5', '!=', 'banana')
->addWhere('field3', 'IS NOT NULL')
->execute();
$this->assertEquals([4, 5], array_column((array) $result, 'field1'));
$this->assertEquals([4, 5, 6], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field1', '>=', '4')
->execute();
$this->assertEquals([4, 5], array_column((array) $result, 'field1'));
$this->assertEquals([4, 5, 6], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field1', '<', '2')
Expand All @@ -115,13 +115,23 @@ public function testArrayGetWithWhere(): void {
$result = MockArrayEntity::get()
->addWhere('field2', 'REGEXP', '(zebra|yac[a-z]|something/else)')
->execute();
$this->assertEquals([1, 2], array_column((array) $result, 'field1'));
$this->assertEquals([1, 2, 6], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field2', 'NOT REGEXP', '^[x|y|z]')
->execute();
$this->assertEquals([4, 5], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field2', 'REGEXP BINARY', 'Yack')
->execute();
$this->assertEquals([6], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field5', 'NOT REGEXP BINARY', 'Apple')
->execute();
$this->assertEquals([1, 2, 3, 4, 5], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field3', 'IS NULL')
->execute();
Expand All @@ -145,17 +155,12 @@ public function testArrayGetWithWhere(): void {
$result = MockArrayEntity::get()
->addWhere('field2', 'NOT LIKE', '%ra%')
->execute();
$this->assertEquals([2, 4, 5], array_column((array) $result, 'field1'));
$this->assertEquals([2, 4, 5, 6], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field6', '=', '0')
->execute();
$this->assertEquals([3, 4, 5], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field6', '=', 0)
->execute();
$this->assertEquals([3, 4, 5], array_column((array) $result, 'field1'));
$this->assertEquals([3, 4, 5, 6], array_column((array) $result, 'field1'));

$result = MockArrayEntity::get()
->addWhere('field1', 'BETWEEN', [3, 5])
Expand All @@ -165,7 +170,7 @@ public function testArrayGetWithWhere(): void {
$result = MockArrayEntity::get()
->addWhere('field1', 'NOT BETWEEN', [3, 4])
->execute();
$this->assertEquals([1, 2, 5], array_column((array) $result, 'field1'));
$this->assertEquals([1, 2, 5, 6], array_column((array) $result, 'field1'));
}

public function testArrayGetWithNestedWhereClauses(): void {
Expand Down
8 changes: 8 additions & 0 deletions tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ public function getRecords() {
'field5' => 'apple',
'field6' => 0,
],
[
'field1' => 6,
'field2' => 'Yack',
'field3' => 1,
'field4' => [4, 5, 6],
'field5' => 'Apple',
'field6' => 0,
],
];
}

Expand Down

0 comments on commit 6dd43f0

Please sign in to comment.