province abbreviation patch - issue 724
[civicrm-core.git] / CRM / Import / DataSource.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
bc77d7c0 4 | Copyright CiviCRM LLC. All rights reserved. |
6a488035 5 | |
bc77d7c0
TO
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
6a488035 9 +--------------------------------------------------------------------+
d25dd0ee 10 */
6a488035
TO
11
12/**
13 *
14 * @package CRM
ca5cec67 15 * @copyright CiviCRM LLC https://civicrm.org/licensing
6a488035
TO
16 */
17
7b057b66
EM
18use Civi\Api4\UserJob;
19
6a488035
TO
20/**
21 * This class defines the DataSource interface but must be subclassed to be
22 * useful.
23 */
24abstract class CRM_Import_DataSource {
25
46a7c32a
EM
26 /**
27 * @var \CRM_Core_DAO
28 */
29 private $queryResultObject;
30
31 /**
32 * @var int
33 */
34 private $limit;
35
36 /**
37 * @param int $limit
38 *
39 * @return CRM_Import_DataSource
40 */
41 public function setLimit(int $limit): CRM_Import_DataSource {
42 $this->limit = $limit;
43 $this->queryResultObject = NULL;
44 return $this;
45 }
46
47 /**
48 * @param int $offset
49 *
50 * @return CRM_Import_DataSource
51 */
52 public function setOffset(int $offset): CRM_Import_DataSource {
53 $this->offset = $offset;
54 $this->queryResultObject = NULL;
55 return $this;
56 }
57
58 /**
59 * @var int
60 */
61 private $offset;
62
63 /**
64 * Statuses of rows to fetch.
65 *
66 * @var array
67 */
68 private $statuses = [];
69
57414073
EM
70 /**
71 * Fields to select.
72 *
73 * @var array
74 */
75 private $selectFields;
76
bccb1d03
EM
77 /**
78 * Fields to select as aggregates.
79 *
80 * @var array
81 */
82 private $aggregateFields;
83
39b4189d
EM
84 /**
85 * The name of the import table.
86 *
87 * @var string
88 */
89 private $tableName;
90
57414073
EM
91 /**
92 * @return array|null
93 */
94 public function getSelectFields(): ?array {
95 return $this->selectFields;
96 }
97
98 /**
99 * @param array $selectFields
100 *
101 * @return CRM_Import_DataSource
102 */
103 public function setSelectFields(array $selectFields): CRM_Import_DataSource {
104 $this->selectFields = $selectFields;
105 return $this;
106 }
107
bccb1d03
EM
108 /**
109 * @param array $fields
110 *
111 * @return CRM_Import_DataSource
112 */
113 public function setAggregateFields(array $fields): CRM_Import_DataSource {
114 $this->aggregateFields = $fields;
115 return $this;
116 }
117
118 /**
119 * @return array|null
120 */
121 public function getAggregateFields(): ?array {
122 return $this->aggregateFields;
123 }
124
46a7c32a
EM
125 /**
126 * Current row.
127 *
128 * @var array
129 */
130 private $row;
131
132 /**
133 * @param array $statuses
134 *
135 * @return self
136 */
137 public function setStatuses(array $statuses): self {
138 $this->statuses = $statuses;
139 $this->queryResultObject = NULL;
140 return $this;
141 }
142
7b057b66
EM
143 /**
144 * Class constructor.
145 *
146 * @param int|null $userJobID
147 */
148 public function __construct(int $userJobID = NULL) {
149 if ($userJobID) {
150 $this->setUserJobID($userJobID);
151 }
152 }
153
154 /**
155 * Form fields declared for this datasource.
156 *
157 * @var string[]
158 */
159 protected $submittableFields = [];
160
161 /**
162 * User job id.
163 *
164 * This is the primary key of the civicrm_user_job table which is used to
165 * track the import.
166 *
167 * @var int
168 */
169 protected $userJobID;
170
171 /**
172 * @return int|null
173 */
174 public function getUserJobID(): ?int {
175 return $this->userJobID;
176 }
177
178 /**
179 * Set user job ID.
180 *
181 * @param int $userJobID
182 */
183 public function setUserJobID(int $userJobID): void {
184 $this->userJobID = $userJobID;
185 }
186
187 /**
188 * User job details.
189 *
190 * This is the relevant row from civicrm_user_job.
191 *
192 * @var array
193 */
194 protected $userJob;
195
196 /**
197 * Get User Job.
198 *
199 * API call to retrieve the userJob row.
200 *
201 * @return array
202 *
203 * @throws \API_Exception
204 */
205 protected function getUserJob(): array {
206 if (!$this->userJob) {
207 $this->userJob = UserJob::get()
208 ->addWhere('id', '=', $this->getUserJobID())
209 ->execute()
210 ->first();
211 }
212 return $this->userJob;
213 }
214
4a01628c
EM
215 /**
216 * Get submitted value.
217 *
218 * Get a value submitted on the form.
219 *
220 * @return mixed
221 *
222 * @throws \API_Exception
223 */
224 protected function getSubmittedValue(string $valueName) {
225 return $this->getUserJob()['metadata']['submitted_values'][$valueName];
226 }
227
228 /**
229 * Get rows as an array.
230 *
231 * The array has all values.
232 *
46a7c32a
EM
233 * @param bool $nonAssociative
234 * Return as a non-associative array?
4a01628c
EM
235 *
236 * @return array
237 *
238 * @throws \API_Exception
239 * @throws \CRM_Core_Exception
240 */
46a7c32a 241 public function getRows(bool $nonAssociative = TRUE): array {
4a01628c 242 $rows = [];
46a7c32a 243 while ($this->getRow()) {
4a01628c 244 // Historically we expect a non-associative array...
46a7c32a 245 $rows[] = $nonAssociative ? array_values($this->row) : $this->row;
4a01628c 246 }
46a7c32a 247 $this->queryResultObject = NULL;
4a01628c
EM
248 return $rows;
249 }
250
46a7c32a
EM
251 /**
252 * Get the next row.
253 *
254 * @return array|null
255 * @throws \API_Exception
256 * @throws \CRM_Core_Exception
257 */
258 public function getRow(): ?array {
259 if (!$this->queryResultObject) {
260 $this->instantiateQueryObject();
261 }
262 if (!$this->queryResultObject->fetch()) {
263 return NULL;
264 }
265 $values = $this->queryResultObject->toArray();
266 /* trim whitespace around the values */
267 foreach ($values as $k => $v) {
268 $values[$k] = trim($v, " \t\r\n");
269 }
270 $this->row = $values;
271 return $values;
272 }
273
274 /**
275 * Get row count.
276 *
277 * The array has all values.
278 *
279 * @return int
280 *
281 * @throws \API_Exception
282 * @throws \CRM_Core_Exception
283 */
284 public function getRowCount(array $statuses = []): int {
285 $this->statuses = $statuses;
286 $query = 'SELECT count(*) FROM ' . $this->getTableName() . ' ' . $this->getStatusClause();
287 return CRM_Core_DAO::singleValueQuery($query);
288 }
289
4a01628c
EM
290 /**
291 * Get an array of column headers, if any.
292 *
293 * Null is returned when there are none - ie because a csv file does not
294 * have an initial header row.
295 *
296 * This is presented to the user in the MapField screen so
297 * that can see what fields they are mapping.
298 *
299 * @return array
300 * @throws \API_Exception
301 */
302 public function getColumnHeaders(): array {
303 return $this->getUserJob()['metadata']['DataSource']['column_headers'];
304 }
305
9cf618c4
EM
306 /**
307 * Get the field names of the fields holding data in the import tracking table.
308 *
309 * @return array
310 * @throws \API_Exception
311 * @throws \CRM_Core_Exception
312 */
313 public function getDataFieldNames(): array {
314 $result = CRM_Core_DAO::executeQuery(
315 'SHOW FIELDS FROM ' . $this->getTableName() . "
316 WHERE Field NOT LIKE '\_%'");
317 $fields = [];
318 while ($result->fetch()) {
319 $fields[] = $result->Field;
320 }
321 return $fields;
322 }
323
4a01628c
EM
324 /**
325 * Get an array of column headers, if any.
326 *
327 * Null is returned when there are none - ie because a csv file does not
328 * have an initial header row.
329 *
330 * This is presented to the user in the MapField screen so
331 * that can see what fields they are mapping.
332 *
333 * @return int
334 * @throws \API_Exception
335 */
336 public function getNumberOfColumns(): int {
337 return $this->getUserJob()['metadata']['DataSource']['number_of_columns'];
338 }
339
7b057b66
EM
340 /**
341 * Generated metadata relating to the the datasource.
342 *
343 * This is values that are computed within the DataSource class and
344 * which are stored in the userJob metadata in the DataSource key - eg.
345 *
346 * ['table_name' => $]
347 *
348 * Will be in the user_job.metadata field encoded into the json like
349 *
350 * `{'DataSource' : ['table_name' => $], 'submitted_values' : .....}`
351 *
352 * @var array
353 */
354 protected $dataSourceMetadata = [];
355
356 /**
4a01628c
EM
357 * Get metadata about the datasource.
358 *
7b057b66 359 * @return array
4a01628c
EM
360 *
361 * @throws \API_Exception
7b057b66
EM
362 */
363 public function getDataSourceMetadata(): array {
4a01628c
EM
364 if (!$this->dataSourceMetadata && $this->getUserJobID()) {
365 $this->dataSourceMetadata = $this->getUserJob()['metadata']['DataSource'];
366 }
367
7b057b66
EM
368 return $this->dataSourceMetadata;
369 }
370
4a01628c
EM
371 /**
372 * Get the table name for the datajob.
373 *
374 * @return string|null
375 *
376 * @throws \API_Exception
377 * @throws \CRM_Core_Exception
378 */
379 protected function getTableName(): ?string {
380 // The old name is still stored...
381 $tableName = $this->getDataSourceMetadata()['table_name'];
382 if (!$tableName) {
383 return NULL;
384 }
39b4189d
EM
385 if (!$this->tableName) {
386 // If we are just loading this table we will do some validation.
387 // In the case of viewing historical jobs the table could have
388 // been deleted so we check that when we first load it.
389 if (strpos($tableName, 'civicrm_tmp_') !== 0
390 || !CRM_Utils_Rule::alphanumeric($tableName)) {
391 // The table name is generated and stored by code, not users so it
392 // should be safe - but a check seems prudent all the same.
393 throw new CRM_Core_Exception('Table cannot be deleted');
394 }
395 if (!CRM_Core_DAO::singleValueQuery('SHOW TABLES LIKE %1', [1 => [$tableName, 'String']])) {
396 throw new CRM_Import_Exception_ImportTableUnavailable('table deleted');
397 }
398 $this->tableName = $tableName;
4a01628c 399 }
39b4189d 400 return $this->tableName;
4a01628c
EM
401 }
402
7b057b66
EM
403 /**
404 * Get the fields declared for this datasource.
405 *
406 * @return string[]
407 */
408 public function getSubmittableFields(): array {
409 return $this->submittableFields;
410 }
411
6a488035 412 /**
fe482240 413 * Provides information about the data source.
6a488035 414 *
a6c01b45 415 * @return array
11749569
TO
416 * Description of this data source, including:
417 * - title: string, translated, required
418 * - permissions: array, optional
419 *
6a488035
TO
420 */
421 abstract public function getInfo();
422
6a488035 423 /**
54957108 424 * This is function is called by the form object to get the DataSource's form snippet.
6a488035 425 *
54957108 426 * It should add all fields necessary to get the data uploaded to the temporary table in the DB.
6c8f6e67 427 *
54957108 428 * @param CRM_Core_Form $form
6a488035
TO
429 */
430 abstract public function buildQuickForm(&$form);
431
1a783cdb
EM
432 /**
433 * Initialize the datasource, based on the submitted values stored in the user job.
434 *
435 * @throws \API_Exception
436 * @throws \CRM_Core_Exception
437 */
438 public function initialize(): void {
439
440 }
441
11749569
TO
442 /**
443 * Determine if the current user has access to this data source.
444 *
445 * @return bool
446 */
447 public function checkPermission() {
448 $info = $this->getInfo();
449 return empty($info['permissions']) || CRM_Core_Permission::check($info['permissions']);
450 }
451
4a01628c
EM
452 /**
453 * @param string $key
454 * @param array $data
455 *
456 * @throws \API_Exception
457 * @throws \Civi\API\Exception\UnauthorizedException
458 */
459 protected function updateUserJobMetadata(string $key, array $data): void {
460 $metaData = array_merge(
461 $this->getUserJob()['metadata'],
462 [$key => $data]
463 );
464 UserJob::update(FALSE)
465 ->addWhere('id', '=', $this->getUserJobID())
466 ->setValues(['metadata' => $metaData])
467 ->execute();
468 $this->userJob['metadata'] = $metaData;
469 }
470
46a7c32a
EM
471 /**
472 * Purge any datasource related assets when the datasource is dropped.
473 *
474 * This is the datasource's chance to delete any tables etc that it created
475 * which will now not be used.
476 *
477 * @param array $newParams
478 * If the dataSource is being updated to another variant of the same
479 * class (eg. the csv upload was set to no column headers and they
480 * have resubmitted WITH skipColumnHeader (first row is a header) then
481 * the dataSource is still CSV and the params for the new intance
482 * are passed in. When changing from csv to SQL (for example) newParams is
483 * empty.
484 *
485 * @return array
486 * The details to update the DataSource key in the userJob metadata to.
487 * Generally and empty array but it the datasource decided (for example)
488 * that the table it created earlier is still consistent with the new params
489 * then it might decided not to drop the table and would want to retain
490 * some metadata.
491 *
492 * @throws \API_Exception
493 * @throws \CRM_Core_Exception
60912c71 494 *
46a7c32a
EM
495 * @noinspection PhpUnusedParameterInspection
496 */
497 public function purge(array $newParams = []) :array {
498 // The old name is still stored...
499 $oldTableName = $this->getTableName();
500 if ($oldTableName) {
501 CRM_Core_DAO::executeQuery('DROP TABLE IF EXISTS ' . $oldTableName);
502 }
503 return [];
504 }
505
506 /**
507 * Add a status columns to the import table.
508 *
509 * We add
510 * _id - primary key
511 * _status
512 * _statusMsg
513 *
514 * Note that
515 * 1) the use of the preceding underscore has 2 purposes - it avoids clashing
516 * with an id field (code comments from 14 years ago suggest perhaps there
517 * could be cases where it still clashes but time didn't tell in this case)
518 * 2) the show fields query used to get the column names excluded the
519 * administrative fields, relying on this convention.
2a4de39f 520 * 3) we have the capitalisation on _statusMsg - @param string $tableName
46a7c32a 521 *
2a4de39f
EM
522 * @throws \API_Exception
523 * @todo change to _status_message
46a7c32a
EM
524 */
525 protected function addTrackingFieldsToTable(string $tableName): void {
526 CRM_Core_DAO::executeQuery("
527 ALTER TABLE $tableName
5e21b588 528 ADD COLUMN _entity_id INT,
2a4de39f 529 " . $this->getAdditionalTrackingFields() . "
46a7c32a 530 ADD COLUMN _status VARCHAR(32) DEFAULT 'NEW' NOT NULL,
36da91ca 531 ADD COLUMN _status_message LONGTEXT,
58a65f77
EM
532 ADD COLUMN _id INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
533 ADD INDEX(_id),
534 ADD INDEX(_status)
535 "
46a7c32a
EM
536 );
537 }
538
2a4de39f
EM
539 /**
540 * Get any additional import specific tracking fields.
541 *
542 * @throws \API_Exception
543 */
544 private function getAdditionalTrackingFields(): string {
545 $sql = '';
546 $fields = $this->getParser()->getTrackingFields();
547 foreach ($fields as $fieldName => $spec) {
bccb1d03 548 $sql .= 'ADD COLUMN _' . $fieldName . ' ' . $spec['type'] . ',';
2a4de39f
EM
549 }
550 return $sql;
551 }
552
553 /**
554 * Get the import parser.
555 *
556 * @return CRM_Import_Parser
557 *
558 * @throws \API_Exception
559 */
560 private function getParser() {
561 $parserClass = '';
562 foreach (CRM_Core_BAO_UserJob::getTypes() as $type) {
d2b81f5a 563 if ($this->getUserJob()['job_type'] === $type['id']) {
2a4de39f 564 $parserClass = $type['class'];
3764aead 565 break;
2a4de39f
EM
566 }
567 }
568 /* @var \CRM_Import_Parser */
569 $parser = new $parserClass();
570 $parser->setUserJobID($this->getUserJobID());
571 return $parser;
572 }
573
5e21b588
EM
574 /**
575 * Has the import job completed.
576 *
577 * @return bool
578 * True if no rows remain to be imported.
579 *
580 * @throws \API_Exception
581 * @throws \CRM_Core_Exception
582 */
583 public function isCompleted(): bool {
584 return (bool) $this->getRowCount(['new']);
585 }
586
587 /**
588 * Update the status of the import row to reflect the processing outcome.
589 *
590 * @param int $id
591 * @param string $status
592 * @param string $message
593 * @param int|null $entityID
594 * Optional created entity ID
2a4de39f 595 * @param array $additionalFields
5e21b588
EM
596 * Optional array e.g ['related_contact' => 4]
597 *
598 * @throws \API_Exception
599 * @throws \CRM_Core_Exception
600 */
2a4de39f 601 public function updateStatus(int $id, string $status, string $message, ? int $entityID = NULL, array $additionalFields = []): void {
5e21b588
EM
602 $sql = 'UPDATE ' . $this->getTableName() . ' SET _status = %1, _status_message = %2 ';
603 $params = [1 => [$status, 'String'], 2 => [$message, 'String']];
604 if ($entityID) {
605 $sql .= ', _entity_id = %3';
606 $params[3] = [$entityID, 'Integer'];
607 }
2a4de39f
EM
608 $nextParam = 4;
609 foreach ($additionalFields as $fieldName => $value) {
610 $sql .= ', _' . $fieldName . ' = %' . $nextParam;
611 $params[$nextParam] = is_numeric($value) ? [$value, 'Int'] : [json_encode($value), 'String'];
612 $nextParam++;
5e21b588
EM
613 }
614 CRM_Core_DAO::executeQuery($sql . ' WHERE _id = ' . $id, $params);
615 }
616
46a7c32a
EM
617 /**
618 *
619 * @throws \API_Exception
620 * @throws \CRM_Core_Exception
621 */
622 private function instantiateQueryObject(): void {
57414073 623 $query = 'SELECT ' . $this->getSelectClause() . ' FROM ' . $this->getTableName() . ' ' . $this->getStatusClause();
46a7c32a
EM
624 if ($this->limit) {
625 $query .= ' LIMIT ' . $this->limit . ($this->offset ? (' OFFSET ' . $this->offset) : NULL);
626 }
627 $this->queryResultObject = CRM_Core_DAO::executeQuery($query);
628 }
629
57414073
EM
630 /**
631 * @return string
632 */
633 private function getSelectClause(): string {
bccb1d03
EM
634 if ($this->getAggregateFields()) {
635 $fields = [];
636 foreach ($this->getAggregateFields() as $field) {
637 $fields[] = $field['operation'] . '(_' . $field['name'] . ') as ' . $field['name'];
638 }
639 return implode(',', $fields);
640 }
9cf618c4 641 return $this->getSelectFields() ? '`' . implode('`, `', $this->getSelectFields()) . '`' : '*';
57414073
EM
642 }
643
46a7c32a
EM
644 /**
645 * Get the mapping of constants to database status codes.
646 *
99e3c5f7 647 * @return array[]
46a7c32a 648 */
99e3c5f7 649 protected function getStatusMapping(): array {
46a7c32a 650 return [
58a65f77
EM
651 CRM_Import_Parser::VALID => ['imported', 'new', 'soft_credit_imported', 'pledge_payment_imported'],
652 CRM_Import_Parser::ERROR => ['error', 'invalid', 'soft_credit_error', 'pledge_payment_error'],
99e3c5f7 653 CRM_Import_Parser::DUPLICATE => ['duplicate'],
c4f66023
EM
654 CRM_Import_Parser::NO_MATCH => ['invalid_no_match'],
655 CRM_Import_Parser::UNPARSED_ADDRESS_WARNING => ['warning_unparsed_address'],
58a65f77
EM
656 CRM_Contribute_Import_Parser_Contribution::SOFT_CREDIT_ERROR => ['soft_credit_error'],
657 CRM_Contribute_Import_Parser_Contribution::SOFT_CREDIT => ['soft_credit_imported'],
658 CRM_Contribute_Import_Parser_Contribution::PLEDGE_PAYMENT => ['pledge_payment_imported'],
659 CRM_Contribute_Import_Parser_Contribution::PLEDGE_PAYMENT_ERROR => ['pledge_payment_error'],
5e21b588 660 'new' => ['new'],
46a7c32a
EM
661 ];
662 }
663
664 /**
665 * Get the status filter clause.
666 *
667 * @return string
668 */
669 private function getStatusClause(): string {
670 if (!empty($this->statuses)) {
671 $statuses = [];
672 foreach ($this->statuses as $status) {
99e3c5f7
EM
673 foreach ($this->getStatusMapping()[$status] as $statusName) {
674 $statuses[] = '"' . $statusName . '"';
675 }
46a7c32a
EM
676 }
677 return ' WHERE _status IN (' . implode(',', $statuses) . ')';
678 }
679 return '';
680 }
681
6a488035 682}