Skip to content

Commit

Permalink
[9.x] Add support for casting arrays containing enums (#45621)
Browse files Browse the repository at this point in the history
* Implement enum array casts

* Update typehint, fix merge error

* Style

* Update HasAttributes.php

* Style

* Style

* Remove unnecessary param

* Remove space

* Revert "Remove space"

This reverts commit c53c2ff.

* Try re-arranging commas

* wip

* wip

* wip

* Refactor enum array cast to AsEnumCollection

* Reset HasAttributes trait

* Update AsEnumCollection.php

* Style

* Update DatabaseEloquentModelTest.php

* Fix tests

* Style

* Fix typo

* add support for enum array object

Co-authored-by: Dries Vints <[email protected]>
Co-authored-by: Taylor Otwell <[email protected]>
  • Loading branch information
3 people authored Jan 19, 2023
1 parent 7a35c57 commit 0a90dfa
Show file tree
Hide file tree
Showing 6 changed files with 344 additions and 3 deletions.
82 changes: 82 additions & 0 deletions src/Illuminate/Database/Eloquent/Casts/AsEnumArrayObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace Illuminate\Database\Eloquent\Casts;

use BackedEnum;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Casts\ArrayObject;
use Illuminate\Support\Collection;

class AsEnumArrayObject implements Castable
{
/**
* Get the caster class to use when casting from / to this cast target.
*
* @param array $arguments
* @return object|string
*/
public static function castUsing(array $arguments)
{
return new class($arguments) implements CastsAttributes {
protected $arguments;

public function __construct(array $arguments)
{
$this->arguments = $arguments;
}

public function get($model, $key, $value, $attributes)
{
if (! isset($attributes[$key]) || is_null($attributes[$key])) {
return;
}

$data = json_decode($attributes[$key], true);

if (! is_array($data)) {
return;
}

$enumClass = $this->arguments[0];

return new ArrayObject((new Collection($data))->map(function ($value) use ($enumClass) {
return is_subclass_of($enumClass, BackedEnum::class)
? $enumClass::from($value)
: constant($enumClass.'::'.$value);
})->toArray());
}

public function set($model, $key, $value, $attributes)
{
if ($value === null) {
return [$key => null];
}

$storable = [];

foreach ($value as $enum) {
$storable[] = $this->getStorableEnumValue($enum);
}

return [$key => json_encode($storable)];
}

public function serialize($model, string $key, $value, array $attributes)
{
return (new Collection($value->getArrayCopy()))->map(function ($enum) {
return $this->getStorableEnumValue($enum);
})->toArray();
}

protected function getStorableEnumValue($enum)
{
if (is_string($enum) || is_int($enum)) {
return $enum;
}

return $enum instanceof BackedEnum ? $enum->value : $enum->name;
}
};
}
}
77 changes: 77 additions & 0 deletions src/Illuminate/Database/Eloquent/Casts/AsEnumCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

namespace Illuminate\Database\Eloquent\Casts;

use BackedEnum;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Collection;

class AsEnumCollection implements Castable
{
/**
* Get the caster class to use when casting from / to this cast target.
*
* @param array $arguments
* @return object|string
*/
public static function castUsing(array $arguments)
{
return new class($arguments) implements CastsAttributes {
protected $arguments;

public function __construct(array $arguments)
{
$this->arguments = $arguments;
}

public function get($model, $key, $value, $attributes)
{
if (! isset($attributes[$key]) || is_null($attributes[$key])) {
return;
}

$data = json_decode($attributes[$key], true);

if (! is_array($data)) {
return;
}

$enumClass = $this->arguments[0];

return (new Collection($data))->map(function ($value) use ($enumClass) {
return is_subclass_of($enumClass, BackedEnum::class)
? $enumClass::from($value)
: constant($enumClass.'::'.$value);
});
}

public function set($model, $key, $value, $attributes)
{
$value = $value !== null
? (new Collection($value))->map(function ($enum) {
return $this->getStorableEnumValue($enum);
})->toJson()
: null;

return [$key => $value];
}

public function serialize($model, string $key, $value, array $attributes)
{
return (new Collection($value))->map(function ($enum) {
return $this->getStorableEnumValue($enum);
})->toArray();
}

protected function getStorableEnumValue($enum)
{
if (is_string($enum) || is_int($enum)) {
return $enum;
}

return $enum instanceof BackedEnum ? $enum->value : $enum->name;
}
};
}
}
52 changes: 51 additions & 1 deletion tests/Database/DatabaseEloquentModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Connection;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\ConnectionResolverInterface as Resolver;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\ArrayObject;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Casts\AsCollection;
use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject;
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection;
use Illuminate\Database\Eloquent\Casts\AsEnumArrayObject;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Casts\AsStringable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\JsonEncodingException;
Expand All @@ -43,6 +45,10 @@
use ReflectionClass;
use stdClass;

if (PHP_VERSION_ID >= 80100) {
include 'Enums.php';
}

class DatabaseEloquentModelTest extends TestCase
{
use InteractsWithTime;
Expand Down Expand Up @@ -301,6 +307,48 @@ public function testDirtyOnCastedEncryptedArrayObject()
$this->assertTrue($model->isDirty('asEncryptedArrayObjectAttribute'));
}

/**
* @requires PHP >= 8.1
*/
public function testDirtyOnEnumCollectionObject()
{
$model = new EloquentModelCastingStub;
$model->setRawAttributes([
'asEnumCollectionAttribute' => json_encode(['draft', 'pending']),
]);
$model->syncOriginal();

$this->assertInstanceOf(BaseCollection::class, $model->asEnumCollectionAttribute);
$this->assertFalse($model->isDirty('asEnumCollectionAttribute'));

$model->asEnumCollectionAttribute = ['draft', 'pending'];
$this->assertFalse($model->isDirty('asEnumCollectionAttribute'));

$model->asEnumCollectionAttribute = ['draft', 'done'];
$this->assertTrue($model->isDirty('asEnumCollectionAttribute'));
}

/**
* @requires PHP >= 8.1
*/
public function testDirtyOnEnumArrayObject()
{
$model = new EloquentModelCastingStub;
$model->setRawAttributes([
'asEnumArrayObjectAttribute' => json_encode(['draft', 'pending']),
]);
$model->syncOriginal();

$this->assertInstanceOf(ArrayObject::class, $model->asEnumArrayObjectAttribute);
$this->assertFalse($model->isDirty('asEnumArrayObjectAttribute'));

$model->asEnumArrayObjectAttribute = ['draft', 'pending'];
$this->assertFalse($model->isDirty('asEnumArrayObjectAttribute'));

$model->asEnumArrayObjectAttribute = ['draft', 'done'];
$this->assertTrue($model->isDirty('asEnumArrayObjectAttribute'));
}

public function testCleanAttributes()
{
$model = new EloquentModelStub(['foo' => '1', 'bar' => 2, 'baz' => 3]);
Expand Down Expand Up @@ -2990,6 +3038,8 @@ class EloquentModelCastingStub extends Model
'asStringableAttribute' => AsStringable::class,
'asEncryptedCollectionAttribute' => AsEncryptedCollection::class,
'asEncryptedArrayObjectAttribute' => AsEncryptedArrayObject::class,
'asEnumCollectionAttribute' => AsEnumCollection::class.':'.StringStatus::class,
'asEnumArrayObjectAttribute' => AsEnumArrayObject::class.':'.StringStatus::class,
];

public function jsonAttributeValue()
Expand Down
42 changes: 42 additions & 0 deletions tests/Database/Enums.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Illuminate\Tests\Database;

use Illuminate\Contracts\Support\Arrayable;

enum StringStatus: string
{
case draft = 'draft';
case pending = 'pending';
case done = 'done';
}

enum IntegerStatus: int
{
case draft = 0;
case pending = 1;
case done = 2;
}

enum ArrayableStatus: string implements Arrayable
{
case pending = 'pending';
case done = 'done';

public function description(): string
{
return match ($this) {
self::pending => 'pending status description',
self::done => 'done status description'
};
}

public function toArray()
{
return [
'name' => $this->name,
'value' => $this->value,
'description' => $this->description(),
];
}
}
Loading

0 comments on commit 0a90dfa

Please sign in to comment.