Cleanup cleanup on old tables for form re-submission
[civicrm-core.git] / CRM / Import / DataSource.php
index 2b547608223439447040af6bf5309de70129a021..77db320a37162fa09a7c979b2831752163b48a1d 100644 (file)
  *
  * @package CRM
  * @copyright CiviCRM LLC https://civicrm.org/licensing
- * $Id$
- *
  */
 
+use Civi\Api4\UserJob;
+
 /**
  * This class defines the DataSource interface but must be subclassed to be
  * useful.
 abstract class CRM_Import_DataSource {
 
   /**
-   * Provides information about the data source.
+   * @var \CRM_Core_DAO
+   */
+  private $queryResultObject;
+
+  /**
+   * @var int
+   */
+  private $limit;
+
+  /**
+   * @param int $limit
+   *
+   * @return CRM_Import_DataSource
+   */
+  public function setLimit(int $limit): CRM_Import_DataSource {
+    $this->limit = $limit;
+    $this->queryResultObject = NULL;
+    return $this;
+  }
+
+  /**
+   * @param int $offset
+   *
+   * @return CRM_Import_DataSource
+   */
+  public function setOffset(int $offset): CRM_Import_DataSource {
+    $this->offset = $offset;
+    $this->queryResultObject = NULL;
+    return $this;
+  }
+
+  /**
+   * @var int
+   */
+  private $offset;
+
+  /**
+   * Statuses of rows to fetch.
+   *
+   * @var array
+   */
+  private $statuses = [];
+
+  /**
+   * Current row.
+   *
+   * @var array
+   */
+  private $row;
+
+  /**
+   * @param array $statuses
+   *
+   * @return self
+   */
+  public function setStatuses(array $statuses): self {
+    $this->statuses = $statuses;
+    $this->queryResultObject = NULL;
+    return $this;
+  }
+
+  /**
+   * Class constructor.
+   *
+   * @param int|null $userJobID
+   */
+  public function __construct(int $userJobID = NULL) {
+    if ($userJobID) {
+      $this->setUserJobID($userJobID);
+    }
+  }
+
+  /**
+   * Form fields declared for this datasource.
+   *
+   * @var string[]
+   */
+  protected $submittableFields = [];
+
+  /**
+   * User job id.
+   *
+   * This is the primary key of the civicrm_user_job table which is used to
+   * track the import.
+   *
+   * @var int
+   */
+  protected $userJobID;
+
+  /**
+   * @return int|null
+   */
+  public function getUserJobID(): ?int {
+    return $this->userJobID;
+  }
+
+  /**
+   * Set user job ID.
+   *
+   * @param int $userJobID
+   */
+  public function setUserJobID(int $userJobID): void {
+    $this->userJobID = $userJobID;
+  }
+
+  /**
+   * User job details.
+   *
+   * This is the relevant row from civicrm_user_job.
+   *
+   * @var array
+   */
+  protected $userJob;
+
+  /**
+   * Get User Job.
+   *
+   * API call to retrieve the userJob row.
    *
    * @return array
-   *   Description of this data source, including:
-   *   - title: string, translated, required
-   *   - permissions: array, optional
    *
+   * @throws \API_Exception
    */
-  abstract public function getInfo();
+  protected function getUserJob(): array {
+    if (!$this->userJob) {
+      $this->userJob = UserJob::get()
+        ->addWhere('id', '=', $this->getUserJobID())
+        ->execute()
+        ->first();
+    }
+    return $this->userJob;
+  }
 
   /**
-   * Set variables up before form is built.
+   * Get submitted value.
    *
-   * @param CRM_Core_Form $form
+   * Get a value submitted on the form.
+   *
+   * @return mixed
+   *
+   * @throws \API_Exception
    */
-  abstract public function preProcess(&$form);
+  protected function getSubmittedValue(string $valueName) {
+    return $this->getUserJob()['metadata']['submitted_values'][$valueName];
+  }
 
   /**
-   * This is function is called by the form object to get the DataSource's form snippet.
+   * Get rows as an array.
    *
-   * It should add all fields necessary to get the data uploaded to the temporary table in the DB.
+   * The array has all values.
    *
-   * @param CRM_Core_Form $form
+   * @param bool $nonAssociative
+   *   Return as a non-associative array?
+   *
+   * @return array
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
    */
-  abstract public function buildQuickForm(&$form);
+  public function getRows(bool $nonAssociative = TRUE): array {
+    $rows = [];
+    while ($this->getRow()) {
+      // Historically we expect a non-associative array...
+      $rows[] = $nonAssociative ? array_values($this->row) : $this->row;
+    }
+    $this->queryResultObject = NULL;
+    return $rows;
+  }
 
   /**
-   * Process the form submission.
+   * Get the next row.
+   *
+   * @return array|null
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  public function getRow(): ?array {
+    if (!$this->queryResultObject) {
+      $this->instantiateQueryObject();
+    }
+    if (!$this->queryResultObject->fetch()) {
+      return NULL;
+    }
+    $values = $this->queryResultObject->toArray();
+    /* trim whitespace around the values */
+    foreach ($values as $k => $v) {
+      $values[$k] = trim($v, " \t\r\n");
+    }
+    $this->row = $values;
+    return $values;
+  }
+
+  /**
+   * Get row count.
+   *
+   * The array has all values.
+   *
+   * @return int
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  public function getRowCount(array $statuses = []): int {
+    $this->statuses = $statuses;
+    $query = 'SELECT count(*) FROM ' . $this->getTableName() . ' ' . $this->getStatusClause();
+    return CRM_Core_DAO::singleValueQuery($query);
+  }
+
+  /**
+   * Get an array of column headers, if any.
+   *
+   * Null is returned when there are none - ie because a csv file does not
+   * have an initial header row.
+   *
+   * This is presented to the user in the MapField screen so
+   * that can see what fields they are mapping.
+   *
+   * @return array
+   * @throws \API_Exception
+   */
+  public function getColumnHeaders(): array {
+    return $this->getUserJob()['metadata']['DataSource']['column_headers'];
+  }
+
+  /**
+   * Get an array of column headers, if any.
+   *
+   * Null is returned when there are none - ie because a csv file does not
+   * have an initial header row.
+   *
+   * This is presented to the user in the MapField screen so
+   * that can see what fields they are mapping.
+   *
+   * @return int
+   * @throws \API_Exception
+   */
+  public function getNumberOfColumns(): int {
+    return $this->getUserJob()['metadata']['DataSource']['number_of_columns'];
+  }
+
+  /**
+   * Generated metadata relating to the the datasource.
+   *
+   * This is values that are computed within the DataSource class and
+   * which are stored in the userJob metadata in the DataSource key - eg.
+   *
+   * ['table_name' => $]
+   *
+   * Will be in the user_job.metadata field encoded into the json like
+   *
+   * `{'DataSource' : ['table_name' => $], 'submitted_values' : .....}`
+   *
+   * @var array
+   */
+  protected $dataSourceMetadata = [];
+
+  /**
+   * Get metadata about the datasource.
+   *
+   * @return array
+   *
+   * @throws \API_Exception
+   */
+  public function getDataSourceMetadata(): array {
+    if (!$this->dataSourceMetadata && $this->getUserJobID()) {
+      $this->dataSourceMetadata = $this->getUserJob()['metadata']['DataSource'];
+    }
+
+    return $this->dataSourceMetadata;
+  }
+
+  /**
+   * Get the table name for the datajob.
+   *
+   * @return string|null
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  protected function getTableName(): ?string {
+    // The old name is still stored...
+    $tableName = $this->getDataSourceMetadata()['table_name'];
+    if (!$tableName) {
+      return NULL;
+    }
+    if (strpos($tableName, 'civicrm_tmp_') !== 0
+      || !CRM_Utils_Rule::alphanumeric($tableName)) {
+      // The table name is generated and stored by code, not users so it
+      // should be safe - but a check seems prudent all the same.
+      throw new CRM_Core_Exception('Table cannot be deleted');
+    }
+    return $tableName;
+  }
+
+  /**
+   * Get the fields declared for this datasource.
+   *
+   * @return string[]
+   */
+  public function getSubmittableFields(): array {
+    return $this->submittableFields;
+  }
+
+  /**
+   * Provides information about the data source.
+   *
+   * @return array
+   *   Description of this data source, including:
+   *   - title: string, translated, required
+   *   - permissions: array, optional
+   *
+   */
+  abstract public function getInfo();
+
+  /**
+   * This is function is called by the form object to get the DataSource's form snippet.
+   *
+   * It should add all fields necessary to get the data uploaded to the temporary table in the DB.
    *
-   * @param array $params
-   * @param string $db
    * @param CRM_Core_Form $form
    */
-  abstract public function postProcess(&$params, &$db, &$form);
+  abstract public function buildQuickForm(&$form);
 
   /**
    * Determine if the current user has access to this data source.
@@ -69,4 +357,127 @@ abstract class CRM_Import_DataSource {
     return empty($info['permissions']) || CRM_Core_Permission::check($info['permissions']);
   }
 
+  /**
+   * @param string $key
+   * @param array $data
+   *
+   * @throws \API_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  protected function updateUserJobMetadata(string $key, array $data): void {
+    $metaData = array_merge(
+      $this->getUserJob()['metadata'],
+      [$key => $data]
+    );
+    UserJob::update(FALSE)
+      ->addWhere('id', '=', $this->getUserJobID())
+      ->setValues(['metadata' => $metaData])
+      ->execute();
+    $this->userJob['metadata'] = $metaData;
+  }
+
+  /**
+   * Purge any datasource related assets when the datasource is dropped.
+   *
+   * This is the datasource's chance to delete any tables etc that it created
+   * which will now not be used.
+   *
+   * @param array $newParams
+   *   If the dataSource is being updated to another variant of the same
+   *   class (eg. the csv upload was set to no column headers and they
+   *   have resubmitted WITH skipColumnHeader (first row is a header) then
+   *   the dataSource is still CSV and the params for the new intance
+   *   are passed in. When changing from csv to SQL (for example) newParams is
+   *   empty.
+   *
+   * @return array
+   *   The details to update the DataSource key in the userJob metadata to.
+   *   Generally and empty array but it the datasource decided (for example)
+   *   that the table it created earlier is still consistent with the new params
+   *   then it might decided not to drop the table and would want to retain
+   *   some metadata.
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   *
+   * @noinspection PhpUnusedParameterInspection
+   */
+  public function purge(array $newParams = []) :array {
+    // The old name is still stored...
+    $oldTableName = $this->getTableName();
+    if ($oldTableName) {
+      CRM_Core_DAO::executeQuery('DROP TABLE IF EXISTS ' . $oldTableName);
+    }
+    return [];
+  }
+
+  /**
+   * Add a status columns to the import table.
+   *
+   * We add
+   *  _id - primary key
+   *  _status
+   *  _statusMsg
+   *
+   * Note that
+   * 1) the use of the preceding underscore has 2 purposes - it avoids clashing
+   *   with an id field (code comments from 14 years ago suggest perhaps there
+   *   could be cases where it still clashes but time didn't tell in this case)
+   * 2) the show fields query used to get the column names excluded the
+   *   administrative fields, relying on this convention.
+   * 3) we have the capitalisation on _statusMsg - @todo change to _status_message
+   *
+   * @param string $tableName
+   */
+  protected function addTrackingFieldsToTable(string $tableName): void {
+    CRM_Core_DAO::executeQuery("
+     ALTER TABLE $tableName
+       ADD COLUMN _status VARCHAR(32) DEFAULT 'NEW' NOT NULL,
+       ADD COLUMN _statusMsg TEXT,
+       ADD COLUMN _id INT PRIMARY KEY NOT NULL AUTO_INCREMENT"
+    );
+  }
+
+  /**
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  private function instantiateQueryObject(): void {
+    $query = 'SELECT * FROM ' . $this->getTableName() . ' ' . $this->getStatusClause();
+    if ($this->limit) {
+      $query .= ' LIMIT ' . $this->limit . ($this->offset ? (' OFFSET ' . $this->offset) : NULL);
+    }
+    $this->queryResultObject = CRM_Core_DAO::executeQuery($query);
+  }
+
+  /**
+   * Get the mapping of constants to database status codes.
+   *
+   * @return string[]
+   */
+  protected function getStatusMapping() {
+    return [
+      CRM_Import_Parser::VALID => 'imported',
+      CRM_Import_Parser::ERROR => 'error',
+      CRM_Import_Parser::DUPLICATE => 'duplicate',
+    ];
+  }
+
+  /**
+   * Get the status filter clause.
+   *
+   * @return string
+   */
+  private function getStatusClause(): string {
+    if (!empty($this->statuses)) {
+      $statuses = [];
+      foreach ($this->statuses as $status) {
+        $statuses[] = '"' . $this->getStatusMapping()[$status] . '"';
+      }
+      return ' WHERE _status IN (' . implode(',', $statuses) . ')';
+    }
+    return '';
+  }
+
 }