From 6dd43f076916e16d88d6733737f15c65576c758a Mon Sep 17 00:00:00 2001 From: Justin Freeman Date: Tue, 19 Sep 2023 17:25:58 +1000 Subject: [PATCH] CIVICRM-2174 SearchKit, add case-sensitive pattern matching search operator --- .../Generic/Traits/ArrayQueryActionTrait.php | 8 ++- Civi/Api4/Query/Api4Query.php | 5 +- Civi/Api4/Utils/CoreUtil.php | 2 + ext/afform/core/Civi/Afform/Utils.php | 2 + ext/search_kit/Civi/Search/Admin.php | 2 + .../crmSearchInputVal.component.js | 2 +- ext/search_kit/css/crmSearchAdmin.css | 2 +- .../phpunit/api/v4/Action/ContactGetTest.php | 62 ++++++++++++++++++- .../api/v4/Action/GetFromArrayTest.php | 35 ++++++----- .../Mock/Api4/Action/MockArrayEntity/Get.php | 8 +++ 10 files changed, 105 insertions(+), 23 deletions(-) diff --git a/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php b/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php index d0e943a9751b..eee54e51992f 100644 --- a/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php +++ b/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php @@ -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); diff --git a/Civi/Api4/Query/Api4Query.php b/Civi/Api4/Query/Api4Query.php index a40cfd4a67e7..4f13808e9384 100644 --- a/Civi/Api4/Query/Api4Query.php +++ b/Civi/Api4/Query/Api4Query.php @@ -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 { @@ -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)); } diff --git a/Civi/Api4/Utils/CoreUtil.php b/Civi/Api4/Utils/CoreUtil.php index 75dc24b5fbd7..80abe31d5de6 100644 --- a/Civi/Api4/Utils/CoreUtil.php +++ b/Civi/Api4/Utils/CoreUtil.php @@ -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; } diff --git a/ext/afform/core/Civi/Afform/Utils.php b/ext/afform/core/Civi/Afform/Utils.php index 613787f0c6ee..9032fbb4e9c9 100644 --- a/ext/afform/core/Civi/Afform/Utils.php +++ b/ext/afform/core/Civi/Afform/Utils.php @@ -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)"), ]; } diff --git a/ext/search_kit/Civi/Search/Admin.php b/ext/search_kit/Civi/Search/Admin.php index 8232f2523325..08c08fd0702c 100644 --- a/ext/search_kit/Civi/Search/Admin.php +++ b/ext/search_kit/Civi/Search/Admin.php @@ -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'), diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js index 102e2bc36f78..8225a2f7e8ba 100644 --- a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js +++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js @@ -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'; } diff --git a/ext/search_kit/css/crmSearchAdmin.css b/ext/search_kit/css/crmSearchAdmin.css index 375f63358b05..02a8e323f692 100644 --- a/ext/search_kit/css/crmSearchAdmin.css +++ b/ext/search_kit/css/crmSearchAdmin.css @@ -156,7 +156,7 @@ } #bootstrap-theme.crm-search .api4-operator { - width: 110px; + width: 235px; } #bootstrap-theme.crm-search input[type=number] { diff --git a/tests/phpunit/api/v4/Action/ContactGetTest.php b/tests/phpunit/api/v4/Action/ContactGetTest.php index 7e776833f3f3..7c684291adcc 100644 --- a/tests/phpunit/api/v4/Action/ContactGetTest.php +++ b/tests/phpunit/api/v4/Action/ContactGetTest.php @@ -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); } diff --git a/tests/phpunit/api/v4/Action/GetFromArrayTest.php b/tests/phpunit/api/v4/Action/GetFromArrayTest.php index 36ec00d5f27a..8f9835057319 100644 --- a/tests/phpunit/api/v4/Action/GetFromArrayTest.php +++ b/tests/phpunit/api/v4/Action/GetFromArrayTest.php @@ -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 { @@ -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') @@ -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(); @@ -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]) @@ -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 { diff --git a/tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php b/tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php index 1ef8b198358d..3748a1c010b3 100644 --- a/tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php +++ b/tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php @@ -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, + ], ]; }