*
* @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.
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 '';
+ }
+
}