Skip to content

[12.x] Add enum functionality to morph map #56057

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 48 additions & 14 deletions src/Illuminate/Database/Eloquent/Relations/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Illuminate\Database\Eloquent\Relations;

use BackedEnum;
use Closure;
use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract;
use Illuminate\Database\Eloquent\Builder;
Expand All @@ -13,11 +14,16 @@
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Support\Traits\ForwardsCalls;
use Illuminate\Support\Traits\Macroable;
use InvalidArgumentException;
use ReflectionEnum;

use function Illuminate\Support\enum_value;

/**
* @template TRelatedModel of \Illuminate\Database\Eloquent\Model
* @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
* @template TResult
* @template TMorphMapEnum of \BackedEnum
*
* @mixin \Illuminate\Database\Eloquent\Builder<TRelatedModel>
*/
Expand Down Expand Up @@ -65,7 +71,7 @@ abstract class Relation implements BuilderContract
/**
* An array to map morph names to their class names in the database.
*
* @var array<string, class-string<\Illuminate\Database\Eloquent\Model>>
* @var array<string, class-string<\Illuminate\Database\Eloquent\Model>>|TMorphMapMapEnum
*/
public static $morphMap = [];

Expand Down Expand Up @@ -452,7 +458,7 @@ public static function requiresMorphMap()
/**
* Define the morph map for polymorphic relations and require all morphed models to be explicitly mapped.
*
* @param array<string, class-string<\Illuminate\Database\Eloquent\Model>> $map
* @param array<string, class-string<\Illuminate\Database\Eloquent\Model>>|class-string<\BackedEnum> $map
* @param bool $merge
* @return array
*/
Expand All @@ -466,15 +472,25 @@ public static function enforceMorphMap(array $map, $merge = true)
/**
* Set or get the morph map for polymorphic relations.
*
* @param array<string, class-string<\Illuminate\Database\Eloquent\Model>>|null $map
* @param array<string, class-string<\Illuminate\Database\Eloquent\Model>>|class-string<\BackedEnum>|null $map
* @param bool $merge
* @return array<string, class-string<\Illuminate\Database\Eloquent\Model>>
* @return array<string, class-string<\Illuminate\Database\Eloquent\Model>>|class-string<\BackedEnum>
*/
public static function morphMap(?array $map = null, $merge = true)
public static function morphMap(array|string|null $map = null, $merge = null)
{
$map = static::buildMorphMapFromModels($map);

if (is_array($map)) {
$merge ??= is_string($map) ? false : true;

if (is_string($map) && (! enum_exists($map) || ! (new ReflectionEnum($map))->isBacked())) {
throw new InvalidArgumentException('Mapping must be a an array, null, or a backed enum!');
}

if ((is_string($map) && $merge) || ($map !== null && is_string(static::$morphMap) && $merge)) {
throw new InvalidArgumentException('Enum morph maps cannot be merged!');
}

if (is_array($map) || is_string($map)) {
static::$morphMap = $merge && static::$morphMap
? $map + static::$morphMap
: $map;
Expand All @@ -486,12 +502,12 @@ public static function morphMap(?array $map = null, $merge = true)
/**
* Builds a table-keyed array from model class names.
*
* @param list<class-string<\Illuminate\Database\Eloquent\Model>>|null $models
* @return array<string, class-string<\Illuminate\Database\Eloquent\Model>>|null
* @param list<class-string<\Illuminate\Database\Eloquent\Model>>|class-string<\BackedEnum>|null $models
* @return array<string, class-string<\Illuminate\Database\Eloquent\Model>>|class-string<\BackedEnum>|null
*/
protected static function buildMorphMapFromModels(?array $models = null)
protected static function buildMorphMapFromModels(array|string|null $models = null)
{
if (is_null($models) || ! array_is_list($models)) {
if (is_null($models) || is_string($models) || ! array_is_list($models)) {
return $models;
}

Expand All @@ -503,22 +519,40 @@ protected static function buildMorphMapFromModels(?array $models = null)
/**
* Get the model associated with a custom polymorphic type.
*
* @param string $alias
* @param string|key-of<TMorphMapEnum> $alias
* @return class-string<\Illuminate\Database\Eloquent\Model>|null
*/
public static function getMorphedModel($alias)
{
if (is_string(static::$morphMap)) {
$reflection = new ReflectionEnum(static::$morphMap);

if ($reflection->hasCase($alias)) {
return $reflection->getCase($alias)->getValue()->value;
}

return null;
}

return static::$morphMap[$alias] ?? null;
}

/**
* Get the alias associated with a custom polymorphic class.
*
* @param class-string<\Illuminate\Database\Eloquent\Model> $className
* @return int|string
* @param class-string<\Illuminate\Database\Eloquent\Model>|value-of<TMorphMapEnum> $className
* @return int|string|key-of<TMorphMapEnum>
*/
public static function getMorphAlias(string $className)
public static function getMorphAlias(string|BackedEnum $className)
{
if (is_string(static::$morphMap)) {
return static::$morphMap::tryFrom(enum_value($className))->name ?? $className;
}

if (! is_string($className)) {
throw new InvalidArgumentException('Class name cannot be an enum value when the morph map is not an enum!');
}

return array_search($className, static::$morphMap, strict: true) ?: $className;
}

Expand Down
122 changes: 122 additions & 0 deletions tests/Integration/Database/EloquentRelationMorphMapTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

namespace Illuminate\Tests\Integration\Database;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\TestWith;

class EloquentRelationMorphMapTest extends DatabaseTestCase
{
protected function setUp(): void
{
parent::setUp();

Relation::morphMap([], false);
}

#[TestWith([MorphMap::class])]
public function testMorphMapEnum(string $fqn)
{
$map = Relation::morphMap($fqn);
$this->assertSame($fqn, $map);
$this->assertSame($fqn, Relation::morphMap());
}

#[TestWith([MorphMap::class])]
public function testMorphMapEnumMerge(string $fqn)
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Enum morph maps cannot be merged!');
Relation::morphMap($fqn, true);
}

#[TestWith([MorphMap::class, ['a' => A::class]])]
public function testMorphMapEnumMergePrevious(string $first, array $second)
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Enum morph maps cannot be merged!');
Relation::morphMap($first);
Relation::morphMap($second, true);
}

#[TestWith(['gibberish'])]
public function testMorphMapArbitraryString(string $input)
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Mapping must be a an array, null, or a backed enum!');
Relation::morphMap($input);
}

#[TestWith([[A::class, B::class], ['a_s' => A::class, 'b_s' => B::class]])]
public function testMorphMapList(array $input, array $output)
{
$map = Relation::morphMap($input);
$this->assertSame($output, $map);
$this->assertSame($output, Relation::morphMap());
}

#[TestWith([['a' => A::class]])]
public function testMorphMapAssociativeArray(array $map)
{
$res = Relation::morphMap($map);
$this->assertSame($res, $map);
$this->assertSame($res, Relation::morphMap());
}

#[TestWith(['A', A::class])]
#[TestWith(['C', null])]
public function testGetMorphedModelEnum(string $alias, ?string $model)
{
Relation::morphMap(MorphMap::class);
$this->assertSame($model, Relation::getMorphedModel($alias));
}

#[TestWith(['a', A::class])]
#[TestWith(['b', null])]
public function testGetMorphedModelArray(string $alias, ?string $model)
{
Relation::morphMap(['a' => A::class]);
$this->assertSame($model, Relation::getMorphedModel($alias));
}

#[TestWith([A::class, 'A'])]
#[TestWith([MorphMap::A, 'A'])]
#[TestWith(['C', 'C'])]
public function testGetMorphAliasEnum(string|MorphMap $className, string|MorphMap|null $alias)
{
Relation::morphMap(MorphMap::class);
$this->assertSame($alias, Relation::getMorphAlias($className));
}

#[TestWith([A::class, 'a'])]
#[TestWith(['gibberish', 'gibberish'])]
public function testGetMorphAliasArray(string $className, string $alias)
{
Relation::morphMap(['a' => A::class]);
$this->assertSame($alias, Relation::getMorphAlias($className));
}

#[TestWith([MorphMap::A, A::class])]
public function testGetMorphAliasArrayEnumClassName(string|MorphMap $className, string $alias)
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Class name cannot be an enum value when the morph map is not an enum!');
Relation::morphMap(['a' => A::class]);
Relation::getMorphAlias($className);
}
}

enum MorphMap: string
{
case A = A::class;
case B = B::class;
}

class A extends Model
{
}
class B extends Model
{
}
Loading