Passed
Push — master ( d1197d...9ea59f )
by Matthew
02:01
created

Mapper::resetProductStream()   A

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
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
1
<?php
2
3
namespace Dynamic\Salsify\Model;
4
5
use Dynamic\Salsify\ORM\SalsifyIDExtension;
6
use Dynamic\Salsify\Task\ImportTask;
7
use Exception;
8
use JsonMachine\JsonMachine;
9
use SilverStripe\Core\Injector\Injector;
10
use SilverStripe\ORM\DataList;
11
use SilverStripe\ORM\DataObject;
12
use SilverStripe\ORM\HasManyList;
13
use SilverStripe\ORM\ManyManyList;
14
use SilverStripe\Versioned\Versioned;
0 ignored issues
show
Bug introduced by
The type SilverStripe\Versioned\Versioned 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...
15
16
/**
17
 * Class Mapper
18
 * @package Dynamic\Salsify\Model
19
 */
20
class Mapper extends Service
21
{
22
23
    /**
24
     * @var
25
     */
26
    private $file = null;
27
28
    /**
29
     * @var JsonMachine
30
     */
31
    private $productStream;
32
33
    /**
34
     * @var JsonMachine
35
     */
36
    private $assetStream;
37
38
    /**
39
     * @var array
40
     */
41
    private $currentUniqueFields = [];
42
43
    /**
44
     * @var int
45
     */
46
    private $importCount = 0;
47
48
    /**
49
     * @var bool
50
     */
51
    public $skipSilently = false;
52
53
    /**
54
     * Mapper constructor.
55
     * @param string $importerKey
56
     * @param $file
57
     * @throws \Exception
58
     */
59
    public function __construct($importerKey, $file = null)
60
    {
61
        parent::__construct($importerKey);
62
        if (!$this->config()->get('mapping')) {
63
            throw  new Exception('A Mapper needs a mapping');
64
        }
65
66
        if ($file !== null) {
67
            $this->file = $file;
68
            $this->resetProductStream();
69
            $this->resetAssetStream();
70
        }
71
    }
72
73
    /**
74
     *
75
     */
76
    public function resetProductStream()
77
    {
78
        $this->productStream = JsonMachine::fromFile($this->file, '/4/products');
79
    }
80
81
    /**
82
     *
83
     */
84
    public function resetAssetStream()
85
    {
86
        $this->assetStream = JsonMachine::fromFile($this->file, '/3/digital_assets');
87
    }
88
89
    /**
90
     * Maps the data
91
     * @throws \Exception
92
     */
93
    public function map()
94
    {
95
        foreach ($this->yieldKeyVal($this->productStream) as $name => $data) {
96
            foreach ($this->yieldKeyVal($this->config()->get('mapping')) as $class => $mappings) {
97
                $this->mapToObject($class, $mappings, $data);
98
                $this->currentUniqueFields = [];
99
            }
100
        }
101
102
        if ($this->mappingHasSalsifyRelation()) {
103
            ImportTask::output("----------------");
104
            ImportTask::output("Setting up salsify relations");
105
            ImportTask::output("----------------");
106
            $this->resetProductStream();
107
108
            foreach ($this->yieldKeyVal($this->productStream) as $name => $data) {
109
                foreach ($this->yieldKeyVal($this->config()->get('mapping')) as $class => $mappings) {
110
                    $this->mapToObject($class, $mappings, $data, null, true);
111
                    $this->currentUniqueFields = [];
112
                }
113
            }
114
        }
115
        ImportTask::output("Imported and updated $this->importCount products.");
116
    }
117
118
    /**
119
     * @param string|DataObject $class
120
     * @param array $mappings The mapping for a specific class
121
     * @param array $data
122
     * @param DataObject|null $object
123
     * @param bool $salsifyRelations
124
     * @param bool $forceUpdate
125
     *
126
     * @return DataObject|null
127
     * @throws \Exception
128
     */
129
    public function mapToObject(
130
        $class,
131
        $mappings,
132
        $data,
133
        $object = null,
134
        $salsifyRelations = false,
135
        $forceUpdate = false
136
    ) {
137
        if ($salsifyRelations) {
138
            if (!$this->classConfigHasSalsifyRelation($mappings)) {
139
                return null;
140
            }
141
        }
142
143
        // if object was not passed
144
        if ($object === null) {
145
            $object = $this->findObjectByUnique($class, $mappings, $data);
146
            // if no existing object was found
147
            if (!$object) {
0 ignored issues
show
introduced by
$object is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
148
                $object = $class::create();
149
            }
150
        }
151
152
        $wasPublished = $object->hasExtension(Versioned::class) ? $object->isPublished() : false;
153
        $wasWritten = $object->isInDB();
154
155
        $firstUniqueKey = array_keys($this->uniqueFields($class, $mappings))[0];
156
        if (array_key_exists($mappings[$firstUniqueKey]['salsifyField'], $data)) {
157
            $firstUniqueValue = $data[$mappings[$firstUniqueKey]['salsifyField']];
158
        } else {
159
            $firstUniqueValue = 'NULL';
160
        }
161
        ImportTask::output("Updating $class $firstUniqueKey $firstUniqueValue");
162
163
        if (
164
            !$forceUpdate &&
165
            $this->objectUpToDate($object, $data, $firstUniqueKey, $firstUniqueValue, $salsifyRelations)
166
        ) {
167
            return $object;
168
        }
169
170
        foreach ($this->yieldKeyVal($mappings) as $dbField => $salsifyField) {
171
            $field = $this->getField($salsifyField, $data);
172
            if ($field === false) {
173
                continue;
174
            }
175
176
            $type = $this->getFieldType($salsifyField);
177
            if ($salsifyRelations && $type != 'SalsifyRelation') {
178
                continue;
179
            }
180
181
            $value = null;
182
            $objectData = $data;
0 ignored issues
show
Unused Code introduced by
The assignment to $objectData is dead and can be removed.
Loading history...
183
184
            if ($this->handleShouldSkip($class, $dbField, $salsifyField, $data)) {
185
                if (!$this->skipSilently) {
186
                    ImportTask::output("Skipping $class $firstUniqueKey $firstUniqueValue");
187
                    $this->skipSilently = false;
188
                }
189
                return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type SilverStripe\ORM\DataObject|null.
Loading history...
190
            };
191
192
            $objectData = $this->handleModification($class, $dbField, $salsifyField, $data);
193
            $sortColumn = $this->getSortColumn($salsifyField);
194
195
            if ($salsifyRelations == false && !array_key_exists($field, $objectData)) {
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
196
                continue;
197
            }
198
199
            $value = $this->handleType($type, $class, $objectData, $field, $salsifyField, $dbField);
0 ignored issues
show
Bug introduced by
$type of type string is incompatible with the type integer expected by parameter $type of Dynamic\Salsify\Model\Mapper::handleType(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

199
            $value = $this->handleType(/** @scrutinizer ignore-type */ $type, $class, $objectData, $field, $salsifyField, $dbField);
Loading history...
200
            $this->writeValue($object, $dbField, $value, $sortColumn);
201
        }
202
203
        if ($object->isChanged()) {
204
            $object->write();
205
            $this->importCount++;
206
            $this->extend('afterObjectWrite', $object, $wasWritten, $wasPublished);
207
        } else {
208
            ImportTask::output("$class $firstUniqueKey $firstUniqueValue was not changed.");
209
        }
210
        return $object;
211
    }
212
213
    /**
214
     * @param DataObject $object
215
     * @param array $data
216
     * @param string $firstUniqueKey
217
     * @param string $firstUniqueValue
218
     * @param bool $salsifyRelations
219
     * @return bool
220
     */
221
    private function objectUpToDate($object, $data, $firstUniqueKey, $firstUniqueValue, $salsifyRelations = false)
222
    {
223
        if ($this->config()->get('skipUpToDate') == true) {
224
            if (
225
                $salsifyRelations == false &&
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
226
                $object->hasField('SalsifyUpdatedAt') &&
227
                $data['salsify:updated_at'] == $object->getField('SalsifyUpdatedAt')
228
            ) {
229
                ImportTask::output("Skipping $firstUniqueKey $firstUniqueValue. It is up to Date.");
230
                return true;
231
            }
232
233
            if (
234
                $salsifyRelations == true &&
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like you are loosely comparing two booleans. Considering using the strict comparison === instead.

When comparing two booleans, it is generally considered safer to use the strict comparison operator.

Loading history...
235
                $object->hasField('SalsifyRelationsUpdatedAt') &&
236
                isset($data['salsify:relations_updated_at']) &&
237
                $data['salsify:relations_updated_at'] == $object->getField('SalsifyRelationsUpdatedAt')
238
            ) {
239
                return true;
240
            }
241
        }
242
        return false;
243
    }
244
245
    /**
246
     * @param array $salsifyField
247
     * @param array $data
248
     *
249
     * @return string|false
250
     */
251
    private function getField($salsifyField, $data)
252
    {
253
        if (!is_array($salsifyField)) {
0 ignored issues
show
introduced by
The condition is_array($salsifyField) is always true.
Loading history...
254
            return array_key_exists($salsifyField, $data) ? $salsifyField : false;
255
        }
256
257
        $hasSalsifyField = array_key_exists('salsifyField', $salsifyField);
258
        $isLiteralField = (
259
            $this->getFieldType($salsifyField) === 'Literal' &&
260
            array_key_exists('value', $salsifyField)
261
        );
262
        $isSalsifyRelationField = (
263
            $this->getFieldType($salsifyField) === 'SalsifyRelation' &&
264
            $hasSalsifyField
265
        );
266
267
        if ($isLiteralField) {
268
            return $salsifyField['value'];
269
        }
270
271
        if ($isSalsifyRelationField) {
272
            return $salsifyField['salsifyField'];
273
        }
274
275
        if (!$hasSalsifyField) {
276
            return false;
277
        }
278
279
        if (array_key_exists($salsifyField['salsifyField'], $data)) {
280
            return $salsifyField['salsifyField'];
281
        } elseif (array_key_exists('fallback', $salsifyField)) {
282
            // make fallback an array
283
            if (!is_array($salsifyField['fallback'])) {
284
                $salsifyField['fallback'] = [$salsifyField['fallback']];
285
            }
286
287
            foreach ($this->yieldSingle($salsifyField['fallback']) as $fallback) {
288
                if (array_key_exists($fallback, $data)) {
289
                    return $fallback;
290
                }
291
            }
292
        } elseif (array_key_exists('modification', $salsifyField)) {
293
            return $salsifyField['salsifyField'];
294
        }
295
296
        return false;
297
    }
298
299
    /**
300
     * @param string $class
301
     * @param array $mappings
302
     * @param array $data
303
     *
304
     * @return \SilverStripe\ORM\DataObject
305
     */
306
    private function findObjectByUnique($class, $mappings, $data)
307
    {
308
        if ($obj = $this->findBySalsifyID($class, $mappings, $data)) {
309
            return $obj;
310
        }
311
312
        $uniqueFields = $this->uniqueFields($class, $mappings);
313
        // creates a filter
314
        $filter = [];
315
        foreach ($this->yieldKeyVal($uniqueFields) as $dbField => $salsifyField) {
316
            $modifiedData = $data;
317
            $fieldMapping = $mappings[$dbField];
318
319
            $modifiedData = $this->handleModification($class, $dbField, $fieldMapping, $modifiedData);
320
321
            // adds unique fields to filter
322
            if (array_key_exists($salsifyField, $modifiedData)) {
323
                $filter[$dbField] = $modifiedData[$salsifyField];
324
            }
325
        }
326
327
        return DataObject::get($class)->filter($filter)->first();
328
    }
329
330
    /**
331
     * @param string $class
332
     * @param array $mappings
333
     * @param array $data
334
     *
335
     * @return \SilverStripe\ORM\DataObject|bool
336
     */
337
    private function findBySalsifyID($class, $mappings, $data)
338
    {
339
        /** @var DataObject $genericObject */
340
        $genericObject = Injector::inst()->get($class);
341
        if (
342
            !$genericObject->hasExtension(SalsifyIDExtension::class) &&
343
            !$genericObject->hasField('SalsifyID')
344
        ) {
345
            return false;
346
        }
347
348
        $modifiedData = $data;
349
        if (array_key_exists('salsify:id', $mappings)) {
350
            $modifiedData = $this->handleModification($class, 'salsify:id', $mappings['salsify:id'], $modifiedData);
351
        }
352
        $obj = DataObject::get($class)->filter([
353
            'SalsifyID' => $modifiedData['salsify:id'],
354
        ])->first();
355
        if ($obj) {
0 ignored issues
show
introduced by
$obj is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
356
            return $obj;
357
        }
358
359
        return false;
360
    }
361
362
    /**
363
     * Gets a list of all the unique field keys
364
     *
365
     * @param string class
0 ignored issues
show
Bug introduced by
The type Dynamic\Salsify\Model\class 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...
366
     * @param array $mappings
367
     * @return array
368
     */
369
    private function uniqueFields($class, $mappings)
370
    {
371
        // cached after first map
372
        if (array_key_exists($class, $this->currentUniqueFields) && !empty($this->currentUniqueFields[$class])) {
373
            return $this->currentUniqueFields[$class];
374
        }
375
376
        $uniqueFields = [];
377
        foreach ($this->yieldKeyVal($mappings) as $dbField => $salsifyField) {
378
            if (!is_array($salsifyField)) {
379
                continue;
380
            }
381
382
            if (
383
                !array_key_exists('unique', $salsifyField) ||
384
                !array_key_exists('salsifyField', $salsifyField)
385
            ) {
386
                continue;
387
            }
388
389
            if ($salsifyField['unique'] !== true) {
390
                continue;
391
            }
392
393
            $uniqueFields[$dbField] = $salsifyField['salsifyField'];
394
        }
395
396
        $this->currentUniqueFields[$class] = $uniqueFields;
397
        return $uniqueFields;
398
    }
399
400
    /**
401
     * @param array|string $salsifyField
402
     * @return bool|mixed
403
     */
404
    private function getSortColumn($salsifyField)
405
    {
406
        if (!is_array($salsifyField)) {
407
            return false;
408
        }
409
410
        if (array_key_exists('sortColumn', $salsifyField)) {
411
            return $salsifyField['sortColumn'];
412
        }
413
414
        return false;
415
    }
416
417
    /**
418
     * @return bool
419
     */
420
    private function mappingHasSalsifyRelation()
421
    {
422
        foreach ($this->yieldKeyVal($this->config()->get('mapping')) as $class => $mappings) {
423
            if ($this->classConfigHasSalsifyRelation($mappings)) {
424
                return true;
425
            }
426
        }
427
        return false;
428
    }
429
430
    /**
431
     * @param array $classConfig
432
     * @return bool
433
     */
434
    private function classConfigHasSalsifyRelation($classConfig)
435
    {
436
        foreach ($this->yieldKeyVal($classConfig) as $field => $config) {
437
            if (!is_array($config)) {
438
                continue;
439
            }
440
441
            if (!array_key_exists('salsifyField', $config)) {
442
                continue;
443
            }
444
445
            if (!array_key_exists('type', $config)) {
446
                continue;
447
            }
448
449
            if ($config['type'] === 'SalsifyRelation') {
450
                return true;
451
            }
452
        }
453
        return false;
454
    }
455
456
    /**
457
     * @param string $class
458
     * @param string $dbField
459
     * @param array $config
460
     * @param array $data
461
     * @return array
462
     */
463
    private function handleModification($class, $dbField, $config, $data)
464
    {
465
        if (!is_array($config)) {
0 ignored issues
show
introduced by
The condition is_array($config) is always true.
Loading history...
466
            return $data;
467
        }
468
469
        if (array_key_exists('modification', $config)) {
470
            $mod = $config['modification'];
471
            if ($this->hasMethod($mod)) {
472
                return $this->{$mod}($class, $dbField, $config, $data);
473
            }
474
            ImportTask::output("{$mod} is not a valid field modifier. skipping modification for field {$dbField}.");
475
        }
476
        return $data;
477
    }
478
479
    /**
480
     * @param string $class
481
     * @param string $dbField
482
     * @param array $config
483
     * @param array $data
484
     * @return boolean
485
     */
486
    public function handleShouldSkip($class, $dbField, $config, $data)
487
    {
488
        if (!is_array($config)) {
0 ignored issues
show
introduced by
The condition is_array($config) is always true.
Loading history...
489
            return false;
490
        }
491
492
        if (array_key_exists('shouldSkip', $config)) {
493
            $skipMethod = $config['shouldSkip'];
494
            if ($this->hasMethod($skipMethod)) {
495
                return $this->{$skipMethod}($class, $dbField, $config, $data);
496
            }
497
            ImportTask::output(
498
                "{$skipMethod} is not a valid skip test method. Skipping skip test for field {$dbField}."
499
            );
500
        }
501
        return false;
502
    }
503
504
    /**
505
     * @param string|array $field
506
     * @return string
507
     */
508
    public function getFieldType($field)
509
    {
510
        $fieldTypes = $this->config()->get('field_types');
511
        if (is_array($field) && array_key_exists('type', $field)) {
512
            if (in_array($field['type'], $fieldTypes)) {
513
                return $field['type'];
514
            }
515
        }
516
        // default to raw
517
        return 'Raw';
518
    }
519
520
    /**
521
     * @param int $type
522
     * @param string|DataObject $class
523
     * @param array $salsifyData
524
     * @param string $salsifyField
525
     * @param array $dbFieldConfig
526
     * @param string $dbField
527
     *
528
     * @return mixed
529
     */
530
    private function handleType($type, $class, $salsifyData, $salsifyField, $dbFieldConfig, $dbField)
531
    {
532
        if ($this->hasMethod("handle{$type}Type")) {
533
            return $this->{"handle{$type}Type"}($class, $salsifyData, $salsifyField, $dbFieldConfig, $dbField);
534
        }
535
        ImportTask::output("{$type} is not a valid type. skipping field {$dbField}.");
536
        return '';
537
    }
538
539
    /**
540
     * @param DataObject $object
541
     * @param string $dbField
542
     * @param mixed $value
543
     * @param string|bool $sortColumn
544
     *
545
     * @throws \Exception
546
     */
547
    private function writeValue($object, $dbField, $value, $sortColumn)
548
    {
549
        $isManyRelation = array_key_exists($dbField, $object->config()->get('has_many')) ||
550
            array_key_exists($dbField, $object->config()->get('many_many')) ||
551
            array_key_exists($dbField, $object->config()->get('belongs_many_many'));
552
553
        $isSingleRelation = array_key_exists(rtrim($dbField, 'ID'), $object->config()->get('has_one'));
554
555
        if (!$isManyRelation) {
556
            if (!$isSingleRelation || ($isSingleRelation && $value !== false)) {
557
                $object->$dbField = $value;
558
            }
559
            return;
560
        }
561
562
        // write the object so relations can be written
563
        if (!$object->exists()) {
564
            $object->write();
565
        }
566
567
        // change to an array and filter out empty values
568
        if (!is_array($value)) {
569
            $value = [$value];
570
        }
571
        $value = array_filter($value);
572
573
        $this->removeUnrelated($object, $dbField, $value);
574
        $this->writeManyRelation($object, $dbField, $value, $sortColumn);
575
    }
576
577
    /**
578
     * @param DataObject $object
579
     * @param string $dbField
580
     * @param array $value
581
     * @param string|bool $sortColumn
582
     *
583
     * @throws \Exception
584
     */
585
    private function writeManyRelation($object, $dbField, $value, $sortColumn)
586
    {
587
        /** @var DataList|HasManyList|ManyManyList $relation */
588
        $relation = $object->{$dbField}();
589
590
        if ($sortColumn && $relation instanceof ManyManyList) {
591
            for ($i = 0; $i < count($value); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
592
                $relation->add($value[$i], [$sortColumn => $i]);
593
            }
594
            return;
595
        }
596
597
        // HasManyList, so it exists on the value
598
        if ($sortColumn) {
599
            for ($i = 0; $i < count($value); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
600
                $value[$i]->{$sortColumn} = $i;
601
                $relation->add($value[$i]);
602
            }
603
            return;
604
        }
605
606
        $relation->addMany($value);
607
    }
608
609
    /**
610
     * Removes unrelated objects in the relation that were previously related
611
     * @param DataObject $object
612
     * @param string $dbField
613
     * @param array $value
614
     */
615
    private function removeUnrelated($object, $dbField, $value)
616
    {
617
        $ids = [];
618
        foreach ($value as $v) {
619
            $ids[] = $v->ID;
620
        }
621
622
        /** @var DataList $relation */
623
        $relation = $object->{$dbField}();
624
        // remove all unrelated - removeAll had an odd side effect (relations only got added back half the time)
625
        if (!empty($ids)) {
626
            $relation->removeMany(
627
                $relation->exclude([
628
                    'ID' => $ids,
629
                ])->column('ID')
630
            );
631
        }
632
    }
633
634
    /**
635
     * @return \JsonMachine\JsonMachine
636
     */
637
    public function getAssetStream()
638
    {
639
        return $this->assetStream;
640
    }
641
642
    /**
643
     * @return bool
644
     */
645
    public function hasFile()
646
    {
647
        return $this->file !== null;
648
    }
649
}
650