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.

Elements::setGroupLabel()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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

343
        $old = $request->old($this->relationName, /** @scrutinizer ignore-type */ false);
Loading history...
344
345
        return $old ?: $request->get($this->relationName, []);
346
    }
347
348
    protected function buildGroupsCollection()
349
    {
350
        $old = false;
351
        $relatedValues = $this->relatedValues;
352
353
        if (count($data = $this->getRequestData()) !== 0) {
354
            $old = true;
355
            $relatedValues = $this->getRelatedValuesFromRequestData($data);
356
        }
357
358
        foreach ($relatedValues as $key => $item) {
359
            $this->groups->push($this->createGroup($item, $old, $key));
360
        }
361
    }
362
363
    protected function getRelatedValuesFromRequestData(array $values): Collection
364
    {
365
        $collection = collect();
366
        foreach ($values as $key => $attributes) {
367
            if ($key === static::REMOVE) {
368
                // If key is about to be removed we need to save it and show later in rendered form. But we don't
369
                // need to put value with this relation in collection of elements, that's why we need to continue the
370
                // loop
371
                $this->toRemove = collect($attributes);
372
                continue;
373
            }
374
375
            if (strpos($key, static::NEW_ITEM) !== false) {
376
                // If item is new, wee need to implement counter of new items to prevent duplicates,
377
                // check limits and etc.
378
                $this->new++;
379
            }
380
381
            if ($this->relatedValues->has($key)) {
382
                $attributes = $this->safeFillModel($this->relatedValues->get($key), $attributes);
383
            }
384
385
            // Finally, we put filled model values into collection of future groups
386
            $collection->put($key, $attributes);
387
        }
388
389
        return $collection;
390
    }
391
392
    protected function createGroup($attributes, $old = false, $key = null): Group
393
    {
394
        $model = $attributes instanceof Model ? $attributes
395
            : $this->safeCreateModel($this->getModelClassForElements(), $attributes);
396
        $group = new Group($model);
397
398
        if ($this->groupLabel) {
399
            $group->setLabel($this->groupLabel);
400
        }
401
402
        if ($key) {
403
            $group->setPrimary($key);
404
        }
405
406
        $this->forEachElement($elements = $this->getNewElements(), function (NamedFormElement $el) use ($model, $key, $old) {
407
            // Setting default value, name and model for element with name attribute
408
            $el->setDefaultValue($el->prepareValue($this->getElementValue($model, $el)));
409
            $el->setName(sprintf('%s[%s][%s]', $this->relationName, $key ?? $model->getKey(), $this->formatElementName($el->getName())));
410
            $el->setModel($model);
411
412
            if ($old && strpos($el->getPath(), '->') === false && ! ($el instanceof HasFakeModel)) {
413
                // If there were old values (validation fail, etc.) each element must have different path to get the old
414
                // value. If we don't do it, there will be collision if two elements with same name present in main form
415
                // and related form. For example: we have "Company" and "Shop" models with field "name" and include HasMany
416
                // form with company's shops inside "Companies" section. There will be collisions of "name" if validation
417
                // fails, and each "shop"-form will have "company->name" value inside "name" field.
418
                $el->setPath($el->getName());
419
            }
420
        });
421
422
        foreach ($elements as $el) {
423
            $group->push($el);
424
        }
425
426
        return $group;
427
    }
428
429
    /**
430
     * Returns value from model for given element.
431
     *
432
     * @param \Illuminate\Database\Eloquent\Model $model
433
     * @param NamedFormElement $el
434
     *
435
     * @return mixed|null
436
     */
437
    protected function getElementValue(Model $model, NamedFormElement $el)
438
    {
439
        $attribute = $el->getModelAttributeKey();
440
        if (strpos($attribute, '->') === false) {
441
            return $model->getAttribute($attribute);
442
        }
443
444
        // Parse json attributes
445
        $casts = collect($model->getCasts());
446
        $jsonParts = collect(explode('->', $attribute));
447
        $cast = $casts->get($jsonParts->first(), false);
448
449
        if (! in_array($cast, ['json', 'array'])) {
450
            return;
451
        }
452
453
        $jsonAttr = $model->{$jsonParts->first()};
454
455
        return Arr::get($jsonAttr, $jsonParts->slice(1)->implode('.'));
456
    }
457
458
    protected function formatElementName(string $name)
459
    {
460
        return preg_replace("/{$this->relationName}\[[\w]+\]\[(.+?)\]/", '$1', $name);
461
    }
462
463
    /**
464
     * Applies given callback to every element of form.
465
     *
466
     * @param \Illuminate\Support\Collection $elements
467
     * @param $callback
468
     */
469
    protected function forEachElement(Collection $elements, $callback)
470
    {
471
        foreach ($this->flatNamedElements($elements) as $element) {
472
            $callback($element);
473
        }
474
    }
475
476
    /**
477
     * Returns flat collection of elements in form ignoring everything but NamedFormElement. Works recursive.
478
     *
479
     * @param \Illuminate\Support\Collection $elements
480
     *
481
     * @return mixed
482
     */
483
    protected function flatNamedElements(Collection $elements)
484
    {
485
        return $elements->reduce(function (Collection $initial, $element) {
486
            if ($element instanceof NamedFormElement) {
487
                // Is it what we're looking for? if so we'll push it to final collection
488
                $initial->push($element);
489
            } elseif ($element instanceof FormElements) {
490
                // Go deeper and repeat everything again
491
                return $initial->merge($this->flatNamedElements($element->getElements()));
492
            }
493
494
            return $initial;
495
        }, collect());
496
    }
497
498
    protected function safeCreateModel(string $modelClass, array $attributes = []): Model
499
    {
500
        return $this->safeFillModel(new $modelClass, $attributes);
501
    }
502
503
    /**
504
     * @param \Illuminate\Database\Eloquent\Model $model
505
     * @param array $attributes
506
     * @return \Illuminate\Database\Eloquent\Model
507
     */
508
    protected function safeFillModel(Model $model, array $attributes = []): Model
509
    {
510
        foreach ($attributes as $attribute => $value) {
511
            // Prevent numeric attribute name. If it is, so it's an error
512
            if (is_numeric($attribute)) {
513
                continue;
514
            }
515
516
            try {
517
                $model->setAttribute($attribute, $value);
518
            } catch (Throwable $exception) {
519
                // Not add Attribute
520
            }
521
        }
522
523
        return $model;
524
    }
525
526
    /**
527
     * Returns empty relation of model.
528
     *
529
     * @return \Illuminate\Database\Eloquent\Relations\Relation
530
     */
531
    protected function getEmptyRelation()
532
    {
533
        return $this->emptyRelation ?? $this->emptyRelation = $this->getModel()->{$this->relationName}();
534
    }
535
536
    protected function getRelation(): Relation
537
    {
538
        return $this->instance->{$this->relationName}();
539
    }
540
541
    /**
542
     * Saves request.
543
     *
544
     * @param \Illuminate\Http\Request $request
545
     */
546
    public function save(Request $request)
547
    {
548
        $connection = app(ConnectionInterface::class);
549
        $this->prepareRelatedValues($this->getRequestData());
550
551
        $this->transactionLevel = $connection->transactionLevel();
552
        $connection->beginTransaction();
553
        // Nothing to do here...
554
    }
555
556
    /**
557
     * @param array $rules
558
     * @return array
559
     */
560
    public function getValidationRulesFromElements(array $rules = []): array
561
    {
562
        $this->flatNamedElements($this->getElements())->each(function ($element) use (&$rules) {
563
            $rules += $this->modifyValidationParameters($element->getValidationRules());
564
        });
565
566
        return $rules;
567
    }
568
569
    /**
570
     * @param array $messages
571
     * @return array
572
     */
573
    public function getValidationMessagesForElements(array $messages = []): array
574
    {
575
        $this->flatNamedElements($this->getElements())->each(function ($element) use (&$messages) {
576
            $messages += $this->modifyValidationParameters($element->getValidationMessages());
577
        });
578
579
        return $messages;
580
    }
581
582
    /**
583
     * @param \Illuminate\Http\Request $request
584
     * @throws \Throwable
585
     */
586
    public function afterSave(Request $request)
587
    {
588
        $connection = app(ConnectionInterface::class);
589
590
        try {
591
            // By this time getModel method will always return existed model object, not empty
592
            // so wee need to fresh it, because if it's new model creating relation will throw
593
            // exception 'call relation method on null'
594
            $this->setInstance($this->getModel());
595
            $this->proceedSave($request);
596
            $connection->commit();
597
598
            $this->prepareRequestToBeCopied($request);
599
        } catch (Throwable $exception) {
600
            $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

600
            $connection->/** @scrutinizer ignore-call */ 
601
                         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...
601
602
            throw $exception;
603
        }
604
    }
605
606
    /**
607
     * Returns model class for each element in form.
608
     *
609
     * @return string
610
     */
611
    protected function getModelClassForElements(): string
612
    {
613
        return get_class($this->getModelForElements());
614
    }
615
616
    /**
617
     * @param array $parameters
618
     * @return array
619
     */
620
    protected function modifyValidationParameters(array $parameters): array
621
    {
622
        $result = [];
623
        foreach ($parameters as $name => $parameter) {
624
            $result["{$this->relationName}.*.{$name}"] = $parameter;
625
        }
626
627
        return $result;
628
    }
629
630
    /**
631
     * Get the instance as an array.
632
     *
633
     * @return array
634
     */
635
    public function toArray()
636
    {
637
        $this->buildGroupsCollection();
638
639
        return parent::toArray() + [
640
            'stub' => $this->stubElements,
641
            'name' => $this->relationName,
642
            'label' => $this->label,
643
            'groups' => $this->groups,
644
            'remove' => $this->toRemove,
645
            'newEntitiesCount' => $this->new,
646
            'limit' => $this->limit,
647
        ];
648
    }
649
650
    /**
651
     * @param string $groupLabel
652
     *
653
     * @return Elements|\Illuminate\Database\Eloquent\Model
654
     */
655
    public function setGroupLabel(string $groupLabel): self
656
    {
657
        $this->groupLabel = $groupLabel;
658
659
        return $this;
660
    }
661
662
    /**
663
     * Checks if count of relations to be created exceeds limit.
664
     *
665
     * @return bool
666
     */
667
    public function exceedsLimit()
668
    {
669
        if ($this->limit === null) {
670
            return false;
671
        }
672
673
        return $this->relatedValues->count() >= $this->limit;
674
    }
675
676
    /**
677
     * Appends fresh related model if total count is not exceeding limit.
678
     *
679
     * @param $key
680
     *
681
     * @return $this
682
     */
683
    protected function addOrGetRelated($key)
684
    {
685
        $related = $this->relatedValues->get($key) ?? $this->getFreshModelForElements();
686
687
        if (! $related->exists && ! $this->exceedsLimit()) {
688
            $this->relatedValues->put($key, $related);
689
        }
690
691
        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...
692
    }
693
694
    /**
695
     * @return Elements
696
     */
697
    public function disableCreation(): self
698
    {
699
        $this->setLimit(0);
700
701
        return $this;
702
    }
703
704
    abstract protected function retrieveRelationValuesFromQuery($query): Collection;
705
706
    /**
707
     * Returns model for each element in form.
708
     *
709
     * @return \Illuminate\Database\Eloquent\Model
710
     */
711
    abstract protected function getModelForElements(): Model;
712
713
    /**
714
     * Returns fresh instance of model for each element in form.
715
     *
716
     * @return \Illuminate\Database\Eloquent\Model
717
     */
718
    abstract protected function getFreshModelForElements(): Model;
719
720
    /**
721
     * Proceeds saving related values after all validations passes.
722
     *
723
     * @param \Illuminate\Http\Request $request
724
     *
725
     * @return mixed
726
     */
727
    abstract protected function proceedSave(Request $request);
728
729
    /**
730
     * Here you must add all new relations to main collection and etc.
731
     *
732
     * @param array $data
733
     *
734
     * @return mixed
735
     */
736
    abstract protected function prepareRelatedValues(array $data);
737
}
738