• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Info updated!

plank / laravel-metable / 12172137688

05 Dec 2024 02:34AM UTC coverage: 99.535%. Remained the same
12172137688

push

github

web-flow
Merge pull request #111 from plank/php-84

Add PHP 8.4 support

642 of 645 relevant lines covered (99.53%)

191.03 hits per line

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

99.41
/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);
972✔
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) {
972✔
61
            if (method_exists($model, 'isForceDeleting') && !$model->isForceDeleting()) {
18✔
62
                return;
6✔
63
            }
64
            $model->purgeMeta();
12✔
65
        });
972✔
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');
912✔
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)) {
882✔
87
            $meta = $this->getMetaRecord($key);
24✔
88
            $meta->setAttribute('value', $this->castMetaValueIfNeeded($key, $value));
24✔
89
            if ($encrypt || $this->hasEncryptedMetaCast($key)) {
24✔
90
                $meta->encrypt();
×
91
            }
92
            $meta->save();
24✔
93
        } else {
94
            $meta = $this->makeMeta($key, $value, $encrypt);
882✔
95
            $this->meta()->save($meta);
828✔
96
            $this->meta[] = $meta;
828✔
97
            $this->indexedMetaCollection[$key] = $meta;
828✔
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)) {
654✔
175
            return $this->getMetaRecord($key)->getAttribute('value');
630✔
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)) {
30✔
181
            return $this->getDefaultMetaValue($key);
6✔
182
        }
183

184
        return $default;
24✔
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());
18✔
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);
918✔
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);
648✔
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)) {
24✔
369
            $value = $operator;
18✔
370
            $operator = '=';
18✔
371
        }
372

373
        $stringValue = $this->valueToString($value);
24✔
374
        $q->whereHas(
24✔
375
            'meta',
24✔
376
            function (Builder $q) use ($key, $operator, $stringValue, $value) {
24✔
377
                $q->where('key', $key);
24✔
378
                [
24✔
379
                    $needPartialMatch,
24✔
380
                    $needExactMatch
24✔
381
                ] = $this->determineQueryValueMatchTypes($q, [$stringValue]);
24✔
382

383
                if ($needPartialMatch) {
24✔
384
                    $indexLength = (int)config('metable.stringValueIndexLength', 255);
24✔
385
                    $q->where(
24✔
386
                        $q->raw("SUBSTR(value, 1, $indexLength)"),
24✔
387
                        $operator,
24✔
388
                        substr($stringValue, 0, $indexLength)
24✔
389
                    );
24✔
390
                }
391

392
                if ($needExactMatch) {
24✔
393
                    $q->where('value', $operator, $stringValue);
12✔
394
                }
395

396
                // null and empty string look the same in the database,
397
                // use the type column to differentiate.
398
                if ($value === null) {
24✔
399
                    $q->where('type', 'null');
12✔
400
                } elseif ($value === '') {
18✔
401
                    $q->where('type', '!=', 'null');
6✔
402
                }
403
            }
24✔
404
        );
24✔
405
    }
406

407
    /**
408
     * Query scope to restrict the query to records which have `Meta` with a specific key and numeric value.
409
     *
410
     * Performs numeric comparison instead of string comparison.
411
     *
412
     * @param Builder $q
413
     * @param string $key
414
     * @param mixed|string $operator
415
     * @param mixed $value
416
     *
417
     * @return void
418
     */
419
    public function scopeWhereMetaNumeric(
420
        Builder $q,
421
        string $key,
422
        mixed $operator,
423
        mixed $value = null
424
    ): void {
425
        // Shift arguments if no operator is present.
426
        if (!isset($value)) {
12✔
427
            $value = $operator;
12✔
428
            $operator = '=';
12✔
429
        }
430

431
        $numericValue = $this->valueToNumeric($value);
12✔
432
        $q->whereHas('meta', function (Builder $q) use ($key, $operator, $numericValue) {
6✔
433
            $q->where('key', $key);
6✔
434
            $q->where('numeric_value', $operator, $numericValue);
6✔
435
        });
6✔
436
    }
437

438
    public function scopeWhereMetaBetween(
439
        Builder $q,
440
        string $key,
441
        mixed $min,
442
        mixed $max,
443
        bool $not = false
444
    ): void {
445
        $min = $this->valueToString($min);
18✔
446
        $max = $this->valueToString($max);
18✔
447

448
        $q->whereHas(
18✔
449
            'meta',
18✔
450
            function (Builder $q) use ($key, $min, $max, $not) {
18✔
451
                $q->where('key', $key);
18✔
452

453
                [
18✔
454
                    $needPartialMatch,
18✔
455
                    $needExactMatch
18✔
456
                ] = $this->determineQueryValueMatchTypes($q, [$min, $max]);
18✔
457

458
                if ($needPartialMatch) {
18✔
459
                    $indexLength = (int)config('metable.stringValueIndexLength', 255);
18✔
460
                    $q->whereBetween(
18✔
461
                        $q->raw("SUBSTR(value, 1, $indexLength)"),
18✔
462
                        [
18✔
463
                            substr($min, 0, $indexLength),
18✔
464
                            substr($max, 0, $indexLength)
18✔
465
                        ],
18✔
466
                        'and',
18✔
467
                        $not
18✔
468
                    );
18✔
469
                }
470
                if ($needExactMatch) {
18✔
471
                    $q->whereBetween('value', [$min, $max], 'and', $not);
6✔
472
                }
473
            }
18✔
474
        );
18✔
475
    }
476

477
    public function scopeWhereMetaNotBetween(
478
        Builder $q,
479
        string $key,
480
        mixed $min,
481
        mixed $max,
482
    ): void {
483
        $this->scopeWhereMetaBetween($q, $key, $min, $max, true);
6✔
484
    }
485

486
    public function scopeWhereMetaBetweenNumeric(
487
        Builder $q,
488
        string $key,
489
        mixed $min,
490
        mixed $max,
491
        bool $not = false
492
    ): void {
493
        $min = $this->valueToNumeric($min);
12✔
494
        $max = $this->valueToNumeric($max);
12✔
495

496
        $q->whereHas('meta', function (Builder $q) use ($key, $min, $max, $not) {
12✔
497
            $q->where('key', $key);
12✔
498
            $q->whereBetween('numeric_value', [$min, $max], 'and', $not);
12✔
499
        });
12✔
500
    }
501

502
    public function scopeWhereMetaNotBetweenNumeric(
503
        Builder $q,
504
        string $key,
505
        mixed $min,
506
        mixed $max
507
    ): void {
508
        $this->scopeWhereMetaBetweenNumeric($q, $key, $min, $max, true);
6✔
509
    }
510

511
    /**
512
     * Query scope to restrict the query to records which have `Meta` with a specific key and a `null` value.
513
     * @param Builder $q
514
     * @param string $key
515
     * @return void
516
     */
517
    public function scopeWhereMetaIsNull(Builder $q, string $key): void
518
    {
519
        $this->scopeWhereMeta($q, $key, null);
6✔
520
    }
521

522
    public function scopeWhereMetaIsModel(
523
        Builder $q,
524
        string $key,
525
        Model|string $classOrInstance,
526
        null|int|string $id = null
527
    ): void {
528
        if ($classOrInstance instanceof Model) {
6✔
529
            $id = $classOrInstance->getKey();
6✔
530
            $classOrInstance = get_class($classOrInstance);
6✔
531
        }
532
        $value = $classOrInstance;
6✔
533
        if ($id) {
6✔
534
            $value .= '#' . $id;
6✔
535
        } else {
536
            $value .= '%';
6✔
537
        }
538

539
        $this->scopeWhereMeta($q, $key, 'like', $value);
6✔
540
    }
541

542
    /**
543
     * Query scope to restrict the query to records which have `Meta` with a specific key and a value within a specified set of options.
544
     *
545
     * @param Builder $q
546
     * @param string $key
547
     * @param array $values
548
     * @param bool $not
549
     *
550
     * @return void
551
     */
552
    public function scopeWhereMetaIn(
553
        Builder $q,
554
        string $key,
555
        array $values,
556
        bool $not = false
557
    ): void {
558
        $values = array_map(function ($val) use ($key) {
18✔
559
            return $this->valueToString($val);
18✔
560
        }, $values);
18✔
561

562
        $q->whereHas('meta', function (Builder $q) use ($key, $values, $not) {
18✔
563
            $q->where('key', $key);
18✔
564

565
            [
18✔
566
                $needPartialMatch,
18✔
567
                $needExactMatch
18✔
568
            ] = $this->determineQueryValueMatchTypes($q, $values);
18✔
569
            if ($needPartialMatch) {
18✔
570
                $indexLength = (int)config('metable.stringValueIndexLength', 255);
18✔
571
                $q->whereIn(
18✔
572
                    $q->raw("SUBSTR(value, 1, $indexLength)"),
18✔
573
                    array_map(
18✔
574
                        fn ($val) => substr($val, 0, $indexLength),
18✔
575
                        $values
18✔
576
                    ),
18✔
577
                    'and',
18✔
578
                    $not
18✔
579
                );
18✔
580
            }
581

582
            if ($needExactMatch) {
18✔
583
                $q->whereIn('value', $values, 'and', $not);
6✔
584
            }
585
        });
18✔
586
    }
587

588
    public function scopeWhereMetaNotIn(
589
        Builder $q,
590
        string $key,
591
        array $values
592
    ): void {
593
        $this->scopeWhereMetaIn($q, $key, $values, true);
6✔
594
    }
595

596
    public function scopeWhereMetaInNumeric(
597
        Builder $q,
598
        string $key,
599
        array $values,
600
        bool $not = false
601
    ): void {
602
        $values = array_map(function ($val) use ($key) {
12✔
603
            return $this->valueToNumeric($val);
12✔
604
        }, $values);
12✔
605

606
        $q->whereHas('meta', function (Builder $q) use ($key, $values, $not) {
12✔
607
            $q->where('key', $key);
12✔
608
            $q->whereIn('numeric_value', $values, 'and', $not);
12✔
609
        });
12✔
610
    }
611

612
    public function scopeWhereMetaNotInNumeric(
613
        Builder $q,
614
        string $key,
615
        array $values
616
    ): void {
617
        $this->scopeWhereMetaInNumeric($q, $key, $values, true);
6✔
618
    }
619

620
    /**
621
     * Query scope to order the query results by the string value of an attached meta.
622
     *
623
     * @param Builder $q
624
     * @param string $key
625
     * @param string $direction
626
     * @param bool $strict if true, will exclude records that do not have meta for the provided `$key`.
627
     *
628
     * @return void
629
     */
630
    public function scopeOrderByMeta(
631
        Builder $q,
632
        string $key,
633
        string $direction = 'asc',
634
        bool $strict = false
635
    ): void {
636
        $table = $this->joinMetaTable($q, $key, $strict ? 'inner' : 'left');
24✔
637

638
        [$needPartialMatch] = $this->determineQueryValueMatchTypes($q, []);
24✔
639
        if ($needPartialMatch) {
24✔
640
            $indexLength = (int)config('metable.stringValueIndexLength', 255);
24✔
641
            $q->orderBy(
24✔
642
                $q->raw("SUBSTR({$table}.value, 1, $indexLength)"),
24✔
643
                $direction
24✔
644
            );
24✔
645
        }
646

647
        $q->orderBy("{$table}.value", $direction);
24✔
648
    }
649

650
    /**
651
     * Query scope to order the query results by the numeric value of an attached meta.
652
     *
653
     * @param Builder $q
654
     * @param string $key
655
     * @param string $direction
656
     * @param bool $strict if true, will exclude records that do not have meta for the provided `$key`.
657
     *
658
     * @return void
659
     */
660
    public function scopeOrderByMetaNumeric(
661
        Builder $q,
662
        string $key,
663
        string $direction = 'asc',
664
        bool $strict = false
665
    ): void {
666
        $table = $this->joinMetaTable($q, $key, $strict ? 'inner' : 'left');
12✔
667
        $q->orderBy("{$table}.numeric_value", $direction);
12✔
668
    }
669

670
    /**
671
     * Join the meta table to the query.
672
     *
673
     * @param Builder $q
674
     * @param string $key
675
     * @param string $type Join type.
676
     *
677
     * @return string
678
     */
679
    private function joinMetaTable(Builder $q, string $key, string $type = 'left'): string
680
    {
681
        $relation = $this->meta();
36✔
682
        $metaTable = $relation->getRelated()->getTable();
36✔
683

684
        // Create an alias for the join, to allow the same
685
        // table to be joined multiple times for different keys.
686
        $alias = $metaTable . '__' . $key;
36✔
687

688
        // If no explicit select columns are specified,
689
        // avoid column collision by excluding meta table from select.
690
        if (!$q->getQuery()->columns) {
36✔
691
            $q->select($this->getTable() . '.*');
36✔
692
        }
693

694
        // Join the meta table to the query
695
        $q->join(
36✔
696
            "{$metaTable} as {$alias}",
36✔
697
            function (JoinClause $q) use ($relation, $key, $alias) {
36✔
698
                $q->on(
36✔
699
                    $relation->getQualifiedParentKeyName(),
36✔
700
                    '=',
36✔
701
                    $alias . '.' . $relation->getForeignKeyName()
36✔
702
                )
36✔
703
                    ->where($alias . '.key', '=', $key)
36✔
704
                    ->where(
36✔
705
                        $alias . '.' . $relation->getMorphType(),
36✔
706
                        '=',
36✔
707
                        $this->getMorphClass()
36✔
708
                    );
36✔
709
            },
36✔
710
            null,
36✔
711
            null,
36✔
712
            $type
36✔
713
        );
36✔
714

715
        // Return the alias so that the calling context can
716
        // reference the table.
717
        return $alias;
36✔
718
    }
719

720
    /**
721
     * fetch all meta for the model, if necessary.
722
     *
723
     * In Laravel versions prior to 5.3, relations that are lazy loaded by the
724
     * `getRelationFromMethod()` method ( invoked by the `__get()` magic method)
725
     * are not passed through the `setRelation()` method, so we load the relation
726
     * manually.
727
     *
728
     * @return mixed
729
     */
730
    private function getMetaCollection(): mixed
731
    {
732
        // load meta relation if not loaded.
733
        if (!$this->relationLoaded('meta')) {
930✔
734
            $this->setRelation('meta', $this->meta()->get());
894✔
735
        }
736

737
        // reindex by key for quicker lookups if necessary.
738
        if ($this->indexedMetaCollection === null) {
930✔
739
            $this->indexedMetaCollection = $this->meta->keyBy('key');
930✔
740
        }
741

742
        return $this->indexedMetaCollection;
930✔
743
    }
744

745
    /**
746
     * {@inheritdoc}
747
     */
748
    public function setRelation($relation, $value)
749
    {
750
        $this->indexedMetaCollection = null;
930✔
751
        return parent::setRelation($relation, $value);
930✔
752
    }
753

754
    /**
755
     * Set the entire relations array on the model.
756
     *
757
     * @param array $relations
758
     * @return $this
759
     */
760
    public function setRelations(array $relations)
761
    {
762
        if (isset($relations['meta'])) {
6✔
763
            // clear the indexed cache
764
            $this->indexedMetaCollection = null;
6✔
765
        }
766

767
        return parent::setRelations($relations);
6✔
768
    }
769

770
    /**
771
     * Retrieve the FQCN of the class to use for Meta models.
772
     *
773
     * @return class-string<Meta>
774
     */
775
    protected function getMetaClassName(): string
776
    {
777
        return config('metable.model', Meta::class);
912✔
778
    }
779

780
    protected function getMetaInstance(): Meta
781
    {
782
        $class = $this->getMetaClassName();
882✔
783
        return new $class;
882✔
784
    }
785

786
    /**
787
     * Create a new `Meta` record.
788
     *
789
     * @param string $key
790
     * @param mixed $value
791
     *
792
     * @return Meta
793
     */
794
    protected function makeMeta(
795
        string $key = null,
796
        mixed $value = null,
797
        bool $encrypt = false
798
    ): Meta {
799
        $meta = $this->getMetaInstance();
882✔
800
        $meta->key = $key;
882✔
801
        $meta->value = $this->castMetaValueIfNeeded($key, $value);
882✔
802
        $meta->metable_type = $this->getMorphClass();
828✔
803
        $meta->metable_id = $this->getKey();
828✔
804

805
        if ($encrypt || $this->hasEncryptedMetaCast($key)) {
828✔
806
            $meta->encrypt();
48✔
807
        }
808

809
        return $meta;
828✔
810
    }
811

812
    protected function getAllDefaultMeta(): array
813
    {
814
        return property_exists($this, 'defaultMetaValues')
36✔
815
            ? $this->defaultMetaValues
36✔
816
            : [];
36✔
817
    }
818

819
    protected function hasEncryptedMetaCast(string $key): bool
820
    {
821
        $cast = $this->getCastForMetaKey($key);
822✔
822
        return $cast === 'encrypted'
822✔
823
            || str_starts_with((string)$cast, 'encrypted:');
822✔
824
    }
825

826
    protected function castMetaValueIfNeeded(string $key, mixed $value): mixed
827
    {
828
        $cast = $this->getCastForMetaKey($key);
882✔
829
        if ($cast === null || $value === null) {
882✔
830
            return $value;
366✔
831
        }
832

833
        if ($cast == 'encrypted') {
516✔
834
            return $value;
12✔
835
        }
836

837
        if (str_starts_with($cast, 'encrypted:')) {
504✔
838
            $cast = substr($cast, 10);
24✔
839
        }
840

841
        return $this->castMetaValue($key, $value, $cast);
504✔
842
    }
843

844
    protected function castMetaValue(string $key, mixed $value, string $cast): mixed
845
    {
846
        if ($cast == 'array' || $cast == 'object') {
504✔
847
            return $this->castMetaToJson($cast, $value);
36✔
848
        }
849

850
        if ($cast == 'hashed') {
468✔
851
            return $this->castAttributeAsHashedString($key, $value);
12✔
852
        }
853

854
        if ($cast == 'collection' || str_starts_with($cast, 'collection:')) {
456✔
855
            return $this->castMetaToCollection($cast, $value);
78✔
856
        }
857

858
        if (class_exists($cast)
378✔
859
            && !is_a($cast, Castable::class, true)
378✔
860
            && $cast != 'datetime'
378✔
861
        ) {
862
            return $this->castMetaToClass($value, $cast);
48✔
863
        }
864

865
        // leverage Eloquent built-in casting functionality
866
        $castKey = "meta.$key";
330✔
867
        $this->casts[$castKey] = $cast;
330✔
868
        $value = $this->castAttribute($castKey, $value);
330✔
869

870
        // cleanup to avoid polluting the model's casts
871
        unset($this->casts[$castKey]);
330✔
872
        unset($this->attributeCastCache[$castKey]);
330✔
873
        unset($this->classCastCache[$castKey]);
330✔
874

875
        return $value;
330✔
876
    }
877

878
    protected function getCastForMetaKey(string $key): ?string
879
    {
880
        if (isset($this->mergedMetaCasts[$key])) {
882✔
881
            return $this->mergedMetaCasts[$key];
624✔
882
        }
883

884
        if (method_exists($this, 'metaCasts')) {
264✔
885
            $casts = $this->metaCasts();
252✔
886
            if (isset($casts[$key])) {
252✔
887
                return $casts[$key];
6✔
888
            }
889
        }
890

891
        if (property_exists($this, 'metaCasts')
264✔
892
            && isset($this->metaCasts[$key])
264✔
893
        ) {
894
            return $this->metaCasts[$key];
6✔
895
        }
896

897
        return null;
258✔
898
    }
899

900
    public function mergeMetaCasts(array $casts): void
901
    {
902
        $this->mergedMetaCasts = array_merge($this->mergedMetaCasts, $casts);
624✔
903
    }
904

905
    private function valueToString(mixed $value): string
906
    {
907
        return $this->getHandlerForValue($value)->serializeValue($value);
48✔
908
    }
909

910
    private function valueToNumeric(mixed $value): int|float
911
    {
912
        $numericValue = $this->getHandlerForValue($value)->getNumericValue($value);
36✔
913

914
        if ($numericValue === null) {
36✔
915
            throw new \InvalidArgumentException('Cannot convert to a numeric value');
6✔
916
        }
917

918
        return $numericValue;
30✔
919
    }
920

921
    private function getHandlerForValue(mixed $value): HandlerInterface
922
    {
923
        /** @var Registry $registry */
924
        $registry = app('metable.datatype.registry');
84✔
925
        return $registry->getHandlerForValue($value);
84✔
926
    }
927

928
    /**
929
     * @param Builder $q
930
     * @param string[] $stringValues
931
     * @return array{bool, bool} [needPartialMatch, needExactMatch]
932
     */
933
    protected function determineQueryValueMatchTypes(
934
        Builder $q,
935
        array $stringValues
936
    ): array {
937
        $driver = $q->getConnection()->getDriverName();
66✔
938
        $indexLength = (int)config('metable.stringValueIndexLength', 255);
66✔
939

940
        // only sqlite and pgsql support expression indexes, which must be partially matched
941
        // mysql and mariadb support prefix indexes, which works with the entire value
942
        // sqlserv does not support any substring indexing mechanism
943
        if (!in_array($driver, ['sqlite', 'pgsql'])) {
66✔
944
            return [false, true];
×
945
        }
946
        // if any value is longer than the index length, we need to do both a
947
        // substring match to leverage the index and an exact match to avoid false positives
948
        foreach ($stringValues as $stringValue) {
66✔
949
            if (strlen($stringValue) > $indexLength) {
48✔
950
                return [true, true];
12✔
951
            }
952
        }
953

954
        // if all values are shorter than the index length,
955
        // we only need to do a substring match
956
        return [true, false];
66✔
957
    }
958

959
    abstract public function getKey();
960

961
    abstract public function getMorphClass();
962

963
    abstract protected function castAttribute($key, $value);
964

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

967
    abstract public function load($relations);
968

969
    abstract public function relationLoaded($key);
970

971
    abstract protected function castAttributeAsHashedString($key, $value);
972

973
    /**
974
     * @param mixed $value
975
     * @param string $cast
976
     * @return Collection|\Illuminate\Support\Collection
977
     */
978
    protected function castMetaToCollection(string $cast, mixed $value): \Illuminate\Support\Collection
979
    {
980
        if ($value instanceof \Illuminate\Support\Collection) {
78✔
981
            $collection = $value;
42✔
982
        } elseif ($value instanceof Model) {
36✔
983
            $collection = $value->newCollection([$value]);
18✔
984
        } elseif (is_iterable($value)) {
18✔
985
            $isEloquentModels = true;
18✔
986
            $notEmpty = false;
18✔
987

988
            foreach ($value as $item) {
18✔
989
                $notEmpty = true;
18✔
990
                if (!$item instanceof Model) {
18✔
991
                    $isEloquentModels = false;
12✔
992
                    break;
12✔
993
                }
994
            }
995
            $collection = $isEloquentModels && $notEmpty
18✔
996
                ? $value[0]->newCollection($value)
6✔
997
                : collect($value);
12✔
998
        }
999

1000
        if (str_starts_with($cast, 'collection:')) {
78✔
1001
            $class = substr($cast, 11);
42✔
1002
            $collection->each(function ($item) use ($class): void {
42✔
1003
                if (!$item instanceof $class) {
42✔
1004
                    throw CastException::invalidClassCast($class, $item);
18✔
1005
                }
1006
            });
42✔
1007
        }
1008

1009
        return $collection;
60✔
1010
    }
1011

1012
    /**
1013
     * @param string $cast
1014
     * @param mixed $value
1015
     * @return mixed
1016
     * @throws \JsonException
1017
     */
1018
    protected function castMetaToJson(string $cast, mixed $value): mixed
1019
    {
1020
        $assoc = $cast == 'array';
36✔
1021
        if (is_string($value)) {
36✔
1022
            $value = json_decode($value, $assoc, 512, JSON_THROW_ON_ERROR);
12✔
1023
        }
1024
        return json_decode(
36✔
1025
            json_encode($value, JSON_THROW_ON_ERROR),
36✔
1026
            $assoc,
36✔
1027
            512,
36✔
1028
            JSON_THROW_ON_ERROR
36✔
1029
        );
36✔
1030
    }
1031

1032
    /**
1033
     * @param mixed $value
1034
     * @param string $cast
1035
     * @return mixed
1036
     */
1037
    protected function castMetaToClass(mixed $value, string $cast): mixed
1038
    {
1039
        if ($value instanceof $cast) {
48✔
1040
            return $value;
6✔
1041
        }
1042

1043
        if (is_a($cast, Model::class, true)
42✔
1044
            && (is_string($value) || is_int($value))
42✔
1045
        ) {
1046
            return $cast::findOrFail($value);
18✔
1047
        }
1048

1049
        throw CastException::invalidClassCast($cast, $value);
24✔
1050
    }
1051
}
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

© 2025 Coveralls, Inc