APIv4 - Refactor writeObjects to choose BAO function based on annotations
[civicrm-core.git] / Civi / Api4 / Generic / Traits / DAOActionTrait.php
index 98707aff3b7f5da14c1183f5ad61c26cc3000994..201f4c2ab98ac56c2ee580d6d2c946bacbe932cb 100644 (file)
@@ -16,6 +16,7 @@ use Civi\Api4\CustomField;
 use Civi\Api4\Service\Schema\Joinable\CustomGroupJoinable;
 use Civi\Api4\Utils\FormattingUtil;
 use Civi\Api4\Utils\CoreUtil;
+use Civi\Api4\Utils\ReflectionUtils;
 
 /**
  * @method string getLanguage()
@@ -32,6 +33,8 @@ trait DAOActionTrait {
    */
   protected $language;
 
+  private $_maxWeights = [];
+
   /**
    * @return \CRM_Core_DAO|string
    */
@@ -93,7 +96,7 @@ trait DAOActionTrait {
   }
 
   /**
-   * Write bao objects as part of a create/update action.
+   * Write bao objects as part of a create/update/save action.
    *
    * @param array $items
    *   The records to write to the DB.
@@ -103,19 +106,16 @@ trait DAOActionTrait {
    * @throws \API_Exception
    * @throws \CRM_Core_Exception
    */
-  protected function writeObjects(&$items) {
-    $baoName = $this->getBaoName();
-
-    // TODO: Opt-in more entities to use the new writeRecords BAO method.
-    $functionNames = [
-      'Address' => 'add',
-      'CustomField' => 'writeRecords',
-      'EntityTag' => 'add',
-      'GroupContact' => 'add',
-    ];
-    $method = $functionNames[$this->getEntityName()] ?? NULL;
-    if (!isset($method)) {
-      $method = method_exists($baoName, 'create') ? 'create' : (method_exists($baoName, 'add') ? 'add' : 'writeRecords');
+  protected function writeObjects($items) {
+    $updateWeights = FALSE;
+    // Adjust weights for sortable entities
+    if (in_array('SortableEntity', CoreUtil::getInfoItem($this->getEntityName(), 'type'))) {
+      $weightField = CoreUtil::getInfoItem($this->getEntityName(), 'order_by');
+      // Only take action if updating a single record, or if no weights are specified in any record
+      // This avoids messing up a bulk update with multiple recalculations
+      if (count($items) === 1 || !array_filter(array_column($items, $weightField))) {
+        $updateWeights = TRUE;
+      }
     }
 
     $result = [];
@@ -125,46 +125,55 @@ trait DAOActionTrait {
       FormattingUtil::formatWriteParams($item, $this->entityFields());
       $this->formatCustomParams($item, $entityId);
 
-      // Skip individual processing if using writeRecords
-      if ($method === 'writeRecords') {
-        continue;
+      // Adjust weights for sortable entities
+      if ($updateWeights) {
+        $this->updateWeight($item);
       }
-      $item['check_permissions'] = $this->getCheckPermissions();
 
-      // For some reason the contact bao requires this
-      if ($entityId && $this->getEntityName() === 'Contact') {
-        $item['contact_id'] = $entityId;
-      }
+      $item['check_permissions'] = $this->getCheckPermissions();
+    }
 
-      if ($this->getEntityName() === 'Address') {
-        $createResult = $baoName::$method($item, $this->fixAddress);
-      }
-      else {
-        $createResult = $baoName::$method($item);
-      }
+    // Ensure array keys start at 0
+    $items = array_values($items);
 
-      if (!$createResult) {
+    foreach ($this->write($items) as $index => $dao) {
+      if (!$dao) {
         $errMessage = sprintf('%s write operation failed', $this->getEntityName());
         throw new \API_Exception($errMessage);
       }
-
-      $result[] = $this->baoToArray($createResult, $item);
-      \CRM_Utils_API_HTMLInputCoder::singleton()->decodeRows($result);
-    }
-
-    // Use bulk `writeRecords` method if the BAO doesn't have a create or add method
-    // TODO: reverse this from opt-in to opt-out and default to using `writeRecords` for all BAOs
-    if ($method === 'writeRecords') {
-      $items = array_values($items);
-      foreach ($baoName::writeRecords($items) as $i => $createResult) {
-        $result[] = $this->baoToArray($createResult, $items[$i]);
-      }
+      $result[] = $this->baoToArray($dao, $items[$index]);
     }
 
+    \CRM_Utils_API_HTMLInputCoder::singleton()->decodeRows($result);
     FormattingUtil::formatOutputValues($result, $this->entityFields());
     return $result;
   }
 
+  /**
+   * Overrideable function to save items using the appropriate BAO function
+   *
+   * @param array[] $items
+   *   Items already formatted by self::writeObjects
+   * @return \CRM_Core_DAO[]
+   *   Array of saved DAO records
+   */
+  protected function write(array $items) {
+    $saved = [];
+    $baoName = $this->getBaoName();
+
+    $method = method_exists($baoName, 'create') ? 'create' : (method_exists($baoName, 'add') ? 'add' : NULL);
+    // Use BAO create or add method if not deprecated
+    if ($method && !ReflectionUtils::isMethodDeprecated($baoName, $method)) {
+      foreach ($items as $item) {
+        $saved[] = $baoName::$method($item);
+      }
+    }
+    else {
+      $saved = $baoName::writeRecords($items);
+    }
+    return $saved;
+  }
+
   /**
    * @inheritDoc
    */
@@ -179,8 +188,12 @@ trait DAOActionTrait {
    * @param array $record
    */
   private function resolveFKValues(array &$record): void {
+    // Resolve domain id first
+    uksort($record, function($a, $b) {
+      return substr($a, 0, 9) == 'domain_id' ? -1 : 1;
+    });
     foreach ($record as $key => $value) {
-      if (substr_count($key, '.') !== 1) {
+      if (!$value || substr_count($key, '.') !== 1) {
         continue;
       }
       [$fieldName, $fkField] = explode('.', $key);
@@ -189,7 +202,27 @@ trait DAOActionTrait {
         continue;
       }
       $fkDao = CoreUtil::getBAOFromApiName($field['fk_entity']);
-      $record[$fieldName] = \CRM_Core_DAO::getFieldValue($fkDao, $value, 'id', $fkField);
+      // Constrain search to the domain of the current entity
+      $domainConstraint = NULL;
+      if (isset($fkDao::getSupportedFields()['domain_id'])) {
+        if (!empty($record['domain_id'])) {
+          $domainConstraint = $record['domain_id'] === 'current_domain' ? \CRM_Core_Config::domainID() : $record['domain_id'];
+        }
+        elseif (!empty($record['id']) && isset($this->entityFields()['domain_id'])) {
+          $domainConstraint = \CRM_Core_DAO::getFieldValue($this->getBaoName(), $record['id'], 'domain_id');
+        }
+      }
+      if ($domainConstraint) {
+        $fkSearch = new $fkDao();
+        $fkSearch->domain_id = $domainConstraint;
+        $fkSearch->$fkField = $value;
+        $fkSearch->find(TRUE);
+        $record[$fieldName] = $fkSearch->id;
+      }
+      // Simple lookup without all the fuss about domains
+      else {
+        $record[$fieldName] = \CRM_Core_DAO::getFieldValue($fkDao, $value, 'id', $fkField);
+      }
       unset($record[$key]);
     }
   }
@@ -213,43 +246,44 @@ trait DAOActionTrait {
         continue;
       }
 
-      // todo are we sure we don't want to allow setting to NULL? need to test
-      if (NULL !== $value) {
+      // Null and empty string are interchangeable as far as the custom bao understands
+      if (NULL === $value) {
+        $value = '';
+      }
 
-        if ($field['suffix']) {
-          $options = FormattingUtil::getPseudoconstantList($field, $name, $params, $this->getActionName());
-          $value = FormattingUtil::replacePseudoconstant($options, $value, TRUE);
-        }
+      if ($field['suffix']) {
+        $options = FormattingUtil::getPseudoconstantList($field, $name, $params, $this->getActionName());
+        $value = FormattingUtil::replacePseudoconstant($options, $value, TRUE);
+      }
 
-        if ($field['html_type'] === 'CheckBox') {
-          // this function should be part of a class
-          formatCheckBoxField($value, 'custom_' . $field['id'], $this->getEntityName());
-        }
+      if ($field['html_type'] === 'CheckBox') {
+        // this function should be part of a class
+        formatCheckBoxField($value, 'custom_' . $field['id'], $this->getEntityName());
+      }
 
-        // Match contact id to strings like "user_contact_id"
-        // FIXME handle arrays for multi-value contact reference fields, etc.
-        if ($field['data_type'] === 'ContactReference' && is_string($value) && !is_numeric($value)) {
-          // FIXME decouple from v3 API
-          require_once 'api/v3/utils.php';
-          $value = \_civicrm_api3_resolve_contactID($value);
-          if ('unknown-user' === $value) {
-            throw new \API_Exception("\"{$field['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $field['name'], "type" => "integer"]);
-          }
+      // Match contact id to strings like "user_contact_id"
+      // FIXME handle arrays for multi-value contact reference fields, etc.
+      if ($field['data_type'] === 'ContactReference' && is_string($value) && !is_numeric($value)) {
+        // FIXME decouple from v3 API
+        require_once 'api/v3/utils.php';
+        $value = \_civicrm_api3_resolve_contactID($value);
+        if ('unknown-user' === $value) {
+          throw new \API_Exception("\"{$field['name']}\" \"{$value}\" cannot be resolved to a contact ID", 2002, ['error_field' => $field['name'], "type" => "integer"]);
         }
-
-        \CRM_Core_BAO_CustomField::formatCustomField(
-          $field['id'],
-          $customParams,
-          $value,
-          $field['custom_group_id.extends'],
-          // todo check when this is needed
-          NULL,
-          $entityId,
-          FALSE,
-          $this->getCheckPermissions(),
-          TRUE
-        );
       }
+
+      \CRM_Core_BAO_CustomField::formatCustomField(
+        $field['id'],
+        $customParams,
+        $value,
+        $field['custom_group_id.extends'],
+        // todo check when this is needed
+        NULL,
+        $entityId,
+        FALSE,
+        $this->getCheckPermissions(),
+        TRUE
+      );
     }
 
     $params['custom'] = $customParams ?: NULL;
@@ -287,4 +321,58 @@ trait DAOActionTrait {
     return isset($info[$fieldName]) ? ['suffix' => $suffix] + $info[$fieldName] : NULL;
   }
 
+  /**
+   * Update weights when inserting or updating a sortable entity
+   * @param array $record
+   * @see SortableEntity
+   */
+  protected function updateWeight(array &$record) {
+    /** @var \CRM_Core_DAO|string $daoName */
+    $daoName = CoreUtil::getInfoItem($this->getEntityName(), 'dao');
+    $weightField = CoreUtil::getInfoItem($this->getEntityName(), 'order_by');
+    $idField = CoreUtil::getIdFieldName($this->getEntityName());
+    // If updating an existing record without changing weight, do nothing
+    if (!isset($record[$weightField]) && !empty($record[$idField])) {
+      return;
+    }
+    $daoFields = $daoName::getSupportedFields();
+    $newWeight = $record[$weightField] ?? NULL;
+    $oldWeight = empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $weightField);
+
+    // FIXME: Need a more metadata-ish approach. For now here's a hardcoded list of the fields sortable entities use for grouping.
+    $guesses = ['option_group_id', 'price_set_id', 'price_field_id', 'premiums_id', 'uf_group_id', 'custom_group_id', 'parent_id', 'domain_id'];
+    $filters = [];
+    foreach (array_intersect($guesses, array_keys($daoFields)) as $filter) {
+      $filters[$filter] = $record[$filter] ?? (empty($record[$idField]) ? NULL : \CRM_Core_DAO::getFieldValue($daoName, $record[$idField], $filter));
+    }
+    // Supply default weight for new record
+    if (!isset($record[$weightField]) && empty($record[$idField])) {
+      $record[$weightField] = $this->getMaxWeight($daoName, $filters, $weightField);
+    }
+    else {
+      $record[$weightField] = \CRM_Utils_Weight::updateOtherWeights($daoName, $oldWeight, $newWeight, $filters, $weightField);
+    }
+  }
+
+  /**
+   * Looks up max weight for a set of sortable entities
+   *
+   * Keeps it in memory in case this operation is writing more than one record
+   *
+   * @param $daoName
+   * @param $filters
+   * @param $weightField
+   * @return int|mixed
+   */
+  private function getMaxWeight($daoName, $filters, $weightField) {
+    $key = $daoName . json_encode($filters);
+    if (!isset($this->_maxWeights[$key])) {
+      $this->_maxWeights[$key] = \CRM_Utils_Weight::getMax($daoName, $filters, $weightField) + 1;
+    }
+    else {
+      ++$this->_maxWeights[$key];
+    }
+    return $this->_maxWeights[$key];
+  }
+
 }