Add is empty filter to search / api
authoreileen <emcnaughton@wikimedia.org>
Tue, 2 Mar 2021 12:31:55 +0000 (01:31 +1300)
committerColeman Watts <coleman@civicrm.org>
Mon, 8 Mar 2021 22:34:53 +0000 (17:34 -0500)
This is already offered in Query
https://github.com/civicrm/civicrm-core/blob/5db2212e2d408f4611439734db1a31ab32dced2f/CRM/Contact/BAO/Query.php#L3420-L3428

And in Report
https://github.com/civicrm/civicrm-core/blob/c3fffe27cb8203634c7a2c047686ba3d12cc38bd/CRM/Report/Form.php#L2105-L2124

(the latter munges it in with NULL but as we often save empty strings NULL
does not alwasy work for strings)

Civi/Api4/Generic/BasicGetFieldsAction.php
Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Utils/CoreUtil.php
ang/api4Explorer/Explorer.js
ext/search/Civi/Search/Admin.php
ext/search/ang/crmSearchAdmin/crmSearchClause.component.js
ext/search/ang/crmSearchAdmin/crmSearchClause.html
tests/phpunit/api/v4/Action/BasicActionsTest.php
tests/phpunit/api/v4/Action/ContactGetTest.php

index 49af587ffb2dfe5beb53257cff2ba71187225909..6999cdc20d34284be0551912f6276af222496c21 100644 (file)
@@ -256,28 +256,29 @@ class BasicGetFieldsAction extends BasicGetAction {
         'name' => 'data_type',
         'data_type' => 'String',
         'options' => [
-          'Integer' => ts('Integer'),
+          'Array' => ts('Array'),
           'Boolean' => ts('Boolean'),
+          'Date' => ts('Date'),
+          'Float' => ts('Float'),
+          'Integer' => ts('Integer'),
           'String' => ts('String'),
           'Text' => ts('Text'),
-          'Date' => ts('Date'),
           'Timestamp' => ts('Timestamp'),
-          'Array' => ts('Array'),
         ],
       ],
       [
         'name' => 'input_type',
         'data_type' => 'String',
         'options' => [
-          'Text' => ts('Text'),
-          'Number' => ts('Number'),
-          'Select' => ts('Select'),
+          'ChainSelect' => ts('ChainSelect'),
           'CheckBox' => ts('CheckBox'),
-          'Radio' => ts('Radio'),
           'Date' => ts('Date'),
-          'File' => ts('File'),
           'EntityRef' => ts('EntityRef'),
-          'ChainSelect' => ts('ChainSelect'),
+          'File' => ts('File'),
+          'Number' => ts('Number'),
+          'Radio' => ts('Radio'),
+          'Select' => ts('Select'),
+          'Text' => ts('Text'),
         ],
       ],
       [
index fd515b9a4b712a71b0076538840fa55f6c5c825e..88c24a78c49de6dd1b33c1be23ec5c5246455e9d 100644 (file)
@@ -130,6 +130,10 @@ trait ArrayQueryActionTrait {
       case 'IS NOT NULL':
         return is_null($value) == ($operator == 'IS NULL');
 
+      case 'IS EMPTY':
+      case 'IS NOT EMPTY':
+        return empty($value) == ($operator == 'IS EMPTY');
+
       case '>':
         return $value > $expected;
 
index f15db6f1d597dfd796598f770cf9c1298848983e..e588e710853bc946af3c6f5631f5c3338b1c447c 100644 (file)
@@ -434,7 +434,7 @@ class Api4SelectQuery {
         if ($fieldName && $valExpr->getType() === 'SqlString') {
           $value = $valExpr->getExpr();
           FormattingUtil::formatInputValue($value, $fieldName, $this->apiFieldSpec[$fieldName], $operator);
-          return \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
+          return $this->createSQLClause($fieldAlias, $operator, $value, $this->apiFieldSpec[$fieldName]);
         }
         else {
           $value = $valExpr->render($this->apiFieldSpec);
@@ -447,6 +447,22 @@ class Api4SelectQuery {
       }
     }
 
+    $sqlClause = $this->createSQLClause($fieldAlias, $operator, $value, $field ?? NULL);
+    if ($sqlClause === NULL) {
+      throw new \API_Exception("Invalid value in $type clause for '$expr'");
+    }
+    return $sqlClause;
+  }
+
+  /**
+   * @param string $fieldAlias
+   * @param string $operator
+   * @param mixed $value
+   * @param array|null $field
+   * @return array|string|NULL
+   * @throws \Exception
+   */
+  protected function createSQLClause($fieldAlias, $operator, $value, $field) {
     if ($operator === 'CONTAINS') {
       switch ($field['serialize'] ?? NULL) {
         case \CRM_Core_DAO::SERIALIZE_JSON:
@@ -468,11 +484,18 @@ class Api4SelectQuery {
       }
     }
 
-    $sql_clause = \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
-    if ($sql_clause === NULL) {
-      throw new \API_Exception("Invalid value in $type clause for '$expr'");
+    if ($operator === 'IS EMPTY' || $operator === 'IS NOT EMPTY') {
+      // If field is not a string or number, this will pass through and use IS NULL/IS NOT NULL
+      $operator = str_replace('EMPTY', 'NULL', $operator);
+      // For strings & numbers, create an OR grouping of empty value OR null
+      if (in_array($field['data_type'] ?? NULL, ['String', 'Integer', 'Float'], TRUE)) {
+        $emptyVal = $field['data_type'] === 'String' ? '""' : '0';
+        $isEmptyClause = $operator === 'IS NULL' ? "= $emptyVal OR" : "<> $emptyVal AND";
+        return "($fieldAlias $isEmptyClause $fieldAlias $operator)";
+      }
     }
-    return $sql_clause;
+
+    return \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
   }
 
   /**
index 25c01f0e74bd642c50bde61399f0f340f30181c6..ebe79137970d4e7a01ee6e310af5b044f49e44e7 100644 (file)
@@ -77,6 +77,8 @@ class CoreUtil {
   public static function getOperators() {
     $operators = \CRM_Core_DAO::acceptedSQLOperators();
     $operators[] = 'CONTAINS';
+    $operators[] = 'IS EMPTY';
+    $operators[] = 'IS NOT EMPTY';
     return $operators;
   }
 
index fb9f5c71ce0d03ccc3c7218e65de3734b5492681..7def6e386f46d516b366f647dd8e797e9cf35a14 100644 (file)
 
       // Add/remove value if operator allows for one
       this.changeClauseOperator = function(clause) {
-        if (_.contains(clause[1], 'NULL')) {
+        if (_.contains(clause[1], 'IS ')) {
           clause.length = 2;
         } else if (clause.length === 2) {
           clause.push('');
             op = field.serialize || dataType === 'Array' ? 'IN' : '=';
           }
           multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], op);
-          if (op === 'IS NULL' || op === 'IS NOT NULL') {
+          // IS NULL, IS EMPTY, etc.
+          if (_.contains(op, 'IS ')) {
             $el.hide();
             return;
           }
index 711a8378ca16fbd44f4b1defc10706eb5280fc28..34d82738bdee312476f3c5c3977754f6dd5c8e1d 100644 (file)
@@ -60,8 +60,8 @@ class Admin {
       'NOT LIKE' => E::ts('Not Like'),
       'BETWEEN' => E::ts('Is Between'),
       'NOT BETWEEN' => E::ts('Not Between'),
-      'IS NULL' => E::ts('Is Null'),
-      'IS NOT NULL' => E::ts('Not Null'),
+      'IS EMPTY' => E::ts('Is Empty'),
+      'IS NOT EMPTY' => E::ts('Not Empty'),
     ];
   }
 
index de5ce60e43d8c1b0e222941f0d179fa76c9063f2..b91dea8128c127218208873c151064577aa7ce24 100644 (file)
         }
       };
 
+      // Returns false for 'IS NULL', 'IS EMPTY', etc. true otherwise.
+      this.operatorTakesInput = function(operator) {
+        return operator.indexOf('IS ') !== 0;
+      };
+
       this.changeClauseOperator = function(clause) {
-        // Add/remove value if operator allows for one
-        if (_.contains(clause[1], 'NULL')) {
+        // Add/remove value depending on whether operator allows for one
+        if (!ctrl.operatorTakesInput(clause[1])) {
           clause.length = 2;
         } else {
           if (clause.length === 2) {
index 1d5f98d96f9309db747d710c116d77f14abe2939..73d2ba64a9a342f148e9d14b2747040cae1f89db 100644 (file)
@@ -17,7 +17,7 @@
       <div ng-if="!$ctrl.conjunctions[clause[0]]" class="api4-input-group">
         <input class="form-control" ng-model="clause[0]" crm-ui-select="{data: $ctrl.fields, allowClear: true, placeholder: 'Field'}" ng-change="$ctrl.changeClauseField(clause, index)" />
         <select class="form-control api4-operator" ng-model="clause[1]" ng-options="o.key as o.value for o in $ctrl.operators" ng-change="$ctrl.changeClauseOperator(clause)" ></select>
-        <crm-search-input ng-if="clause[1] !== 'IS NULL' && clause[1] !== 'IS NOT NULL'" ng-model="clause[2]" field="$ctrl.getField(clause[0])" option-key="$ctrl.getOptionKey(clause[0])" op="clause[1]" format="$ctrl.format" class="form-group"></crm-search-input>
+        <crm-search-input ng-if="$ctrl.operatorTakesInput(clause[1])" ng-model="clause[2]" field="$ctrl.getField(clause[0])" option-key="$ctrl.getOptionKey(clause[0])" op="clause[1]" format="$ctrl.format" class="form-group"></crm-search-input>
       </div>
       <fieldset class="clearfix" ng-if="$ctrl.conjunctions[clause[0]]">
         <crm-search-clause clauses="clause[1]" format="{{ $ctrl.format }}" op="{{ clause[0] }}" fields="$ctrl.fields" delete-group="$ctrl.deleteRow(index)" ></crm-search-clause>
index 8df90eb87782d3f270592072fe28e8f93d43cb11..9e54d3b6f9c7e76acecd7fc87e0f9c68a66a3264 100644 (file)
@@ -27,6 +27,13 @@ use Civi\Api4\MockBasicEntity;
  */
 class BasicActionsTest extends UnitTestCase {
 
+  private function replaceRecords(&$records) {
+    MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
+    foreach ($records as &$record) {
+      $record['id'] = MockBasicEntity::create()->setValues($record)->execute()->first()['id'];
+    }
+  }
+
   public function testCrud() {
     MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
 
@@ -89,8 +96,6 @@ class BasicActionsTest extends UnitTestCase {
   }
 
   public function testReplace() {
-    MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
-
     $objects = [
       ['group' => 'one', 'color' => 'red'],
       ['group' => 'one', 'color' => 'blue'],
@@ -98,9 +103,7 @@ class BasicActionsTest extends UnitTestCase {
       ['group' => 'two', 'color' => 'orange'],
     ];
 
-    foreach ($objects as &$object) {
-      $object['id'] = MockBasicEntity::create()->setValues($object)->execute()->first()['id'];
-    }
+    $this->replaceRecords($objects);
 
     // Keep red, change blue, delete green, and add yellow
     $replacements = [
@@ -125,17 +128,13 @@ class BasicActionsTest extends UnitTestCase {
   }
 
   public function testBatchFrobnicate() {
-    MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
-
     $objects = [
       ['group' => 'one', 'color' => 'red', 'number' => 10],
       ['group' => 'one', 'color' => 'blue', 'number' => 20],
       ['group' => 'one', 'color' => 'green', 'number' => 30],
       ['group' => 'two', 'color' => 'blue', 'number' => 40],
     ];
-    foreach ($objects as &$object) {
-      $object['id'] = MockBasicEntity::create()->setValues($object)->execute()->first()['id'];
-    }
+    $this->replaceRecords($objects);
 
     $result = MockBasicEntity::batchFrobnicate()->addWhere('color', '=', 'blue')->execute();
     $this->assertEquals(2, count($result));
@@ -235,13 +234,11 @@ class BasicActionsTest extends UnitTestCase {
   }
 
   public function testWildcardSelect() {
-    MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
-
     $records = [
       ['group' => 'one', 'color' => 'red', 'shape' => 'round', 'size' => 'med', 'weight' => 10],
       ['group' => 'two', 'color' => 'blue', 'shape' => 'round', 'size' => 'med', 'weight' => 20],
     ];
-    MockBasicEntity::save()->setRecords($records)->execute();
+    $this->replaceRecords($records);
 
     foreach (MockBasicEntity::get()->addSelect('*')->execute() as $result) {
       ksort($result);
@@ -255,14 +252,57 @@ class BasicActionsTest extends UnitTestCase {
     $this->assertEquals(['shape', 'size', 'weight'], array_keys($result));
   }
 
-  public function testContainsOperator() {
-    MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
+  public function testEmptyAndNullOperators() {
+    $records = [
+      [],
+      ['color' => '', 'weight' => 0],
+      ['color' => 'yellow', 'weight' => 100000000000],
+    ];
+    $this->replaceRecords($records);
+
+    $result = MockBasicEntity::get()
+      ->addWhere('color', 'IS NULL')
+      ->execute()->indexBy('id');
+    $this->assertCount(1, $result);
+    $this->assertArrayHasKey($records[0]['id'], (array) $result);
 
+    $result = MockBasicEntity::get()
+      ->addWhere('color', 'IS EMPTY')
+      ->execute()->indexBy('id');
+    $this->assertCount(2, $result);
+    $this->assertArrayNotHasKey($records[2]['id'], (array) $result);
+
+    $result = MockBasicEntity::get()
+      ->addWhere('color', 'IS NOT EMPTY')
+      ->execute()->indexBy('id');
+    $this->assertCount(1, $result);
+    $this->assertArrayHasKey($records[2]['id'], (array) $result);
+
+    $result = MockBasicEntity::get()
+      ->addWhere('weight', 'IS NULL')
+      ->execute()->indexBy('id');
+    $this->assertCount(1, $result);
+    $this->assertArrayHasKey($records[0]['id'], (array) $result);
+
+    $result = MockBasicEntity::get()
+      ->addWhere('weight', 'IS EMPTY')
+      ->execute()->indexBy('id');
+    $this->assertCount(2, $result);
+    $this->assertArrayNotHasKey($records[2]['id'], (array) $result);
+
+    $result = MockBasicEntity::get()
+      ->addWhere('weight', 'IS NOT EMPTY')
+      ->execute()->indexBy('id');
+    $this->assertCount(1, $result);
+    $this->assertArrayHasKey($records[2]['id'], (array) $result);
+  }
+
+  public function testContainsOperator() {
     $records = [
       ['group' => 'one', 'fruit:name' => ['apple', 'pear'], 'weight' => 11],
       ['group' => 'two', 'fruit:name' => ['pear', 'banana'], 'weight' => 12],
     ];
-    MockBasicEntity::save()->setRecords($records)->execute();
+    $this->replaceRecords($records);
 
     $result = MockBasicEntity::get()
       ->addWhere('fruit:name', 'CONTAINS', 'apple')
@@ -299,13 +339,11 @@ class BasicActionsTest extends UnitTestCase {
   }
 
   public function testPseudoconstantMatch() {
-    MockBasicEntity::delete()->addWhere('id', '>', 0)->execute();
-
     $records = [
       ['group:label' => 'First', 'shape' => 'round', 'fruit:name' => 'banana'],
       ['group:name' => 'Second', 'shape' => 'square', 'fruit:label' => 'Pear'],
     ];
-    MockBasicEntity::save()->setRecords($records)->execute();
+    $this->replaceRecords($records);
 
     $results = MockBasicEntity::get()
       ->addSelect('*', 'group:label', 'group:name', 'fruit:name', 'fruit:color', 'fruit:label')
index 965a8b88ee9f73fe31a3c3bfc4f5d3afde03a2da..8218e5189eac4f9859b3f2b6d97f6c4bc29f5ba2 100644 (file)
@@ -132,4 +132,66 @@ class ContactGetTest extends \api\v4\UnitTestCase {
     }
   }
 
+  public function testEmptyAndNullOperators() {
+    $last_name = uniqid(__FUNCTION__);
+
+    $bob = Contact::create()
+      ->setValues(['first_name' => 'Bob', 'last_name' => $last_name, 'prefix_id' => 0])
+      ->execute()->first();
+    // Initial value is NULL, but to test the empty operator, change it to an empty string
+    \CRM_Core_DAO::executeQuery("UPDATE civicrm_contact SET middle_name = '' WHERE id = " . $bob['id']);
+
+    $jan = Contact::create()
+      ->setValues(['first_name' => 'Jan', 'middle_name' => 'J', 'last_name' => $last_name, 'prefix_id' => 1])
+      ->execute()->first();
+
+    $dan = Contact::create()
+      ->setValues(['first_name' => 'Dan', 'last_name' => $last_name, 'prefix_id' => NULL])
+      ->execute()->first();
+
+    // Test EMPTY and NULL operators on string fields
+    $result = Contact::get(FALSE)
+      ->addWhere('last_name', '=', $last_name)
+      ->addWhere('middle_name', 'IS EMPTY')
+      ->execute()->indexBy('id');
+    $this->assertCount(2, $result);
+    $this->assertArrayNotHasKey($jan['id'], (array) $result);
+
+    $result = Contact::get(FALSE)
+      ->addWhere('last_name', '=', $last_name)
+      ->addWhere('middle_name', 'IS NOT NULL')
+      ->execute()->indexBy('id');
+    $this->assertCount(2, $result);
+    $this->assertArrayNotHasKey($dan['id'], (array) $result);
+
+    $result = Contact::get(FALSE)
+      ->addWhere('last_name', '=', $last_name)
+      ->addWhere('middle_name', 'IS NOT EMPTY')
+      ->execute()->indexBy('id');
+    $this->assertCount(1, $result);
+    $this->assertArrayHasKey($jan['id'], (array) $result);
+
+    // Test EMPTY and NULL operators on Integer fields
+    $result = Contact::get(FALSE)
+      ->addWhere('last_name', '=', $last_name)
+      ->addWhere('prefix_id', 'IS EMPTY')
+      ->execute()->indexBy('id');
+    $this->assertCount(2, $result);
+    $this->assertArrayNotHasKey($jan['id'], (array) $result);
+
+    $result = Contact::get(FALSE)
+      ->addWhere('last_name', '=', $last_name)
+      ->addWhere('prefix_id', 'IS NOT NULL')
+      ->execute()->indexBy('id');
+    $this->assertCount(2, $result);
+    $this->assertArrayNotHasKey($dan['id'], (array) $result);
+
+    $result = Contact::get(FALSE)
+      ->addWhere('last_name', '=', $last_name)
+      ->addWhere('prefix_id', 'IS NOT EMPTY')
+      ->execute()->indexBy('id');
+    $this->assertCount(1, $result);
+    $this->assertArrayHasKey($jan['id'], (array) $result);
+  }
+
 }