GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Completed
Push — master ( a0e705...a0e705 )
by Dave
43:17 queued 27:40
created

Elements::flatNamedElements()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 1
nop 1
dl 0
loc 13
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SleepingOwl\Admin\Form\Related;
4
5
use Illuminate\Http\Request;
6
use Admin\Contracts\HasFakeModel;
7
use Illuminate\Support\Collection;
8
use Illuminate\Database\Eloquent\Model;
9
use SleepingOwl\Admin\Form\FormElements;
10
use KodiComponents\Support\HtmlAttributes;
11
use SleepingOwl\Admin\Form\Columns\Columns;
12
use SleepingOwl\Admin\Contracts\Initializable;
13
use SleepingOwl\Admin\Form\Element\NamedFormElement;
14
use Illuminate\Database\Eloquent\ModelNotFoundException;
15
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
16
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
17
use Illuminate\Database\Eloquent\RelationNotFoundException;
18
19
abstract class Elements extends FormElements
20
{
21
    use HtmlAttributes, HasUniqueValidation, ManipulatesRequestRelations;
22
23
    protected $view = 'form.element.related.elements';
24
25
    const NEW_ITEM = 'new';
26
27
    const REMOVE = 'remove';
28
29
    /**
30
     * How many items can be created.
31
     *
32
     * @var int
33
     */
34
    protected $limit;
35
36
    /**
37
     * Relation name of the model.
38
     *
39
     * @var string
40
     */
41
    protected $relationName;
42
43
    /**
44
     * New relations counter.
45
     *
46
     * @var int
47
     */
48
    protected $new = 0;
49
50
    /**
51
     * @var string
52
     */
53
    protected $groupLabel;
54
55
    /**
56
     * Main label of dynamic form.
57
     *
58
     * @var string
59
     */
60
    protected $label;
61
62
    /**
63
     * Loaded related values.
64
     *
65
     * @var \Illuminate\Support\Collection
66
     */
67
    protected $relatedValues;
68
69
    /**
70
     * @var \Illuminate\Database\Eloquent\Model
71
     */
72
    protected $instance;
73
74
    protected $stubElements;
75
76
    /**
77
     * Elements that are about to be removed.
78
     *
79
     * @var \Illuminate\Support\Collection
80
     */
81
    protected $toRemove;
82
83
    protected $unique;
84
85
    /**
86
     * @var
87
     */
88
    protected $groups;
89
90
    protected $queryCallbacks = [];
91
92
    public function __construct(string $relationName, array $elements = [])
93
    {
94
        $this->toRemove = collect();
95
        $this->groups = collect();
96
        $this->relatedValues = collect();
97
        parent::__construct($elements);
98
99
        $this->setRelationName($relationName);
100
        $this->initializeRemoveEntities();
101
    }
102
103
    /**
104
     * @param int $limit
105
     *
106
     * @return $this
107
     */
108
    public function setLimit(int $limit): self
109
    {
110
        $this->limit = $limit;
111
112
        return $this;
113
    }
114
115
    public function modifyQuery(callable $callback): self
116
    {
117
        $this->queryCallbacks[] = $callback;
118
119
        return $this;
120
    }
121
122
    public function setLabel(string $label): self
123
    {
124
        $this->label = $label;
125
126
        return $this;
127
    }
128
129
    public function initialize()
130
    {
131
        parent::initialize();
132
        $this->checkRelationOfModel();
133
134
        $this->stubElements = $this->getNewElements();
135
        $this->forEachElement($this->stubElements, function ($element) {
136
            $element->setDefaultValue(null);
137
            if (! $element instanceof HasFakeModel) {
138
                $element->setPath('');
139
            }
140
        });
141
    }
142
143
    protected function initializeRemoveEntities()
144
    {
145
        $key = $this->relationName.'.'.static::REMOVE;
146
        $newKey = 'remove_'.$this->relationName;
147
        $request = request();
148
149
        $remove = $request->input($key, $request->old($key, []));
150
        if ($remove) {
151
            $request->merge([$newKey => $remove]);
152
        }
153
154
        $this->toRemove = collect($request->input($newKey, $request->old($newKey, [])));
155
        $request->replace($request->except($key));
156
    }
157
158
    /**
159
     * @return void
160
     */
161
    public function initializeElements()
162
    {
163
        $this->getElements()->each(function ($el) {
164
            $this->initializeElement($el);
165
        });
166
    }
167
168
    public function initializeElement($element)
169
    {
170
        if ($element instanceof Initializable) {
171
            $element->initialize();
172
        }
173
174
        if ($element instanceof HasFakeModel) {
175
            $element->setFakeModel($this->getModel());
176
        }
177
178
        if ($element instanceof \SleepingOwl\Admin\Contracts\Form\Columns\ColumnInterface) {
179
            $element->getElements()->each(function ($el) {
180
                $this->initializeElement($el);
181
            });
182
        }
183
    }
184
185
    /**
186
     * @param $item
187
     *
188
     * @param array $columns
189
     *
190
     * @return string
191
     */
192
    protected function getCompositeKey($item, array $columns): string
193
    {
194
        $primaries = [];
195
        if ($item instanceof Model) {
196
            $item = $item->getAttributes();
197
        }
198
199
        foreach ($columns as $name) {
200
            // Only existing keys must be in primaries array
201
            if (array_key_exists($name, $item)) {
202
                $primaries[] = $item[$name];
203
            }
204
        }
205
206
        return implode('_', $primaries);
207
    }
208
209
    protected function makeValidationAttribute(string $name): string
210
    {
211
        return $this->relationName.'.*.'.$name;
212
    }
213
214
    protected function getNewElements(): Collection
215
    {
216
        return $this->cloneElements($this);
217
    }
218
219
    protected function cloneElements(FormElements $element)
220
    {
221
        $elements = clone $element->getElements()->map(function ($element) {
222
            return clone $element;
223
        });
224
225
        return $elements->map(function ($element) {
226
            return $this->emptyElement($element);
227
        });
228
    }
229
230
    protected function emptyElement($element)
231
    {
232
        $el = clone $element;
233
        if ($el instanceof \SleepingOwl\Admin\Form\Columns\Columns) {
234
            $col = new Columns();
235
            $columns = $el->getElements();
236
            $col->setElements((clone $columns)->map(function ($column) {
237
                return $this->emptyElement($column);
238
            })->all());
239
240
            return $col;
241
        }
242
243
        if ($el instanceof FormElements) {
244
            $el->setElements($this->cloneElements($el)->all());
245
        } else {
246
            $el->setDefaultValue(null);
247
            $el->setValueSkipped(true);
248
        }
249
250
        return $el;
251
    }
252
253
    /**
254
     * @param Model $model
255
     *
256
     * @return FormElements|void
257
     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
258
     */
259
    public function setModel(Model $model)
260
    {
261
        parent::setModel($model);
262
263
        if ($model->exists) {
264
            $this->setInstance($model);
265
            $this->loadRelationValues();
266
        }
267
    }
268
269
    public function setInstance($instance)
270
    {
271
        $this->instance = $instance;
272
    }
273
274
    /**
275
     * @throws \InvalidArgumentException
276
     * @throws \Illuminate\Database\Eloquent\RelationNotFoundException
277
     */
278
    protected function checkRelationOfModel()
279
    {
280
        $model = $this->getModel();
281
        $class = get_class($model);
282
        if (! method_exists($model, $this->relationName)) {
283
            throw new RelationNotFoundException("Relation {$this->relationName} doesn't exist on {$class}");
284
        }
285
286
        $relation = $model->{$this->relationName}();
287
        if (! ($relation instanceof BelongsToMany) && ! ($relation instanceof HasOneOrMany) && ! ($relation instanceof \Illuminate\Database\Eloquent\Relations\BelongsTo)) {
288
            throw new \InvalidArgumentException("Relation {$this->relationName} of model {$class} must be instance of HasMany, BelongsTo or BelongsToMany");
289
        }
290
    }
291
292
    /**
293
     * Sets relation name property.
294
     *
295
     * @param string
296
     *
297
     * @return Elements
298
     */
299
    public function setRelationName(string $name): self
300
    {
301
        $this->relationName = $name;
302
303
        return $this;
304
    }
305
306
    /**
307
     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
308
     */
309
    protected function loadRelationValues()
310
    {
311
        if (! $this->instance) {
312
            throw new ModelNotFoundException("Model {$this->getModel()} wasn't found for loading relation");
313
        }
314
315
        $query = $this->getRelation();
316
        if (count($this->queryCallbacks) > 0) {
317
            foreach ($this->queryCallbacks as $callback) {
318
                $callback($query);
319
            }
320
        }
321
322
        $this->relatedValues = $this->retrieveRelationValuesFromQuery($query);
323
    }
324
325
    /**
326
     * @return array
327
     */
328
    protected function getRequestData(): array
329
    {
330
        $request = request();
331
332
        $old = $request->old($this->relationName, false);
0 ignored issues
show
Bug introduced by
false of type false is incompatible with the type array|null|string expected by parameter $default of Illuminate\Http\Request::old(). ( Ignorable by Annotation )

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

332
        $old = $request->old($this->relationName, /** @scrutinizer ignore-type */ false);
Loading history...
333
334
        return $old ?: $request->get($this->relationName, []);
335
    }
336
337
    protected function buildGroupsCollection()
338
    {
339
        $old = false;
340
        $relatedValues = $this->relatedValues;
341
342
        if (count($data = $this->getRequestData()) !== 0) {
343
            $old = true;
344
            $relatedValues = $this->getRelatedValuesFromRequestData($data);
345
        }
346
347
        foreach ($relatedValues as $key => $item) {
348
            $this->groups->push($this->createGroup($item, $old, $key));
349
        }
350
    }
351
352
    protected function getRelatedValuesFromRequestData(array $values): Collection
353
    {
354
        $collection = collect();
355
        foreach ($values as $key => $attributes) {
356
            if ($key === static::REMOVE) {
357
                // If key is about to be removed we need to save it and show later in rendered form. But we don't
358
                // need to put value with this relation in collection of elements, that's why we need to continue the
359
                // loop
360
                $this->toRemove = collect($attributes);
361
                continue;
362
            }
363
364
            if (strpos($key, static::NEW_ITEM) !== false) {
365
                // If item is new, wee need to implement counter of new items to prevent duplicates,
366
                // check limits and etc.
367
                $this->new++;
368
            }
369
370
            if ($this->relatedValues->has($key)) {
371
                $attributes = $this->safeFillModel($this->relatedValues->get($key), $attributes);
372
            }
373
374
            // Finally, we put filled model values into collection of future groups
375
            $collection->put($key, $attributes);
376
        }
377
378
        return $collection;
379
    }
380
381
    protected function createGroup($attributes, $old = false, $key = null): Group
382
    {
383
        $model = $attributes instanceof Model ? $attributes
384
            : $this->safeCreateModel($this->getModelClassForElements(), $attributes);
385
        $group = new Group($model);
386
387
        if ($this->groupLabel) {
388
            $group->setLabel($this->groupLabel);
389
        }
390
391
        if ($key) {
392
            $group->setPrimary($key);
393
        }
394
395
        $this->forEachElement($elements = $this->getNewElements(), function (NamedFormElement $el) use ($model, $key, $old) {
396
            // Setting default value, name and model for element with name attribute
397
            $el->setDefaultValue($el->prepareValue($this->getElementValue($model, $el)));
398
            $el->setName(sprintf('%s[%s][%s]', $this->relationName, $key ?? $model->getKey(), $this->formatElementName($el->getName())));
399
            $el->setModel($model);
400
401
            if ($old && strpos($el->getPath(), '->') === false && ! ($el instanceof HasFakeModel)) {
402
                // If there were old values (validation fail, etc.) each element must have different path to get the old
403
                // value. If we don't do it, there will be collision if two elements with same name present in main form
404
                // and related form. For example: we have "Company" and "Shop" models with field "name" and include HasMany
405
                // form with company's shops inside "Companies" section. There will be collisions of "name" if validation
406
                // fails, and each "shop"-form will have "company->name" value inside "name" field.
407
                $el->setPath($el->getName());
408
            }
409
        });
410
411
        foreach ($elements as $el) {
412
            $group->push($el);
413
        }
414
415
        return $group;
416
    }
417
418
    /**
419
     * Returns value from model for given element.
420
     *
421
     * @param \Illuminate\Database\Eloquent\Model $model
422
     * @param NamedFormElement $el
423
     *
424
     * @return mixed|null
425
     */
426
    protected function getElementValue(Model $model, NamedFormElement $el)
427
    {
428
        $attribute = $el->getModelAttributeKey();
429
        if (strpos($attribute, '->') === false) {
430
            return $model->getAttribute($attribute);
431
        }
432
433
        // Parse json attributes
434
        $casts = collect($model->getCasts());
435
        $jsonParts = collect(explode('->', $attribute));
436
        $cast = $casts->get($jsonParts->first(), false);
437
438
        if (! in_array($cast, ['json', 'array'])) {
439
            return;
440
        }
441
442
        $jsonAttr = $model->{$jsonParts->first()};
443
444
        return array_get($jsonAttr, $jsonParts->slice(1)->implode('.'));
0 ignored issues
show
Deprecated Code introduced by
The function array_get() has been deprecated: Arr::get() should be used directly instead. Will be removed in Laravel 5.9. ( Ignorable by Annotation )

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

444
        return /** @scrutinizer ignore-deprecated */ array_get($jsonAttr, $jsonParts->slice(1)->implode('.'));

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...
445
    }
446
447
    protected function formatElementName(string $name)
448
    {
449
        return preg_replace("/{$this->relationName}\[[\w]+\]\[(.+?)\]/", '$1', $name);
450
    }
451
452
    /**
453
     * Applies given callback to every element of form.
454
     *
455
     * @param \Illuminate\Support\Collection $elements
456
     * @param $callback
457
     */
458
    protected function forEachElement(Collection $elements, $callback)
459
    {
460
        foreach ($this->flatNamedElements($elements) as $element) {
461
            $callback($element);
462
        }
463
    }
464
465
    /**
466
     * Returns flat collection of elements in form ignoring everything but NamedFormElement. Works recursive.
467
     *
468
     * @param \Illuminate\Support\Collection $elements
469
     *
470
     * @return mixed
471
     */
472
    protected function flatNamedElements(Collection $elements)
473
    {
474
        return $elements->reduce(function (Collection $initial, $element) {
475
            if ($element instanceof NamedFormElement) {
476
                // Is it what we're looking for? if so we'll push it to final collection
477
                $initial->push($element);
478
            } elseif ($element instanceof FormElements) {
479
                // Go deeper and repeat everything again
480
                return $initial->merge($this->flatNamedElements($element->getElements()));
481
            }
482
483
            return $initial;
484
        }, collect());
485
    }
486
487
    protected function safeCreateModel(string $modelClass, array $attributes = []): Model
488
    {
489
        return $this->safeFillModel(new $modelClass, $attributes);
490
    }
491
492
    protected function safeFillModel(Model $model, array $attributes = []): Model
493
    {
494
        foreach ($attributes as $attribute => $value) {
495
            // Prevent numeric attribute name. If it is, so it's an error
496
            if (is_numeric($attribute)) {
497
                continue;
498
            }
499
500
            try {
501
                $model->setAttribute($attribute, $value);
502
            } catch (\Throwable $exception) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
503
            }
504
        }
505
506
        return $model;
507
    }
508
509
    /**
510
     * Returns empty relation of model.
511
     *
512
     * @return \Illuminate\Database\Eloquent\Relations\Relation
513
     */
514
    protected function getEmptyRelation()
515
    {
516
        return $this->emptyRelation ?? $this->emptyRelation = $this->getModel()->{$this->relationName}();
0 ignored issues
show
Bug Best Practice introduced by
The property emptyRelation does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
517
    }
518
519
    protected function getRelation(): \Illuminate\Database\Eloquent\Relations\Relation
520
    {
521
        return $this->instance->{$this->relationName}();
522
    }
523
524
    /**
525
     * Saves request.
526
     *
527
     * @param \Illuminate\Http\Request $request
528
     */
529
    public function save(Request $request)
530
    {
531
        $connection = app(\Illuminate\Database\ConnectionInterface::class);
532
        $this->prepareRelatedValues($this->getRequestData());
533
534
        $this->transactionLevel = $connection->transactionLevel();
0 ignored issues
show
Bug Best Practice introduced by
The property transactionLevel does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
535
        $connection->beginTransaction();
536
        // Nothing to do here...
537
    }
538
539
    public function getValidationRulesFromElements(array $rules = []): array
540
    {
541
        $this->flatNamedElements($this->getElements())->each(function ($element) use (&$rules) {
542
            $rules += $this->modifyValidationParameters($element->getValidationRules());
543
        });
544
545
        return $rules;
546
    }
547
548
    public function getValidationMessagesForElements(array $messages = []): array
549
    {
550
        $this->flatNamedElements($this->getElements())->each(function ($element) use (&$messages) {
551
            $messages += $this->modifyValidationParameters($element->getValidationMessages());
552
        });
553
554
        return $messages;
555
    }
556
557
    public function afterSave(Request $request)
558
    {
559
        $connection = app(\Illuminate\Database\ConnectionInterface::class);
560
561
        try {
562
            // By this time getModel method will always return existed model object, not empty
563
            // so wee need to fresh it, because if it's new model creating relation will throw
564
            // exception 'call relation method on null'
565
            $this->setInstance($this->getModel());
566
            $this->proceedSave($request);
567
            $connection->commit();
568
569
            $this->prepareRequestToBeCopied($request);
570
        } catch (\Throwable $exception) {
571
            $connection->rollBack($this->transactionLevel);
0 ignored issues
show
Unused Code introduced by
The call to Illuminate\Database\Conn...onInterface::rollBack() has too many arguments starting with $this->transactionLevel. ( Ignorable by Annotation )

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

571
            $connection->/** @scrutinizer ignore-call */ 
572
                         rollBack($this->transactionLevel);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
572
573
            throw $exception;
574
        }
575
    }
576
577
    /**
578
     * Returns model class for each element in form.
579
     *
580
     * @return string
581
     */
582
    protected function getModelClassForElements(): string
583
    {
584
        return get_class($this->getModelForElements());
585
    }
586
587
    protected function modifyValidationParameters(array $parameters): array
588
    {
589
        $result = [];
590
        foreach ($parameters as $name => $parameter) {
591
            $result["{$this->relationName}.*.{$name}"] = $parameter;
592
        }
593
594
        return $result;
595
    }
596
597
    /**
598
     * Get the instance as an array.
599
     *
600
     * @return array
601
     */
602
    public function toArray()
603
    {
604
        $this->buildGroupsCollection();
605
606
        return parent::toArray() + [
607
                'stub'             => $this->stubElements,
608
                'name'             => $this->relationName,
609
                'label'            => $this->label,
610
                'groups'           => $this->groups,
611
                'remove'           => $this->toRemove,
612
                'newEntitiesCount' => $this->new,
613
                'limit'            => $this->limit,
614
            ];
615
    }
616
617
    /**
618
     * @param string $groupLabel
619
     *
620
     * @return Elements
621
     */
622
    public function setGroupLabel(string $groupLabel): self
623
    {
624
        $this->groupLabel = $groupLabel;
625
626
        return $this;
627
    }
628
629
    /**
630
     * Checks if count of relations to be created exceeds limit.
631
     *
632
     * @return int
633
     */
634
    public function exceedsLimit()
635
    {
636
        if ($this->limit === null) {
637
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type integer.
Loading history...
638
        }
639
640
        return $this->relatedValues->count() >= $this->limit;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->relatedVal...count() >= $this->limit returns the type boolean which is incompatible with the documented return type integer.
Loading history...
641
    }
642
643
    /**
644
     * Appends fresh related model if total count is not exceeding limit.
645
     *
646
     * @param $key
647
     *
648
     * @return $this
649
     */
650
    protected function addOrGetRelated($key)
651
    {
652
        $related = $this->relatedValues->get($key) ?? $this->getFreshModelForElements();
653
654
        if (! $related->exists && ! $this->exceedsLimit()) {
655
            $this->relatedValues->put($key, $related);
656
        }
657
658
        return $related;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $related also could return the type Illuminate\Database\Eloquent\Model which is incompatible with the documented return type SleepingOwl\Admin\Form\Related\Elements.
Loading history...
659
    }
660
661
    /**
662
     * @return Elements
663
     */
664
    public function disableCreation(): self
665
    {
666
        $this->setLimit(0);
667
668
        return $this;
669
    }
670
671
    abstract protected function retrieveRelationValuesFromQuery($query): Collection;
672
673
    /**
674
     * Returns model for each element in form.
675
     *
676
     * @return \Illuminate\Database\Eloquent\Model
677
     */
678
    abstract protected function getModelForElements(): Model;
679
680
    /**
681
     * Returns fresh instance of model for each element in form.
682
     *
683
     * @return \Illuminate\Database\Eloquent\Model
684
     */
685
    abstract protected function getFreshModelForElements(): Model;
686
687
    /**
688
     * Proceeds saving related values after all validations passes.
689
     *
690
     * @param \Illuminate\Http\Request $request
691
     *
692
     * @return mixed
693
     */
694
    abstract protected function proceedSave(Request $request);
695
696
    /**
697
     * Here you must add all new relations to main collection and etc.
698
     *
699
     * @param array $data
700
     *
701
     * @return mixed
702
     */
703
    abstract protected function prepareRelatedValues(array $data);
704
}
705