Add NOT CONTAINS to API and SK
authorlarssandergreen <lars@wildsight.ca>
Sun, 28 May 2023 19:16:38 +0000 (13:16 -0600)
committerlarssandergreen <lars@wildsight.ca>
Sun, 28 May 2023 19:16:38 +0000 (13:16 -0600)
Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Utils/CoreUtil.php
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchAdmin/crmSearchCondition.component.js
tests/phpunit/api/v4/Action/BasicActionsTest.php

index dc33d11885c00c3356f0d9b21b00c42375162936..e1763f4c0260ff40afb658034360e778102e059a 100644 (file)
@@ -174,14 +174,15 @@ trait ArrayQueryActionTrait {
         return !in_array($value, $expected);
 
       case 'CONTAINS':
+      case 'NOT CONTAINS':
         if (is_array($value)) {
-          return in_array($expected, $value);
+          return in_array($expected, $value) == ($operator == 'CONTAINS');
         }
         elseif (is_string($value) || is_numeric($value)) {
           // Lowercase check if string contains string
-          return strpos(strtolower((string) $value), strtolower((string) $expected)) !== FALSE;
+          return (strpos(strtolower((string) $value), strtolower((string) $expected)) !== FALSE) == ($operator == 'CONTAINS');
         }
-        return $value == $expected;
+        return ($value == $expected) == ($operator == 'CONTAINS');
 
       default:
         throw new NotImplementedException("Unsupported operator: '$operator' cannot be used with array data");
index 7c93362e28c43817138789fdc53fa676687dcca0..29fbd18fb88b3775fd479866fe5c7936509e6b04 100644 (file)
@@ -29,8 +29,8 @@ use Civi\Api4\Utils\SelectUtil;
  *
  * * '=', '<=', '>=', '>', '<', 'LIKE', "<>", "!=",
  * * 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN',
- * * 'IS NOT NULL', 'IS NULL', 'CONTAINS', 'IS EMPTY', 'IS NOT EMPTY',
- * * 'REGEXP', 'NOT REGEXP'.
+ * * 'IS NOT NULL', 'IS NULL', 'CONTAINS', 'NOT CONTAINS',
+ * * 'IS EMPTY', 'IS NOT EMPTY', 'REGEXP', 'NOT REGEXP'.
  */
 class Api4SelectQuery {
 
@@ -593,27 +593,27 @@ class Api4SelectQuery {
       return $sql ? implode(' AND ', $sql) : NULL;
     }
 
-    // The CONTAINS operator matches a substring for strings. For arrays & serialized fields,
-    // it only matches a complete (not partial) string within the array.
-    if ($operator === 'CONTAINS') {
+    // The CONTAINS and NOT CONTAINS operators match a substring for strings.
+    // For arrays & serialized fields, they only match a complete (not partial) string within the array.
+    if ($operator === 'CONTAINS' || $operator === 'NOT CONTAINS') {
       $sep = \CRM_Core_DAO::VALUE_SEPARATOR;
       switch ($field['serialize'] ?? NULL) {
 
         case \CRM_Core_DAO::SERIALIZE_JSON:
-          $operator = 'LIKE';
+          $operator = ($operator === 'CONTAINS') ? 'LIKE' : 'NOT 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';
+          $operator = ($operator === 'CONTAINS') ? 'LIKE' : 'NOT LIKE';
           // This is easy to query because the string is always bookended by separators.
           $value = '%' . $sep . $value . $sep . '%';
           break;
 
         case \CRM_Core_DAO::SERIALIZE_SEPARATOR_TRIMMED:
-          $operator = 'REGEXP';
+          $operator = ($operator === 'CONTAINS') ? 'REGEXP' : 'NOT REGEXP';
           // This is harder to query because there's no bookend.
           // Use regex to match string within separators or content boundary
           // Escaping regex per https://stackoverflow.com/questions/3782379/whats-the-best-way-to-escape-user-input-for-regular-expressions-in-mysql
@@ -621,14 +621,14 @@ class Api4SelectQuery {
           break;
 
         case \CRM_Core_DAO::SERIALIZE_COMMA:
-          $operator = 'REGEXP';
+          $operator = ($operator === 'CONTAINS') ? 'REGEXP' : 'NOT REGEXP';
           // Match string within commas or content boundary
           // Escaping regex per https://stackoverflow.com/questions/3782379/whats-the-best-way-to-escape-user-input-for-regular-expressions-in-mysql
           $value = '(^|,)' . preg_quote($value, '&') . '(,|$)';
           break;
 
         default:
-          $operator = 'LIKE';
+          $operator = ($operator === 'CONTAINS') ? 'LIKE' : 'NOT LIKE';
           $value = '%' . $value . '%';
           break;
       }
index 24c4fb62b1c3fb2165f1d905fca8fbf33f29e131..918e2d56d184ceeb93f2f74e2f76acabc9dc823f 100644 (file)
@@ -113,6 +113,7 @@ class CoreUtil {
   public static function getOperators() {
     $operators = \CRM_Core_DAO::acceptedSQLOperators();
     $operators[] = 'CONTAINS';
+    $operators[] = 'NOT CONTAINS';
     $operators[] = 'IS EMPTY';
     $operators[] = 'IS NOT EMPTY';
     $operators[] = 'REGEXP';
index d5fa1149a1c4158d7c46c5f9385bfdfe30928709..087b27832ea61fe216b6b32022314089cfa0c4a7 100644 (file)
         '>=': '≥',
         '<=': '≤',
         'CONTAINS': ts('Contains'),
+        'NOT CONTAINS': ts("Doesn't Contain"),
         'IN': ts('Is One Of'),
         'NOT IN': ts('Not One Of'),
         'LIKE': ts('Is Like'),
index 4bd91142941b9857387504c49e882a3e9c0862a1..4fedccb7f278e91ca6748858c7868f1b3b5d5d86 100644 (file)
@@ -87,6 +87,7 @@ class Admin {
       '>=' => '≥',
       '<=' => '≤',
       'CONTAINS' => E::ts('Contains'),
+      'NOT CONTAINS' => E::ts("Doesn't Contain"),
       'IN' => E::ts('Is One Of'),
       'NOT IN' => E::ts('Not One Of'),
       'LIKE' => E::ts('Is Like'),
index 44f9a7fa324ca33fd73460b18a91f7f8c4136185..aa90a53f26a63764a215b9cc2491940835b7c5ff 100644 (file)
@@ -65,7 +65,7 @@
           allowedOps = ['=', '!=', '<', '>', '<=', '>=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'IS EMPTY', 'IS NOT EMPTY'];
         }
         if (!allowedOps && (field.data_type === 'Array' || field.serialize)) {
-          allowedOps = ['CONTAINS', 'IS EMPTY', 'IS NOT EMPTY'];
+          allowedOps = ['CONTAINS', 'NOT CONTAINS', 'IS EMPTY', 'IS NOT EMPTY'];
         }
         if (!allowedOps) {
           return CRM.crmSearchAdmin.operators;
index fad0c8f079cc3cd845b3f709a31e47907b767257..de193ed1a4d002096e2d9253f7f956ae63f2e194 100644 (file)
@@ -337,7 +337,7 @@ class BasicActionsTest extends Api4TestBase implements HookInterface, Transactio
     $this->assertArrayHasKey($records[2]['identifier'], (array) $result);
   }
 
-  public function testContainsOperator() {
+  public function testContainsOperators() {
     $records = [
       ['group' => 'one', 'fruit:name' => ['apple', 'pear'], 'weight' => 11],
       ['group' => 'two', 'fruit:name' => ['pear', 'banana'], 'weight' => 12],
@@ -350,32 +350,65 @@ class BasicActionsTest extends Api4TestBase implements HookInterface, Transactio
     $this->assertCount(1, $result);
     $this->assertEquals('one', $result->first()['group']);
 
+    $result = MockBasicEntity::get()
+      ->addWhere('fruit:name', 'NOT CONTAINS', 'apple')
+      ->execute();
+    $this->assertCount(1, $result);
+    $this->assertEquals('two', $result->first()['group']);
+
     $result = MockBasicEntity::get()
       ->addWhere('fruit:name', 'CONTAINS', 'pear')
       ->execute();
     $this->assertCount(2, $result);
 
+    $result = MockBasicEntity::get()
+      ->addWhere('fruit:name', 'NOT CONTAINS', 'pear')
+      ->execute();
+    $this->assertCount(0, $result);
+
     $result = MockBasicEntity::get()
       ->addWhere('group', 'CONTAINS', 'o')
       ->execute();
     $this->assertCount(2, $result);
 
+    $result = MockBasicEntity::get()
+      ->addWhere('group', 'NOT CONTAINS', 'w')
+      ->execute();
+    $this->assertCount(1, $result);
+
     $result = MockBasicEntity::get()
       ->addWhere('weight', 'CONTAINS', 1)
       ->execute();
     $this->assertCount(2, $result);
 
+    $result = MockBasicEntity::get()
+      ->addWhere('weight', 'NOT CONTAINS', 2)
+      ->execute();
+    $this->assertCount(1, $result);
+
     $result = MockBasicEntity::get()
       ->addWhere('fruit:label', 'CONTAINS', 'Banana')
       ->execute();
     $this->assertCount(1, $result);
     $this->assertEquals('two', $result->first()['group']);
 
+    $result = MockBasicEntity::get()
+      ->addWhere('fruit:label', 'NOT CONTAINS', 'Banana')
+      ->execute();
+    $this->assertCount(1, $result);
+    $this->assertEquals('one', $result->first()['group']);
+
     $result = MockBasicEntity::get()
       ->addWhere('weight', 'CONTAINS', 2)
       ->execute();
     $this->assertCount(1, $result);
     $this->assertEquals('two', $result->first()['group']);
+
+    $result = MockBasicEntity::get()
+      ->addWhere('weight', 'NOT CONTAINS', 2)
+      ->execute();
+    $this->assertCount(1, $result);
+    $this->assertEquals('one', $result->first()['group']);
   }
 
   public function testRegexpOperators() {