Skip to content

[12.x] Adds Cast to Collect into classes or callback #55377

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
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
103 changes: 103 additions & 0 deletions src/Illuminate/Database/Eloquent/Casts/AsCollectionMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace Illuminate\Database\Eloquent\Casts;

use Closure;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;

class AsCollectionMap implements Castable
{
/**
* Get the caster class to use when casting from / to this cast target.
*
* @template TValue
*
* @param array{0: class-string<TValue>|(callable(mixed):TValue), 1?: string} $arguments
* @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection<array-key, TValue>, iterable<TValue>>
*/
public static function castUsing(array $arguments)
{
return new class($arguments) implements CastsAttributes
{
protected $callable;

public function __construct(array $arguments)
{
$arguments = array_values($arguments);

if (empty($arguments) || empty($arguments[0])) {
throw new InvalidArgumentException('No class or callable has been set to map the Collection.');
}

$this->callable = isset($arguments[1]) && ! is_array($arguments[0])
? [$arguments[0], $arguments[1]]
: $arguments[0];
}

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

$data = Json::decode($attributes[$key]);

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

if (is_callable($this->callable)) {
return Collection::make($data)->map($this->callable);
}

[$class, $method] = Str::parseCallback($this->callable);

return $method
? Collection::make($data)->map([$class, $method])
: Collection::make($data)->mapInto($class);
}

public function set($model, $key, $value, $attributes)
{
return [$key => Json::encode($value)];
}
};
}

/**
* Specify the class to map into each item in the Collection cast.
*
* @param class-string $class
* @return string
*/
public static function into($class)
{
return static::class.':'.$class;
}

/**
* Specify the callable to map each item in the Collection cast.
*
* @param callable-string|array{0: class-string, 1: string} $callback
* @param string|null $method
* @return string
*/
public static function using($callback, $method = null)
{
if ($callback instanceof Closure) {
throw new InvalidArgumentException('The provided callback should be a callable array or string.');
}

if (is_array($callback) && is_callable($callback)) {
[$callback, $method] = [$callback[0], $callback[1]];
}

return $method === null
? static::class.':'.$callback
: static::class.':'.$callback.'@'.$method;
}
}
104 changes: 104 additions & 0 deletions src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollectionMap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace Illuminate\Database\Eloquent\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use InvalidArgumentException;

class AsEncryptedCollectionMap extends AsCollectionMap
{
/**
* Get the caster class to use when casting from / to this cast target.
*
* @template TValue
*
* @param array{class-string<TValue>|(callable(mixed):TValue)} $arguments
* @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection<array-key, TValue>, iterable<TValue>>
*/
public static function castUsing(array $arguments)
{
return new class($arguments) implements CastsAttributes
{
protected $callable;

public function __construct(array $arguments)
{
$arguments = array_values($arguments);

if (empty($arguments) || empty($arguments[0])) {
throw new InvalidArgumentException('No class or callable has been set to map the Collection.');
}

$this->callable = isset($arguments[1]) && ! is_array($arguments[0])
? [$arguments[0], $arguments[1]]
: $arguments[0];
}

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

$data = Json::decode(Crypt::decryptString($attributes[$key]));

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

if (is_callable($this->callable)) {
return Collection::make($data)->map($this->callable);
}

[$class, $method] = Str::parseCallback($this->callable);

return $method
? Collection::make($data)->map([$class, $method])
: Collection::make($data)->mapInto($class);
}

public function set($model, $key, $value, $attributes)
{
return is_null($value)
? null
: [$key => Crypt::encryptString(Json::encode($value))];
}
};
}

/**
* Specify the class to map into each item in the Collection cast.
*
* @param class-string $class
* @return string
*/
public static function into($class)
{
return static::class.':'.$class;
}

/**
* Specify the callable to map each item in the Collection cast.
*
* @param callable-string|array{0: class-string, 1: string} $callback
* @param string|null $method
* @return string
*/
public static function using($callback, $method = null)
{
if ($callback instanceof Closure) {
throw new InvalidArgumentException('The provided callback should be a callable array or string.');
}

if (is_array($callback) && is_callable($callback)) {
[$callback, $method] = [$callback[0], $callback[1]];
}

return $method === null
? static::class.':'.$callback
: static::class.':'.$callback.'@'.$method;
}
}
6 changes: 4 additions & 2 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Casts\AsCollection;
use Illuminate\Database\Eloquent\Casts\AsCollectionMap;
use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject;
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection;
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollectionMap;
use Illuminate\Database\Eloquent\Casts\AsEnumArrayObject;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Casts\Attribute;
Expand Down Expand Up @@ -2237,11 +2239,11 @@ public function originalIsEquivalent($key)
} elseif ($this->hasCast($key, static::$primitiveCastTypes)) {
return $this->castAttribute($key, $attribute) ===
$this->castAttribute($key, $original);
} elseif ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class])) {
} elseif ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class, AsCollectionMap::class])) {
return $this->fromJson($attribute) === $this->fromJson($original);
} elseif ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsEnumArrayObject::class, AsEnumCollection::class])) {
return $this->fromJson($attribute) === $this->fromJson($original);
} elseif ($this->isClassCastable($key) && $original !== null && Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class])) {
} elseif ($this->isClassCastable($key) && $original !== null && Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class, AsEncryptedCollectionMap::class])) {
if (empty(static::currentEncrypter()->getPreviousKeys())) {
return $this->fromEncryptedString($attribute) === $this->fromEncryptedString($original);
}
Expand Down
86 changes: 86 additions & 0 deletions tests/Integration/Database/DatabaseCustomCastsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

use Illuminate\Database\Eloquent\Casts\AsArrayObject;
use Illuminate\Database\Eloquent\Casts\AsCollection;
use Illuminate\Database\Eloquent\Casts\AsCollectionMap;
use Illuminate\Database\Eloquent\Casts\AsStringable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Fluent;
use Illuminate\Support\Stringable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;

class DatabaseCustomCastsTest extends DatabaseTestCase
{
Expand Down Expand Up @@ -151,6 +155,73 @@ public function test_custom_casting_nullable_values()
$model->array_object_json->toArray()
);
}

public function test_custom_casting_map_into_collection(): void
{
$model = new TestEloquentModelWithCustomCastsNullable();
$model->mergeCasts(['collection' => AsCollectionMap::into(Fluent::class)]);
$model->fill([
'collection' => [
['name' => 'Taylor'],
],
]);

$fluent = $model->collection->first();

$this->assertInstanceOf(Fluent::class, $fluent);
$this->assertSame('Taylor', $fluent->name);
}

public static function provideMapArguments()
{
return [
[TestCollectionMapCallable::class],
[TestCollectionMapCallable::class, 'make'],
[TestCollectionMapCallable::class.'@'.'make'],
];
}

#[DataProvider('provideMapArguments')]
public function test_custom_casting_map_collection($class, $method = null): void
{
$model = new TestEloquentModelWithCustomCastsNullable();
$model->mergeCasts(['collection' => AsCollectionMap::using($class, $method)]);
$model->fill([
'collection' => [
['name' => 'Taylor'],
],
]);

$result = $model->collection->first();

$this->assertInstanceOf(TestCollectionMapCallable::class, $result);
$this->assertSame('Taylor', $result->name);
}

public function test_custom_casting_map_collection_throw_when_no_arguments(): void
{
$model = new TestEloquentModelWithCustomCastsNullable();
$model->mergeCasts(['collection' => AsCollectionMap::class]);

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('No class or callable has been set to map the Collection.');

$model->fill([
'collection' => [
['name' => 'Taylor'],
],
]);
}

public function test_custom_casting_map_collection_throw_when_using_closure(): void
{
$model = new TestEloquentModelWithCustomCastsNullable();

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The provided callback should be a callable array or string.');

$model->mergeCasts(['collection' => AsCollectionMap::using(TestCollectionMapCallable::make(...))]);
}
}

class TestEloquentModelWithCustomCasts extends Model
Expand Down Expand Up @@ -197,3 +268,18 @@ class TestEloquentModelWithCustomCastsNullable extends Model
'stringable' => AsStringable::class,
];
}

class TestCollectionMapCallable
{
public $name;

public function __construct($data)
{
$this->name = $data['name'];
}

public static function make($data)
{
return new static($data);
}
}
Loading
Loading