Passed
Push — master ( 6d6d6e...810d83 )
by Thomas
05:43 queued 02:46
created

ExcelBulkLoader::preview()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace LeKoala\ExcelImportExport;
4
5
use Exception;
6
use LeKoala\SpreadCompat\SpreadCompat;
0 ignored issues
show
Bug introduced by
The type LeKoala\SpreadCompat\SpreadCompat was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
7
use SilverStripe\Dev\BulkLoader;
8
use SilverStripe\ORM\DataObject;
9
use SilverStripe\Core\Environment;
10
use SilverStripe\Dev\BulkLoader_Result;
11
use SilverStripe\Control\HTTPResponse_Exception;
12
use SilverStripe\Core\ClassInfo;
13
use SilverStripe\ORM\DB;
14
15
/**
16
 * @author Koala
17
 */
18
class ExcelBulkLoader extends BulkLoader
19
{
20
    private bool $checkPermissions = false;
21
22
    private bool $useTransaction = false;
23
24
    /**
25
     * Delimiter character
26
     * We use auto detection for csv because we can't ask the user what he is using
27
     *
28
     * @var string
29
     */
30
    public $delimiter = 'auto';
31
32
    /**
33
     * Enclosure character (Default: doublequote)
34
     *
35
     * @var string
36
     */
37
    public $enclosure = '"';
38
39
    /**
40
     * Identifies if the file has a header row.
41
     *
42
     * @var boolean
43
     */
44
    public $hasHeaderRow = true;
45
46
    /**
47
     * @var array<string,string>
48
     */
49
    public $duplicateChecks = [
50
        'ID' => 'ID',
51
    ];
52
53
    /**
54
     * The uploaded file infos
55
     * @var array<mixed>
56
     */
57
    protected $uploadFile = null;
58
59
    /**
60
     *
61
     * @var DataObject
62
     */
63
    protected $singleton = null;
64
65
    /**
66
     * @var array<mixed>
67
     */
68
    protected $db = [];
69
70
    /**
71
     * Type of file if not able to determine through uploaded file
72
     *
73
     * @var string
74
     */
75
    protected $fileType = 'xlsx';
76
77
    /**
78
     * @return BulkLoader_Result
79
     */
80
    public function preview($filepath)
81
    {
82
        return $this->processAll($filepath, true);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->processAll($filepath, true) returns the type SilverStripe\Dev\BulkLoader_Result which is incompatible with the return type mandated by SilverStripe\Dev\BulkLoader::preview() of array.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
83
    }
84
85
    /**
86
     * Load the given file via {@link self::processAll()} and {@link self::processRecord()}.
87
     * Optionally truncates (clear) the table before it imports.
88
     *
89
     * @return BulkLoader_Result See {@link self::processAll()}
90
     */
91
    public function load($filepath)
92
    {
93
        // A small hack to allow model admin import form to work properly
94
        if (!is_array($filepath) && isset($_FILES['_CsvFile'])) {
95
            $filepath = $_FILES['_CsvFile'];
96
        }
97
        if (is_array($filepath)) {
98
            $this->uploadFile = $filepath;
99
            $filepath = $filepath['tmp_name'];
100
        }
101
        if (is_string($filepath)) {
102
            $ext = pathinfo($filepath, PATHINFO_EXTENSION);
103
            if ($ext == 'csv' || $ext == 'xlsx') {
104
                $this->fileType = $ext;
105
            }
106
        }
107
108
        // upload is resource intensive
109
        Environment::increaseTimeLimitTo(3600);
110
        Environment::increaseMemoryLimitTo('512M');
111
112
        if ($this->useTransaction) {
113
            DB::get_conn()->transactionStart();
114
        }
115
116
        try {
117
            //get all instances of the to be imported data object
118
            if ($this->deleteExistingRecords) {
119
                if ($this->getCheckPermissions()) {
120
                    // We need to check each record, in case there's some fancy conditional logic in the canDelete method.
121
                    // If we can't delete even a single record, we should bail because otherwise the result would not be
122
                    // what the user expects.
123
                    /** @var DataObject $record */
124
                    foreach (DataObject::get($this->objectClass) as $record) {
125
                        if (!$record->canDelete()) {
126
                            $type = $record->i18n_singular_name();
127
                            throw new HTTPResponse_Exception(
128
                                _t(__CLASS__ . '.CANNOT_DELETE', "Not allowed to delete '{type}' records", ["type" => $type]),
129
                                403
130
                            );
131
                        }
132
                    }
133
                }
134
                DataObject::get($this->objectClass)->removeAll();
135
            }
136
137
            $result = $this->processAll($filepath);
138
139
            if ($this->useTransaction) {
140
                DB::get_conn()->transactionEnd();
141
            }
142
        } catch (Exception $e) {
143
            if ($this->useTransaction) {
144
                DB::get_conn()->transactionRollback();
145
            }
146
            $code = $e->getCode() ?: 500;
147
            throw new HTTPResponse_Exception($e->getMessage(), $code);
148
        }
149
        return $result;
150
    }
151
152
    /**
153
     * @return string
154
     */
155
    protected function getUploadFileExtension()
156
    {
157
        if ($this->uploadFile) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->uploadFile of type array<mixed,mixed> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
158
            return pathinfo($this->uploadFile['name'], PATHINFO_EXTENSION);
0 ignored issues
show
Bug Best Practice introduced by
The expression return pathinfo($this->u...ort\PATHINFO_EXTENSION) also could return the type array which is incompatible with the documented return type string.
Loading history...
159
        }
160
        return $this->fileType;
161
    }
162
163
    /**
164
     * Merge a row with its headers
165
     *
166
     * @param array $row
167
     * @param array $headers
168
     * @param int $headersCount (optional) Limit to a specifc number of headers
169
     * @return array
170
     */
171
    protected function mergeRowWithHeaders($row, $headers, $headersCount = null)
172
    {
173
        if ($headersCount === null) {
174
            $headersCount = count($headers);
175
        }
176
        $row = array_slice($row, 0, $headersCount);
177
        $row = array_combine($headers, $row);
178
        return $row;
179
    }
180
181
    /**
182
     * @param string $filepath
183
     * @param boolean $preview
184
     */
185
    protected function processAll($filepath, $preview = false)
186
    {
187
        $this->extend('onBeforeProcessAll', $filepath, $preview);
188
189
        $results = new BulkLoader_Result();
190
        $ext = $this->getUploadFileExtension();
191
192
        if (!is_readable($filepath)) {
193
            throw new Exception("Cannot read $filepath");
194
        }
195
196
        $opts = [
197
            'separator' => $this->delimiter,
198
            'enclosure' => $this->enclosure,
199
            'extension' => $ext,
200
        ];
201
        if ($this->hasHeaderRow) {
202
            $opts['assoc'] = true;
203
        }
204
205
        $data = SpreadCompat::read($filepath, ...$opts);
206
207
        $objectClass = $this->objectClass;
208
        $objectConfig = $objectClass::config();
209
        $this->db = $objectConfig->db;
210
        $this->singleton = singleton($objectClass);
211
212
        foreach ($data as $row) {
213
            $this->processRecord(
214
                $row,
215
                $this->columnMap,
216
                $results,
217
                $preview
218
            );
219
        }
220
221
        $this->extend('onAfterProcessAll', $result, $preview);
222
223
        return $results;
224
    }
225
226
    /**
227
     *
228
     * @param array $record
229
     * @param array $columnMap
230
     * @param BulkLoader_Result $results
231
     * @param boolean $preview
232
     *
233
     * @return int
234
     */
235
    protected function processRecord(
236
        $record,
237
        $columnMap,
238
        &$results,
239
        $preview = false,
240
        $makeRelations = false
241
    ) {
242
        // find existing object, or create new one
243
        $existingObj = $this->findExistingObject($record, $columnMap);
244
        $alreadyExists = (bool) $existingObj;
245
246
        // If we can't edit the existing object, bail early.
247
        if ($this->getCheckPermissions() && !$preview && $alreadyExists && !$existingObj->canEdit()) {
248
            $type = $existingObj->i18n_singular_name();
249
            throw new HTTPResponse_Exception(
250
                _t(BulkLoader::class . '.CANNOT_EDIT', "Not allowed to edit '{type}' records", ["type" => $type]),
251
                403
252
            );
253
        }
254
255
        $class = $record['ClassName'] ?? $this->objectClass;
256
        $obj = $existingObj ? $existingObj : new $class();
257
258
        // If we can't create a new record, bail out early.
259
        if ($this->getCheckPermissions() && !$preview && !$alreadyExists && !$obj->canCreate()) {
260
            $type = $obj->i18n_singular_name();
261
            throw new HTTPResponse_Exception(
262
                _t(BulkLoader::class . '.CANNOT_CREATE', "Not allowed to create '{type}' records", ["type" => $type]),
263
                403
264
            );
265
        }
266
267
        // first run: find/create any relations and store them on the object
268
        // we can't combine runs, as other columns might rely on the relation being present
269
        if ($makeRelations) {
270
            foreach ($record as $fieldName => $val) {
271
                // don't bother querying of value is not set
272
                if ($this->isNullValue($val)) {
273
                    continue;
274
                }
275
276
                // checking for existing relations
277
                if (isset($this->relationCallbacks[$fieldName])) {
278
                    // trigger custom search method for finding a relation based on the given value
279
                    // and write it back to the relation (or create a new object)
280
                    $relationName = $this->relationCallbacks[$fieldName]['relationname'];
281
                    if ($this->hasMethod($this->relationCallbacks[$fieldName]['callback'])) {
282
                        $relationObj = $this->{$this->relationCallbacks[$fieldName]['callback']}(
283
                            $obj,
284
                            $val,
285
                            $record
286
                        );
287
                    } elseif ($obj->hasMethod($this->relationCallbacks[$fieldName]['callback'])) {
288
                        $relationObj = $obj->{$this->relationCallbacks[$fieldName]['callback']}(
289
                            $val,
290
                            $record
291
                        );
292
                    }
293
                    if (!$relationObj || !$relationObj->exists()) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $relationObj does not seem to be defined for all execution paths leading up to this point.
Loading history...
294
                        $relationClass = $obj->hasOneComponent($relationName);
295
                        $relationObj = new $relationClass();
296
                        //write if we aren't previewing
297
                        if (!$preview) {
298
                            $relationObj->write();
299
                        }
300
                    }
301
                    $obj->{"{$relationName}ID"} = $relationObj->ID;
302
                    //write if we are not previewing
303
                    if (!$preview) {
304
                        $obj->write();
305
                        $obj->flushCache(); // avoid relation caching confusion
306
                    }
307
                } elseif (strpos($fieldName, '.') !== false) {
308
                    // we have a relation column with dot notation
309
                    list($relationName) = explode('.', $fieldName);
310
                    // always gives us an component (either empty or existing)
311
                    $relationObj = $obj->getComponent($relationName);
312
                    if (!$preview) {
313
                        $relationObj->write();
314
                    }
315
                    $obj->{"{$relationName}ID"} = $relationObj->ID;
316
317
                    //write if we are not previewing
318
                    if (!$preview) {
319
                        $obj->write();
320
                        $obj->flushCache(); // avoid relation caching confusion
321
                    }
322
                }
323
            }
324
        }
325
326
327
        // second run: save data
328
329
        $db = $this->db;
330
331
        foreach ($record as $fieldName => $val) {
332
            // break out of the loop if we are previewing
333
            if ($preview) {
334
                break;
335
            }
336
337
            // Do not update ID if any exist
338
            if ($fieldName == 'ID' && $obj->ID) {
339
                continue;
340
            }
341
342
            // look up the mapping to see if this needs to map to callback
343
            $mapping = ($columnMap && isset($columnMap[$fieldName])) ? $columnMap[$fieldName]
0 ignored issues
show
Bug Best Practice introduced by
The expression $columnMap of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
344
                : null;
345
346
            // Mapping that starts with -> map to a method
347
            if ($mapping && strpos($mapping, '->') === 0) {
348
                $funcName = substr($mapping, 2);
349
350
                $this->$funcName($obj, $val, $record);
351
            } elseif ($obj->hasMethod("import{$fieldName}")) {
352
                // Try to call import_myFieldName
353
                $obj->{"import{$fieldName}"}($val, $record);
354
            } else {
355
                // Map column to field
356
                $usedName = $mapping ? $mapping : $fieldName;
357
358
                // Basic value mapping based on datatype if needed
359
                if (isset($db[$usedName])) {
360
                    switch ($db[$usedName]) {
361
                        case 'Boolean':
362
                            if ((string) $val == 'yes') {
363
                                $val = true;
364
                            } elseif ((string) $val == 'no') {
365
                                $val = false;
366
                            }
367
                    }
368
                }
369
370
                $obj->update(array($usedName => $val));
371
            }
372
        }
373
374
        // write record
375
        if (!$preview) {
376
            $obj->write();
377
        }
378
379
        // @todo better message support
380
        $message = '';
381
382
        // save to results
383
        if ($existingObj) {
384
            $results->addUpdated($obj, $message);
385
        } else {
386
            $results->addCreated($obj, $message);
387
        }
388
389
        $objID = $obj->ID;
390
391
        $obj->destroy();
392
393
        // memory usage
394
        unset($existingObj);
395
        unset($obj);
396
397
        return $objID;
398
    }
399
400
    /**
401
     * Find an existing objects based on one or more uniqueness columns
402
     * specified via {@link self::$duplicateChecks}.
403
     *
404
     * @param array $record CSV data column
405
     * @param array $columnMap
406
     *
407
     * @return DataObject|false
408
     */
409
    public function findExistingObject($record, $columnMap = [])
410
    {
411
        $SNG_objectClass = $this->singleton;
412
413
        // checking for existing records (only if not already found)
414
        foreach ($this->duplicateChecks as $fieldName => $duplicateCheck) {
415
            $existingRecord = null;
416
            if (is_string($duplicateCheck)) {
417
                // Skip current duplicate check if field value is empty
418
                if (empty($record[$duplicateCheck])) {
419
                    continue;
420
                }
421
422
                $dbFieldValue = $record[$duplicateCheck];
423
424
                // Even if $record['ClassName'] is a subclass, this will work
425
                $existingRecord = DataObject::get($this->objectClass)
426
                    ->filter($duplicateCheck, $dbFieldValue)
427
                    ->first();
428
429
                if ($existingRecord) {
430
                    return $existingRecord;
431
                }
432
            } elseif (is_array($duplicateCheck) && isset($duplicateCheck['callback'])) {
433
                if ($this->hasMethod($duplicateCheck['callback'])) {
434
                    $existingRecord = $this->{$duplicateCheck['callback']}(
435
                        $record[$fieldName],
436
                        $record
437
                    );
438
                } elseif ($SNG_objectClass->hasMethod($duplicateCheck['callback'])) {
439
                    $existingRecord = $SNG_objectClass->{$duplicateCheck['callback']}(
440
                        $record[$fieldName],
441
                        $record
442
                    );
443
                } else {
444
                    throw new \RuntimeException(
445
                        "ExcelBulkLoader::processRecord():"
446
                            . " {$duplicateCheck['callback']} not found on importer or object class."
447
                    );
448
                }
449
450
                if ($existingRecord) {
451
                    return $existingRecord;
452
                }
453
            } else {
454
                throw new \InvalidArgumentException(
455
                    'ExcelBulkLoader::processRecord(): Wrong format for $duplicateChecks'
456
                );
457
            }
458
        }
459
460
        return false;
461
    }
462
463
    /**
464
     * Determine whether any loaded files should be parsed with a
465
     * header-row (otherwise we rely on {@link self::$columnMap}.
466
     *
467
     * @return boolean
468
     */
469
    public function hasHeaderRow()
470
    {
471
        return ($this->hasHeaderRow || isset($this->columnMap));
472
    }
473
474
    /**
475
     * Set file type as import
476
     *
477
     * @param string $fileType
478
     * @return void
479
     */
480
    public function setFileType($fileType)
481
    {
482
        $this->fileType = $fileType;
483
    }
484
485
    /**
486
     * Get file type (default is xlsx)
487
     *
488
     * @return string
489
     */
490
    public function getFileType()
491
    {
492
        return $this->fileType;
493
    }
494
495
    /**
496
     * If true, this bulk loader will respect create/edit/delete permissions.
497
     */
498
    public function getCheckPermissions(): bool
499
    {
500
        return $this->checkPermissions;
501
    }
502
503
    /**
504
     * Determine whether this bulk loader should respect create/edit/delete permissions.
505
     */
506
    public function setCheckPermissions(bool $value): ExcelBulkLoader
507
    {
508
        $this->checkPermissions = $value;
509
        return $this;
510
    }
511
512
    /**
513
     * If true, will wrap everything in a transaction
514
     */
515
    public function getUseTransaction(): bool
516
    {
517
        return $this->useTransaction;
518
    }
519
520
    /**
521
     * Determines if everything will be wrapped in a transaction
522
     */
523
    public function setUseTransaction(bool $value): ExcelBulkLoader
524
    {
525
        $this->useTransaction = $value;
526
        return $this;
527
    }
528
}
529