Issues (68)

src/Eloquent/Concerns/InteractsWithRelations.php (12 issues)

1
<?php
2
3
namespace Bakery\Eloquent\Concerns;
4
5
use RuntimeException;
6
use Bakery\Utils\Utils;
7
use Illuminate\Support\Arr;
8
use Illuminate\Support\Str;
9
use GraphQL\Error\UserError;
10
use Illuminate\Database\Eloquent\Model;
11
use Bakery\Exceptions\InvariantViolation;
12
use Illuminate\Database\Eloquent\Relations;
13
14
trait InteractsWithRelations
15
{
16
    /**
17
     * @var \Bakery\Support\TypeRegistry
18
     */
19
    protected $registry;
20
21
    /**
22
     * @var \Illuminate\Database\Eloquent\Model
23
     */
24
    protected $instance;
25
26
    /**
27
     * @var \Illuminate\Contracts\Auth\Access\Gate
28
     */
29
    protected $gate;
30
31
    /**
32
     * The relationships that are supported by Bakery.
33
     *
34
     * @var array
35
     */
36
    protected $relationships = [
37
        Relations\HasOne::class,
38
        Relations\HasMany::class,
39
        Relations\BelongsTo::class,
40
        Relations\BelongsToMany::class,
41
        Relations\MorphTo::class,
42
        Relations\MorphMany::class,
43
    ];
44
45
    /**
46
     * @param \Closure $closure
47
     * @return void
48
     */
49
    abstract protected function queue(\Closure $closure);
50
51
    /**
52
     * Fill the relations in the model.
53
     *
54
     * @param array $relations
55
     * @return void
56
     */
57
    protected function fillRelations(array $relations)
58
    {
59
        foreach ($relations as $key => $attributes) {
60
            $relation = $this->resolveRelation($key);
61
            $relationType = $this->getRelationTypeName($relation);
62
            $method = "fill{$relationType}Relation";
63
64
            if (! method_exists($this, $method)) {
65
                throw new RuntimeException("Unknown or unfillable relation type: {$key} of type ${relationType}");
66
            }
67
68
            $this->{$method}($relation, $attributes);
69
        }
70
    }
71
72
    /**
73
     * Fill the connections in the model.
74
     *
75
     * @param array $connections
76
     * @return void
77
     */
78
    protected function fillConnections(array $connections)
79
    {
80
        foreach ($connections as $key => $attributes) {
81
            $relation = $this->resolveRelationOfConnection($key);
82
            $relationType = $this->getRelationTypeName($relation);
83
            $method = "connect{$relationType}Relation";
84
85
            if (! method_exists($this, $method)) {
86
                throw new RuntimeException("Unknown or unfillable connection type: {$key} of type ${relationType}");
87
            }
88
89
            $this->{$method}($relation, $attributes);
90
        }
91
    }
92
93
    /**
94
     * Connect a belongs to relation.
95
     *
96
     * @param Relations\BelongsTo $relation
97
     * @param mixed $id
98
     * @return void
99
     */
100
    protected function connectBelongsToRelation(Relations\BelongsTo $relation, $id)
101
    {
102
        if (! $id) {
103
            $relation->dissociate();
104
105
            return;
106
        }
107
108
        $model = $relation->getRelated()->findOrFail($id);
109
        $schema = $this->registry->getSchemaForModel($model);
110
111
        $relation->associate($model);
112
113
        $this->queue(function () use ($schema) {
114
            $schema->authorizeToAdd($this->getModel());
0 ignored issues
show
It seems like getModel() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

114
            $schema->authorizeToAdd($this->/** @scrutinizer ignore-call */ getModel());
Loading history...
115
        });
116
    }
117
118
    /**
119
     * Fill a belongs to relation.
120
     *
121
     * @param Relations\BelongsTo $relation
122
     * @param array $attributes
123
     * @return void
124
     */
125
    protected function fillBelongsToRelation(Relations\BelongsTo $relation, $attributes = [])
126
    {
127
        if (! $attributes) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $attributes of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
128
            $relation->dissociate();
129
130
            return;
131
        }
132
133
        $related = $relation->getRelated();
134
        $schema = $this->registry->getSchemaForModel($related);
135
        $model = $schema->createIfAuthorized($attributes);
136
137
        $relation->associate($model);
138
139
        $this->queue(function () use ($schema) {
140
            $schema->authorizeToAdd($this->getModel());
141
        });
142
    }
143
144
    /**
145
     * Connect a has one relation.
146
     *
147
     * @param Relations\HasOne $relation
148
     * @param string $id
149
     * @return void
150
     */
151
    protected function connectHasOneRelation(Relations\HasOne $relation, $id)
152
    {
153
        if (! $id) {
154
            $relation->delete();
155
156
            return;
157
        }
158
159
        $this->queue(function () use ($id, $relation) {
160
            $model = $relation->getRelated()->findOrFail($id);
161
            $this->authorizeToAdd($model);
0 ignored issues
show
It seems like authorizeToAdd() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

161
            $this->/** @scrutinizer ignore-call */ 
162
                   authorizeToAdd($model);
Loading history...
162
            $relation->save($model);
163
        });
164
    }
165
166
    /**
167
     * Create a new has one relation.
168
     *
169
     * @param Relations\HasOne $relation
170
     * @param mixed $attributes
171
     * @return void
172
     * @throws \Illuminate\Auth\Access\AuthorizationException
173
     */
174
    protected function fillHasOneRelation(Relations\HasOne $relation, $attributes)
175
    {
176
        if (! $attributes) {
177
            $relation->delete();
178
179
            return;
180
        }
181
182
        $related = $relation->getRelated();
183
        $modelSchema = $this->registry->getSchemaForModel($related);
184
        $modelSchema->authorizeToCreate();
185
        $modelSchema->fill($attributes);
186
187
        $this->queue(function () use ($modelSchema, $relation) {
188
            $this->authorizeToAdd($modelSchema->getModel());
189
            $relation->save($modelSchema->getInstance());
190
            $modelSchema->save();
191
        });
192
    }
193
194
    /**
195
     * Connect a has many relation.
196
     *
197
     * @param Relations\HasMany $relation
198
     * @param array $ids
199
     * @return void
200
     */
201
    protected function connectHasManyRelation(Relations\HasMany $relation, array $ids)
202
    {
203
        $this->queue(function () use ($relation, $ids) {
204
            $models = $relation->getModel()->findMany($ids);
205
206
            foreach ($models as $model) {
207
                $this->authorizeToAdd($model);
208
            }
209
210
            $relation->saveMany($models);
211
        });
212
    }
213
214
    /**
215
     * Fill a has many relation.
216
     *
217
     * @param Relations\HasMany $relation
218
     * @param array $values
219
     * @return void
220
     */
221
    protected function fillHasManyRelation(Relations\HasMany $relation, array $values)
222
    {
223
        $this->queue(function () use ($relation, $values) {
224
            $related = $relation->getRelated();
225
            $relation->delete();
226
227
            foreach ($values as $attributes) {
228
                $modelSchema = $this->registry->getSchemaForModel($related);
229
                $modelSchema->authorizeToCreate();
230
                $model = $modelSchema->make($attributes);
231
                $this->authorizeToAdd($model);
232
                $relation->save($model);
233
                $modelSchema->save();
234
            }
235
        });
236
    }
237
238
    /**
239
     * Connect the belongs to many relation.
240
     *
241
     * @param Relations\BelongsToMany $relation
242
     * @param array $values
243
     * @param bool $detaching
244
     * @return void
245
     */
246
    public function connectBelongsToManyRelation(Relations\BelongsToMany $relation, array $values, $detaching = true)
247
    {
248
        $pivotClass = $relation->getPivotClass();
249
250
        if (is_subclass_of($pivotClass, Relations\Pivot::class) && $this->registry->hasSchemaForModel($pivotClass)) {
251
            $this->connectBelongsToManyWithPivot($relation, $values, $detaching);
252
        } else {
253
            $this->connectBelongsToManyWithoutPivot($relation, $values, $detaching);
254
        }
255
    }
256
257
    /**
258
     * Connect the belongs to many relation without pivot class.
259
     *
260
     * @param Relations\BelongsToMany $relation
261
     * @param array $values
262
     * @param bool $detaching
263
     * @return void
264
     */
265
    protected function connectBelongsToManyWithoutPivot(
266
        Relations\BelongsToMany $relation,
267
        array $values,
268
        $detaching = true
269
    ) {
270
        $values = collect($values);
271
272
        $this->queue(function () use ($detaching, $relation, $values) {
273
            $current = $relation->newQuery()->pluck($relation->getRelatedPivotKeyName());
274
275
            if ($detaching) {
276
                $detach = $current->diff($values);
277
278
                $relation->getRelated()->newQuery()->findMany($detach)->each(function (Model $model) {
279
                    $this->authorizeToDetach($model);
0 ignored issues
show
It seems like authorizeToDetach() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

279
                    $this->/** @scrutinizer ignore-call */ 
280
                           authorizeToDetach($model);
Loading history...
280
                });
281
            }
282
283
            $attach = $values->diff($current);
284
285
            $relation->getRelated()->newQuery()->findMany($attach)->each(function (Model $model) {
286
                $this->authorizeToAttach($model);
0 ignored issues
show
It seems like authorizeToAttach() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

286
                $this->/** @scrutinizer ignore-call */ 
287
                       authorizeToAttach($model);
Loading history...
287
            });
288
289
            $relation->sync($values, $detaching);
290
        });
291
    }
292
293
    /**
294
     * Connect the belongs to many relation with pivot class.
295
     *
296
     * @param Relations\BelongsToMany $relation
297
     * @param array $values
298
     * @param bool $detaching
299
     * @return void
300
     */
301
    protected function connectBelongsToManyWithPivot(
302
        Relations\BelongsToMany $relation,
303
        array $values,
304
        $detaching = true
305
    ) {
306
        $accessor = $relation->getPivotAccessor();
307
        $relatedKey = $relation->getRelated()->getKeyName();
308
309
        $pivotClass = $relation->getPivotClass();
310
311
        // Returns an associative array of [ id => pivot ]
312
        $values = collect($values)->mapWithKeys(function ($data) use ($accessor, $relatedKey) {
313
            return [$data[$relatedKey] => $data[$accessor] ?? []];
314
        })->map(function ($attributes) use ($pivotClass) {
315
            $instance = new $pivotClass;
316
            $pivotSchema = $this->registry->getSchemaForModel($instance);
317
            $pivotSchema->fill($attributes);
318
319
            return $pivotSchema->getInstance()->getAttributes();
320
        });
321
322
        $this->queue(function () use ($values, $detaching, $relation) {
323
            $current = $relation->newQuery()->pluck($relation->getRelatedPivotKeyName());
324
325
            if ($detaching) {
326
                $detach = $current->diff($values->keys());
327
328
                $relation->getRelated()->newQuery()->findMany($detach)->each(function (Model $model) {
329
                    $this->authorizeToDetach($model);
330
                });
331
            }
332
333
            $attach = $values->keys()->diff($current);
334
335
            $relation->getRelated()->newQuery()->findMany($attach)->each(function (Model $model) use ($values) {
336
                $this->authorizeToAttach($model, $values->get($model->getKey()));
337
            });
338
339
            $relation->sync($values, $detaching);
340
        });
341
    }
342
343
    /**
344
     * Fill the belongs to many relation.
345
     *
346
     * @param Relations\BelongsToMany $relation
347
     * @param array $value
348
     * @param bool $detaching
349
     * @return void
350
     */
351
    protected function fillBelongsToManyRelation(Relations\BelongsToMany $relation, array $value, $detaching = true)
352
    {
353
        $pivots = collect();
354
        $instances = collect();
355
        $related = $relation->getRelated();
356
        $accessor = $relation->getPivotAccessor();
357
        $relatedSchema = $this->registry->getSchemaForModel($related);
358
359
        foreach ($value as $attributes) {
360
            $instances[] = $relatedSchema->createIfAuthorized($attributes);
361
            $pivots[] = $attributes[$accessor] ?? null;
362
        }
363
364
        $this->queue(function () use ($detaching, $relation, $instances, $pivots) {
365
            $data = $instances->pluck('id')->mapWithKeys(function ($id, $key) use ($pivots) {
366
                $pivot = $pivots[$key] ?? null;
367
368
                return $pivot ? [$id => $pivot] : [$key => $id];
369
            });
370
371
            $results = $relation->sync($data, $detaching);
372
373
            $relation->getRelated()->newQuery()->findMany($results['detached'])->each(function (Model $model) {
374
                $this->authorizeToDetach($model);
375
            });
376
377
            $relation->getRelated()->newQuery()->findMany($results['attached'])->each(function (Model $model) {
378
                $this->authorizeToAttach($model);
379
            });
380
        });
381
    }
382
383
    /**
384
     * Connect a belongs to relation.
385
     *
386
     * @param \Illuminate\Database\Eloquent\Relations\MorphTo $relation
387
     * @param $data
388
     * @return void
389
     */
390
    protected function connectMorphToRelation(Relations\MorphTo $relation, $data)
391
    {
392
        if (! $data) {
393
            $relation->dissociate();
394
395
            return;
396
        }
397
398
        if (is_array($data)) {
399
            if (count($data) !== 1) {
400
                throw new UserError(sprintf('There must be only one key with polymorphic input. %s given for relation %s.', count($data), $relation->getRelationName()));
401
            }
402
403
            $data = collect($data);
404
405
            [$key, $id] = $data->mapWithKeys(function ($item, $key) {
406
                return [$key, $item];
407
            });
408
409
            $model = $this->getPolymorphicModel($relation, $key);
410
411
            $instance = $model->findOrFail($id);
412
            $modelSchema = $this->registry->getSchemaForModel($instance);
413
            $modelSchema->authorizeToAdd($this->getModel());
414
            $relation->associate($instance);
415
        }
416
    }
417
418
    /**
419
     * Fill a belongs to relation.
420
     *
421
     * @param \Illuminate\Database\Eloquent\Relations\MorphTo $relation
422
     * @param array $data
423
     * @return void
424
     */
425
    protected function fillMorphToRelation(Relations\MorphTo $relation, $data)
426
    {
427
        if (! $data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
428
            $relation->dissociate();
429
430
            return;
431
        }
432
433
        if (is_array($data)) {
0 ignored issues
show
The condition is_array($data) is always true.
Loading history...
434
            if (count($data) !== 1) {
435
                throw new UserError(sprintf('There must be only one key with polymorphic input. %s given for relation %s.', count($data), $relation->getRelationName()));
436
            }
437
438
            $data = collect($data);
439
440
            [$key, $attributes] = $data->mapWithKeys(function ($item, $key) {
441
                return [$key, $item];
442
            });
443
444
            $model = $this->getPolymorphicModel($relation, $key);
445
            $modelSchema = $this->registry->getSchemaForModel($model);
446
            $instance = $modelSchema->create($attributes);
447
            $relation->associate($instance);
448
        }
449
    }
450
451
    /**
452
     * Get the polymorphic type that belongs to the relation so we can figure
453
     * out the model.
454
     *
455
     * @param \Illuminate\Database\Eloquent\Relations\MorphTo $relation
456
     * @param string $key
457
     * @return \Illuminate\Database\Eloquent\Model
458
     */
459
    protected function getPolymorphicModel(Relations\MorphTo $relation, string $key): Model
460
    {
461
        /** @var \Bakery\Fields\PolymorphicField $type */
462
        $type = Arr::get($this->getRelationFields(), $relation->getRelationName());
0 ignored issues
show
The method getRelationFields() does not exist on Bakery\Eloquent\Concerns\InteractsWithRelations. Did you maybe mean getRelationTypeName()? ( Ignorable by Annotation )

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

462
        $type = Arr::get($this->/** @scrutinizer ignore-call */ getRelationFields(), $relation->getRelationName());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
463
464
        return resolve($type->getModelSchemaByKey($key))->getModel();
0 ignored issues
show
The method getModel() does not exist on Illuminate\Contracts\Foundation\Application. ( Ignorable by Annotation )

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

464
        return resolve($type->getModelSchemaByKey($key))->/** @scrutinizer ignore-call */ getModel();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
465
    }
466
467
    /**
468
     * Connect a morph many relation.
469
     *
470
     * @param \Illuminate\Database\Eloquent\Relations\MorphMany $relation
471
     * @param array $ids
472
     * @return void
473
     */
474
    protected function connectMorphManyRelation(Relations\MorphMany $relation, array $ids)
475
    {
476
        $this->queue(function () use ($relation, $ids) {
477
            $relation->each(function (Model $model) use ($relation) {
478
                $model->setAttribute($relation->getMorphType(), null);
479
                $model->setAttribute($relation->getForeignKeyName(), null);
480
                $model->save();
481
            });
482
483
            $models = $relation->getRelated()->newQuery()->whereIn($relation->getRelated()->getKeyName(), $ids)->get();
484
485
            $relation->saveMany($models);
486
        });
487
    }
488
489
    /**
490
     * Fill a morph many relation.
491
     *
492
     * @param \Illuminate\Database\Eloquent\Relations\MorphMany $relation
493
     * @param array $values
494
     * @return void
495
     */
496
    protected function fillMorphManyRelation(Relations\MorphMany $relation, array $values)
497
    {
498
        $this->queue(function () use ($relation, $values) {
499
            $relation->delete();
500
            $related = $relation->getRelated();
501
            $relatedSchema = $this->registry->getSchemaForModel($related);
502
503
            foreach ($values as $attributes) {
504
                $relatedSchema->make($attributes);
505
                $relatedSchema->getModel()->setAttribute($relation->getMorphType(), $relation->getMorphClass());
506
                $relatedSchema->getModel()->setAttribute($relation->getForeignKeyName(), $relation->getParentKey());
507
                $relatedSchema->save();
508
            }
509
        });
510
    }
511
512
    /**
513
     * Resolve the relation by name.
514
     *
515
     * @param string $relation
516
     * @return Relations\Relation
517
     */
518
    protected function resolveRelation(string $relation): Relations\Relation
519
    {
520
        Utils::invariant(method_exists($this->instance, $relation), class_basename($this->instance).' has no relation named '.$relation);
521
522
        $resolvedRelation = $this->instance->{$relation}();
523
524
        return $resolvedRelation;
525
    }
526
527
    /**
528
     * Get the relation name of a connection.
529
     * e.g. userId => user
530
     *      commentIds => comments.
531
     *
532
     * @param string $connection
533
     * @return Relations\Relation
534
     */
535
    protected function getRelationOfConnection(string $connection): string
536
    {
537
        if (Str::endsWith($connection, 'Ids')) {
538
            return Str::plural(Str::before($connection, 'Ids'));
0 ignored issues
show
Bug Best Practice introduced by
The expression return Illuminate\Suppor...re($connection, 'Ids')) returns the type string which is incompatible with the documented return type Illuminate\Database\Eloquent\Relations\Relation.
Loading history...
539
        }
540
541
        if (Str::endsWith($connection, 'Id')) {
542
            return Str::singular(Str::before($connection, 'Id'));
0 ignored issues
show
Bug Best Practice introduced by
The expression return Illuminate\Suppor...ore($connection, 'Id')) returns the type string which is incompatible with the documented return type Illuminate\Database\Eloquent\Relations\Relation.
Loading history...
543
        }
544
545
        throw new InvariantViolation('Could not get relation of connection: '.$connection);
546
    }
547
548
    /**
549
     * Resolve the relation class of a connection.
550
     *
551
     * @param string $connection
552
     * @return Relations\Relation
553
     */
554
    protected function resolveRelationOfConnection(string $connection): Relations\Relation
555
    {
556
        return $this->resolveRelation($this->getRelationOfconnection($connection));
557
    }
558
559
    /**
560
     * Return if the relation is a plural relation.
561
     *
562
     * @param Relations\Relation $relation
563
     * @return bool
564
     */
565
    protected function isPluralRelation(Relations\Relation $relation)
566
    {
567
        return $relation instanceof Relations\HasMany || $relation instanceof Relations\BelongsToMany;
568
    }
569
570
    /**
571
     * Return if the relation is a singular relation.
572
     *
573
     * @param Relations\Relation $relation
574
     * @return bool
575
     */
576
    protected function isSingularRelation(Relations\Relation $relation)
577
    {
578
        return $relation instanceof Relations\BelongsTo || $relation instanceof Relations\HasOne;
579
    }
580
581
    /**
582
     * Get the basename of the relation.
583
     *
584
     * If the relation is extended from the actual
585
     * Illuminate relationship we try to resolve the parent here.
586
     *
587
     * @param Relations\Relation $relation
588
     * @return string
589
     */
590
    protected function getRelationTypeName(Relations\Relation $relation): string
591
    {
592
        if (in_array(get_class($relation), $this->relationships)) {
593
            return class_basename($relation);
594
        }
595
596
        foreach (class_parents($relation) as $parent) {
597
            if (in_array($parent, $this->relationships)) {
598
                return class_basename($parent);
599
            }
600
        }
601
602
        throw new RuntimeException('Could not found a relationship name for relation '.$relation);
0 ignored issues
show
Are you sure $relation of type Illuminate\Database\Eloquent\Relations\Relation can be used in concatenation? Consider adding a __toString()-method. ( Ignorable by Annotation )

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

602
        throw new RuntimeException('Could not found a relationship name for relation './** @scrutinizer ignore-type */ $relation);
Loading history...
603
    }
604
}
605