CIVICRM-2174 SearchKit, add case-sensitive pattern matching search operator
authorJustin Freeman <justin@agileware.com.au>
Tue, 19 Sep 2023 07:25:58 +0000 (17:25 +1000)
committerJustin Freeman <justin@agileware.com.au>
Fri, 13 Oct 2023 20:46:25 +0000 (07:46 +1100)
Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
Civi/Api4/Query/Api4Query.php
Civi/Api4/Utils/CoreUtil.php
ext/afform/core/Civi/Afform/Utils.php
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js
ext/search_kit/css/crmSearchAdmin.css
tests/phpunit/api/v4/Action/ContactGetTest.php
tests/phpunit/api/v4/Action/GetFromArrayTest.php
tests/phpunit/api/v4/Mock/Api4/Action/MockArrayEntity/Get.php

index d0e943a9751b850b46759b34dea9990d49ccdb1a..eee54e51992f3a937a5f99b55937a8b84609d17a 100644 (file)
@@ -166,8 +166,12 @@ trait ArrayQueryActionTrait {
 
       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);
index a40cfd4a67e790417bf76ca599c75b33f4812a9d..4f13808e9384a979da17a48496d0966c7d99a94a 100644 (file)
@@ -27,7 +27,8 @@ use Civi\Api4\Utils\FormattingUtil;
  * * '=', '<=', '>=', '>', '<', '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 @@ abstract class Api4Query {
       }
     }
 
-    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));
     }
 
index 75dc24b5fbd7a42f7fd46e8da6f63f9e3b281a09..80abe31d5de6ca6555238cc7a792e8c7bc7a2f5f 100644 (file)
@@ -150,6 +150,8 @@ class CoreUtil {
     $operators[] = 'IS NOT EMPTY';
     $operators[] = 'REGEXP';
     $operators[] = 'NOT REGEXP';
+    $operators[] = 'REGEXP BINARY';
+    $operators[] = 'NOT REGEXP BINARY';
     return $operators;
   }
 
index 613787f0c6ee271ac3b20eb25e5c1cab16b55381..9032fbb4e9c93038b18de6157fb4672be07cad9c 100644 (file)
@@ -75,6 +75,8 @@ class Utils {
       '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)"),
     ];
   }
 
index 8232f2523325f3e733e4e0dd784dd7dec19c31b7..08c08fd0702c6cbe5001d0d17e2a6f3cf4b12cf2 100644 (file)
@@ -95,6 +95,8 @@ class Admin {
       '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'),
index 102e2bc36f78541ca6ff04d4b6c74ada81cae0ca..8225a2f7e8ba8002a170424cdc7e19ff1e4e752f 100644 (file)
       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';
         }
 
index 375f63358b05228b10576b128ad26a3ee7a0ca3e..02a8e323f6925c1883c15a553a3199ff59095513 100644 (file)
 }
 
 #bootstrap-theme.crm-search .api4-operator {
-  width: 110px;
+  width: 235px;
 }
 
 #bootstrap-theme.crm-search input[type=number] {
index 7e776833f3f3ab3a0c1de857f8774eeafc3b4506..7c684291adcc679e00e8256519923ec5fa310b7a 100644 (file)
@@ -215,19 +215,77 @@ class ContactGetTest extends Api4TestBase implements TransactionalInterface {
       ->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);
   }
 
index 36ec00d5f27af4c442d1c5364c4dd2f76564fd30..8f9835057319b1ce6ac394832bb0c85026aa6d8b 100644 (file)
@@ -37,26 +37,26 @@ class GetFromArrayTest extends Api4TestBase {
 
     // 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 @@ class GetFromArrayTest extends Api4TestBase {
       ->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 @@ class GetFromArrayTest extends Api4TestBase {
     $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 @@ class GetFromArrayTest extends Api4TestBase {
     $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 @@ class GetFromArrayTest extends Api4TestBase {
     $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 {
index 1ef8b198358d6d8aaa388c8de3853296172715cf..3748a1c010b31e1e5dbe11b8c3e6b7a5472258f4 100644 (file)
@@ -64,6 +64,14 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
         'field5' => 'apple',
         'field6' => 0,
       ],
+      [
+        'field1' => 6,
+        'field2' => 'Yack',
+        'field3' => 1,
+        'field4' => [4, 5, 6],
+        'field5' => 'Apple',
+        'field6' => 0,
+      ],
     ];
   }