Issues (68)

src/Collection.php (1 issue)

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|null the scenario
187
     */
188
    public function getScenario(): ?string
189
    {
190
        return $this->modelOptions['scenario'] ?? null;
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)) {
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 (isset($data['selection']) && is_iterable($data['selection'])) {
258
                $res = [];
259
                foreach ($data['selection'] as $id) {
260
                    $array = $this->model->primaryKey();
261
                    $res[$id] = [reset($array) => $id];
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->performOperation('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
    public function update($runValidation = true, $attributes = null, array $queryOptions = [])
376
    {
377
        if (!$attributes) {
378
            $attributes = $this->attributes ?: $this->first->activeAttributes();
379
        }
380
        if ($runValidation && !$this->validate($attributes)) {
381
            return false;
382
        }
383
        if (!$this->beforeSave()) {
384
            return false;
385
        }
386
387
        $data    = $this->collectData($attributes);
388
        $results = $this->performOperation('update', $data, $queryOptions);
389
390
        foreach ($this->models as $key => $model) {
391
            $changedAttributes = [];
392
            $values            = array_key_exists($key, $data) ? $data[$key] : $data[$model->id]; /// XXX not good
393
            foreach ($values as $name => $value) {
394
                $changedAttributes[$name] = $model->getOldAttribute($name);
395
                $model->setOldAttribute($name, $value);
396
            }
397
            $model->afterSave(false, $changedAttributes);
398
            // update models
399
            foreach ($results as $id => $payload) {
400
                $pk = $this->first->primaryKey()[0];
401
                if ((string)$model->{$pk} === (string)$id) {
402
                    $model->setAttributes($payload);
403
                    break;
404
                }
405
            }
406
        }
407
408
        $this->afterSave();
409
410
        return true;
411
    }
412
413
    public function delete()
414
    {
415
        if (!$this->beforeDelete()) {
416
            return false;
417
        }
418
419
        $data    = $this->collectData();
420
        $results = $this->performOperation('delete', $data);
421
422
        $this->afterDelete();
423
424
        return $results;
425
    }
426
427
    /**
428
     * Collects data from the stored models.
429
     * @param string|array $attributes list of attributes names
430
     * @return array
431
     */
432
    public function collectData($attributes = null)
433
    {
434
        $data = [];
435
        foreach ($this->models as $model) {
436
            if ($this->dataCollector instanceof Closure) {
437
                [$key, $row] = call_user_func($this->dataCollector, $model, $this);
438
            } else {
439
                $key = $model->getPrimaryKey();
440
                $row = $model->getAttributes($attributes);
441
            }
442
443
            if ($key) {
444
                $data[$key] = $row;
445
            } else {
446
                $data[] = $row;
447
            }
448
        }
449
450
        return $data;
451
    }
452
453
    /**
454
     * Whether one of models has an error.
455
     * @return bool
456
     */
457
    public function hasErrors()
458
    {
459
        foreach ($this->models as $model) {
460
            if ($model->hasErrors()) {
461
                return true;
462
            }
463
        }
464
465
        return false;
466
    }
467
468
    /**
469
     * Returns the first error of the collection.
470
     * @return bool|mixed
471
     */
472
    public function getFirstError()
473
    {
474
        foreach ($this->models as $model) {
475
            if ($model->hasErrors()) {
476
                $errors = $model->getFirstErrors();
477
478
                return array_shift($errors);
479
            }
480
        }
481
482
        return false;
483
    }
484
485
    public function count()
486
    {
487
        return is_array($this->models) ? count($this->models) : 0;
488
    }
489
490
    public function validate($attributes = null)
491
    {
492
        if (!$this->beforeValidate()) {
493
            return false;
494
        }
495
496
        if (!$this->first->validateMultiple($this->models, $attributes)) {
497
            return false;
498
        }
499
500
        $this->afterValidate();
501
502
        return true;
503
    }
504
505
    public function beforeValidate()
506
    {
507
        $event = new ModelEvent();
508
        $this->triggerAll(self::EVENT_BEFORE_VALIDATE, $event);
509
510
        return $event->isValid;
511
    }
512
513
    public function afterValidate()
514
    {
515
        $event = new ModelEvent();
516
517
        $this->triggerAll(self::EVENT_AFTER_VALIDATE, $event);
518
519
        return $event->isValid;
520
    }
521
522
    public function beforeSave($insert = false)
523
    {
524
        $event = new ModelEvent();
525
        if ($this->isEmpty()) {
526
            $event->isValid = false;
527
        }
528
        $this->triggerAll($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event);
529
530
        return $event->isValid;
531
    }
532
533
    public function afterSave()
534
    {
535
        $this->triggerAll(self::EVENT_AFTER_SAVE);
536
    }
537
538
    public function beforeLoad()
539
    {
540
        $event = new ModelEvent();
541
        $this->trigger(self::EVENT_BEFORE_LOAD, $event);
542
543
        return $event->isValid;
544
    }
545
546
    public function afterLoad()
547
    {
548
        $this->trigger(self::EVENT_AFTER_LOAD);
549
    }
550
551
    public function beforeDelete()
552
    {
553
        $event = new ModelEvent();
554
        $this->trigger(self::EVENT_BEFORE_DELETE, $event);
555
556
        return $event->isValid;
557
    }
558
559
    public function afterDelete()
560
    {
561
        $this->trigger(self::EVENT_AFTER_DELETE);
562
    }
563
564
    /**
565
     * Iterates over all of the models and triggers some event.
566
     * @param string     $name  the event name
567
     * @param ModelEvent $event
568
     * @return bool whether is valid
569
     */
570
    public function triggerModels($name, ModelEvent $event = null)
571
    {
572
        if ($event === null) {
573
            $event = new ModelEvent();
574
        }
575
        foreach ($this->models as $model) {
576
            $model->trigger($name, $event);
577
        }
578
579
        return $event->isValid;
580
    }
581
582
    /**
583
     * Calls [[triggerModels()]], then calls [[trigger()]].
584
     * @param string     $name  the event name
585
     * @param ModelEvent $event
586
     * @return bool whether is valid
587
     */
588
    public function triggerAll($name, ModelEvent $event = null)
589
    {
590
        if ($event === null) {
591
            $event = new ModelEvent();
592
        }
593
        if ($this->triggerModels($name, $event)) {
594
            $this->trigger($name, $event);
595
        }
596
597
        return $event->isValid;
598
    }
599
600
    public function isConsistent()
601
    {
602
        $new       = $this->first->getIsNewRecord();
603
        $className = $this->first->className();
604
        foreach ($this->models as $model) {
605
            if ($new !== $model->getIsNewRecord() || $className !== $model->className()) {
606
                return false;
607
            }
608
        }
609
610
        return true;
611
    }
612
613
    public function isEmpty()
614
    {
615
        return empty($this->models);
616
    }
617
618
    /**
619
     * Try to find the model data if the response from the API came without an index by ID.
620
     *
621
     * @param $data
622
     * @param $model
623
     * @param $pk
624
     * @return mixed
625
     */
626
    protected function findAssociatedModelData($data, $model, $pk)
0 ignored issues
show
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

626
    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...
627
    {
628
        if (isset($data[$pk])) {
629
            return $data;
630
        }
631
632
        // todo: Add implementation for batch response
633
        throw new InvalidValueException('There is no implementation for a response from api without an index on ID');
634
    }
635
636
   /**
637
     * Perform operation with collection models
638
     * @param string $command (create||update||delete)
639
     * @param array $data
640
     * @param array $queryOptions
641
     * @return array
642
     */
643
    protected function performOperation(string $command, array $data, array $queryOptions = []) : array
644
    {
645
        return $this->first->batchQuery($command, $data, $queryOptions);
646
    }
647
}
648