• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

plank / laravel-metable / 8842761288

26 Apr 2024 03:37AM UTC coverage: 94.939% (-3.8%) from 98.76%
8842761288

Pull #102

github

frasmage
use prefix index on value instead of separate string_value column
Pull Request #102: V6

373 of 402 new or added lines in 25 files covered. (92.79%)

3 existing lines in 1 file now uncovered.

544 of 573 relevant lines covered (94.94%)

192.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.42
/src/Metable.php
1
<?php
2

3
namespace Plank\Metable;
4

5
use Illuminate\Contracts\Database\Eloquent\Castable;
6
use Illuminate\Database\Eloquent\Builder;
7
use Illuminate\Database\Eloquent\Collection;
8
use Illuminate\Database\Eloquent\Model;
9
use Illuminate\Database\Eloquent\Relations\MorphMany;
10
use Illuminate\Database\Query\JoinClause;
11
use Plank\Metable\DataType\HandlerInterface;
12
use Plank\Metable\DataType\Registry;
13
use Plank\Metable\Exceptions\CastException;
14

15
/**
16
 * Trait for giving Eloquent models the ability to handle Meta.
17
 *
18
 * @property Collection<Meta> $meta
19
 * @method static Builder whereHasMeta(string|string[] $key): void
20
 * @method static Builder whereDoesntHaveMeta(string|string[] $key)
21
 * @method static Builder whereHasMetaKeys(array $keys)
22
 * @method static Builder whereMeta(string $key, mixed $operator, mixed $value = null)
23
 * @method static Builder whereMetaNumeric(string $key, mixed $operator, mixed $value = null)
24
 * @method static Builder whereMetaIn(string $key, array $values)
25
 * @method static Builder whereMetaInNumeric(string $key, array $values)
26
 * @method static Builder whereMetaNotIn(string $key, array $values)
27
 * @method static Builder whereMetaNotInNumeric(string $key, array $values)
28
 * @method static Builder whereMetaBetween(string $key, mixed $min, mixed $max, bool $not = false)
29
 * @method static Builder whereMetaBetweenNumeric(string $key, mixed $min, mixed $max, bool $not = false)
30
 * @method static Builder whereMetaNotBetween(string $key, mixed $min, mixed $max)
31
 * @method static Builder whereMetaNotBetweenNumeric(string $key, mixed $min, mixed $max)
32
 * @method static Builder whereMetaIsNull(string $key)
33
 * @method static Builder whereMetaIsModel(string $key, Model|string $classOrInstance, null|int|string $id = null)
34
 * @method static Builder orderByMeta(string $key, string $direction = 'asc', bool $strict = false)
35
 * @method static Builder orderByMetaNumeric(string $key, string $direction = 'asc', bool $strict = false)
36
 */
37
trait Metable
38
{
39
    /**
40
     * @var Collection<Meta>
41
     */
42
    private $indexedMetaCollection;
43

44
    private array $mergedMetaCasts = [];
45

46

47
    public function __construct(array $attributes = [])
48
    {
49
        parent::__construct($attributes);
876✔
50
    }
51

52
    /**
53
     * Initialize the trait.
54
     *
55
     * @return void
56
     */
57
    public static function bootMetable(): void
58
    {
59
        // delete all attached meta on deletion
60
        static::deleted(function (self $model) {
876✔
61
            if (method_exists($model, 'isForceDeleting') && !$model->isForceDeleting()) {
18✔
62
                return;
6✔
63
            }
64
            $model->purgeMeta();
12✔
65
        });
876✔
66
    }
67

68
    /**
69
     * Relationship to the `Meta` model.
70
     *
71
     * @return MorphMany
72
     */
73
    public function meta(): MorphMany
74
    {
75
        return $this->morphMany($this->getMetaClassName(), 'metable');
816✔
76
    }
77

78
    /**
79
     * Add or update the value of the `Meta` at a given key.
80
     *
81
     * @param string $key
82
     * @param mixed $value
83
     */
84
    public function setMeta(string $key, mixed $value, bool $encrypt = false): void
85
    {
86
        if ($this->hasMeta($key)) {
786✔
87
            $meta = $this->getMetaRecord($key);
18✔
88
            $meta->setAttribute('value', $this->castMetaValueIfNeeded($key, $value));
18✔
89
            if ($encrypt || $this->hasEncryptedMetaCast($key)) {
18✔
NEW
90
                $meta->encrypt();
×
91
            }
92
            $meta->save();
18✔
93
        } else {
94
            $meta = $this->makeMeta($key, $value, $encrypt);
786✔
95
            $this->meta()->save($meta);
786✔
96
            $this->meta[] = $meta;
786✔
97
            $this->indexedMetaCollection[$key] = $meta;
786✔
98
        }
99
    }
100

101
    public function setMetaEncrypted(string $key, mixed $value): void
102
    {
103
        $this->setMeta($key, $value, true);
6✔
104
    }
105

106
    /**
107
     * Add or update many `Meta` values.
108
     *
109
     * @param array<string,mixed> $metaDictionary key-value pairs
110
     *
111
     * @return void
112
     */
113
    public function setManyMeta(array $metaDictionary): void
114
    {
115
        if (empty($metaDictionary)) {
12✔
116
            return;
6✔
117
        }
118

119
        $builder = $this->meta()->getBaseQuery();
6✔
120
        $needReload = $this->relationLoaded('meta');
6✔
121

122
        $metaModels = new Collection();
6✔
123
        foreach ($metaDictionary as $key => $value) {
6✔
124
            $metaModels[$key] = $this->makeMeta($key, $value);
6✔
125
        }
126

127
        $builder->upsert(
6✔
128
            $metaModels->map(function (Meta $model) {
6✔
129
                return $model->getAttributesForInsert();
6✔
130
            })->all(),
6✔
131
            ['metable_type', 'metable_id', 'key'],
6✔
132
            ['type', 'value', 'numeric_value', 'hmac']
6✔
133
        );
6✔
134

135
        if ($needReload) {
6✔
136
            // reload media relation and indexed cache
137
            $this->load('meta');
6✔
138
        }
139
    }
140

141
    /**
142
     * Replace all associated `Meta` with the keys and values provided.
143
     *
144
     * @param iterable $array
145
     *
146
     * @return void
147
     */
148
    public function syncMeta(iterable $array): void
149
    {
150
        $meta = [];
6✔
151

152
        foreach ($array as $key => $value) {
6✔
153
            $meta[$key] = $this->makeMeta($key, $value);
6✔
154
        }
155

156
        $this->meta()->delete();
6✔
157
        $this->meta()->saveMany($meta);
6✔
158

159
        // Update cached relationship.
160
        $collection = $this->getMetaInstance()->newCollection($meta);
6✔
161
        $this->setRelation('meta', $collection);
6✔
162
    }
163

164
    /**
165
     * Retrieve the value of the `Meta` at a given key.
166
     *
167
     * @param string $key
168
     * @param mixed $default Fallback value if no Meta is found.
169
     *
170
     * @return mixed
171
     */
172
    public function getMeta(string $key, mixed $default = null): mixed
173
    {
174
        if ($this->hasMeta($key)) {
612✔
175
            return $this->getMetaRecord($key)->getAttribute('value');
594✔
176
        }
177

178
        // If we have only one argument provided (i.e. default is not set)
179
        // then we check the model for the defaultMetaValues
180
        if (func_num_args() == 1 && $this->hasDefaultMetaValue($key)) {
24✔
181
            return $this->getDefaultMetaValue($key);
6✔
182
        }
183

184
        return $default;
18✔
185
    }
186

187
    /**
188
     * Check if the default meta array exists and the key is set
189
     *
190
     * @param string $key
191
     * @return boolean
192
     */
193
    protected function hasDefaultMetaValue(string $key): bool
194
    {
195
        return array_key_exists($key, $this->getAllDefaultMeta());
12✔
196
    }
197

198
    /**
199
     * Get the default meta value by key
200
     *
201
     * @param string $key
202
     * @return mixed
203
     */
204
    protected function getDefaultMetaValue(string $key): mixed
205
    {
206
        return $this->getAllDefaultMeta()[$key];
6✔
207
    }
208

209
    /**
210
     * Retrieve all meta attached to the model as a key/value map.
211
     *
212
     * @return \Illuminate\Support\Collection<int, mixed>
213
     */
214
    public function getAllMeta(): \Illuminate\Support\Collection
215
    {
216
        return collect($this->getAllDefaultMeta())->merge(
24✔
217
            $this->getMetaCollection()->toBase()->map(
24✔
218
                fn (Meta $meta) => $meta->getAttribute('value')
24✔
219
            )
24✔
220
        );
24✔
221
    }
222

223
    /**
224
     * Check if a `Meta` has been set at a given key.
225
     *
226
     * @param string $key
227
     *
228
     * @return bool
229
     */
230
    public function hasMeta(string $key): bool
231
    {
232
        return $this->getMetaCollection()->has($key);
822✔
233
    }
234

235
    /**
236
     * Delete the `Meta` at a given key.
237
     *
238
     * @param string $key
239
     *
240
     * @return void
241
     */
242
    public function removeMeta(string $key): void
243
    {
244
        if ($this->hasMeta($key)) {
18✔
245
            $this->getMetaCollection()->pull($key)->delete();
12✔
246
        }
247
    }
248

249
    /**
250
     * Delete many `Meta` keys.
251
     *
252
     * @param string[] $keys
253
     *
254
     * @return void
255
     */
256
    public function removeManyMeta(array $keys): void
257
    {
258
        $relation = $this->meta();
6✔
259
        $relation->newQuery()
6✔
260
            ->where($relation->getMorphType(), $this->getMorphClass())
6✔
261
            ->where($relation->getForeignKeyName(), $this->getKey())
6✔
262
            ->whereIn('key', $keys)
6✔
263
            ->delete();
6✔
264

265
        if ($this->relationLoaded('meta')) {
6✔
266
            $this->load('meta');
6✔
267
        }
268
    }
269

270
    /**
271
     * Delete all meta attached to the model.
272
     *
273
     * @return void
274
     */
275
    public function purgeMeta(): void
276
    {
277
        $this->meta()->delete();
18✔
278
        $this->setRelation('meta', $this->getMetaInstance()->newCollection());
18✔
279
    }
280

281
    /**
282
     * Retrieve the `Meta` model instance attached to a given key.
283
     *
284
     * @param string $key
285
     *
286
     * @return Meta|null
287
     */
288
    public function getMetaRecord(string $key): ?Meta
289
    {
290
        return $this->getMetaCollection()->get($key);
612✔
291
    }
292

293
    /**
294
     * Query scope to restrict the query to records which have `Meta` attached to a given key.
295
     *
296
     * If an array of keys is passed instead, will restrict the query to records having one or more Meta with any of the keys.
297
     *
298
     * @param Builder $q
299
     * @param string|string[] $key
300
     *
301
     * @return void
302
     */
303
    public function scopeWhereHasMeta(Builder $q, string|array $key): void
304
    {
305
        $q->whereHas('meta', function (Builder $q) use ($key) {
12✔
306
            $q->whereIn('key', (array)$key);
12✔
307
        });
12✔
308
    }
309

310
    /**
311
     * Query scope to restrict the query to records which doesnt have `Meta` attached to a given key.
312
     *
313
     * If an array of keys is passed instead, will restrict the query to records having one or more Meta with any of the keys.
314
     *
315
     * @param Builder $q
316
     * @param string|string[] $key
317
     *
318
     * @return void
319
     */
320
    public function scopeWhereDoesntHaveMeta(Builder $q, string|array $key): void
321
    {
322
        $q->whereDoesntHave('meta', function (Builder $q) use ($key) {
12✔
323
            $q->whereIn('key', (array)$key);
12✔
324
        });
12✔
325
    }
326

327
    /**
328
     * Query scope to restrict the query to records which have `Meta` for all of the provided keys.
329
     *
330
     * @param Builder $q
331
     * @param array $keys
332
     *
333
     * @return void
334
     */
335
    public function scopeWhereHasMetaKeys(Builder $q, array $keys): void
336
    {
337
        $q->whereHas(
6✔
338
            'meta',
6✔
339
            function (Builder $q) use ($keys) {
6✔
340
                $q->whereIn('key', $keys);
6✔
341
            },
6✔
342
            '=',
6✔
343
            count($keys)
6✔
344
        );
6✔
345
    }
346

347
    /**
348
     * Query scope to restrict the query to records which have `Meta` with a specific key and value.
349
     *
350
     * If the `$value` parameter is omitted, the $operator parameter will be considered the value.
351
     *
352
     * Values will be serialized to a string before comparison. If using the `>`, `>=`, `<`, or `<=` comparison operators, note that the value will be compared as a string. If comparing numeric values, use `Metable::scopeWhereMetaNumeric()` instead.
353
     *
354
     * @param Builder $q
355
     * @param string $key
356
     * @param mixed $operator
357
     * @param mixed $value
358
     *
359
     * @return void
360
     */
361
    public function scopeWhereMeta(
362
        Builder $q,
363
        string $key,
364
        mixed $operator,
365
        mixed $value = null
366
    ): void {
367
        // Shift arguments if no operator is present.
368
        if (!isset($value)) {
18✔
369
            $value = $operator;
12✔
370
            $operator = '=';
12✔
371
        }
372

373
        $stringValue = $this->valueToString($value);
18✔
374
        $q->whereHas(
18✔
375
            'meta',
18✔
376
            function (Builder $q) use ($key, $operator, $stringValue, $value) {
18✔
377
                $q->where('key', $key);
18✔
378
                $q->where('value', $operator, $stringValue);
18✔
379

380
                // null and empty string look the same in the database,
381
                // use the type column to differentiate.
382
                if ($value === null) {
18✔
383
                    $q->where('type', 'null');
12✔
384
                } elseif ($value === '') {
12✔
385
                    $q->where('type', '!=', 'null');
6✔
386
                }
387
            }
18✔
388
        );
18✔
389
    }
390

391
    /**
392
     * Query scope to restrict the query to records which have `Meta` with a specific key and numeric value.
393
     *
394
     * Performs numeric comparison instead of string comparison.
395
     *
396
     * @param Builder $q
397
     * @param string $key
398
     * @param mixed|string $operator
399
     * @param mixed $value
400
     *
401
     * @return void
402
     */
403
    public function scopeWhereMetaNumeric(
404
        Builder $q,
405
        string $key,
406
        mixed $operator,
407
        mixed $value = null
408
    ): void {
409
        // Shift arguments if no operator is present.
410
        if (!isset($value)) {
12✔
411
            $value = $operator;
12✔
412
            $operator = '=';
12✔
413
        }
414

415
        $numericValue = $this->valueToNumeric($value);
12✔
416
        $q->whereHas('meta', function (Builder $q) use ($key, $operator, $numericValue) {
6✔
417
            $q->where('key', $key);
6✔
418
            $q->where('numeric_value', $operator, $numericValue);
6✔
419
        });
6✔
420
    }
421

422
    public function scopeWhereMetaBetween(
423
        Builder $q,
424
        string $key,
425
        mixed $min,
426
        mixed $max,
427
        bool $not = false
428
    ): void {
429
        $min = $this->valueToString($min);
12✔
430
        $max = $this->valueToString($max);
12✔
431

432
        $q->whereHas(
12✔
433
            'meta',
12✔
434
            function (Builder $q) use ($key, $min, $max, $not) {
12✔
435
                $q->where('key', $key);
12✔
436
                $q->whereBetween('value', [$min, $max], 'and', $not);
12✔
437
            }
12✔
438
        );
12✔
439
    }
440

441
    public function scopeWhereMetaNotBetween(
442
        Builder $q,
443
        string $key,
444
        mixed $min,
445
        mixed $max,
446
    ): void {
447
        $this->scopeWhereMetaBetween($q, $key, $min, $max, true);
6✔
448
    }
449

450
    public function scopeWhereMetaBetweenNumeric(
451
        Builder $q,
452
        string $key,
453
        mixed $min,
454
        mixed $max,
455
        bool $not = false
456
    ): void {
457
        $min = $this->valueToNumeric($min);
12✔
458
        $max = $this->valueToNumeric($max);
12✔
459

460
        $q->whereHas('meta', function (Builder $q) use ($key, $min, $max, $not) {
12✔
461
            $q->where('key', $key);
12✔
462
            $q->whereBetween('numeric_value', [$min, $max], 'and', $not);
12✔
463
        });
12✔
464
    }
465

466
    public function scopeWhereMetaNotBetweenNumeric(
467
        Builder $q,
468
        string $key,
469
        mixed $min,
470
        mixed $max
471
    ): void {
472
        $this->scopeWhereMetaBetweenNumeric($q, $key, $min, $max, true);
6✔
473
    }
474

475
    /**
476
     * Query scope to restrict the query to records which have `Meta` with a specific key and a `null` value.
477
     * @param Builder $q
478
     * @param string $key
479
     * @return void
480
     */
481
    public function scopeWhereMetaIsNull(Builder $q, string $key): void
482
    {
483
        $this->scopeWhereMeta($q, $key, null);
6✔
484
    }
485

486
    public function scopeWhereMetaIsModel(
487
        Builder $q,
488
        string $key,
489
        Model|string $classOrInstance,
490
        null|int|string $id = null
491
    ): void {
492
        if ($classOrInstance instanceof Model) {
6✔
493
            $id = $classOrInstance->getKey();
6✔
494
            $classOrInstance = get_class($classOrInstance);
6✔
495
        }
496
        $value = $classOrInstance;
6✔
497
        if ($id) {
6✔
498
            $value .= '#' . $id;
6✔
499
        } else {
500
            $value .= '%';
6✔
501
        }
502

503
        $this->scopeWhereMeta($q, $key, 'like', $value);
6✔
504
    }
505

506
    /**
507
     * Query scope to restrict the query to records which have `Meta` with a specific key and a value within a specified set of options.
508
     *
509
     * @param Builder $q
510
     * @param string $key
511
     * @param array $values
512
     * @param bool $not
513
     *
514
     * @return void
515
     */
516
    public function scopeWhereMetaIn(
517
        Builder $q,
518
        string $key,
519
        array $values,
520
        bool $not = false
521
    ): void {
522
        $values = array_map(function ($val) use ($key) {
12✔
523
            return $this->valueToString($val);
12✔
524
        }, $values);
12✔
525

526
        $q->whereHas('meta', function (Builder $q) use ($key, $values, $not) {
12✔
527
            $q->where('key', $key);
12✔
528
            $q->whereIn('value', $values, 'and', $not);
12✔
529
        });
12✔
530
    }
531

532
    public function scopeWhereMetaNotIn(
533
        Builder $q,
534
        string $key,
535
        array $values
536
    ): void {
537
        $this->scopeWhereMetaIn($q, $key, $values, true);
6✔
538
    }
539

540
    public function scopeWhereMetaInNumeric(
541
        Builder $q,
542
        string $key,
543
        array $values,
544
        bool $not = false
545
    ): void {
546
        $values = array_map(function ($val) use ($key) {
12✔
547
            return $this->valueToNumeric($val);
12✔
548
        }, $values);
12✔
549

550
        $q->whereHas('meta', function (Builder $q) use ($key, $values, $not) {
12✔
551
            $q->where('key', $key);
12✔
552
            $q->whereIn('numeric_value', $values, 'and', $not);
12✔
553
        });
12✔
554
    }
555

556
    public function scopeWhereMetaNotInNumeric(
557
        Builder $q,
558
        string $key,
559
        array $values
560
    ): void {
561
        $this->scopeWhereMetaInNumeric($q, $key, $values, true);
6✔
562
    }
563

564
    /**
565
     * Query scope to order the query results by the string value of an attached meta.
566
     *
567
     * @param Builder $q
568
     * @param string $key
569
     * @param string $direction
570
     * @param bool $strict if true, will exclude records that do not have meta for the provided `$key`.
571
     *
572
     * @return void
573
     */
574
    public function scopeOrderByMeta(
575
        Builder $q,
576
        string $key,
577
        string $direction = 'asc',
578
        bool $strict = false
579
    ): void {
580
        $table = $this->joinMetaTable($q, $key, $strict ? 'inner' : 'left');
18✔
581
        $q->orderBy("{$table}.value", $direction);
18✔
582
    }
583

584
    /**
585
     * Query scope to order the query results by the numeric value of an attached meta.
586
     *
587
     * @param Builder $q
588
     * @param string $key
589
     * @param string $direction
590
     * @param bool $strict if true, will exclude records that do not have meta for the provided `$key`.
591
     *
592
     * @return void
593
     */
594
    public function scopeOrderByMetaNumeric(
595
        Builder $q,
596
        string $key,
597
        string $direction = 'asc',
598
        bool $strict = false
599
    ): void {
600
        $table = $this->joinMetaTable($q, $key, $strict ? 'inner' : 'left');
12✔
601
        $q->orderBy("{$table}.numeric_value", $direction);
12✔
602
    }
603

604
    /**
605
     * Join the meta table to the query.
606
     *
607
     * @param Builder $q
608
     * @param string $key
609
     * @param string $type Join type.
610
     *
611
     * @return string
612
     */
613
    private function joinMetaTable(Builder $q, string $key, string $type = 'left'): string
614
    {
615
        $relation = $this->meta();
30✔
616
        $metaTable = $relation->getRelated()->getTable();
30✔
617

618
        // Create an alias for the join, to allow the same
619
        // table to be joined multiple times for different keys.
620
        $alias = $metaTable . '__' . $key;
30✔
621

622
        // If no explicit select columns are specified,
623
        // avoid column collision by excluding meta table from select.
624
        if (!$q->getQuery()->columns) {
30✔
625
            $q->select($this->getTable() . '.*');
30✔
626
        }
627

628
        // Join the meta table to the query
629
        $q->join(
30✔
630
            "{$metaTable} as {$alias}",
30✔
631
            function (JoinClause $q) use ($relation, $key, $alias) {
30✔
632
                $q->on(
30✔
633
                    $relation->getQualifiedParentKeyName(),
30✔
634
                    '=',
30✔
635
                    $alias . '.' . $relation->getForeignKeyName()
30✔
636
                )
30✔
637
                    ->where($alias . '.key', '=', $key)
30✔
638
                    ->where(
30✔
639
                        $alias . '.' . $relation->getMorphType(),
30✔
640
                        '=',
30✔
641
                        $this->getMorphClass()
30✔
642
                    );
30✔
643
            },
30✔
644
            null,
30✔
645
            null,
30✔
646
            $type
30✔
647
        );
30✔
648

649
        // Return the alias so that the calling context can
650
        // reference the table.
651
        return $alias;
30✔
652
    }
653

654
    /**
655
     * fetch all meta for the model, if necessary.
656
     *
657
     * In Laravel versions prior to 5.3, relations that are lazy loaded by the
658
     * `getRelationFromMethod()` method ( invoked by the `__get()` magic method)
659
     * are not passed through the `setRelation()` method, so we load the relation
660
     * manually.
661
     *
662
     * @return mixed
663
     */
664
    private function getMetaCollection(): mixed
665
    {
666
        // load meta relation if not loaded.
667
        if (!$this->relationLoaded('meta')) {
834✔
668
            $this->setRelation('meta', $this->meta()->get());
798✔
669
        }
670

671
        // reindex by key for quicker lookups if necessary.
672
        if ($this->indexedMetaCollection === null) {
834✔
673
            $this->indexedMetaCollection = $this->meta->keyBy('key');
834✔
674
        }
675

676
        return $this->indexedMetaCollection;
834✔
677
    }
678

679
    /**
680
     * {@inheritdoc}
681
     */
682
    public function setRelation($relation, $value)
683
    {
684
        $this->indexedMetaCollection = null;
834✔
685
        return parent::setRelation($relation, $value);
834✔
686
    }
687

688
    /**
689
     * Set the entire relations array on the model.
690
     *
691
     * @param array $relations
692
     * @return $this
693
     */
694
    public function setRelations(array $relations)
695
    {
696
        if (isset($relations['meta'])) {
6✔
697
            // clear the indexed cache
698
            $this->indexedMetaCollection = null;
6✔
699
        }
700

701
        return parent::setRelations($relations);
6✔
702
    }
703

704
    /**
705
     * Retrieve the FQCN of the class to use for Meta models.
706
     *
707
     * @return class-string<Meta>
708
     */
709
    protected function getMetaClassName(): string
710
    {
711
        return config('metable.model', Meta::class);
816✔
712
    }
713

714
    protected function getMetaInstance(): Meta
715
    {
716
        $class = $this->getMetaClassName();
786✔
717
        return new $class;
786✔
718
    }
719

720
    /**
721
     * Create a new `Meta` record.
722
     *
723
     * @param string $key
724
     * @param mixed $value
725
     *
726
     * @return Meta
727
     */
728
    protected function makeMeta(
729
        string $key = null,
730
        mixed $value = null,
731
        bool $encrypt = false
732
    ): Meta {
733
        $meta = $this->getMetaInstance();
786✔
734
        $meta->key = $key;
786✔
735
        $meta->value = $this->castMetaValueIfNeeded($key, $value);
786✔
736
        $meta->metable_type = $this->getMorphClass();
786✔
737
        $meta->metable_id = $this->getKey();
786✔
738

739
        if ($encrypt || $this->hasEncryptedMetaCast($key)) {
786✔
740
            $meta->encrypt();
48✔
741
        }
742

743
        return $meta;
786✔
744
    }
745

746
    protected function getAllDefaultMeta(): array
747
    {
748
        return property_exists($this, 'defaultMetaValues')
30✔
749
            ? $this->defaultMetaValues
30✔
750
            : [];
30✔
751
    }
752

753
    protected function hasEncryptedMetaCast(string $key): bool
754
    {
755
        $cast = $this->getCastForMetaKey($key);
780✔
756
        return $cast === 'encrypted'
780✔
757
            || str_starts_with((string)$cast, 'encrypted:');
780✔
758
    }
759

760
    protected function castMetaValueIfNeeded(string $key, mixed $value): mixed
761
    {
762
        $cast = $this->getCastForMetaKey($key);
786✔
763
        if ($cast === null || $value === null) {
786✔
764
            return $value;
360✔
765
        }
766

767
        if ($cast == 'encrypted') {
426✔
768
            return $value;
12✔
769
        }
770

771
        if (str_starts_with($cast, 'encrypted:')) {
414✔
772
            $cast = substr($cast, 10);
24✔
773
        }
774

775
        return $this->castMetaValue($key, $value, $cast);
414✔
776
    }
777

778
    protected function castMetaValue(string $key, mixed $value, string $cast): mixed
779
    {
780
        if ($cast == 'array' || $cast == 'object') {
414✔
781
            $assoc = $cast == 'array';
36✔
782
            if (is_string($value)) {
36✔
783
                $value = json_decode($value, $assoc, 512, JSON_THROW_ON_ERROR);
12✔
784
            }
785
            return json_decode(
36✔
786
                json_encode($value, JSON_THROW_ON_ERROR),
36✔
787
                $assoc,
36✔
788
                512,
36✔
789
                JSON_THROW_ON_ERROR
36✔
790
            );
36✔
791
        }
792

793
        if ($cast == 'hashed') {
378✔
794
            return $this->castAttributeAsHashedString($key, $value);
12✔
795
        }
796

797
        if ($cast == 'collection' || str_starts_with($cast, 'collection:')) {
366✔
798
            if ($value instanceof \Illuminate\Support\Collection) {
36✔
799
                $collection = $value;
12✔
800
            } elseif ($value instanceof Model) {
24✔
801
                $collection = $value->newCollection([$value]);
12✔
802
            } else {
803
                $collection = collect($value);
12✔
804
            }
805

806
            if (str_starts_with($cast, 'collection:')) {
36✔
NEW
807
                $class = substr($cast, 11);
×
NEW
808
                $collection->each(function ($item) use ($class): void {
×
NEW
UNCOV
809
                    if (!$item instanceof $class) {
×
NEW
UNCOV
810
                        throw CastException::invalidClassCast($class, $item);
×
811
                    }
NEW
UNCOV
812
                });
×
813
            }
814

815
            return $collection;
36✔
816
        }
817

818
        if (class_exists($cast)
330✔
819
            && !is_a($cast, Castable::class, true)
330✔
820
            && $cast != 'datetime'
330✔
821
        ) {
NEW
822
            if ($value instanceof $cast) {
×
NEW
823
                return $value;
×
824
            }
825

NEW
826
            if (is_a($cast, Model::class, true)
×
NEW
827
                && (is_string($value) || is_int($value))
×
828
            ) {
NEW
829
                return $cast::find($value);
×
830
            }
831

NEW
832
            throw CastException::invalidClassCast($cast, $value);
×
833
        }
834

835
        // leverage Eloquent built-in casting functionality
836
        $castKey = "meta.$key";
330✔
837
        $this->casts[$castKey] = $cast;
330✔
838
        $value = $this->castAttribute($castKey, $value);
330✔
839

840
        // cleanup to avoid polluting the model's casts
841
        unset($this->casts[$castKey]);
330✔
842
        unset($this->attributeCastCache[$castKey]);
330✔
843
        unset($this->classCastCache[$castKey]);
330✔
844

845
        return $value;
330✔
846
    }
847

848
    protected function getCastForMetaKey(string $key): ?string
849
    {
850
        if (isset($this->mergedMetaCasts[$key])) {
786✔
851
            return $this->mergedMetaCasts[$key];
528✔
852
        }
853

854
        if (method_exists($this, 'metaCasts')) {
258✔
NEW
855
            $casts = $this->metaCasts();
×
NEW
856
            if (isset($casts[$key])) {
×
NEW
857
                return $casts[$key];
×
858
            }
859
        }
860

861
        if (property_exists($this, 'metaCasts')
258✔
862
            && isset($this->metaCasts[$key])
258✔
863
        ) {
864
            return $this->metaCasts[$key];
6✔
865
        }
866

867
        return null;
252✔
868
    }
869

870
    public function mergeMetaCasts(array $casts): void
871
    {
872
        $this->mergedMetaCasts = array_merge($this->mergedMetaCasts, $casts);
528✔
873
    }
874

875
    private function valueToString(mixed $value): string
876
    {
877
        return $this->getHandlerForValue($value)->serializeValue($value);
42✔
878
    }
879

880
    private function valueToNumeric(mixed $value): int|float
881
    {
882
        $numericValue = $this->getHandlerForValue($value)->getNumericValue($value);
36✔
883

884
        if ($numericValue === null) {
36✔
885
            throw new \InvalidArgumentException('Cannot convert to a numeric value');
6✔
886
        }
887

888
        return $numericValue;
30✔
889
    }
890

891
    private function getHandlerForValue(mixed $value): HandlerInterface
892
    {
893
        /** @var Registry $registry */
894
        $registry = app('metable.datatype.registry');
78✔
895
        return $registry->getHandlerForValue($value);
78✔
896
    }
897

898
    abstract public function getKey();
899

900
    abstract public function getMorphClass();
901

902
    abstract protected function castAttribute($key, $value);
903

904
    abstract public function morphMany($related, $name, $type = null, $id = null, $localKey = null);
905

906
    abstract public function load($relations);
907

908
    abstract public function relationLoaded($key);
909

910
    abstract protected function castAttributeAsHashedString($key, $value);
911
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc