From: Coleman Watts Date: Fri, 11 Dec 2020 17:33:14 +0000 (-0500) Subject: APIv4: Support relative date range input X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=3dd9e4a0f1e36cd0778ca1aebf213c4f23e2e53e;p=civicrm-core.git APIv4: Support relative date range input Supports relative date range expressions with most operators and adds test coverage --- diff --git a/CRM/Utils/Date.php b/CRM/Utils/Date.php index af0fd42584..d3b8074022 100644 --- a/CRM/Utils/Date.php +++ b/CRM/Utils/Date.php @@ -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); diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php index b8478c6ddc..922f0954a2 100644 --- a/Civi/Api4/Query/Api4SelectQuery.php +++ b/Civi/Api4/Query/Api4SelectQuery.php @@ -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); } } diff --git a/Civi/Api4/Utils/FormattingUtil.php b/Civi/Api4/Utils/FormattingUtil.php index 6b4ff620a0..f6a7e49c4d 100644 --- a/Civi/Api4/Utils/FormattingUtil.php +++ b/Civi/Api4/Utils/FormattingUtil.php @@ -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 * diff --git a/tests/phpunit/api/v4/Action/DateTest.php b/tests/phpunit/api/v4/Action/DateTest.php index 272c249db8..138fbe2b1a 100644 --- a/tests/phpunit/api/v4/Action/DateTest.php +++ b/tests/phpunit/api/v4/Action/DateTest.php @@ -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); + } + } diff --git a/tests/phpunit/api/v4/Entity/ParticipantTest.php b/tests/phpunit/api/v4/Entity/ParticipantTest.php index 5140c184a3..efc66ffe2b 100644 --- a/tests/phpunit/api/v4/Entity/ParticipantTest.php +++ b/tests/phpunit/api/v4/Entity/ParticipantTest.php @@ -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'],