Completed
Push — master ( e2e127...87b6ac )
by Klochok
02:34
created

Collection::beforeLoad()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 7
ccs 0
cts 5
cp 0
rs 9.4285
cc 1
eloc 4
nc 1
nop 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-2017, 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
118
    public function init()
119
    {
120
        if (!isset($this->queryOptions['batch'])) {
121
            $this->queryOptions['batch'] = true;
122
        }
123
    }
124
125
    /**
126
     * Sets the model of the collection.
127
     * @param ActiveRecord|array $model if the model is an instance of [[Model]] - sets it, otherwise - creates the model
128
     * using given options array
129
     * @return object|ActiveRecord
130
     */
131
    public function setModel($model)
132
    {
133
        if ($model instanceof Model) {
134
            $this->model = $model;
135
        } else {
136
            $this->model = Yii::createObject($model);
137
        }
138
139
        $model = $this->model;
140
        $this->updateFormName();
141
142
        if (empty($this->getScenario())) {
143
            $this->setScenario($model->scenario);
144
        }
145
146
        return $this->model;
147
    }
148
149
    /**
150
     * Returns the [[model]].
151
     * @return ActiveRecord
152
     */
153
    public function getModel()
154
    {
155
        return $this->model;
156
    }
157
158
    public function getIds()
159
    {
160
        $ids = [];
161
        foreach ($this->models as $model) {
162
            $ids[] = $model->getPrimaryKey();
163
        }
164
165
        return $ids;
166
    }
167
168
    /**
169
     * @return ActiveRecord[] models
170
     */
171
    public function getModels()
172
    {
173
        return $this->models;
174
    }
175
176
    /**
177
     * Sets the scenario of the default model.
178
     * @param $value string scenario
179
     */
180
    public function setScenario($value)
181
    {
182
        $this->modelOptions['scenario'] = $value;
183
    }
184
185
    /**
186
     * Gets the scenario the default model.
187
     * @return string the scenario
188
     */
189
    public function getScenario()
190
    {
191
        return $this->modelOptions['scenario'];
192
    }
193
194
    /**
195
     * Updates [[formName]] from the current [[model]].
196
     * @return string the form name
197
     */
198
    public function updateFormName()
199
    {
200
        if (!($this->model instanceof Model)) {
201
            throw new InvalidCallException('The model should be set first');
202
        }
203
204
        return $this->formName = $this->model->formName();
205
    }
206
207
    /**
208
     * We can load data from 3 different structures:.
209
     * 1) POST: [
210
     *     'ModelName' => [
211
     *         'attribute1' => 'value1',
212
     *         'attribute2' => 'value2'
213
     *     ]
214
     * ]
215
     * 2) POST: [
216
     *      'ModelName' => [
217
     *          1   => [
218
     *              'attribute1' => 'value1',
219
     *              'attribute2' => 'value2'
220
     *          ],
221
     *          2   => [
222
     *              ...
223
     *          ]
224
     *      ]
225
     * }
226
     * 3) foreach ($selection as $id) {
227
     *      $res[$id] = [reset($model->primaryKey()) => $id];
228
     *    }.
229
     * @param array|callable $data - the data to be proceeded.
230
     *                             If is callable - gets arguments:
231
     *                             - model
232
     *                             - fromName
233
     * @throws InvalidConfigException
234
     * @return Collection
235
     */
236
    public function load($data = null)
237
    {
238
        $models    = [];
239
        $finalData = [];
240
241
        if ($data === null) {
242
            $data = Yii::$app->request->post();
243
244
            if (isset($data[$this->formName])) {
245
                $data = $data[$this->formName];
246
247
                $is_batch = true;
248
                foreach ($data as $k => $v) {
249
                    if (!is_array($v)) {
250
                        $is_batch = false;
251
                        break;
252
                    }
253
                }
254
255
                if (!$is_batch) {
256
                    $data = [$data];
257
                }
258
            } elseif ($data['selection']) {
259
                $res = [];
260
                foreach ($data['selection'] as $id) {
261
                    $res[$id] = [reset($this->model->primaryKey()) => $id];
0 ignored issues
show
Bug introduced by
$this->model->primaryKey() cannot be passed to reset() as the parameter $array expects a reference.
Loading history...
262
                }
263
                $data = $res;
264
            }
265
        } elseif ($data instanceof Closure) {
266
            $data = call_user_func($data, $this->model, $this->formName);
267
        }
268
269
        foreach ($data as $key => $value) {
270
            if ($this->loadFormatter instanceof Closure) {
271
                $item = call_user_func($this->loadFormatter, $this->model, $key, $value);
272
                $key  = $item[0];
273
            } else {
274
                $item = [$key, $value];
275
            }
276
            $options      = ArrayHelper::merge(['class' => $this->model->className()], $this->modelOptions);
277
            $models[$key] = Yii::createObject($options);
278
279
            $finalData[$this->formName][$key] = $item[1];
280
        }
281
        $this->model->loadMultiple($models, $finalData);
282
283
        return $this->set($models);
284
    }
285
286
    /**
287
     * Sets the array of AR models to the collection.
288
     * @param array|ActiveRecord $models - array of AR Models or a single model
289
     * @return $this
290
     */
291
    public function set($models)
292
    {
293
        if ($models instanceof ActiveRecord) {
294
            $models = [$models];
295
        }
296
297
        $first = reset($models);
298
        if ($first === false) {
299
            return $this;
300
        }
301
        $this->first = $first;
302
303
        $this->formName = $first->formName();
304
        $this->model    = $this->setModel($first);
305
        $this->models   = $models;
306
307
        if ($this->checkConsistency && !$this->isConsistent()) {
308
            throw new InvalidValueException('Models are not objects of same class or not follow same operation');
309
        }
310
311
        return $this;
312
    }
313
314
    /**
315
     * Saves the current collection.
316
     * This method will call [[insert()]] or [[update()]].
317
     * @param bool  $runValidation whether to perform validation before saving the collection
318
     * @param array $attributes    list of attribute names that need to be saved. Defaults to null,
319
     *                             meaning all attributes that are loaded will be saved. If the scenario is specified, will use only
320
     *                             fields from the scenario
321
     * @param array $options       the array of options that will be passed to [[insert]] or [[update]] methods to override
322
     *                             model parameters
323
     * @return bool whether the saving succeeds
324
     */
325
    public function save($runValidation = true, $attributes = null, $options = [])
326
    {
327
        if ($this->isEmpty()) {
328
            throw new InvalidCallException('Collection is empty, nothing to save');
329
        }
330
        $options = array_merge($this->queryOptions, $options);
331
332
        if ($this->first->getIsNewRecord()) {
333
            return $this->insert($runValidation, $attributes, $options);
334
        } else {
335
            return $this->update($runValidation, $attributes, $options);
336
        }
337
    }
338
339
    public function insert($runValidation = true, $attributes = null, array $queryOptions = [])
340
    {
341
        if (!$attributes) {
342
            $attributes = $this->attributes ?: $this->first->activeAttributes();
343
        }
344
        if ($runValidation && !$this->validate($attributes)) {
345
            return false;
346
        }
347
        if (!$this->beforeSave(true)) {
348
            return false;
349
        }
350
351
        $data    = $this->collectData($attributes);
352
        $results = $this->first->batchQuery('create', $data, $queryOptions);
353
        $pk      = $this->first->primaryKey()[0];
354
        foreach ($this->models as $key => $model) {
355
            $values = &$data[$key];
356
            $result = &$results[$key];
357
            if (!$result) {
358
                $result = $this->findAssociatedModelData($results, $model, $pk);
359
            }
360
361
            $model->{$pk} = $result['id'];
362
            if ($pk !== 'id') {
363
                $values[$pk] = $result['id'];
364
            }
365
            $changedAttributes = array_fill_keys(array_keys($values), null);
366
            $model->setOldAttributes($values);
367
            $model->afterSave(true, $changedAttributes);
368
        }
369
370
        $this->afterSave();
371
372
        return true;
373
    }
374
375
    /**
376
     * Try to find the model data if the response from the API came without an index by ID
377
     *
378
     * @param $data
379
     * @param $model
380
     * @param $pk
381
     * @return mixed
382
     */
383
    private function findAssociatedModelData($data, $model, $pk)
0 ignored issues
show
Unused Code introduced by
The parameter $model is not used and could be removed.

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

Loading history...
384
    {
385
        if (isset($data[$pk])) {
386
            return $data;
387
        }
388
389
        // todo: Add implementation for batch response
390
        throw new InvalidValueException('There is no implementation for a response from api without an index on ID');
391
    }
392
393
    public function update($runValidation = true, $attributes = null, array $queryOptions = [])
394
    {
395
        if (!$attributes) {
396
            $attributes = $this->attributes ?: $this->first->activeAttributes();
397
        }
398
        if ($runValidation && !$this->validate($attributes)) {
399
            return false;
400
        }
401
        if (!$this->beforeSave()) {
402
            return false;
403
        }
404
405
        $data    = $this->collectData($attributes);
406
        $results = $this->first->batchQuery('update', $data, $queryOptions);
0 ignored issues
show
Unused Code introduced by
$results is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
407
408
        foreach ($this->models as $key => $model) {
409
            $changedAttributes = [];
410
            $values            = array_key_exists($key, $data) ? $data[$key] : $data[$model->id]; /// XXX not good
0 ignored issues
show
Documentation introduced by
The property id does not exist on object<hiqdev\hiart\ActiveRecord>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
411
            foreach ($values as $name => $value) {
412
                $changedAttributes[$name] = $model->getOldAttribute($name);
413
                $model->setOldAttribute($name, $value);
414
            }
415
            $model->afterSave(false, $changedAttributes);
416
        }
417
418
        $this->afterSave();
419
420
        return true;
421
    }
422
423
    public function delete()
424
    {
425
        if (!$this->beforeDelete()) {
426
            return false;
427
        }
428
429
        $data    = $this->collectData();
430
        $results = $this->first->batchQuery('delete', $data);
431
432
        $this->afterDelete();
433
434
        return $results;
435
    }
436
437
    /**
438
     * Collects data from the stored models.
439
     * @param string|array $attributes list of attributes names
440
     * @return array
441
     */
442
    public function collectData($attributes = null)
443
    {
444
        $data = [];
445
        foreach ($this->models as $model) {
446
            if ($this->dataCollector instanceof Closure) {
447
                list($key, $row) = call_user_func($this->dataCollector, $model, $this);
448
            } else {
449
                $key = $model->getPrimaryKey();
450
                $row = $model->getAttributes($attributes);
0 ignored issues
show
Bug introduced by
It seems like $attributes defined by parameter $attributes on line 442 can also be of type string; however, yii\base\Model::getAttributes() does only seem to accept array|null, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
451
            }
452
453
            if ($key) {
454
                $data[$key] = $row;
455
            } else {
456
                $data[] = $row;
457
            }
458
        }
459
460
        return $data;
461
    }
462
463
    /**
464
     * Whether one of models has an error.
465
     * @return bool
466
     */
467
    public function hasErrors()
468
    {
469
        foreach ($this->models as $model) {
470
            if ($model->hasErrors()) {
471
                return true;
472
            }
473
        }
474
475
        return false;
476
    }
477
478
    /**
479
     * Returns the first error of the collection.
480
     * @return bool|mixed
481
     */
482
    public function getFirstError()
483
    {
484
        foreach ($this->models as $model) {
485
            if ($model->hasErrors()) {
486
                $errors = $model->getFirstErrors();
487
488
                return array_shift($errors);
489
            }
490
        }
491
492
        return false;
493
    }
494
495
    public function count()
496
    {
497
        return is_array($this->models) ? count($this->models) : 0;
498
    }
499
500
    public function validate($attributes = null)
501
    {
502
        if (!$this->beforeValidate()) {
503
            return false;
504
        }
505
506
        if (!$this->first->validateMultiple($this->models, $attributes)) {
507
            return false;
508
        }
509
510
        $this->afterValidate();
511
512
        return true;
513
    }
514
515
    public function beforeValidate()
516
    {
517
        $event = new ModelEvent();
518
        $this->triggerAll(self::EVENT_BEFORE_VALIDATE, $event);
519
520
        return $event->isValid;
521
    }
522
523
    public function afterValidate()
524
    {
525
        $event = new ModelEvent();
526
527
        $this->triggerAll(self::EVENT_AFTER_VALIDATE, $event);
528
529
        return $event->isValid;
530
    }
531
532
    public function beforeSave($insert = false)
533
    {
534
        $event = new ModelEvent();
535
        if ($this->isEmpty()) {
536
            $event->isValid = false;
537
        }
538
        $this->triggerAll($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event);
539
540
        return $event->isValid;
541
    }
542
543
    public function afterSave()
544
    {
545
        $this->triggerAll(self::EVENT_AFTER_SAVE);
546
    }
547
548
    public function beforeLoad()
549
    {
550
        $event = new ModelEvent();
551
        $this->trigger(self::EVENT_BEFORE_LOAD, $event);
552
553
        return $event->isValid;
554
    }
555
556
    public function afterLoad()
557
    {
558
        $this->trigger(self::EVENT_AFTER_LOAD);
559
    }
560
561
    public function beforeDelete()
562
    {
563
        $event = new ModelEvent();
564
        $this->trigger(self::EVENT_BEFORE_DELETE, $event);
565
566
        return $event->isValid;
567
    }
568
569
    public function afterDelete()
570
    {
571
        $this->trigger(self::EVENT_AFTER_DELETE);
572
    }
573
574
    /**
575
     * Iterates over all of the models and triggers some event.
576
     * @param string     $name  the event name
577
     * @param ModelEvent $event
578
     * @return bool whether is valid
579
     */
580
    public function triggerModels($name, ModelEvent $event = null)
581
    {
582
        if ($event === null) {
583
            $event = new ModelEvent();
584
        }
585
        foreach ($this->models as $model) {
586
            $model->trigger($name, $event);
587
        }
588
589
        return $event->isValid;
590
    }
591
592
    /**
593
     * Calls [[triggerModels()]], then calls [[trigger()]].
594
     * @param string     $name  the event name
595
     * @param ModelEvent $event
596
     * @return bool whether is valid
597
     */
598
    public function triggerAll($name, ModelEvent $event = null)
599
    {
600
        if ($event === null) {
601
            $event = new ModelEvent();
602
        }
603
        if ($this->triggerModels($name, $event)) {
604
            $this->trigger($name, $event);
605
        }
606
607
        return $event->isValid;
608
    }
609
610
    public function isConsistent()
611
    {
612
        $new       = $this->first->getIsNewRecord();
613
        $className = $this->first->className();
614
        foreach ($this->models as $model) {
615
            if ($new !== $model->getIsNewRecord() || $className !== $model->className()) {
616
                return false;
617
            }
618
        }
619
620
        return true;
621
    }
622
623
    public function isEmpty()
624
    {
625
        return empty($this->models);
626
    }
627
}
628