APIv4: Support relative date range input
authorColeman Watts <coleman@civicrm.org>
Fri, 11 Dec 2020 17:33:14 +0000 (12:33 -0500)
committerColeman Watts <coleman@civicrm.org>
Fri, 11 Dec 2020 18:33:16 +0000 (13:33 -0500)
Supports relative date range expressions with most operators and adds test coverage

CRM/Utils/Date.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Utils/FormattingUtil.php
tests/phpunit/api/v4/Action/DateTest.php
tests/phpunit/api/v4/Entity/ParticipantTest.php

index af0fd42584f995dae429f69b398f3e2afff2d3f9..d3b807402228e4a015545c33469e5594fc75d4f4 100644 (file)
@@ -867,7 +867,7 @@ class CRM_Utils_Date {
    * @return array
    *   start date, end date
    */
-  public static function getFromTo($relative, $from, $to, $fromTime = NULL, $toTime = '235959') {
+  public static function getFromTo($relative, $from = NULL, $to = NULL, $fromTime = NULL, $toTime = '235959') {
     if ($relative) {
       list($term, $unit) = explode('.', $relative, 2);
       $dateRange = self::relativeToAbsolute($term, $unit);
index b8478c6ddc7bb940dd604c2c99dd254c4d479bee..922f0954a203fc209dc4ef1ab0bd6a2f70df8065 100644 (file)
@@ -369,7 +369,7 @@ class Api4SelectQuery {
     // For WHERE clause, expr must be the name of a field.
     if ($type === 'WHERE') {
       $field = $this->getField($expr, TRUE);
-      FormattingUtil::formatInputValue($value, $expr, $field);
+      FormattingUtil::formatInputValue($value, $expr, $field, $operator);
       $fieldAlias = $field['sql_name'];
     }
     // For HAVING, expr must be an item in the SELECT clause
@@ -380,7 +380,7 @@ class Api4SelectQuery {
         // Attempt to format if this is a real field
         if (isset($this->apiFieldSpec[$expr])) {
           $field = $this->getField($expr);
-          FormattingUtil::formatInputValue($value, $expr, $field);
+          FormattingUtil::formatInputValue($value, $expr, $field, $operator);
         }
       }
       // Expr references a non-field expression like a function; convert to alias
@@ -394,7 +394,7 @@ class Api4SelectQuery {
           list($selectField) = explode(':', $selectAlias);
           if ($selectAlias === $selectExpr && $fieldName === $selectField && isset($this->apiFieldSpec[$fieldName])) {
             $field = $this->getField($fieldName);
-            FormattingUtil::formatInputValue($value, $expr, $field);
+            FormattingUtil::formatInputValue($value, $expr, $field, $operator);
             $fieldAlias = $selectAlias;
             break;
           }
@@ -412,13 +412,13 @@ class Api4SelectQuery {
       if (is_string($value)) {
         $valExpr = $this->getExpression($value);
         if ($fieldName && $valExpr->getType() === 'SqlString') {
-          FormattingUtil::formatInputValue($valExpr->expr, $fieldName, $this->apiFieldSpec[$fieldName]);
+          FormattingUtil::formatInputValue($valExpr->expr, $fieldName, $this->apiFieldSpec[$fieldName], $operator);
         }
         return sprintf('%s %s %s', $fieldAlias, $operator, $valExpr->render($this->apiFieldSpec));
       }
       elseif ($fieldName) {
         $field = $this->getField($fieldName);
-        FormattingUtil::formatInputValue($value, $fieldName, $field);
+        FormattingUtil::formatInputValue($value, $fieldName, $field, $operator);
       }
     }
 
index 6b4ff620a0c3305126a07fcf390a90de5961f2be..f6a7e49c4ded7629c9c3c933f788c7dbe8866836 100644 (file)
@@ -48,7 +48,7 @@ class FormattingUtil {
         if ($value === 'null') {
           $value = 'Null';
         }
-        self::formatInputValue($value, $name, $field, 'create');
+        self::formatInputValue($value, $name, $field);
         // Ensure we have an array for serialized fields
         if (!empty($field['serialize'] && !is_array($value))) {
           $value = (array) $value;
@@ -82,21 +82,23 @@ class FormattingUtil {
    * @param $value
    * @param string $fieldName
    * @param array $fieldSpec
-   * @param string $action
+   * @param string $operator (only for 'get' actions)
+   * @param int $index (for recursive loops)
    * @throws \API_Exception
    * @throws \CRM_Core_Exception
    */
-  public static function formatInputValue(&$value, $fieldName, $fieldSpec, $action = 'get') {
+  public static function formatInputValue(&$value, $fieldName, $fieldSpec, &$operator = NULL, $index = NULL) {
     // Evaluate pseudoconstant suffix
     $suffix = strpos($fieldName, ':');
     if ($suffix) {
-      $options = self::getPseudoconstantList($fieldSpec, substr($fieldName, $suffix + 1), $action);
+      $options = self::getPseudoconstantList($fieldSpec, substr($fieldName, $suffix + 1), [], $operator ? 'get' : 'create');
       $value = self::replacePseudoconstant($options, $value, TRUE);
       return;
     }
     elseif (is_array($value)) {
+      $i = 0;
       foreach ($value as &$val) {
-        self::formatInputValue($val, $fieldName, $fieldSpec, $action);
+        self::formatInputValue($val, $fieldName, $fieldSpec, $operator, $i++);
       }
       return;
     }
@@ -115,20 +117,70 @@ class FormattingUtil {
 
     switch ($fieldSpec['data_type'] ?? NULL) {
       case 'Timestamp':
-        $value = date('Y-m-d H:i:s', strtotime($value));
+        $value = self::formatDateValue('Y-m-d H:i:s', $value, $operator, $index);
         break;
 
       case 'Date':
-        $value = date('Ymd', strtotime($value));
+        $value = self::formatDateValue('Ymd', $value, $operator, $index);
         break;
     }
 
     $hic = \CRM_Utils_API_HTMLInputCoder::singleton();
-    if (!$hic->isSkippedField($fieldSpec['name']) && is_string($value)) {
+    if (is_string($value) && !$hic->isSkippedField($fieldSpec['name'])) {
       $value = $hic->encodeValue($value);
     }
   }
 
+  /**
+   * Parse date expressions.
+   *
+   * Expands relative date range expressions, modifying the sql operator if necessary
+   *
+   * @param $format
+   * @param $value
+   * @param $operator
+   * @param $index
+   * @return array|string
+   */
+  private static function formatDateValue($format, $value, &$operator = NULL, $index = NULL) {
+    // Non-relative dates (or if no search operator)
+    if (!$operator || !array_key_exists($value, \CRM_Core_OptionGroup::values('relative_date_filters'))) {
+      return date($format, strtotime($value));
+    }
+    if (isset($index) && !strstr($operator, 'BETWEEN')) {
+      throw new \API_Exception("Relative dates cannot be in an array using the $operator operator.");
+    }
+    [$dateFrom, $dateTo] = \CRM_Utils_Date::getFromTo($value);
+    switch ($operator) {
+      // Convert relative date filters to use BETWEEN/NOT BETWEEN operator
+      case '=':
+      case '!=':
+      case '<>':
+      case 'LIKE':
+      case 'NOT LIKE':
+        $operator = ($operator === '=' || $operator === 'LIKE') ? 'BETWEEN' : 'NOT BETWEEN';
+        return [self::formatDateValue($format, $dateFrom), self::formatDateValue($format, $dateTo)];
+
+      // Less-than or greater-than-equal-to comparisons use the lower value
+      case '<':
+      case '>=':
+        return self::formatDateValue($format, $dateFrom);
+
+      // Greater-than or less-than-equal-to comparisons use the higher value
+      case '>':
+      case '<=':
+        return self::formatDateValue($format, $dateTo);
+
+      // For BETWEEN expressions, we are already inside a loop of the 2 values, so give the lower value if index=0, higher value if index=1
+      case 'BETWEEN':
+      case 'NOT BETWEEN':
+        return self::formatDateValue($format, $index ? $dateTo : $dateFrom);
+
+      default:
+        throw new \API_Exception("Relative dates cannot be used with the $operator operator.");
+    }
+  }
+
   /**
    * Unserialize raw DAO values and convert to correct type
    *
index 272c249db8ce4b82a0f8eb8b74d46bb080cd76c7..138fbe2b1af452e0a75d2c45184ae1556d096c5c 100644 (file)
@@ -19,6 +19,7 @@
 
 namespace api\v4\Action;
 
+use Civi\Api4\Activity;
 use Civi\Api4\Contact;
 use Civi\Api4\Relationship;
 use api\v4\UnitTestCase;
@@ -60,4 +61,77 @@ class DateTest extends UnitTestCase {
     $this->assertArrayNotHasKey($r, $result);
   }
 
+  public function testRelativeDateRanges() {
+    $c1 = Contact::create()
+      ->addValue('first_name', 'c')
+      ->addValue('last_name', 'one')
+      ->execute()
+      ->first()['id'];
+    $act = Activity::save()
+      ->setDefaults(['activity_type_id:name' => 'Meeting', 'source_contact_id' => $c1])
+      ->addRecord(['activity_date_time' => 'now - 3 year'])
+      ->addRecord(['activity_date_time' => 'now - 1 year'])
+      ->addRecord(['activity_date_time' => 'now - 1 month'])
+      ->addRecord(['activity_date_time' => 'now'])
+      ->addRecord(['activity_date_time' => 'now + 1 month'])
+      ->addRecord(['activity_date_time' => 'now + 1 year'])
+      ->addRecord(['activity_date_time' => 'now + 3 year'])
+      ->execute()->column('id');
+
+    $result = Activity::get(FALSE)->addSelect('id')
+      ->addWhere('activity_date_time', '>', 'previous.year')
+      ->execute()->column('id');
+    $this->assertNotContains($act[0], $result);
+    $this->assertContains($act[3], $result);
+    $this->assertContains($act[4], $result);
+    $this->assertContains($act[5], $result);
+    $this->assertContains($act[6], $result);
+
+    $result = Activity::get(FALSE)->addSelect('id')
+      ->addWhere('activity_date_time', '>', 'this.year')
+      ->execute()->column('id');
+    $this->assertNotContains($act[0], $result);
+    $this->assertNotContains($act[1], $result);
+    $this->assertNotContains($act[2], $result);
+    $this->assertNotContains($act[3], $result);
+    $this->assertContains($act[5], $result);
+    $this->assertContains($act[6], $result);
+
+    $result = Activity::get(FALSE)->addSelect('id')
+      ->addWhere('activity_date_time', '>=', 'this.year')
+      ->execute()->column('id');
+    $this->assertNotContains($act[0], $result);
+    $this->assertNotContains($act[1], $result);
+    $this->assertContains($act[3], $result);
+    $this->assertContains($act[4], $result);
+    $this->assertContains($act[5], $result);
+    $this->assertContains($act[6], $result);
+
+    $result = Activity::get(FALSE)->addSelect('id')
+      ->addWhere('activity_date_time', '<', 'previous.year')
+      ->execute()->column('id');
+    $this->assertContains($act[0], $result);
+    $this->assertNotContains($act[4], $result);
+    $this->assertNotContains($act[5], $result);
+    $this->assertNotContains($act[6], $result);
+
+    $result = Activity::get(FALSE)->addSelect('id')
+      ->addWhere('activity_date_time', '=', 'next.month')
+      ->execute()->column('id');
+    $this->assertNotContains($act[0], $result);
+    $this->assertNotContains($act[1], $result);
+    $this->assertNotContains($act[2], $result);
+    $this->assertContains($act[4], $result);
+    $this->assertNotContains($act[5], $result);
+    $this->assertNotContains($act[6], $result);
+
+    $result = Activity::get(FALSE)->addSelect('id')
+      ->addWhere('activity_date_time', 'BETWEEN', ['previous.year', 'this.year'])
+      ->execute()->column('id');
+    $this->assertContains($act[2], $result);
+    $this->assertContains($act[3], $result);
+    $this->assertNotContains($act[0], $result);
+    $this->assertNotContains($act[6], $result);
+  }
+
 }
index 5140c184a3f0d08d69479ec9649dd7dcaa096bd4..efc66ffe2b7b00e85d9ce41c27101fad8a74a48a 100644 (file)
@@ -180,6 +180,29 @@ class ParticipantTest extends UnitTestCase {
       $otherParticipantResult->offsetExists($firstParticipantId),
       'excluded wrong record');
 
+    // check syntax for date-range
+
+    $getParticipantsById = function($wheres = []) {
+      return Participant::get(FALSE)
+        ->setWhere($wheres)
+        ->execute()
+        ->indexBy('id');
+    };
+
+    $thisYearParticipants = $getParticipantsById([['register_date', '=', 'this.year']]);
+    $this->assertFalse(isset($thisYearParticipants[$firstParticipantId]));
+
+    $otherYearParticipants = $getParticipantsById([['register_date', '!=', 'this.year']]);
+    $this->assertTrue(isset($otherYearParticipants[$firstParticipantId]));
+
+    Participant::update()->setCheckPermissions(FALSE)
+      ->addWhere('id', '=', $firstParticipantId)
+      ->addValue('register_date', 'now')
+      ->execute();
+
+    $thisYearParticipants = $getParticipantsById([['register_date', '=', 'this.year']]);
+    $this->assertTrue(isset($thisYearParticipants[$firstParticipantId]));
+
     // retrieve a participant record and update some records
     $patchRecord = [
       'source' => "not " . $firstResult['source'],