diff --git a/src/Illuminate/Database/Eloquent/Casts/AsCollectionMap.php b/src/Illuminate/Database/Eloquent/Casts/AsCollectionMap.php new file mode 100644 index 000000000000..63ef5ccd514b --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsCollectionMap.php @@ -0,0 +1,103 @@ +|(callable(mixed):TValue), 1?: string} $arguments + * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection, iterable> + */ + 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; + } +} diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollectionMap.php b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollectionMap.php new file mode 100644 index 000000000000..8964cf544c93 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollectionMap.php @@ -0,0 +1,104 @@ +|(callable(mixed):TValue)} $arguments + * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection, iterable> + */ + 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; + } +} diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 0d0fc454bf0b..92d028fe5cae 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -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; @@ -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); } diff --git a/tests/Integration/Database/DatabaseCustomCastsTest.php b/tests/Integration/Database/DatabaseCustomCastsTest.php index 0337edf26dcc..8f3d851112cf 100644 --- a/tests/Integration/Database/DatabaseCustomCastsTest.php +++ b/tests/Integration/Database/DatabaseCustomCastsTest.php @@ -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 { @@ -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 @@ -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); + } +} diff --git a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php index f4a00d3c98ca..d8c98accb802 100644 --- a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php +++ b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php @@ -6,11 +6,13 @@ use Illuminate\Database\Eloquent\Casts\ArrayObject; use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject; use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection; +use Illuminate\Database\Eloquent\Casts\AsEncryptedCollectionMap; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Fluent; use stdClass; class EloquentModelEncryptedCastingTest extends DatabaseTestCase @@ -231,6 +233,56 @@ public function testAsEncryptedCollection() $this->assertNull($subject->fresh()->secret_collection); } + public function testAsEncryptedCollectionMap() + { + $this->encrypter->expects('encryptString') + ->twice() + ->with('[{"key1":"value1"}]') + ->andReturn('encrypted-secret-collection-string-1'); + $this->encrypter->expects('encryptString') + ->times(10) + ->with('[{"key1":"value1"},{"key2":"value2"}]') + ->andReturn('encrypted-secret-collection-string-2'); + $this->encrypter->expects('decryptString') + ->once() + ->with('encrypted-secret-collection-string-2') + ->andReturn('[{"key1":"value1"},{"key2":"value2"}]'); + + $subject = new EncryptedCast; + + $subject->mergeCasts(['secret_collection' => AsEncryptedCollectionMap::into(Fluent::class)]); + + $subject->secret_collection = new Collection([new Fluent(['key1' => 'value1'])]); + $subject->secret_collection->push(new Fluent(['key2' => 'value2'])); + + $subject->save(); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertSame('value1', $subject->secret_collection->get(0)->key1); + $this->assertSame('value2', $subject->secret_collection->get(1)->key2); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => 'encrypted-secret-collection-string-2', + ]); + + $subject = $subject->fresh(); + + $this->assertInstanceOf(Collection::class, $subject->secret_collection); + $this->assertSame('value1', $subject->secret_collection->get(0)->key1); + $this->assertSame('value2', $subject->secret_collection->get(1)->key2); + + $subject->secret_collection = null; + $subject->save(); + + $this->assertNull($subject->secret_collection); + $this->assertDatabaseHas('encrypted_casts', [ + 'id' => $subject->id, + 'secret_collection' => null, + ]); + + $this->assertNull($subject->fresh()->secret_collection); + } + public function testAsEncryptedArrayObject() { $this->encrypter->expects('encryptString') diff --git a/types/Database/Eloquent/Casts/Castable.php b/types/Database/Eloquent/Casts/Castable.php index bbb934cbe4ee..60bf452722ad 100644 --- a/types/Database/Eloquent/Casts/Castable.php +++ b/types/Database/Eloquent/Casts/Castable.php @@ -12,6 +12,21 @@ \Illuminate\Database\Eloquent\Casts\AsCollection::castUsing([]), ); +assertType( + 'Illuminate\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Illuminate\Database\Eloquent\Casts\AsCollectionMap::castUsing([\Post::class]), +); + +assertType( + 'Illuminate\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Illuminate\Database\Eloquent\Casts\AsCollectionMap::castUsing([fn (): \Post => new \Post]), +); + +assertType( + 'Illuminate\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Illuminate\Database\Eloquent\Casts\AsCollectionMap::castUsing([\Post::class, 'make']), +); + assertType( 'Illuminate\Contracts\Database\Eloquent\CastsAttributes, iterable>', \Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject::castUsing([]), @@ -22,6 +37,21 @@ \Illuminate\Database\Eloquent\Casts\AsEncryptedCollection::castUsing([]), ); +assertType( + 'Illuminate\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Illuminate\Database\Eloquent\Casts\AsEncryptedCollectionMap::castUsing([\Post::class]), +); + +assertType( + 'Illuminate\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Illuminate\Database\Eloquent\Casts\AsEncryptedCollectionMap::castUsing([fn (): \Post => new \Post]), +); + +assertType( + 'Illuminate\Contracts\Database\Eloquent\CastsAttributes, iterable>', + \Illuminate\Database\Eloquent\Casts\AsEncryptedCollectionMap::castUsing([\Post::class, 'make']), +); + assertType( 'Illuminate\Contracts\Database\Eloquent\CastsAttributes, iterable>', \Illuminate\Database\Eloquent\Casts\AsEnumArrayObject::castUsing([\UserType::class]),