Completed
Push — master ( a35aa7...5d5f60 )
by Andrii
13s
created

Collection::collectData()   B

Complexity

Conditions 5
Paths 10

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

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