APIv4 - add is_current as a pseudo (calculated) field
authorColeman Watts <coleman@civicrm.org>
Sun, 13 Jun 2021 01:36:39 +0000 (21:36 -0400)
committerColeman Watts <coleman@civicrm.org>
Thu, 1 Jul 2021 23:40:54 +0000 (19:40 -0400)
This adds the first calculated field to APIv4. It works by injecting a SQL function and aliasing it
with the name of the fake field. This approach allows it to work as part of the WHERE clause
as well as the SELECT clause, even across joins.

Marking the old "is_current" api param deprecated because the field works better.

15 files changed:
Civi/Api4/Action/Campaign/Get.php
Civi/Api4/Action/Event/Get.php
Civi/Api4/Action/Relationship/Get.php
Civi/Api4/Event/Subscriber/IsCurrentSubscriber.php
Civi/Api4/Generic/DAOGetFieldsAction.php
Civi/Api4/Generic/Traits/IsCurrentTrait.php
Civi/Api4/Query/Api4SelectQuery.php
Civi/Api4/Query/SqlField.php
Civi/Api4/Service/Schema/Joinable/Joinable.php
Civi/Api4/Service/Spec/FieldSpec.php
Civi/Api4/Service/Spec/Provider/IsCurrentFieldSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/RequestSpec.php
Civi/Api4/Service/Spec/SpecGatherer.php
Civi/Test/Api3TestTrait.php
tests/phpunit/api/v4/Action/CurrentFilterTest.php

index cdb66de7b6f57bce730e4a4c6889bb9627aa59c8..4861f8dc8ff522b655f73633d6e4ef63a5eb7278 100644 (file)
@@ -13,8 +13,6 @@ namespace Civi\Api4\Action\Campaign;
 
 /**
  * @inheritDoc
- *
- * Set current = true to get active, non past campaigns.
  */
 class Get extends \Civi\Api4\Generic\DAOGetAction {
   use \Civi\Api4\Generic\Traits\IsCurrentTrait;
index bc02338adeb78c3ff1c887d882f716c8285df356..3f8c667aa9296e800bdac743089e5df918099574 100644 (file)
@@ -13,8 +13,6 @@ namespace Civi\Api4\Action\Event;
 
 /**
  * @inheritDoc
- *
- * Set current = true to get active, non past events.
  */
 class Get extends \Civi\Api4\Generic\DAOGetAction {
   use \Civi\Api4\Generic\Traits\IsCurrentTrait;
index e9460283c44bb471c856cb2548b1fa560fce79ba..21a832b5c12e257b4f18f38b73c7c0f46c3ae577 100644 (file)
@@ -14,8 +14,6 @@ namespace Civi\Api4\Action\Relationship;
 
 /**
  * @inheritDoc
- *
- * Set current = true to get active, non past relationships.
  */
 class Get extends \Civi\Api4\Generic\DAOGetAction {
   use \Civi\Api4\Generic\Traits\IsCurrentTrait;
index 2a80539c80e98e4fd4b3d4d7f8867f307cffa764..fcede7f824b655c4ed6947bfbf7ca014312e3578 100644 (file)
@@ -16,8 +16,7 @@ use Civi\API\Event\PrepareEvent;
 use Civi\Api4\Utils\ReflectionUtils;
 
 /**
- * Process $current api param for Get actions
- *
+ * @deprecated
  * @see \Civi\Api4\Generic\Traits\IsCurrentTrait
  */
 class IsCurrentSubscriber extends Generic\AbstractPrepareSubscriber {
index 62165a2f1e2b2145854540dd505bd0466bb82001..e11ac0c8f7c3975e0c425a07ac7cc03e21ced1d8 100644 (file)
@@ -118,6 +118,11 @@ class DAOGetFieldsAction extends BasicGetFieldsAction {
       'data_type' => 'Array',
       '@internal' => TRUE,
     ];
+    $fields[] = [
+      'name' => 'sql_renderer',
+      'data_type' => 'Array',
+      '@internal' => TRUE,
+    ];
     return $fields;
   }
 
index b85301f748b0e7bc64c474501d82832a0b919b86..8853d25651ed6e58ee5d33c6deeb23b522cd3eec 100644 (file)
 namespace Civi\Api4\Generic\Traits;
 
 /**
- * This trait adds the $current param to a Get action.
- *
+ * @deprecated
  * @see \Civi\Api4\Event\Subscriber\IsCurrentSubscriber
  */
 trait IsCurrentTrait {
 
   /**
-   * Convenience filter for selecting items that are enabled and are currently within their start/end dates.
-   *
-   * Adding current = TRUE is a shortcut for
-   *   WHERE is_active = 1 AND (end_date IS NULL OR end_date >= now) AND (start_date IS NULL OR start_DATE <= now)
-   *
-   * Adding current = FALSE is a shortcut for
-   *   WHERE is_active = 0 OR start_date > now OR end_date < now
+   * Param deprecated in favor of is_current filter.
    *
    * @var bool
+   * @deprecated
    */
   protected $current;
 
   /**
+   * @deprecated
    * @return bool
    */
   public function getCurrent() {
@@ -39,6 +34,7 @@ trait IsCurrentTrait {
   }
 
   /**
+   * @deprecated
    * @param bool $current
    * @return $this
    */
index 4136698f1384d235461f4f7c2c7dc066de6006e9..7ecad8b827ceb204a00a1f671d9883bcbc3e591a 100644 (file)
@@ -246,7 +246,7 @@ class Api4SelectQuery {
       foreach ($expr->getFields() as $fieldName) {
         $field = $this->getField($fieldName);
         // Remove expressions with unknown fields without raising an error
-        if (!$field || !in_array($field['type'], ['Field', 'Custom'], TRUE)) {
+        if (!$field || $field['type'] === 'Filter') {
           $select = array_diff($select, [$item]);
           $valid = FALSE;
         }
@@ -439,7 +439,7 @@ class Api4SelectQuery {
     if ($type === 'WHERE') {
       $field = $this->getField($expr, TRUE);
       FormattingUtil::formatInputValue($value, $expr, $field, $operator);
-      $fieldAlias = $field['sql_name'];
+      $fieldAlias = $this->getExpression($expr)->render($this->apiFieldSpec);
     }
     // For HAVING, expr must be an item in the SELECT clause
     elseif ($type === 'HAVING') {
@@ -563,6 +563,9 @@ class Api4SelectQuery {
         return "($fieldAlias $isEmptyClause $fieldAlias $operator)";
       }
     }
+    if (is_bool($value)) {
+      $value = (int) $value;
+    }
 
     return \CRM_Core_DAO::createSQLFilter($fieldAlias, [$operator => $value]);
   }
@@ -816,7 +819,9 @@ class Api4SelectQuery {
     else {
       $joinEntityClass = CoreUtil::getApiClass($joinEntity);
       foreach ($joinEntityClass::get($this->getCheckPermissions())->entityFields() as $name => $field) {
-        $bridgeFields[$field['column_name']] = '`' . $joinAlias . '`.`' . $field['column_name'] . '`';
+        if ($field['type'] === 'Field') {
+          $bridgeFields[$field['column_name']] = '`' . $joinAlias . '`.`' . $field['column_name'] . '`';
+        }
       }
       $select = implode(',', $bridgeFields);
       $joinConditions = array_merge($joinConditions, $bridgeConditions);
@@ -898,7 +903,7 @@ class Api4SelectQuery {
     $bridgeFkFields = [$joinRef->getReferenceKey(), $joinRef->getTypeColumn(), $baseRef->getReferenceKey(), $baseRef->getTypeColumn()];
     $bridgeEntityClass = CoreUtil::getApiClass($bridgeEntity);
     foreach ($bridgeEntityClass::get($this->getCheckPermissions())->entityFields() as $name => $field) {
-      if ($name === 'id' || ($side === 'INNER' && in_array($name, $bridgeFkFields, TRUE))) {
+      if ($field['type'] !== 'Field' || $name === 'id' || ($side === 'INNER' && in_array($name, $bridgeFkFields, TRUE))) {
         continue;
       }
       // For INNER joins, these fields get a sql alias pointing to the bridge entity,
index 6bd8c3c2c9883d3a8c9311c224c342086791eeb8..6180fbf1b4ddde73f70254a2956dd5c9b8665c4e 100644 (file)
@@ -34,6 +34,10 @@ class SqlField extends SqlExpression {
     if ($fieldList[$this->expr] === FALSE) {
       throw new UnauthorizedException("Unauthorized field '{$this->expr}'");
     }
+    if (!empty($fieldList[$this->expr]['sql_renderer'])) {
+      $renderer = $fieldList[$this->expr]['sql_renderer'];
+      return $renderer($fieldList[$this->expr]);
+    }
     return $fieldList[$this->expr]['sql_name'];
   }
 
index e0ced42e5f2ac819a4f838fcf818b003cc1a87bb..d98c6b5bb5c4dc646f1131a86bc3b4bcad5f6d56 100644 (file)
@@ -13,7 +13,6 @@
 namespace Civi\Api4\Service\Schema\Joinable;
 
 use Civi\Api4\Utils\CoreUtil;
-use CRM_Core_DAO_AllCoreTables as AllCoreTables;
 
 class Joinable {
 
@@ -275,17 +274,13 @@ class Joinable {
   }
 
   /**
-   * @return \Civi\Api4\Service\Spec\FieldSpec[]
+   * @return \Civi\Api4\Service\Spec\RequestSpec
    */
   public function getEntityFields() {
-    $entityFields = [];
-    $bao = AllCoreTables::getClassForTable($this->getTargetTable());
-    if ($bao) {
-      foreach ($bao::getSupportedFields() as $field) {
-        $entityFields[] = \Civi\Api4\Service\Spec\SpecFormatter::arrayToField($field, $this->getEntity());
-      }
-    }
-    return $entityFields;
+    /** @var \Civi\Api4\Service\Spec\SpecGatherer $gatherer */
+    $gatherer = \Civi::container()->get('spec_gatherer');
+    $spec = $gatherer->getSpec($this->entity, 'get', FALSE);
+    return $spec;
   }
 
   /**
@@ -303,12 +298,7 @@ class Joinable {
    * @return \Civi\Api4\Service\Spec\FieldSpec|NULL
    */
   public function getField($fieldName) {
-    foreach ($this->getEntityFields() as $field) {
-      if ($field->getName() === $fieldName) {
-        return $field;
-      }
-    }
-    return NULL;
+    return $this->getEntityFields()->getFieldByName($fieldName);
   }
 
 }
index fcaf67ed550f65b63a609813e992aa6f5ca1196b..f5733bac8a07e1b721dbb74b24113252177f099e 100644 (file)
@@ -133,12 +133,17 @@ class FieldSpec {
    */
   public $outputFormatters;
 
+  /**
+   * @var callable
+   */
+  public $sqlRenderer;
 
   /**
    * @var callable[]
    */
   public $sqlFilters;
 
+
   /**
    * Aliases for the valid data types
    *
@@ -432,6 +437,16 @@ class FieldSpec {
     return $this;
   }
 
+  /**
+   * @param callable $sqlRenderer
+   * @return $this
+   */
+  public function setSqlRenderer($sqlRenderer) {
+    $this->sqlRenderer = $sqlRenderer;
+
+    return $this;
+  }
+
   /**
    * @param callable[] $sqlFilters
    * @return $this
diff --git a/Civi/Api4/Service/Spec/Provider/IsCurrentFieldSpecProvider.php b/Civi/Api4/Service/Spec/Provider/IsCurrentFieldSpecProvider.php
new file mode 100644 (file)
index 0000000..03598ac
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+
+namespace Civi\Api4\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class IsCurrentFieldSpecProvider implements Generic\SpecProviderInterface {
+
+  /**
+   * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+   */
+  public function modifySpec(RequestSpec $spec) {
+    $field = new FieldSpec('is_current', $spec->getEntity(), 'Boolean');
+    $field->setLabel(ts('Is Current'))
+      ->setTitle(ts('Current'))
+      // This pseudo-field is like is_active with some extra criteria
+      ->setColumnName('is_current')
+      ->setDescription(ts('Is active with a non-past end-date'))
+      ->setType('Extra')
+      ->setSqlRenderer([__CLASS__, 'renderIsCurrentSql']);
+    $spec->addFieldSpec($field);
+  }
+
+  /**
+   * @param string $entity
+   * @param string $action
+   *
+   * @return bool
+   */
+  public function applies($entity, $action) {
+    if ($action !== 'get') {
+      return FALSE;
+    }
+    // TODO: If we wanted this to not be a hard-coded list, we could always return TRUE here
+    // and then in the `modifySpec` function check for the 3 fields `is_active`, `start_date`, and `end_date`
+    return in_array($entity, ['Relationship', 'RelationshipCache', 'Event', 'Campaign'], TRUE);
+  }
+
+  /**
+   * @param array $field
+   * return string
+   */
+  public static function renderIsCurrentSql(array $field): string {
+    $startDate = substr_replace($field['sql_name'], 'start_date', -11, -1);
+    $endDate = substr_replace($field['sql_name'], 'end_date', -11, -1);
+    $isActive = substr_replace($field['sql_name'], 'is_active', -11, -1);
+    $todayStart = date('Ymd', strtotime('now'));
+    $todayEnd = date('Ymd', strtotime('now'));
+    return "IF($isActive = 1 AND ($startDate <= '$todayStart' OR $startDate IS NULL) AND ($endDate >= '$todayEnd' OR $endDate IS NULL), '1', '0')";
+  }
+
+}
index 78b27386ca8e5f265e0e149d5ba4d1a1310037c7..a66b244a630c4e681b22c2e04d92ce210cb65cb1 100644 (file)
@@ -14,7 +14,7 @@ namespace Civi\Api4\Service\Spec;
 
 use Civi\Api4\Utils\CoreUtil;
 
-class RequestSpec {
+class RequestSpec implements \Iterator {
 
   /**
    * @var string
@@ -133,4 +133,24 @@ class RequestSpec {
     return $this->action;
   }
 
+  public function rewind() {
+    return reset($this->fields);
+  }
+
+  public function current() {
+    return current($this->fields);
+  }
+
+  public function key() {
+    return key($this->fields);
+  }
+
+  public function next() {
+    return next($this->fields);
+  }
+
+  public function valid() {
+    return key($this->fields) !== NULL;
+  }
+
 }
index eee3974134f50762541039e39cc99ea8d6cb1920..f4a9eacd9e3387719c786c2a80a1f3e631c5be32 100644 (file)
@@ -16,6 +16,10 @@ use Civi\Api4\CustomField;
 use Civi\Api4\Service\Spec\Provider\Generic\SpecProviderInterface;
 use Civi\Api4\Utils\CoreUtil;
 
+/**
+ * Class SpecGatherer
+ * @package Civi\Api4\Service\Spec
+ */
 class SpecGatherer {
 
   /**
index 953e484b71fd88a1ded156ee76244d6cebdc7dbf..ca7ba747ce07ecad2a27dc215bec1d014b96c9ac 100644 (file)
@@ -315,7 +315,8 @@ trait Api3TestTrait {
     $onlyId = !empty($v3Params['format.only_id']);
     $onlySuccess = !empty($v3Params['format.is_success']);
     if (!empty($v3Params['filters']['is_current']) || !empty($v3Params['isCurrent'])) {
-      $v4Params['current'] = TRUE;
+      $v3Params['is_current'] = 1;
+      unset($v3Params['filters']['is_current'], $v3Params['isCurrent']);
     }
     $language = $v3Params['options']['language'] ?? $v3Params['option.language'] ?? NULL;
     if ($language) {
index b4d50b3a863d7e421a195df1be9ab432c2c43b0e..febac7383b2062f0e3871befa8ca266abc7e85d5 100644 (file)
@@ -62,15 +62,15 @@ class CurrentFilterTest extends UnitTestCase {
       'is_active' => 0,
     ])->execute()->first();
 
-    $getCurrent = (array) Relationship::get()->setCurrent(TRUE)->execute()->indexBy('id');
-    $notCurrent = (array) Relationship::get()->setCurrent(FALSE)->execute()->indexBy('id');
-    $getAll = (array) Relationship::get()->execute()->indexBy('id');
+    $getCurrent = Relationship::get()->addWhere('is_current', '=', TRUE)->execute()->indexBy('id');
+    $notCurrent = Relationship::get()->addWhere('is_current', '=', FALSE)->execute()->indexBy('id');
+    $getAll = Relationship::get()->addSelect('is_current')->execute()->indexBy('id');
 
-    $this->assertArrayHasKey($current['id'], $getAll);
-    $this->assertArrayHasKey($indefinite['id'], $getAll);
-    $this->assertArrayHasKey($expiring['id'], $getAll);
-    $this->assertArrayHasKey($past['id'], $getAll);
-    $this->assertArrayHasKey($inactive['id'], $getAll);
+    $this->assertTrue($getAll[$current['id']]['is_current']);
+    $this->assertTrue($getAll[$indefinite['id']]['is_current']);
+    $this->assertTrue($getAll[$expiring['id']]['is_current']);
+    $this->assertFalse($getAll[$past['id']]['is_current']);
+    $this->assertFalse($getAll[$inactive['id']]['is_current']);
 
     $this->assertArrayHasKey($current['id'], $getCurrent);
     $this->assertArrayHasKey($indefinite['id'], $getCurrent);