Passed
Pull Request — master (#1)
by Guillaume
04:03
created

InteractsWithPivotTable   F

Complexity

Total Complexity 90

Size/Duplication

Total Lines 658
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 185
dl 0
loc 658
rs 2
c 0
b 0
f 0
wmc 90

How to fix   Complexity   

Complex Class

Complex classes like InteractsWithPivotTable often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use InteractsWithPivotTable, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Illuminate\Database\Eloquent\Relations\Concerns;
4
5
use Illuminate\Database\Eloquent\Collection;
6
use Illuminate\Database\Eloquent\Model;
7
use Illuminate\Database\Eloquent\Relations\Pivot;
8
use Illuminate\Support\Collection as BaseCollection;
9
10
trait InteractsWithPivotTable
11
{
12
    /**
13
     * Toggles a model (or models) from the parent.
14
     *
15
     * Each existing model is detached, and non existing ones are attached.
16
     *
17
     * @param  mixed  $ids
18
     * @param  bool  $touch
19
     * @return array
20
     */
21
    public function toggle($ids, $touch = true)
22
    {
23
        $changes = [
24
            'attached' => [], 'detached' => [],
25
        ];
26
27
        $records = $this->formatRecordsList($this->parseIds($ids));
28
29
        // Next, we will determine which IDs should get removed from the join table by
30
        // checking which of the given ID/records is in the list of current records
31
        // and removing all of those rows from this "intermediate" joining table.
32
        $detach = array_values(array_intersect(
33
            $this->newPivotQuery()->pluck($this->relatedPivotKey)->all(),
34
            array_keys($records)
35
        ));
36
37
        if (count($detach) > 0) {
38
            $this->detach($detach, false);
39
40
            $changes['detached'] = $this->castKeys($detach);
41
        }
42
43
        // Finally, for all of the records which were not "detached", we'll attach the
44
        // records into the intermediate table. Then, we will add those attaches to
45
        // this change list and get ready to return these results to the callers.
46
        $attach = array_diff_key($records, array_flip($detach));
47
48
        if (count($attach) > 0) {
49
            $this->attach($attach, [], false);
50
51
            $changes['attached'] = array_keys($attach);
52
        }
53
54
        // Once we have finished attaching or detaching the records, we will see if we
55
        // have done any attaching or detaching, and if we have we will touch these
56
        // relationships if they are configured to touch on any database updates.
57
        if ($touch && (count($changes['attached']) ||
58
                       count($changes['detached']))) {
59
            $this->touchIfTouching();
60
        }
61
62
        return $changes;
63
    }
64
65
    /**
66
     * Sync the intermediate tables with a list of IDs without detaching.
67
     *
68
     * @param  \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array  $ids
69
     * @return array
70
     */
71
    public function syncWithoutDetaching($ids)
72
    {
73
        return $this->sync($ids, false);
74
    }
75
76
    /**
77
     * Sync the intermediate tables with a list of IDs or collection of models.
78
     *
79
     * @param  \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array  $ids
80
     * @param  bool  $detaching
81
     * @return array
82
     */
83
    public function sync($ids, $detaching = true)
84
    {
85
        $changes = [
86
            'attached' => [], 'detached' => [], 'updated' => [],
87
        ];
88
89
        // First we need to attach any of the associated models that are not currently
90
        // in this joining table. We'll spin through the given IDs, checking to see
91
        // if they exist in the array of current ones, and if not we will insert.
92
        $current = $this->getCurrentlyAttachedPivots()
93
                        ->pluck($this->relatedPivotKey)->all();
94
95
        $detach = array_diff($current, array_keys(
96
            $records = $this->formatRecordsList($this->parseIds($ids))
97
        ));
98
99
        // Next, we will take the differences of the currents and given IDs and detach
100
        // all of the entities that exist in the "current" array but are not in the
101
        // array of the new IDs given to the method which will complete the sync.
102
        if ($detaching && count($detach) > 0) {
103
            $this->detach($detach);
104
105
            $changes['detached'] = $this->castKeys($detach);
106
        }
107
108
        // Now we are finally ready to attach the new records. Note that we'll disable
109
        // touching until after the entire operation is complete so we don't fire a
110
        // ton of touch operations until we are totally done syncing the records.
111
        $changes = array_merge(
112
            $changes, $this->attachNew($records, $current, false)
113
        );
114
115
        // Once we have finished attaching or detaching the records, we will see if we
116
        // have done any attaching or detaching, and if we have we will touch these
117
        // relationships if they are configured to touch on any database updates.
118
        if (count($changes['attached']) ||
119
            count($changes['updated'])) {
120
            $this->touchIfTouching();
121
        }
122
123
        return $changes;
124
    }
125
126
    /**
127
     * Format the sync / toggle record list so that it is keyed by ID.
128
     *
129
     * @param  array  $records
130
     * @return array
131
     */
132
    protected function formatRecordsList(array $records)
133
    {
134
        return collect($records)->mapWithKeys(function ($attributes, $id) {
135
            if (! is_array($attributes)) {
136
                [$id, $attributes] = [$attributes, []];
137
            }
138
139
            return [$id => $attributes];
140
        })->all();
141
    }
142
143
    /**
144
     * Attach all of the records that aren't in the given current records.
145
     *
146
     * @param  array  $records
147
     * @param  array  $current
148
     * @param  bool  $touch
149
     * @return array
150
     */
151
    protected function attachNew(array $records, array $current, $touch = true)
152
    {
153
        $changes = ['attached' => [], 'updated' => []];
154
155
        foreach ($records as $id => $attributes) {
156
            // If the ID is not in the list of existing pivot IDs, we will insert a new pivot
157
            // record, otherwise, we will just update this existing record on this joining
158
            // table, so that the developers will easily update these records pain free.
159
            if (! in_array($id, $current)) {
160
                $this->attach($id, $attributes, $touch);
161
162
                $changes['attached'][] = $this->castKey($id);
163
            }
164
165
            // Now we'll try to update an existing pivot record with the attributes that were
166
            // given to the method. If the model is actually updated we will add it to the
167
            // list of updated pivot records so we return them back out to the consumer.
168
            elseif (count($attributes) > 0 &&
169
                $this->updateExistingPivot($id, $attributes, $touch)) {
170
                $changes['updated'][] = $this->castKey($id);
171
            }
172
        }
173
174
        return $changes;
175
    }
176
177
    /**
178
     * Update an existing pivot record on the table.
179
     *
180
     * @param  mixed  $id
181
     * @param  array  $attributes
182
     * @param  bool  $touch
183
     * @return int
184
     */
185
    public function updateExistingPivot($id, array $attributes, $touch = true)
186
    {
187
        if ($this->using &&
188
            empty($this->pivotWheres) &&
189
            empty($this->pivotWhereIns) &&
190
            empty($this->pivotWhereNulls)) {
191
            return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch);
192
        }
193
194
        if (in_array($this->updatedAt(), $this->pivotColumns)) {
195
            $attributes = $this->addTimestampsToAttachment($attributes, true);
196
        }
197
198
        $updated = $this->newPivotStatementForId($this->parseId($id))->update(
199
            $this->castAttributes($attributes)
200
        );
201
202
        if ($touch) {
203
            $this->touchIfTouching();
204
        }
205
206
        return $updated;
207
    }
208
209
    /**
210
     * Update an existing pivot record on the table via a custom class.
211
     *
212
     * @param  mixed  $id
213
     * @param  array  $attributes
214
     * @param  bool  $touch
215
     * @return int
216
     */
217
    protected function updateExistingPivotUsingCustomClass($id, array $attributes, $touch)
218
    {
219
        $pivot = $this->getCurrentlyAttachedPivots()
220
                    ->where($this->foreignPivotKey, $this->parent->{$this->parentKey})
221
                    ->where($this->relatedPivotKey, $this->parseId($id))
222
                    ->first();
223
224
        $updated = $pivot ? $pivot->fill($attributes)->isDirty() : false;
225
226
        if ($updated) {
227
            $pivot->save();
228
        }
229
230
        if ($touch) {
231
            $this->touchIfTouching();
232
        }
233
234
        return (int) $updated;
235
    }
236
237
    /**
238
     * Attach a model to the parent.
239
     *
240
     * @param  mixed  $id
241
     * @param  array  $attributes
242
     * @param  bool  $touch
243
     * @return void
244
     */
245
    public function attach($id, array $attributes = [], $touch = true)
246
    {
247
        if ($this->using) {
248
            $this->attachUsingCustomClass($id, $attributes);
249
        } else {
250
            // Here we will insert the attachment records into the pivot table. Once we have
251
            // inserted the records, we will touch the relationships if necessary and the
252
            // function will return. We can parse the IDs before inserting the records.
253
            $this->newPivotStatement()->insert($this->formatAttachRecords(
254
                $this->parseIds($id), $attributes
255
            ));
256
        }
257
258
        if ($touch) {
259
            $this->touchIfTouching();
260
        }
261
    }
262
263
    /**
264
     * Attach a model to the parent using a custom class.
265
     *
266
     * @param  mixed  $id
267
     * @param  array  $attributes
268
     * @return void
269
     */
270
    protected function attachUsingCustomClass($id, array $attributes)
271
    {
272
        $records = $this->formatAttachRecords(
273
            $this->parseIds($id), $attributes
274
        );
275
276
        foreach ($records as $record) {
277
            $this->newPivot($record, false)->save();
278
        }
279
    }
280
281
    /**
282
     * Create an array of records to insert into the pivot table.
283
     *
284
     * @param  array  $ids
285
     * @param  array  $attributes
286
     * @return array
287
     */
288
    protected function formatAttachRecords($ids, array $attributes)
289
    {
290
        $records = [];
291
292
        $hasTimestamps = ($this->hasPivotColumn($this->createdAt()) ||
293
                  $this->hasPivotColumn($this->updatedAt()));
294
295
        // To create the attachment records, we will simply spin through the IDs given
296
        // and create a new record to insert for each ID. Each ID may actually be a
297
        // key in the array, with extra attributes to be placed in other columns.
298
        foreach ($ids as $key => $value) {
299
            $records[] = $this->formatAttachRecord(
300
                $key, $value, $attributes, $hasTimestamps
301
            );
302
        }
303
304
        return $records;
305
    }
306
307
    /**
308
     * Create a full attachment record payload.
309
     *
310
     * @param  int  $key
311
     * @param  mixed  $value
312
     * @param  array  $attributes
313
     * @param  bool  $hasTimestamps
314
     * @return array
315
     */
316
    protected function formatAttachRecord($key, $value, $attributes, $hasTimestamps)
317
    {
318
        [$id, $attributes] = $this->extractAttachIdAndAttributes($key, $value, $attributes);
319
320
        return array_merge(
321
            $this->baseAttachRecord($id, $hasTimestamps), $this->castAttributes($attributes)
322
        );
323
    }
324
325
    /**
326
     * Get the attach record ID and extra attributes.
327
     *
328
     * @param  mixed  $key
329
     * @param  mixed  $value
330
     * @param  array  $attributes
331
     * @return array
332
     */
333
    protected function extractAttachIdAndAttributes($key, $value, array $attributes)
334
    {
335
        return is_array($value)
336
                    ? [$key, array_merge($value, $attributes)]
337
                    : [$value, $attributes];
338
    }
339
340
    /**
341
     * Create a new pivot attachment record.
342
     *
343
     * @param  int  $id
344
     * @param  bool  $timed
345
     * @return array
346
     */
347
    protected function baseAttachRecord($id, $timed)
348
    {
349
        $record[$this->relatedPivotKey] = $id;
350
351
        $record[$this->foreignPivotKey] = $this->parent->{$this->parentKey};
352
353
        // If the record needs to have creation and update timestamps, we will make
354
        // them by calling the parent model's "freshTimestamp" method which will
355
        // provide us with a fresh timestamp in this model's preferred format.
356
        if ($timed) {
357
            $record = $this->addTimestampsToAttachment($record);
358
        }
359
360
        foreach ($this->pivotValues as $value) {
361
            $record[$value['column']] = $value['value'];
362
        }
363
364
        return $record;
365
    }
366
367
    /**
368
     * Set the creation and update timestamps on an attach record.
369
     *
370
     * @param  array  $record
371
     * @param  bool  $exists
372
     * @return array
373
     */
374
    protected function addTimestampsToAttachment(array $record, $exists = false)
375
    {
376
        $fresh = $this->parent->freshTimestamp();
377
378
        if ($this->using) {
379
            $pivotModel = new $this->using;
380
381
            $fresh = $fresh->format($pivotModel->getDateFormat());
382
        }
383
384
        if (! $exists && $this->hasPivotColumn($this->createdAt())) {
385
            $record[$this->createdAt()] = $fresh;
386
        }
387
388
        if ($this->hasPivotColumn($this->updatedAt())) {
389
            $record[$this->updatedAt()] = $fresh;
390
        }
391
392
        return $record;
393
    }
394
395
    /**
396
     * Determine whether the given column is defined as a pivot column.
397
     *
398
     * @param  string  $column
399
     * @return bool
400
     */
401
    public function hasPivotColumn($column)
402
    {
403
        return in_array($column, $this->pivotColumns);
404
    }
405
406
    /**
407
     * Detach models from the relationship.
408
     *
409
     * @param  mixed  $ids
410
     * @param  bool  $touch
411
     * @return int
412
     */
413
    public function detach($ids = null, $touch = true)
414
    {
415
        if ($this->using &&
416
            ! empty($ids) &&
417
            empty($this->pivotWheres) &&
418
            empty($this->pivotWhereIns) &&
419
            empty($this->pivotWhereNulls)) {
420
            $results = $this->detachUsingCustomClass($ids);
421
        } else {
422
            $query = $this->newPivotQuery();
423
424
            // If associated IDs were passed to the method we will only delete those
425
            // associations, otherwise all of the association ties will be broken.
426
            // We'll return the numbers of affected rows when we do the deletes.
427
            if (! is_null($ids)) {
428
                $ids = $this->parseIds($ids);
429
430
                if (empty($ids)) {
431
                    return 0;
432
                }
433
434
                $query->whereIn($this->relatedPivotKey, (array) $ids);
435
            }
436
437
            // Once we have all of the conditions set on the statement, we are ready
438
            // to run the delete on the pivot table. Then, if the touch parameter
439
            // is true, we will go ahead and touch all related models to sync.
440
            $results = $query->delete();
441
        }
442
443
        if ($touch) {
444
            $this->touchIfTouching();
445
        }
446
447
        return $results;
448
    }
449
450
    /**
451
     * Detach models from the relationship using a custom class.
452
     *
453
     * @param  mixed  $ids
454
     * @return int
455
     */
456
    protected function detachUsingCustomClass($ids)
457
    {
458
        $results = 0;
459
460
        foreach ($this->parseIds($ids) as $id) {
461
            $results += $this->newPivot([
462
                $this->foreignPivotKey => $this->parent->{$this->parentKey},
463
                $this->relatedPivotKey => $id,
464
            ], true)->delete();
465
        }
466
467
        return $results;
468
    }
469
470
    /**
471
     * Get the pivot models that are currently attached.
472
     *
473
     * @return \Illuminate\Support\Collection
474
     */
475
    protected function getCurrentlyAttachedPivots()
476
    {
477
        return $this->newPivotQuery()->get()->map(function ($record) {
478
            $class = $this->using ? $this->using : Pivot::class;
479
480
            $pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true);
481
482
            return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
483
        });
484
    }
485
486
    /**
487
     * Create a new pivot model instance.
488
     *
489
     * @param  array  $attributes
490
     * @param  bool  $exists
491
     * @return \Illuminate\Database\Eloquent\Relations\Pivot
492
     */
493
    public function newPivot(array $attributes = [], $exists = false)
494
    {
495
        $pivot = $this->related->newPivot(
496
            $this->parent, $attributes, $this->table, $exists, $this->using
497
        );
498
499
        return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey);
500
    }
501
502
    /**
503
     * Create a new existing pivot model instance.
504
     *
505
     * @param  array  $attributes
506
     * @return \Illuminate\Database\Eloquent\Relations\Pivot
507
     */
508
    public function newExistingPivot(array $attributes = [])
509
    {
510
        return $this->newPivot($attributes, true);
511
    }
512
513
    /**
514
     * Get a new plain query builder for the pivot table.
515
     *
516
     * @return \Illuminate\Database\Query\Builder
517
     */
518
    public function newPivotStatement()
519
    {
520
        return $this->query->getQuery()->newQuery()->from($this->table);
521
    }
522
523
    /**
524
     * Get a new pivot statement for a given "other" ID.
525
     *
526
     * @param  mixed  $id
527
     * @return \Illuminate\Database\Query\Builder
528
     */
529
    public function newPivotStatementForId($id)
530
    {
531
        return $this->newPivotQuery()->whereIn($this->relatedPivotKey, $this->parseIds($id));
532
    }
533
534
    /**
535
     * Create a new query builder for the pivot table.
536
     *
537
     * @return \Illuminate\Database\Query\Builder
538
     */
539
    public function newPivotQuery()
540
    {
541
        $query = $this->newPivotStatement();
542
543
        foreach ($this->pivotWheres as $arguments) {
544
            call_user_func_array([$query, 'where'], $arguments);
545
        }
546
547
        foreach ($this->pivotWhereIns as $arguments) {
548
            call_user_func_array([$query, 'whereIn'], $arguments);
549
        }
550
551
        foreach ($this->pivotWhereNulls as $arguments) {
552
            call_user_func_array([$query, 'whereNull'], $arguments);
553
        }
554
555
        return $query->where($this->foreignPivotKey, $this->parent->{$this->parentKey});
556
    }
557
558
    /**
559
     * Set the columns on the pivot table to retrieve.
560
     *
561
     * @param  array|mixed  $columns
562
     * @return $this
563
     */
564
    public function withPivot($columns)
565
    {
566
        $this->pivotColumns = array_merge(
567
            $this->pivotColumns, is_array($columns) ? $columns : func_get_args()
568
        );
569
570
        return $this;
571
    }
572
573
    /**
574
     * Get all of the IDs from the given mixed value.
575
     *
576
     * @param  mixed  $value
577
     * @return array
578
     */
579
    protected function parseIds($value)
580
    {
581
        if ($value instanceof Model) {
582
            return [$value->{$this->relatedKey}];
583
        }
584
585
        if ($value instanceof Collection) {
586
            return $value->pluck($this->relatedKey)->all();
587
        }
588
589
        if ($value instanceof BaseCollection) {
590
            return $value->toArray();
591
        }
592
593
        return (array) $value;
594
    }
595
596
    /**
597
     * Get the ID from the given mixed value.
598
     *
599
     * @param  mixed  $value
600
     * @return mixed
601
     */
602
    protected function parseId($value)
603
    {
604
        return $value instanceof Model ? $value->{$this->relatedKey} : $value;
605
    }
606
607
    /**
608
     * Cast the given keys to integers if they are numeric and string otherwise.
609
     *
610
     * @param  array  $keys
611
     * @return array
612
     */
613
    protected function castKeys(array $keys)
614
    {
615
        return array_map(function ($v) {
616
            return $this->castKey($v);
617
        }, $keys);
618
    }
619
620
    /**
621
     * Cast the given key to convert to primary key type.
622
     *
623
     * @param  mixed  $key
624
     * @return mixed
625
     */
626
    protected function castKey($key)
627
    {
628
        return $this->getTypeSwapValue(
629
            $this->related->getKeyType(),
630
            $key
631
        );
632
    }
633
634
    /**
635
     * Cast the given pivot attributes.
636
     *
637
     * @param  array  $attributes
638
     * @return array
639
     */
640
    protected function castAttributes($attributes)
641
    {
642
        return $this->using
643
                    ? $this->newPivot()->fill($attributes)->getAttributes()
644
                    : $attributes;
645
    }
646
647
    /**
648
     * Converts a given value to a given type value.
649
     *
650
     * @param  string  $type
651
     * @param  mixed  $value
652
     * @return mixed
653
     */
654
    protected function getTypeSwapValue($type, $value)
655
    {
656
        switch (strtolower($type)) {
657
            case 'int':
658
            case 'integer':
659
                return (int) $value;
660
            case 'real':
661
            case 'float':
662
            case 'double':
663
                return (float) $value;
664
            case 'string':
665
                return (string) $value;
666
            default:
667
                return $value;
668
        }
669
    }
670
}
671