ExcelBulkLoader::getCheckPermissions()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
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
        //get all instances of the to be imported data object
113
        if ($this->deleteExistingRecords) {
114
            if ($this->getCheckPermissions()) {
115
                // We need to check each record, in case there's some fancy conditional logic in the canDelete method.
116
                // If we can't delete even a single record, we should bail because otherwise the result would not be
117
                // what the user expects.
118
                /** @var DataObject $record */
119
                foreach (DataObject::get($this->objectClass) as $record) {
120
                    if (!$record->canDelete()) {
121
                        $type = $record->i18n_singular_name();
122
                        throw new HTTPResponse_Exception(
123
                            _t(__CLASS__ . '.CANNOT_DELETE', "Not allowed to delete '{type}' records", ["type" => $type]),
124
                            403
125
                        );
126
                    }
127
                }
128
            }
129
            if ($this->useTransaction) {
130
                DB::get_conn()->transactionStart();
131
            }
132
            DataObject::get($this->objectClass)->removeAll();
133
            if ($this->useTransaction) {
134
                DB::get_conn()->transactionEnd();
135
            }
136
        }
137
138
        $result = $this->processAll($filepath);
139
140
        return $result;
141
    }
142
143
    /**
144
     * @return string
145
     */
146
    protected function getUploadFileExtension()
147
    {
148
        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...
149
            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...
150
        }
151
        return $this->fileType;
152
    }
153
154
    /**
155
     * Merge a row with its headers
156
     *
157
     * @param array $row
158
     * @param array $headers
159
     * @param int $headersCount (optional) Limit to a specifc number of headers
160
     * @return array
161
     */
162
    protected function mergeRowWithHeaders($row, $headers, $headersCount = null)
163
    {
164
        if ($headersCount === null) {
165
            $headersCount = count($headers);
166
        }
167
        $row = array_slice($row, 0, $headersCount);
168
        $row = array_combine($headers, $row);
169
        return $row;
170
    }
171
172
    /**
173
     * @param string $filepath
174
     * @param boolean $preview
175
     */
176
    protected function processAll($filepath, $preview = false)
177
    {
178
        $this->extend('onBeforeProcessAll', $filepath, $preview);
179
180
        if (!is_readable($filepath)) {
181
            throw new Exception("Cannot read $filepath");
182
        }
183
184
        $ext = $this->getUploadFileExtension();
185
        $opts = [
186
            'separator' => $this->delimiter,
187
            'enclosure' => $this->enclosure,
188
            'extension' => $ext,
189
        ];
190
        if ($this->hasHeaderRow) {
191
            $opts['assoc'] = true;
192
        }
193
194
        $data = SpreadCompat::read($filepath, ...$opts);
195
196
        $results = $this->processData($data, $preview);
197
198
        $this->extend('onAfterProcessAll', $result, $preview);
199
200
        return $results;
201
    }
202
203
    /**
204
     * @param iterable $data
205
     * @param bool $preview
206
     * @return BulkLoader_Result
207
     */
208
    public function processData($data, $preview = false)
209
    {
210
        $results = new BulkLoader_Result();
211
212
        $objectClass = $this->objectClass;
213
        $objectConfig = $objectClass::config();
214
        $this->db = $objectConfig->db;
215
        $this->singleton = singleton($objectClass);
216
217
        try {
218
            if ($this->useTransaction) {
219
                DB::get_conn()->transactionStart();
220
            }
221
            foreach ($data as $row) {
222
                $this->processRecord(
223
                    $row,
224
                    $this->columnMap,
225
                    $results,
226
                    $preview
227
                );
228
            }
229
            if ($this->useTransaction) {
230
                DB::get_conn()->transactionEnd();
231
            }
232
        } catch (Exception $e) {
233
            if ($this->useTransaction) {
234
                DB::get_conn()->transactionRollback();
235
            }
236
            $code = $e->getCode() ?: 500;
237
            throw new HTTPResponse_Exception($e->getMessage(), $code);
238
        }
239
240
        return $results;
241
    }
242
243
    /**
244
     *
245
     * @param array $record
246
     * @param array $columnMap
247
     * @param BulkLoader_Result $results
248
     * @param boolean $preview
249
     *
250
     * @return int
251
     */
252
    protected function processRecord(
253
        $record,
254
        $columnMap,
255
        &$results,
256
        $preview = false,
257
        $makeRelations = false
258
    ) {
259
        // find existing object, or create new one
260
        $existingObj = $this->findExistingObject($record, $columnMap);
261
        $alreadyExists = (bool) $existingObj;
262
263
        // If we can't edit the existing object, bail early.
264
        if ($this->getCheckPermissions() && !$preview && $alreadyExists && !$existingObj->canEdit()) {
265
            $type = $existingObj->i18n_singular_name();
266
            throw new HTTPResponse_Exception(
267
                _t(BulkLoader::class . '.CANNOT_EDIT', "Not allowed to edit '{type}' records", ["type" => $type]),
268
                403
269
            );
270
        }
271
272
        $class = $record['ClassName'] ?? $this->objectClass;
273
        $obj = $existingObj ? $existingObj : new $class();
274
275
        // If we can't create a new record, bail out early.
276
        if ($this->getCheckPermissions() && !$preview && !$alreadyExists && !$obj->canCreate()) {
277
            $type = $obj->i18n_singular_name();
278
            throw new HTTPResponse_Exception(
279
                _t(BulkLoader::class . '.CANNOT_CREATE', "Not allowed to create '{type}' records", ["type" => $type]),
280
                403
281
            );
282
        }
283
284
        // first run: find/create any relations and store them on the object
285
        // we can't combine runs, as other columns might rely on the relation being present
286
        if ($makeRelations) {
287
            foreach ($record as $fieldName => $val) {
288
                // don't bother querying of value is not set
289
                if ($this->isNullValue($val)) {
290
                    continue;
291
                }
292
293
                // checking for existing relations
294
                if (isset($this->relationCallbacks[$fieldName])) {
295
                    // trigger custom search method for finding a relation based on the given value
296
                    // and write it back to the relation (or create a new object)
297
                    $relationName = $this->relationCallbacks[$fieldName]['relationname'];
298
                    if ($this->hasMethod($this->relationCallbacks[$fieldName]['callback'])) {
299
                        $relationObj = $this->{$this->relationCallbacks[$fieldName]['callback']}(
300
                            $obj,
301
                            $val,
302
                            $record
303
                        );
304
                    } elseif ($obj->hasMethod($this->relationCallbacks[$fieldName]['callback'])) {
305
                        $relationObj = $obj->{$this->relationCallbacks[$fieldName]['callback']}(
306
                            $val,
307
                            $record
308
                        );
309
                    }
310
                    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...
311
                        $relationClass = $obj->hasOneComponent($relationName);
312
                        $relationObj = new $relationClass();
313
                        //write if we aren't previewing
314
                        if (!$preview) {
315
                            $relationObj->write();
316
                        }
317
                    }
318
                    $obj->{"{$relationName}ID"} = $relationObj->ID;
319
                    //write if we are not previewing
320
                    if (!$preview) {
321
                        $obj->write();
322
                        $obj->flushCache(); // avoid relation caching confusion
323
                    }
324
                } elseif (strpos($fieldName, '.') !== false) {
325
                    // we have a relation column with dot notation
326
                    list($relationName) = explode('.', $fieldName);
327
                    // always gives us an component (either empty or existing)
328
                    $relationObj = $obj->getComponent($relationName);
329
                    if (!$preview) {
330
                        $relationObj->write();
331
                    }
332
                    $obj->{"{$relationName}ID"} = $relationObj->ID;
333
334
                    //write if we are not previewing
335
                    if (!$preview) {
336
                        $obj->write();
337
                        $obj->flushCache(); // avoid relation caching confusion
338
                    }
339
                }
340
            }
341
        }
342
343
344
        // second run: save data
345
346
        $db = $this->db;
347
348
        foreach ($record as $fieldName => $val) {
349
            // break out of the loop if we are previewing
350
            if ($preview) {
351
                break;
352
            }
353
354
            // Do not update ID if any exist
355
            if ($fieldName == 'ID' && $obj->ID) {
356
                continue;
357
            }
358
359
            // look up the mapping to see if this needs to map to callback
360
            $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...
361
                : null;
362
363
            // Mapping that starts with -> map to a method
364
            if ($mapping && strpos($mapping, '->') === 0) {
365
                $funcName = substr($mapping, 2);
366
367
                $this->$funcName($obj, $val, $record);
368
            } elseif ($obj->hasMethod("import{$fieldName}")) {
369
                // Try to call import_myFieldName
370
                $obj->{"import{$fieldName}"}($val, $record);
371
            } else {
372
                // Map column to field
373
                $usedName = $mapping ? $mapping : $fieldName;
374
375
                // Basic value mapping based on datatype if needed
376
                if (isset($db[$usedName])) {
377
                    switch ($db[$usedName]) {
378
                        case 'Boolean':
379
                            if ((string) $val == 'yes') {
380
                                $val = true;
381
                            } elseif ((string) $val == 'no') {
382
                                $val = false;
383
                            }
384
                    }
385
                }
386
387
                $obj->update(array($usedName => $val));
388
            }
389
        }
390
391
        // write record
392
        if (!$preview) {
393
            $obj->write();
394
        }
395
396
        // @todo better message support
397
        $message = '';
398
399
        // save to results
400
        if ($existingObj) {
401
            $results->addUpdated($obj, $message);
402
        } else {
403
            $results->addCreated($obj, $message);
404
        }
405
406
        $objID = $obj->ID;
407
408
        $obj->destroy();
409
410
        // memory usage
411
        unset($existingObj);
412
        unset($obj);
413
414
        return $objID;
415
    }
416
417
    /**
418
     * Find an existing objects based on one or more uniqueness columns
419
     * specified via {@link self::$duplicateChecks}.
420
     *
421
     * @param array $record CSV data column
422
     * @param array $columnMap
423
     *
424
     * @return DataObject|false
425
     */
426
    public function findExistingObject($record, $columnMap = [])
427
    {
428
        $SNG_objectClass = $this->singleton;
429
430
        // checking for existing records (only if not already found)
431
        foreach ($this->duplicateChecks as $fieldName => $duplicateCheck) {
432
            $existingRecord = null;
433
            if (is_string($duplicateCheck)) {
434
                // Skip current duplicate check if field value is empty
435
                if (empty($record[$duplicateCheck])) {
436
                    continue;
437
                }
438
439
                $dbFieldValue = $record[$duplicateCheck];
440
441
                // Even if $record['ClassName'] is a subclass, this will work
442
                $existingRecord = DataObject::get($this->objectClass)
443
                    ->filter($duplicateCheck, $dbFieldValue)
444
                    ->first();
445
446
                if ($existingRecord) {
447
                    return $existingRecord;
448
                }
449
            } elseif (is_array($duplicateCheck) && isset($duplicateCheck['callback'])) {
450
                if ($this->hasMethod($duplicateCheck['callback'])) {
451
                    $existingRecord = $this->{$duplicateCheck['callback']}(
452
                        $record[$fieldName],
453
                        $record
454
                    );
455
                } elseif ($SNG_objectClass->hasMethod($duplicateCheck['callback'])) {
456
                    $existingRecord = $SNG_objectClass->{$duplicateCheck['callback']}(
457
                        $record[$fieldName],
458
                        $record
459
                    );
460
                } else {
461
                    throw new \RuntimeException(
462
                        "ExcelBulkLoader::processRecord():"
463
                            . " {$duplicateCheck['callback']} not found on importer or object class."
464
                    );
465
                }
466
467
                if ($existingRecord) {
468
                    return $existingRecord;
469
                }
470
            } else {
471
                throw new \InvalidArgumentException(
472
                    'ExcelBulkLoader::processRecord(): Wrong format for $duplicateChecks'
473
                );
474
            }
475
        }
476
477
        return false;
478
    }
479
480
    /**
481
     * Determine whether any loaded files should be parsed with a
482
     * header-row (otherwise we rely on {@link self::$columnMap}.
483
     *
484
     * @return boolean
485
     */
486
    public function hasHeaderRow()
487
    {
488
        return ($this->hasHeaderRow || isset($this->columnMap));
489
    }
490
491
    /**
492
     * Set file type as import
493
     *
494
     * @param string $fileType
495
     * @return void
496
     */
497
    public function setFileType($fileType)
498
    {
499
        $this->fileType = $fileType;
500
    }
501
502
    /**
503
     * Get file type (default is xlsx)
504
     *
505
     * @return string
506
     */
507
    public function getFileType()
508
    {
509
        return $this->fileType;
510
    }
511
512
    /**
513
     * If true, this bulk loader will respect create/edit/delete permissions.
514
     */
515
    public function getCheckPermissions(): bool
516
    {
517
        return $this->checkPermissions;
518
    }
519
520
    /**
521
     * Determine whether this bulk loader should respect create/edit/delete permissions.
522
     */
523
    public function setCheckPermissions(bool $value): ExcelBulkLoader
524
    {
525
        $this->checkPermissions = $value;
526
        return $this;
527
    }
528
529
    /**
530
     * If true, will wrap everything in a transaction
531
     */
532
    public function getUseTransaction(): bool
533
    {
534
        return $this->useTransaction;
535
    }
536
537
    /**
538
     * Determines if everything will be wrapped in a transaction
539
     */
540
    public function setUseTransaction(bool $value): ExcelBulkLoader
541
    {
542
        $this->useTransaction = $value;
543
        return $this;
544
    }
545
}
546