Passed
Push — master ( 6c138f...83b985 )
by Dmitry
14:08 queued 12s
created

Collection::performOperation()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 3
dl 0
loc 3
rs 10
ccs 0
cts 0
cp 0
crap 2
1
<?php
2
/**
3
 * ActiveRecord for API
4
 *
5
 * @link      https://github.com/hiqdev/yii2-hiart
6
 * @package   yii2-hiart
7
 * @license   BSD-3-Clause
8
 * @copyright Copyright (c) 2015-2019, HiQDev (http://hiqdev.com/)
9
 */
10
11
namespace hiqdev\hiart;
12
13
use Closure;
14
use Yii;
15
use yii\base\Component;
16
use yii\base\InvalidCallException;
17
use yii\base\InvalidConfigException;
18
use yii\base\InvalidValueException;
19
use yii\base\Model;
20
use yii\base\ModelEvent;
21
use yii\helpers\ArrayHelper;
22
23
/**
24
 * Class Collection manages the collection of the models.
25
 *
26
 * @var ActiveRecord[] the array of models in the collection
27
 *
28
 * @property Model[] $models
29
 */
30
class Collection extends Component
31
{
32
    const EVENT_BEFORE_INSERT   = 'beforeInsert';
33
    const EVENT_BEFORE_UPDATE   = 'beforeUpdate';
34
    const EVENT_BEFORE_VALIDATE = 'beforeValidate';
35
    const EVENT_AFTER_VALIDATE  = 'afterValidate';
36
    const EVENT_AFTER_SAVE      = 'afterSave';
37
    const EVENT_BEFORE_LOAD     = 'beforeLoad';
38
    const EVENT_AFTER_LOAD      = 'afterLoad';
39
    const EVENT_BEFORE_DELETE   = 'beforeDelete';
40
    const EVENT_AFTER_DELETE    = 'afterDelete';
41
42
    /**
43
     * @var boolean Whether to check, that all [[$models]] are instance of the same class
44
     * @see isConsistent
45
     */
46
    public $checkConsistency = true;
47
48
    /**
49
     * @var ActiveRecord[] array of models
50
     */
51
    protected $models = [];
52
53
    /**
54
     * @var string the name of the form. Sets automatically on [[set()]]
55
     * @see set()
56
     */
57
    public $formName;
58
59
    /**
60
     * @var callable the function to format loaded data. Gets three attributes:
61
     *               - model (instance of operating model)
62
     *               - key   - the key of the loaded item
63
     *               - value - the value of the loaded item
64
     * Should return array, where the first item is the new key, and the second - a new value. Example:
65
     * ```
66
     * return [$key, $value];
67
     * ```
68
     */
69
    public $loadFormatter;
70
71
    /**
72
     * @var ActiveRecord the template model instance. May be set manually by [[setModel()]] or
73
     * automatically on [[set()]] call
74
     * @see setModel()
75
     * @see set()
76
     */
77
    protected $model;
78
79
    /**
80
     * @var array options that will be passed to the new model when loading data in [[load]]
81
     * @see load()
82
     */
83
    public $modelOptions = [];
84
85
    /**
86
     * @var array options that will be passed to [[ActiveRecord::query()]] method as third argument
87
     * @see ActiveRecord::query()
88
     */
89
    public $queryOptions = [];
90
91
    /**
92
     * @var ActiveRecord the first model of the set. Fills automatically by [[set()]]
93
     * @see set()
94
     */
95
    public $first;
96
97
    /**
98
     * @var array the model's attributes that will be saved
99
     */
100
    public $attributes;
101
102
    /**
103
     * @var Closure a closure that will used to collect data from [[models]] before saving.
104
     * Signature:
105
     * ```php
106
     * function ($model, $collection)
107
     * ```
108
     *
109
     * Method must return array of two elements:
110
     *  - 0: key of the model in resulting array
111
     *  - 1: corresponding value
112
     *
113
     * @see collectData
114
     */
115
    public $dataCollector;
116
117
    public function init()
118
    {
119
        if (!isset($this->queryOptions['batch'])) {
120
            $this->queryOptions['batch'] = true;
121
        }
122
    }
123
124
    /**
125
     * Sets the model of the collection.
126
     * @param ActiveRecord|array $model if the model is an instance of [[Model]] - sets it, otherwise - creates the model
127
     * using given options array
128
     * @return object|ActiveRecord
129
     */
130
    public function setModel($model)
131
    {
132
        if ($model instanceof Model) {
133
            $this->model = $model;
134
        } else {
135
            $this->model = Yii::createObject($model);
136
        }
137
138
        $model = $this->model;
139
        $this->updateFormName();
140
141
        if (empty($this->getScenario())) {
142
            $this->setScenario($model->scenario);
143
        }
144
145
        return $this->model;
146
    }
147
148
    /**
149
     * Returns the [[model]].
150
     * @return ActiveRecord
151
     */
152
    public function getModel()
153
    {
154
        return $this->model;
155
    }
156
157
    public function getIds()
158
    {
159
        $ids = [];
160
        foreach ($this->models as $model) {
161
            $ids[] = $model->getPrimaryKey();
162
        }
163
164
        return $ids;
165
    }
166
167
    /**
168
     * @return ActiveRecord[] models
169
     */
170
    public function getModels()
171
    {
172
        return $this->models;
173
    }
174
175
    /**
176
     * Sets the scenario of the default model.
177
     * @param $value string scenario
178
     */
179
    public function setScenario($value)
180
    {
181
        $this->modelOptions['scenario'] = $value;
182
    }
183
184
    /**
185
     * Gets the scenario the default model.
186
     * @return string the scenario
187
     */
188
    public function getScenario()
189
    {
190
        return $this->modelOptions['scenario'];
191
    }
192
193
    /**
194
     * Updates [[formName]] from the current [[model]].
195
     * @return string the form name
196
     */
197
    public function updateFormName()
198
    {
199
        if (!($this->model instanceof Model)) {
0 ignored issues
show
introduced by
$this->model is always a sub-type of yii\base\Model.
Loading history...
200
            throw new InvalidCallException('The model should be set first');
201
        }
202
203
        return $this->formName = $this->model->formName();
204
    }
205
206
    /**
207
     * We can load data from 3 different structures:.
208
     * 1) POST: [
209
     *     'ModelName' => [
210
     *         'attribute1' => 'value1',
211
     *         'attribute2' => 'value2'
212
     *     ]
213
     * ]
214
     * 2) POST: [
215
     *      'ModelName' => [
216
     *          1   => [
217
     *              'attribute1' => 'value1',
218
     *              'attribute2' => 'value2'
219
     *          ],
220
     *          2   => [
221
     *              ...
222
     *          ]
223
     *      ]
224
     * }
225
     * 3) foreach ($selection as $id) {
226
     *      $res[$id] = [reset($model->primaryKey()) => $id];
227
     *    }.
228
     * @param array|callable $data - the data to be proceeded.
229
     *                             If is callable - gets arguments:
230
     *                             - model
231
     *                             - fromName
232
     * @throws InvalidConfigException
233
     * @return Collection
234
     */
235
    public function load($data = null)
236
    {
237
        $models    = [];
238
        $finalData = [];
239
240
        if ($data === null) {
241
            $data = Yii::$app->request->post();
242
243
            if (isset($data[$this->formName])) {
244
                $data = $data[$this->formName];
245
246
                $is_batch = true;
247
                foreach ($data as $k => $v) {
248
                    if (!is_array($v)) {
249
                        $is_batch = false;
250
                        break;
251
                    }
252
                }
253
254
                if (!$is_batch) {
255
                    $data = [$data];
256
                }
257
            } elseif ($data['selection']) {
258
                $res = [];
259
                foreach ($data['selection'] as $id) {
260
                    $res[$id] = [reset($this->model->primaryKey()) => $id];
261
                }
262
                $data = $res;
263
            }
264
        } elseif ($data instanceof Closure) {
265
            $data = call_user_func($data, $this->model, $this->formName);
266
        }
267
268
        foreach ($data as $key => $value) {
269
            if ($this->loadFormatter instanceof Closure) {
270
                $item = call_user_func($this->loadFormatter, $this->model, $key, $value);
271
                $key  = $item[0];
272
            } else {
273
                $item = [$key, $value];
274
            }
275
            $options      = ArrayHelper::merge(['class' => $this->model->className()], $this->modelOptions);
0 ignored issues
show
Deprecated Code introduced by
The function yii\base\BaseObject::className() has been deprecated: since 2.0.14. On PHP >=5.5, use `::class` instead. ( Ignorable by Annotation )

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

275
            $options      = ArrayHelper::merge(['class' => /** @scrutinizer ignore-deprecated */ $this->model->className()], $this->modelOptions);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
276
            $models[$key] = Yii::createObject($options);
277
278
            $finalData[$this->formName][$key] = $item[1];
279
        }
280
        $this->model->loadMultiple($models, $finalData);
281
282
        return $this->set($models);
283
    }
284
285
    /**
286
     * Sets the array of AR models to the collection.
287
     * @param array|ActiveRecord $models - array of AR Models or a single model
288
     * @return $this
289
     */
290
    public function set($models)
291
    {
292
        if ($models instanceof ActiveRecord) {
293
            $models = [$models];
294
        }
295
296
        $first = reset($models);
297
        if ($first === false) {
298
            return $this;
299
        }
300
        $this->first = $first;
301
302
        $this->formName = $first->formName();
303
        $this->model    = $this->setModel($first);
304
        $this->models   = $models;
305
306
        if ($this->checkConsistency && !$this->isConsistent()) {
307
            throw new InvalidValueException('Models are not objects of same class or not follow same operation');
308
        }
309
310
        return $this;
311
    }
312
313
    /**
314
     * Saves the current collection.
315
     * This method will call [[insert()]] or [[update()]].
316
     * @param bool  $runValidation whether to perform validation before saving the collection
317
     * @param array $attributes    list of attribute names that need to be saved. Defaults to null,
318
     *                             meaning all attributes that are loaded will be saved. If the scenario is specified, will use only
319
     *                             fields from the scenario
320
     * @param array $options       the array of options that will be passed to [[insert]] or [[update]] methods to override
321
     *                             model parameters
322
     * @return bool whether the saving succeeds
323
     */
324
    public function save($runValidation = true, $attributes = null, $options = [])
325
    {
326
        if ($this->isEmpty()) {
327
            throw new InvalidCallException('Collection is empty, nothing to save');
328
        }
329
        $options = array_merge($this->queryOptions, $options);
330
331
        if ($this->first->getIsNewRecord()) {
332
            return $this->insert($runValidation, $attributes, $options);
333
        } else {
334
            return $this->update($runValidation, $attributes, $options);
335
        }
336
    }
337
338
    public function insert($runValidation = true, $attributes = null, array $queryOptions = [])
339
    {
340
        if (!$attributes) {
341
            $attributes = $this->attributes ?: $this->first->activeAttributes();
342
        }
343
        if ($runValidation && !$this->validate($attributes)) {
344
            return false;
345
        }
346
        if (!$this->beforeSave(true)) {
347
            return false;
348
        }
349
350
        $data    = $this->collectData($attributes);
351
        $results = $this->performOperation('create', $data, $queryOptions);
352
        $pk      = $this->first->primaryKey()[0];
353
        foreach ($this->models as $key => $model) {
354
            $values = &$data[$key];
355
            $result = &$results[$key];
356
            if (!$result) {
357
                $result = $this->findAssociatedModelData($results, $model, $pk);
358
            }
359
360
            $model->{$pk} = $result['id'];
361
            if ($pk !== 'id') {
362
                $values[$pk] = $result['id'];
363
            }
364
            $changedAttributes = array_fill_keys(array_keys($values), null);
365
            $model->setOldAttributes($values);
366
            $model->afterSave(true, $changedAttributes);
367
        }
368
369
        $this->afterSave();
370
371
        return true;
372
    }
373
374
    public function update($runValidation = true, $attributes = null, array $queryOptions = [])
375
    {
376
        if (!$attributes) {
377
            $attributes = $this->attributes ?: $this->first->activeAttributes();
378
        }
379
        if ($runValidation && !$this->validate($attributes)) {
380
            return false;
381
        }
382
        if (!$this->beforeSave()) {
383
            return false;
384
        }
385
386
        $data    = $this->collectData($attributes);
387
        $results = $this->performOperation('update', $data, $queryOptions);
0 ignored issues
show
Unused Code introduced by
The assignment to $results is dead and can be removed.
Loading history...
388
389
        foreach ($this->models as $key => $model) {
390
            $changedAttributes = [];
391
            $values            = array_key_exists($key, $data) ? $data[$key] : $data[$model->id]; /// XXX not good
392
            foreach ($values as $name => $value) {
393
                $changedAttributes[$name] = $model->getOldAttribute($name);
394
                $model->setOldAttribute($name, $value);
395
            }
396
            $model->afterSave(false, $changedAttributes);
397
        }
398
399
        $this->afterSave();
400
401
        return true;
402
    }
403
404
    public function delete()
405
    {
406
        if (!$this->beforeDelete()) {
407
            return false;
408
        }
409
410
        $data    = $this->collectData();
411
        $results = $this->performOperation('delete', $data);
412
413
        $this->afterDelete();
414
415
        return $results;
416
    }
417
418
    /**
419
     * Collects data from the stored models.
420
     * @param string|array $attributes list of attributes names
421
     * @return array
422
     */
423
    public function collectData($attributes = null)
424
    {
425
        $data = [];
426
        foreach ($this->models as $model) {
427
            if ($this->dataCollector instanceof Closure) {
428
                list($key, $row) = call_user_func($this->dataCollector, $model, $this);
429
            } else {
430
                $key = $model->getPrimaryKey();
431
                $row = $model->getAttributes($attributes);
0 ignored issues
show
Bug introduced by
It seems like $attributes can also be of type string; however, parameter $names of yii\base\Model::getAttributes() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

431
                $row = $model->getAttributes(/** @scrutinizer ignore-type */ $attributes);
Loading history...
432
            }
433
434
            if ($key) {
435
                $data[$key] = $row;
436
            } else {
437
                $data[] = $row;
438
            }
439
        }
440
441
        return $data;
442
    }
443
444
    /**
445
     * Whether one of models has an error.
446
     * @return bool
447
     */
448
    public function hasErrors()
449
    {
450
        foreach ($this->models as $model) {
451
            if ($model->hasErrors()) {
452
                return true;
453
            }
454
        }
455
456
        return false;
457
    }
458
459
    /**
460
     * Returns the first error of the collection.
461
     * @return bool|mixed
462
     */
463
    public function getFirstError()
464
    {
465
        foreach ($this->models as $model) {
466
            if ($model->hasErrors()) {
467
                $errors = $model->getFirstErrors();
468
469
                return array_shift($errors);
470
            }
471
        }
472
473
        return false;
474
    }
475
476
    public function count()
477
    {
478
        return is_array($this->models) ? count($this->models) : 0;
0 ignored issues
show
introduced by
The condition is_array($this->models) is always true.
Loading history...
479
    }
480
481
    public function validate($attributes = null)
482
    {
483
        if (!$this->beforeValidate()) {
484
            return false;
485
        }
486
487
        if (!$this->first->validateMultiple($this->models, $attributes)) {
488
            return false;
489
        }
490
491
        $this->afterValidate();
492
493
        return true;
494
    }
495
496
    public function beforeValidate()
497
    {
498
        $event = new ModelEvent();
499
        $this->triggerAll(self::EVENT_BEFORE_VALIDATE, $event);
500
501
        return $event->isValid;
502
    }
503
504
    public function afterValidate()
505
    {
506
        $event = new ModelEvent();
507
508
        $this->triggerAll(self::EVENT_AFTER_VALIDATE, $event);
509
510
        return $event->isValid;
511
    }
512
513
    public function beforeSave($insert = false)
514
    {
515
        $event = new ModelEvent();
516
        if ($this->isEmpty()) {
517
            $event->isValid = false;
518
        }
519
        $this->triggerAll($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event);
520
521
        return $event->isValid;
522
    }
523
524
    public function afterSave()
525
    {
526
        $this->triggerAll(self::EVENT_AFTER_SAVE);
527
    }
528
529
    public function beforeLoad()
530
    {
531
        $event = new ModelEvent();
532
        $this->trigger(self::EVENT_BEFORE_LOAD, $event);
533
534
        return $event->isValid;
535
    }
536
537
    public function afterLoad()
538
    {
539
        $this->trigger(self::EVENT_AFTER_LOAD);
540
    }
541
542
    public function beforeDelete()
543
    {
544
        $event = new ModelEvent();
545
        $this->trigger(self::EVENT_BEFORE_DELETE, $event);
546
547
        return $event->isValid;
548
    }
549
550
    public function afterDelete()
551
    {
552
        $this->trigger(self::EVENT_AFTER_DELETE);
553
    }
554
555
    /**
556
     * Iterates over all of the models and triggers some event.
557
     * @param string     $name  the event name
558
     * @param ModelEvent $event
559
     * @return bool whether is valid
560
     */
561
    public function triggerModels($name, ModelEvent $event = null)
562
    {
563
        if ($event === null) {
564
            $event = new ModelEvent();
565
        }
566
        foreach ($this->models as $model) {
567
            $model->trigger($name, $event);
568
        }
569
570
        return $event->isValid;
571
    }
572
573
    /**
574
     * Calls [[triggerModels()]], then calls [[trigger()]].
575
     * @param string     $name  the event name
576
     * @param ModelEvent $event
577
     * @return bool whether is valid
578
     */
579
    public function triggerAll($name, ModelEvent $event = null)
580
    {
581
        if ($event === null) {
582
            $event = new ModelEvent();
583
        }
584
        if ($this->triggerModels($name, $event)) {
585
            $this->trigger($name, $event);
586
        }
587
588
        return $event->isValid;
589
    }
590
591
    public function isConsistent()
592
    {
593
        $new       = $this->first->getIsNewRecord();
594
        $className = $this->first->className();
0 ignored issues
show
Deprecated Code introduced by
The function yii\base\BaseObject::className() has been deprecated: since 2.0.14. On PHP >=5.5, use `::class` instead. ( Ignorable by Annotation )

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

594
        $className = /** @scrutinizer ignore-deprecated */ $this->first->className();

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
595
        foreach ($this->models as $model) {
596
            if ($new !== $model->getIsNewRecord() || $className !== $model->className()) {
0 ignored issues
show
Deprecated Code introduced by
The function yii\base\BaseObject::className() has been deprecated: since 2.0.14. On PHP >=5.5, use `::class` instead. ( Ignorable by Annotation )

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

596
            if ($new !== $model->getIsNewRecord() || $className !== /** @scrutinizer ignore-deprecated */ $model->className()) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
597
                return false;
598
            }
599
        }
600
601
        return true;
602
    }
603
604
    public function isEmpty()
605
    {
606
        return empty($this->models);
607
    }
608
609
    /**
610
     * Try to find the model data if the response from the API came without an index by ID.
611
     *
612
     * @param $data
613
     * @param $model
614
     * @param $pk
615
     * @return mixed
616
     */
617
    protected function findAssociatedModelData($data, $model, $pk)
0 ignored issues
show
Unused Code introduced by
The parameter $model is not used and could be removed. ( Ignorable by Annotation )

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

617
    protected function findAssociatedModelData($data, /** @scrutinizer ignore-unused */ $model, $pk)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
618
    {
619
        if (isset($data[$pk])) {
620
            return $data;
621
        }
622
623
        // todo: Add implementation for batch response
624
        throw new InvalidValueException('There is no implementation for a response from api without an index on ID');
625
    }
626
627
   /**
628
     * Perform operation with collection models
629
     * @param string $command (create||update||delete)
630
     * @param array $data
631
     * @param array $queryOptions
632
     * @return array
633
     */
634
    protected function performOperation(string $command, array $data, array $queryOptions = []) : array
635
    {
636
        return $this->first->batchQuery($command, $data, $queryOptions);
637
    }
638
}
639