Passed
Push — master ( a10af5...d1836f )
by Thomas
02:42
created

ExcelBulkLoader::getCsvReader()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 1
c 1
b 1
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 1
1
<?php
2
3
namespace LeKoala\ExcelImportExport;
4
5
use Exception;
6
use SilverStripe\Dev\BulkLoader;
7
use SilverStripe\ORM\DataObject;
8
use SilverStripe\Core\Environment;
9
use PhpOffice\PhpSpreadsheet\IOFactory;
10
use SilverStripe\Dev\BulkLoader_Result;
11
use PhpOffice\PhpSpreadsheet\Reader\Csv;
12
use PhpOffice\PhpSpreadsheet\Reader\IReader;
13
14
/**
15
 * Use PHPSpreadsheet to expand BulkLoader file format support
16
 *
17
 * @author Koala
18
 */
19
class ExcelBulkLoader extends BulkLoader
20
{
21
    /**
22
     * Delimiter character (Default: comma).
23
     *
24
     * @var string
25
     */
26
    public $delimiter = ',';
27
28
    /**
29
     * Enclosure character (Default: doublequote)
30
     *
31
     * @var string
32
     */
33
    public $enclosure = '"';
34
35
    /**
36
     * Identifies if the file has a header row.
37
     *
38
     * @var boolean
39
     */
40
    public $hasHeaderRow = true;
41
42
    /**
43
     * The uploaded file infos
44
     * @var array
45
     */
46
    protected $uploadFile = null;
47
48
    /**
49
     *
50
     * @var DataObject
51
     */
52
    protected $singleton = null;
53
54
    /**
55
     * @var array
56
     */
57
    protected $db = array();
58
59
    /**
60
     * Type of file if not able to determine through uploaded file
61
     *
62
     * @var string
63
     */
64
    protected $fileType = 'xlsx';
65
66
    /**
67
     * @inheritDoc
68
     */
69
    public function preview($filepath)
70
    {
71
        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...
72
    }
73
74
    /**
75
     * Load the given file via {@link self::processAll()} and {@link self::processRecord()}.
76
     * Optionally truncates (clear) the table before it imports.
77
     *
78
     * @return BulkLoader_Result See {@link self::processAll()}
79
     */
80
    public function load($filepath)
81
    {
82
        // A small hack to allow model admin import form to work properly
83
        if (!is_array($filepath) && isset($_FILES['_CsvFile'])) {
84
            $filepath = $_FILES['_CsvFile'];
85
        }
86
        if (is_array($filepath)) {
87
            $this->uploadFile = $filepath;
88
            $filepath = $filepath['tmp_name'];
89
        }
90
91
        // upload is resource intensive
92
        Environment::increaseTimeLimitTo(3600);
93
        Environment::increaseMemoryLimitTo('512M');
94
95
        // get all instances of the to be imported data object
96
        if ($this->deleteExistingRecords) {
97
            DataObject::get($this->objectClass)->removeAll();
98
        }
99
100
        return $this->processAll($filepath);
101
    }
102
103
    /**
104
     * @return string
105
     */
106
    protected function getUploadFileExtension()
107
    {
108
        if ($this->uploadFile) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->uploadFile 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...
109
            return pathinfo($this->uploadFile['name'], PATHINFO_EXTENSION);
110
        }
111
        return $this->fileType;
112
    }
113
114
    /**
115
     * Merge a row with its headers
116
     *
117
     * @param array $row
118
     * @param array $headers
119
     * @param int $headersCount (optional) Limit to a specifc number of headers
120
     * @return array
121
     */
122
    protected function mergeRowWithHeaders($row, $headers, $headersCount = null)
123
    {
124
        if ($headersCount === null) {
125
            $headersCount = count($headers);
126
        }
127
        $row = array_slice($row, 0, $headersCount);
128
        $row = array_combine($headers, $row);
129
        return $row;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $row could also return false which is incompatible with the documented return type array. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
130
    }
131
132
    /**
133
     * @param IReader $reader
134
     * @return Csv
135
     */
136
    protected function getCsvReader(IReader $reader)
137
    {
138
        return $reader;
139
    }
140
141
    /**
142
     * @param string $filepath
143
     * @param boolean $preview
144
     */
145
    protected function processAll($filepath, $preview = false)
146
    {
147
        $results = new BulkLoader_Result();
148
        $ext = $this->getUploadFileExtension();
149
150
        $readerType = ExcelImportExport::getReaderForExtension($ext);
151
        $reader = IOFactory::createReader($readerType);
152
        $reader->setReadDataOnly(true);
153
        if ($readerType == 'Csv') {
154
            // @link https://phpspreadsheet.readthedocs.io/en/latest/topics/reading-and-writing-to-file/#setting-csv-options_1
155
            $reader = $this->getCsvReader($reader);
156
            $reader->setDelimiter($this->delimiter);
157
            $reader->setEnclosure($this->enclosure);
158
        }
159
        $data = array();
0 ignored issues
show
Unused Code introduced by
The assignment to $data is dead and can be removed.
Loading history...
160
        if ($reader->canRead($filepath)) {
161
            $excel = $reader->load($filepath);
162
            $data = $excel->getActiveSheet()->toArray(null, true, false, false);
163
        } else {
164
            throw new Exception("Cannot read $filepath");
165
        }
166
167
        $headers = array();
168
169
        if ($this->hasHeaderRow) {
170
            $headers = array_shift($data);
171
            $headers = array_map('trim', $headers);
172
            $headers = array_filter($headers);
173
        }
174
175
        $headersCount = count($headers);
176
177
        $objectClass = $this->objectClass;
178
        $objectConfig = $objectClass::config();
179
        $this->db = $objectConfig->db;
180
        $this->singleton = singleton($objectClass);
181
182
        foreach ($data as $row) {
183
            $row = $this->mergeRowWithHeaders($row, $headers, $headersCount);
184
            $this->processRecord(
185
                $row,
186
                $this->columnMap,
187
                $results,
188
                $preview
189
            );
190
        }
191
192
        return $results;
193
    }
194
195
    /**
196
     *
197
     * @param array $record
198
     * @param array $columnMap
199
     * @param BulkLoader_Result $results
200
     * @param boolean $preview
201
     *
202
     * @return int
203
     */
204
    protected function processRecord(
205
        $record,
206
        $columnMap,
207
        &$results,
208
        $preview = false,
209
        $makeRelations = false
210
    ) {
211
        $class = $this->objectClass;
212
213
        // find existing object, or create new one
214
        $existingObj = $this->findExistingObject($record, $columnMap);
215
216
        /* @var $obj DataObject */
217
        $obj = $existingObj ? $existingObj : new $class();
218
219
        // first run: find/create any relations and store them on the object
220
        // we can't combine runs, as other columns might rely on the relation being present
221
        if ($makeRelations) {
222
            foreach ($record as $fieldName => $val) {
223
                // don't bother querying of value is not set
224
                if ($this->isNullValue($val)) {
225
                    continue;
226
                }
227
228
                // checking for existing relations
229
                if (isset($this->relationCallbacks[$fieldName])) {
230
                    // trigger custom search method for finding a relation based on the given value
231
                    // and write it back to the relation (or create a new object)
232
                    $relationName = $this->relationCallbacks[$fieldName]['relationname'];
233
                    if ($this->hasMethod($this->relationCallbacks[$fieldName]['callback'])) {
234
                        $relationObj = $this->{$this->relationCallbacks[$fieldName]['callback']}(
235
                            $obj,
236
                            $val,
237
                            $record
238
                        );
239
                    } elseif ($obj->hasMethod($this->relationCallbacks[$fieldName]['callback'])) {
240
                        $relationObj = $obj->{$this->relationCallbacks[$fieldName]['callback']}(
241
                            $val,
242
                            $record
243
                        );
244
                    }
245
                    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...
246
                        $relationClass = $obj->hasOneComponent($relationName);
247
                        $relationObj = new $relationClass();
248
                        //write if we aren't previewing
249
                        if (!$preview) {
250
                            $relationObj->write();
251
                        }
252
                    }
253
                    $obj->{"{$relationName}ID"} = $relationObj->ID;
254
                    //write if we are not previewing
255
                    if (!$preview) {
256
                        $obj->write();
257
                        $obj->flushCache(); // avoid relation caching confusion
258
                    }
259
                } elseif (strpos($fieldName, '.') !== false) {
260
                    // we have a relation column with dot notation
261
                    list($relationName) = explode('.', $fieldName);
262
                    // always gives us an component (either empty or existing)
263
                    $relationObj = $obj->getComponent($relationName);
264
                    if (!$preview) {
265
                        $relationObj->write();
266
                    }
267
                    $obj->{"{$relationName}ID"} = $relationObj->ID;
268
269
                    //write if we are not previewing
270
                    if (!$preview) {
271
                        $obj->write();
272
                        $obj->flushCache(); // avoid relation caching confusion
273
                    }
274
                }
275
            }
276
        }
277
278
279
        // second run: save data
280
281
        $db = $this->db;
282
283
        foreach ($record as $fieldName => $val) {
284
            // break out of the loop if we are previewing
285
            if ($preview) {
286
                break;
287
            }
288
289
            // Do not update ID if any exist
290
            if ($fieldName == 'ID' && $obj->ID) {
291
                continue;
292
            }
293
294
            // look up the mapping to see if this needs to map to callback
295
            $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...
296
                : null;
297
298
            // Mapping that starts with -> map to a method
299
            if ($mapping && strpos($mapping, '->') === 0) {
300
                $funcName = substr($mapping, 2);
301
302
                $this->$funcName($obj, $val, $record);
303
            } elseif ($obj->hasMethod("import{$fieldName}")) {
304
                // Try to call import_myFieldName
305
                $obj->{"import{$fieldName}"}($val, $record);
306
            } else {
307
                // Map column to field
308
                $usedName = $mapping ? $mapping : $fieldName;
309
310
                // Basic value mapping based on datatype if needed
311
                if (isset($db[$usedName])) {
312
                    switch ($db[$usedName]) {
313
                        case 'Boolean':
314
                            if ($val == 'yes') {
315
                                $val = true;
316
                            } elseif ($val == 'no') {
317
                                $val = false;
318
                            }
319
                    }
320
                }
321
322
                $obj->update(array($usedName => $val));
323
            }
324
        }
325
326
        // write record
327
        if (!$preview) {
328
            $obj->write();
329
        }
330
331
        // @todo better message support
332
        $message = '';
333
334
        // save to results
335
        if ($existingObj) {
336
            $results->addUpdated($obj, $message);
337
        } else {
338
            $results->addCreated($obj, $message);
339
        }
340
341
        $objID = $obj->ID;
342
343
        $obj->destroy();
344
345
        // memory usage
346
        unset($existingObj);
347
        unset($obj);
348
349
        return $objID;
350
    }
351
352
    /**
353
     * Find an existing objects based on one or more uniqueness columns
354
     * specified via {@link self::$duplicateChecks}.
355
     *
356
     * @param array $record CSV data column
357
     * @param array $columnMap
358
     *
359
     * @return mixed
360
     */
361
    public function findExistingObject($record, $columnMap)
362
    {
363
        $objectClass = $this->objectClass;
364
        $SNG_objectClass = $this->singleton;
365
366
        // checking for existing records (only if not already found)
367
        foreach ($this->duplicateChecks as $fieldName => $duplicateCheck) {
368
            if (is_string($duplicateCheck)) {
369
                // Skip current duplicate check if field value is empty
370
                if (empty($record[$duplicateCheck])) {
371
                    continue;
372
                }
373
374
                $existingRecord = $objectClass::get()
375
                    ->filter($fieldName, $record[$duplicateCheck])
376
                    ->first();
377
378
                if ($existingRecord) {
379
                    return $existingRecord;
380
                }
381
            } elseif (is_array($duplicateCheck) && isset($duplicateCheck['callback'])) {
382
                if ($this->hasMethod($duplicateCheck['callback'])) {
383
                    $existingRecord = $this->{$duplicateCheck['callback']}(
384
                        $record[$fieldName],
385
                        $record
386
                    );
387
                } elseif ($SNG_objectClass->hasMethod($duplicateCheck['callback'])) {
388
                    $existingRecord = $SNG_objectClass->{$duplicateCheck['callback']}(
389
                        $record[$fieldName],
390
                        $record
391
                    );
392
                } else {
393
                    user_error(
394
                        "CsvBulkLoader::processRecord():"
395
                            . " {$duplicateCheck['callback']} not found on importer or object class.",
396
                        E_USER_ERROR
397
                    );
398
                }
399
400
                if ($existingRecord) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $existingRecord does not seem to be defined for all execution paths leading up to this point.
Loading history...
401
                    return $existingRecord;
402
                }
403
            } else {
404
                user_error(
405
                    'CsvBulkLoader::processRecord(): Wrong format for $duplicateChecks',
406
                    E_USER_ERROR
407
                );
408
            }
409
        }
410
411
        return false;
412
    }
413
414
    /**
415
     * Determine whether any loaded files should be parsed with a
416
     * header-row (otherwise we rely on {@link self::$columnMap}.
417
     *
418
     * @return boolean
419
     */
420
    public function hasHeaderRow()
421
    {
422
        return ($this->hasHeaderRow || isset($this->columnMap));
423
    }
424
425
    /**
426
     * Set file type as import
427
     *
428
     * @param string $fileType
429
     * @return void
430
     */
431
    public function setFileType($fileType)
432
    {
433
        $this->fileType = $fileType;
434
    }
435
436
    /**
437
     * Get file type (default is xlsx)
438
     *
439
     * @return string
440
     */
441
    public function getFileType()
442
    {
443
        return $this->fileType;
444
    }
445
}
446