Completed
Push — master ( 5d5f60...028c16 )
by Dmitry
02:16
created

Collection::init()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 6
ccs 0
cts 5
cp 0
rs 9.4285
cc 2
eloc 3
nc 2
nop 0
crap 6
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
class Collection extends Component
29
{
30
    const EVENT_BEFORE_INSERT   = 'beforeInsert';
31
    const EVENT_BEFORE_UPDATE   = 'beforeUpdate';
32
    const EVENT_BEFORE_VALIDATE = 'beforeValidate';
33
    const EVENT_AFTER_VALIDATE  = 'afterValidate';
34
    const EVENT_AFTER_SAVE      = 'afterSave';
35
    const EVENT_BEFORE_LOAD     = 'beforeLoad';
36
    const EVENT_AFTER_LOAD      = 'afterLoad';
37
    const EVENT_BEFORE_DELETE   = 'beforeDelete';
38
    const EVENT_AFTER_DELETE    = 'afterDelete';
39
40
    /**
41
     * @var boolean Whether to check, that all [[$models]] are instance of the same class
42
     * @see isConsistent
43
     */
44
    public $checkConsistency = true;
45
46
    /**
47
     * @var ActiveRecord[] array of models
48
     */
49
    protected $models = [];
50
51
    /**
52
     * @var string the name of the form. Sets automatically on [[set()]]
53
     * @see set()
54
     */
55
    public $formName;
56
57
    /**
58
     * @var callable the function to format loaded data. Gets three attributes:
59
     *               - model (instance of operating model)
60
     *               - key   - the key of the loaded item
61
     *               - value - the value of the loaded item
62
     * Should return array, where the first item is the new key, and the second - a new value. Example:
63
     * ```
64
     * return [$key, $value];
65
     * ```
66
     */
67
    public $loadFormatter;
68
69
    /**
70
     * @var ActiveRecord the template model instance. May be set manually by [[setModel()]] or
71
     * automatically on [[set()]] call
72
     * @see setModel()
73
     * @see set()
74
     */
75
    protected $model;
76
77
    /**
78
     * @var array options that will be passed to the new model when loading data in [[load]]
79
     * @see load()
80
     */
81
    public $modelOptions = [];
82
83
    /**
84
     * @var array Options that will be passed to [[ActiveRecord::query()]] method as third argument.
85
     * @see ActiveRecord::query()
86
     */
87
    public $queryOptions = [];
88
89
    /**
90
     * @var ActiveRecord the first model of the set. Fills automatically by [[set()]]
91
     * @see set()
92
     */
93
    public $first;
94
95
    /**
96
     * @var array the model's attributes that will be saved
97
     */
98
    public $attributes;
99
100
    /**
101
     * @var Closure a closure that will used to collect data from [[models]] before saving.
102
     * Signature:
103
     * ```php
104
     * function ($model, $collection)
105
     * ```
106
     *
107
     * Method must return array of two elements:
108
     *  - 0: key of the model in resulting array
109
     *  - 1: corresponding value
110
     *
111
     * @see collectData
112
     */
113
    public $dataCollector;
114
115
116
    public function init()
117
    {
118
        if (!isset($this->queryOptions['batch'])) {
119
            $this->queryOptions['batch'] = true;
120
        }
121
    }
122
123
    /**
124
     * Sets the model of the collection.
125
     * @param ActiveRecord|array $model if the model is an instance of [[Model]] - sets it, otherwise - creates the model
126
     * using given options array
127
     * @return object|ActiveRecord
128
     */
129
    public function setModel($model)
130
    {
131
        if ($model instanceof Model) {
132
            $this->model = $model;
133
        } else {
134
            $this->model = Yii::createObject($model);
135
        }
136
137
        $model = $this->model;
138
        $this->updateFormName();
139
140
        if (empty($this->getScenario())) {
141
            $this->setScenario($model->scenario);
142
        }
143
144
        return $this->model;
145
    }
146
147
    /**
148
     * Returns the [[model]].
149
     * @return ActiveRecord
150
     */
151
    public function getModel()
152
    {
153
        return $this->model;
154
    }
155
156
    public function getIds()
157
    {
158
        $ids = [];
159
        foreach ($this->models as $model) {
160
            $ids[] = $model->getPrimaryKey();
161
        }
162
163
        return $ids;
164
    }
165
166
    /**
167
     * @return ActiveRecord[] models
168
     */
169
    public function getModels()
170
    {
171
        return $this->models;
172
    }
173
174
    /**
175
     * Sets the scenario of the default model.
176
     * @param $value string scenario
177
     */
178
    public function setScenario($value)
179
    {
180
        $this->modelOptions['scenario'] = $value;
181
    }
182
183
    /**
184
     * Gets the scenario the default model.
185
     * @return string the scenario
186
     */
187
    public function getScenario()
188
    {
189
        return $this->modelOptions['scenario'];
190
    }
191
192
    /**
193
     * Updates [[formName]] from the current [[model]].
194
     * @return string the form name
195
     */
196
    public function updateFormName()
197
    {
198
        if (!($this->model instanceof Model)) {
199
            throw new InvalidCallException('The model should be set first');
200
        }
201
202
        return $this->formName = $this->model->formName();
203
    }
204
205
    /**
206
     * We can load data from 3 different structures:.
207
     * 1) POST: [
208
     *     'ModelName' => [
209
     *         'attribute1' => 'value1',
210
     *         'attribute2' => 'value2'
211
     *     ]
212
     * ]
213
     * 2) POST: [
214
     *      'ModelName' => [
215
     *          1   => [
216
     *              'attribute1' => 'value1',
217
     *              'attribute2' => 'value2'
218
     *          ],
219
     *          2   => [
220
     *              ...
221
     *          ]
222
     *      ]
223
     * }
224
     * 3) foreach ($selection as $id) {
225
     *      $res[$id] = [reset($model->primaryKey()) => $id];
226
     *    }.
227
     * @param array|callable $data - the data to be proceeded.
228
     *                             If is callable - gets arguments:
229
     *                             - model
230
     *                             - fromName
231
     * @throws InvalidConfigException
232
     * @return Collection
233
     */
234
    public function load($data = null)
235
    {
236
        $models    = [];
237
        $finalData = [];
238
239
        if ($data === null) {
240
            $data = Yii::$app->request->post();
241
242
            if (isset($data[$this->formName])) {
243
                $data = $data[$this->formName];
244
245
                $is_batch = true;
246
                foreach ($data as $k => $v) {
247
                    if (!is_array($v)) {
248
                        $is_batch = false;
249
                        break;
250
                    }
251
                }
252
253
                if (!$is_batch) {
254
                    $data = [$data];
255
                }
256
            } elseif ($data['selection']) {
257
                $res = [];
258
                foreach ($data['selection'] as $id) {
259
                    $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...
260
                }
261
                $data = $res;
262
            }
263
        } elseif ($data instanceof Closure) {
264
            $data = call_user_func($data, $this->model, $this->formName);
265
        }
266
267
        foreach ($data as $key => $value) {
268
            if ($this->loadFormatter instanceof Closure) {
269
                $item = call_user_func($this->loadFormatter, $this->model, $key, $value);
270
                $key  = $item[0];
271
            } else {
272
                $item = [$key, $value];
273
            }
274
            $options      = ArrayHelper::merge(['class' => $this->model->className()], $this->modelOptions);
275
            $models[$key] = Yii::createObject($options);
276
277
            $finalData[$this->formName][$key] = $item[1];
278
        }
279
        $this->model->loadMultiple($models, $finalData);
280
281
        return $this->set($models);
282
    }
283
284
    /**
285
     * Sets the array of AR models to the collection.
286
     * @param array|ActiveRecord $models - array of AR Models or a single model
287
     * @return $this
288
     */
289
    public function set($models)
290
    {
291
        if ($models instanceof ActiveRecord) {
292
            $models = [$models];
293
        }
294
295
        $first = reset($models);
296
        if ($first === false) {
297
            return $this;
298
        }
299
        $this->first = $first;
300
301
        $this->formName = $first->formName();
302
        $this->model    = $this->setModel($first);
303
        $this->models   = $models;
304
305
        if ($this->checkConsistency && !$this->isConsistent()) {
306
            throw new InvalidValueException('Models are not objects of same class or not follow same operation');
307
        }
308
309
        return $this;
310
    }
311
312
    /**
313
     * Saves the current collection.
314
     * This method will call [[insert()]] or [[update()]].
315
     * @param bool  $runValidation whether to perform validation before saving the collection
316
     * @param array $attributes    list of attribute names that need to be saved. Defaults to null,
317
     *                             meaning all attributes that are loaded will be saved. If the scenario is specified, will use only
318
     *                             fields from the scenario
319
     * @param array $options       the array of options that will be passed to [[insert]] or [[update]] methods to override
320
     *                             model parameters
321
     * @return bool whether the saving succeeds
322
     */
323
    public function save($runValidation = true, $attributes = null, $options = [])
324
    {
325
        if ($this->isEmpty()) {
326
            throw new InvalidCallException('Collection is empty, nothing to save');
327
        }
328
        $options = array_merge($this->queryOptions, $options);
329
330
        if ($this->first->getIsNewRecord()) {
331
            return $this->insert($runValidation, $attributes, $options);
332
        } else {
333
            return $this->update($runValidation, $attributes, $options);
334
        }
335
    }
336
337
    public function insert($runValidation = true, $attributes = null, array $queryOptions = [])
338
    {
339
        if (!$attributes) {
340
            $attributes = $this->attributes ?: $this->first->activeAttributes();
341
        }
342
        if ($runValidation && !$this->validate($attributes)) {
343
            return false;
344
        }
345
        if (!$this->beforeSave(true)) {
346
            return false;
347
        }
348
349
        $data    = $this->collectData($attributes, $queryOptions);
350
        $results = $this->first->query('create', $data, $queryOptions);
351
        $pk      = $this->first->primaryKey()[0];
352
        foreach ($this->models as $key => $model) {
353
            $values = &$data[$key];
354
            $result = &$results[$key];
355
356
            $model->{$pk} = $result['id'];
357
            if ($pk !== 'id') {
358
                $values[$pk] = $result['id'];
359
            }
360
            $changedAttributes = array_fill_keys(array_keys($values), null);
361
            $model->setOldAttributes($values);
362
            $model->afterSave(true, $changedAttributes);
363
        }
364
365
        $this->afterSave();
366
367
        return true;
368
    }
369
370
    public function update($runValidation = true, $attributes = null, array $queryOptions = [])
371
    {
372
        if (!$attributes) {
373
            $attributes = $this->attributes ?: $this->first->activeAttributes();
374
        }
375
        if ($runValidation && !$this->validate($attributes)) {
376
            return false;
377
        }
378
        if (!$this->beforeSave()) {
379
            return false;
380
        }
381
382
        $data    = $this->collectData($attributes, $queryOptions);
383
        $results = $this->first->query('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...
384
385
        foreach ($this->models as $key => $model) {
386
            $changedAttributes = [];
387
            $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...
388
            foreach ($values as $name => $value) {
389
                $changedAttributes[$name] = $model->getOldAttribute($name);
390
                $model->setOldAttribute($name, $value);
391
            }
392
            $model->afterSave(false, $changedAttributes);
393
        }
394
395
        $this->afterSave();
396
397
        return true;
398
    }
399
400
    public function delete()
401
    {
402
        if (!$this->beforeDelete()) {
403
            return false;
404
        }
405
406
        $data    = $this->collectData();
407
        $results = $this->first->batchQuery('delete', $data);
408
409
        $this->afterDelete();
410
411
        return $results;
412
    }
413
414
    /**
415
     * Collects data from the stored models.
416
     * @param string|array $attributes list of attributes names
417
     * @param array $queryOptions options that are going to be
418
     * @return array
419
     */
420
    public function collectData($attributes = null, $queryOptions = [])
421
    {
422
        $data = [];
423
        foreach ($this->models as $model) {
424
            if ($this->dataCollector instanceof Closure) {
425
                list($key, $row) = call_user_func($this->dataCollector, $model, $this);
426
            } else {
427
                $key = $model->getPrimaryKey();
428
                $row = $model->getAttributes($attributes);
0 ignored issues
show
Bug introduced by
It seems like $attributes defined by parameter $attributes on line 420 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...
429
            }
430
431
            if ($key) {
432
                $data[$key] = $row;
433
            } else {
434
                $data[] = $row;
435
            }
436
        }
437
438
        if (isset($queryOptions['batch']) && (bool)$queryOptions['batch'] === true) {
439
            return $data;
440
        }
441
442
        return reset($data);
443
    }
444
445
    /**
446
     * Whether one of models has an error.
447
     * @return bool
448
     */
449
    public function hasErrors()
450
    {
451
        foreach ($this->models as $model) {
452
            if ($model->hasErrors()) {
453
                return true;
454
            }
455
        }
456
457
        return false;
458
    }
459
460
    /**
461
     * Returns the first error of the collection.
462
     * @return bool|mixed
463
     */
464
    public function getFirstError()
465
    {
466
        foreach ($this->models as $model) {
467
            if ($model->hasErrors()) {
468
                $errors = $model->getFirstErrors();
469
470
                return array_shift($errors);
471
            }
472
        }
473
474
        return false;
475
    }
476
477
    public function count()
478
    {
479
        return is_array($this->models) ? count($this->models) : 0;
480
    }
481
482
    public function validate($attributes = null)
483
    {
484
        if (!$this->beforeValidate()) {
485
            return false;
486
        }
487
488
        if (!$this->first->validateMultiple($this->models, $attributes)) {
489
            return false;
490
        }
491
492
        $this->afterValidate();
493
494
        return true;
495
    }
496
497
    public function beforeValidate()
498
    {
499
        $event = new ModelEvent();
500
        $this->triggerAll(self::EVENT_BEFORE_VALIDATE, $event);
501
502
        return $event->isValid;
503
    }
504
505
    public function afterValidate()
506
    {
507
        $event = new ModelEvent();
508
509
        $this->triggerAll(self::EVENT_AFTER_VALIDATE, $event);
510
511
        return $event->isValid;
512
    }
513
514
    public function beforeSave($insert = false)
515
    {
516
        $event = new ModelEvent();
517
        if ($this->isEmpty()) {
518
            $event->isValid = false;
519
        }
520
        $this->triggerAll($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event);
521
522
        return $event->isValid;
523
    }
524
525
    public function afterSave()
526
    {
527
        $this->triggerAll(self::EVENT_AFTER_SAVE);
528
    }
529
530
    public function beforeLoad()
531
    {
532
        $event = new ModelEvent();
533
        $this->trigger(self::EVENT_BEFORE_LOAD, $event);
534
535
        return $event->isValid;
536
    }
537
538
    public function afterLoad()
539
    {
540
        $this->trigger(self::EVENT_AFTER_LOAD);
541
    }
542
543
    public function beforeDelete()
544
    {
545
        $event = new ModelEvent();
546
        $this->trigger(self::EVENT_BEFORE_DELETE, $event);
547
548
        return $event->isValid;
549
    }
550
551
    public function afterDelete()
552
    {
553
        $this->trigger(self::EVENT_AFTER_DELETE);
554
    }
555
556
    /**
557
     * Iterates over all of the models and triggers some event.
558
     * @param string     $name  the event name
559
     * @param ModelEvent $event
560
     * @return bool whether is valid
561
     */
562
    public function triggerModels($name, ModelEvent $event = null)
563
    {
564
        if ($event === null) {
565
            $event = new ModelEvent();
566
        }
567
        foreach ($this->models as $model) {
568
            $model->trigger($name, $event);
569
        }
570
571
        return $event->isValid;
572
    }
573
574
    /**
575
     * Calls [[triggerModels()]], then calls [[trigger()]].
576
     * @param string     $name  the event name
577
     * @param ModelEvent $event
578
     * @return bool whether is valid
579
     */
580
    public function triggerAll($name, ModelEvent $event = null)
581
    {
582
        if ($event === null) {
583
            $event = new ModelEvent();
584
        }
585
        if ($this->triggerModels($name, $event)) {
586
            $this->trigger($name, $event);
587
        }
588
589
        return $event->isValid;
590
    }
591
592
    public function isConsistent()
593
    {
594
        $new       = $this->first->getIsNewRecord();
595
        $className = $this->first->className();
596
        foreach ($this->models as $model) {
597
            if ($new !== $model->getIsNewRecord() || $className !== $model->className()) {
598
                return false;
599
            }
600
        }
601
602
        return true;
603
    }
604
605
    public function isEmpty()
606
    {
607
        return empty($this->models);
608
    }
609
}
610