APIv4 - Add CONTAINS operator and add to search extension
authorColeman Watts <coleman@civicrm.org>
Sat, 29 Aug 2020 00:56:16 +0000 (20:56 -0400)
committerColeman Watts <coleman@civicrm.org>
Sat, 29 Aug 2020 00:56:16 +0000 (20:56 -0400)
Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Utils/CoreUtil.php
ext/search/ang/search/crmSearch.component.js
ext/search/ang/search/crmSearchValue.directive.js
tests/phpunit/api/v4/Action/BasicActionsTest.php
tests/phpunit/api/v4/Action/PseudoconstantTest.php

index 4ae6096c503965784deff942f29420d785cf50a0..fd515b9a4b712a71b0076538840fa55f6c5c825e 100644 (file)
@@ -158,6 +158,15 @@ trait ArrayQueryActionTrait {
       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");
     }
index 6198b93603c272f705900bf06a17a32f8de2c28a..e4f8ceb187fcd6d37715c9b92cbb2cee4d403d77 100644 (file)
@@ -26,8 +26,8 @@ use Civi\Api4\Utils\SelectUtil;
  * 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 {
 
@@ -379,7 +379,8 @@ class Api4SelectQuery {
         $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
@@ -392,7 +393,8 @@ class Api4SelectQuery {
         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;
           }
@@ -415,7 +417,29 @@ class Api4SelectQuery {
         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;
       }
     }
 
index 9bc7ec9b6a42ad72359e73e6a6f4260761bf0846..b9090c97dec26daaa80e33a57d09cfd42da2ad7d 100644 (file)
@@ -75,7 +75,9 @@ class CoreUtil {
    * @return string[]
    */
   public static function getOperators() {
-    return \CRM_Core_DAO::acceptedSQLOperators();
+    $operators = \CRM_Core_DAO::acceptedSQLOperators();
+    $operators[] = 'CONTAINS';
+    return $operators;
   }
 
 }
index e2d8c564d15e38dc419569b0bc9dc41b926e1bc6..51f2532bc49a8b4c7482a15692339d41a53f52ac 100644 (file)
         else if (type === 'Money') {
           return CRM.formatMoney(value);
         }
+        if (_.isArray(value)) {
+          return value.join(', ');
+        }
         return value;
       }
 
index 0fb160088402b721f10d53e065080a134567a384..ad93d2b8788cefe66dc4baa9deaa0b718255f0d4 100644 (file)
@@ -39,7 +39,7 @@
             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');
index 99d704a18f991517f1eed74cff2cb618174f97d8..8df90eb87782d3f270592072fe28e8f93d43cb11 100644 (file)
@@ -255,6 +255,49 @@ class BasicActionsTest extends UnitTestCase {
     $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();
 
index 9f7e363941de371974cb5b2c8661495be60063f5..248073ab8d42b717d040c20653d4a2b6a1a61b35 100644 (file)
@@ -27,6 +27,7 @@ use Civi\Api4\CustomGroup;
 use Civi\Api4\Email;
 use Civi\Api4\EntityTag;
 use Civi\Api4\OptionValue;
+use Civi\Api4\Participant;
 use Civi\Api4\Tag;
 
 /**
@@ -273,4 +274,29 @@ class PseudoconstantTest extends BaseCustomValueTest {
     $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);
+  }
+
 }