From 4d31559afc43e97f6c115178f3576e2fae674b38 Mon Sep 17 00:00:00 2001 From: Italo Israel Baeza Cabrera Date: Sat, 12 Apr 2025 19:52:18 -0400 Subject: [PATCH 1/3] [12.x] Extends AsCollection for map --- .../Database/Eloquent/Casts/AsCollection.php | 42 +++++++++- .../Eloquent/Casts/AsEncryptedCollection.php | 46 +++++++++-- .../Database/DatabaseCustomCastsTest.php | 76 +++++++++++++++++++ .../EloquentModelEncryptedCastingTest.php | 53 +++++++++++++ 4 files changed, 207 insertions(+), 10 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php index e71df5a3df3c..5c03c06d5563 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use InvalidArgumentException; class AsCollection implements Castable @@ -21,6 +22,7 @@ public static function castUsing(array $arguments) { public function __construct(protected array $arguments) { + $this->arguments = array_pad(array_values($this->arguments), 2, ''); } public function get($model, $key, $value, $attributes) @@ -31,13 +33,29 @@ public function get($model, $key, $value, $attributes) $data = Json::decode($attributes[$key]); - $collectionClass = $this->arguments[0] ?? Collection::class; + $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; if (! is_a($collectionClass, Collection::class, true)) { throw new InvalidArgumentException('The provided class must extend ['.Collection::class.'].'); } - return is_array($data) ? new $collectionClass($data) : null; + if (! is_array($data)) { + return null; + } + + $instance = new $collectionClass($data); + + if (! $this->arguments[1]) { + return $instance; + } + + if (is_string($this->arguments[1])) { + $this->arguments[1] = Str::parseCallback($this->arguments[1]); + } + + return is_callable($this->arguments[1]) + ? $instance->map($this->arguments[1]) + : $instance->mapInto($this->arguments[1][0]); } public function set($model, $key, $value, $attributes) @@ -51,10 +69,26 @@ public function set($model, $key, $value, $attributes) * Specify the collection for the cast. * * @param class-string $class + * @param array{class-string, string}|class-string $map + * @return string + */ + public static function using($class, $map = null) + { + if (is_array($map) && is_callable($map)) { + $map = $map[0].'@'.$map[1]; + } + + return static::class.':'.implode(',', [$class, $map]); + } + + /** + * Specify the callback to map each item. + * + * @param array{class-string, string}|class-string $map * @return string */ - public static function using($class) + public static function map($map) { - return static::class.':'.$class; + return static::using('', $map); } } diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php index a192d2b0c121..6ee3c1ff8959 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Str; use InvalidArgumentException; class AsEncryptedCollection implements Castable @@ -22,21 +23,38 @@ public static function castUsing(array $arguments) { public function __construct(protected array $arguments) { + $this->arguments = array_pad(array_values($this->arguments), 2, ''); } public function get($model, $key, $value, $attributes) { - $collectionClass = $this->arguments[0] ?? Collection::class; + $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; if (! is_a($collectionClass, Collection::class, true)) { throw new InvalidArgumentException('The provided class must extend ['.Collection::class.'].'); } - if (isset($attributes[$key])) { - return new $collectionClass(Json::decode(Crypt::decryptString($attributes[$key]))); + if (! isset($attributes[$key])) { + return null; } - return null; + $instance = new $collectionClass(Json::decode(Crypt::decryptString($attributes[$key]))); + + if (! $this->arguments[1]) { + return $instance; + } + + if (! $this->arguments[1]) { + return $instance; + } + + if (is_string($this->arguments[1])) { + $this->arguments[1] = Str::parseCallback($this->arguments[1]); + } + + return is_callable($this->arguments[1]) + ? $instance->map($this->arguments[1]) + : $instance->mapInto($this->arguments[1][0]); } public function set($model, $key, $value, $attributes) @@ -54,10 +72,26 @@ public function set($model, $key, $value, $attributes) * Specify the collection for the cast. * * @param class-string $class + * @param array{class-string, string}|class-string $map + * @return string + */ + public static function using($class, $map = null) + { + if (is_array($map) && is_callable($map)) { + $map = $map[0].'@'.$map[1]; + } + + return static::class.':'.implode(',', [$class, $map]); + } + + /** + * Specify the callback to map each item. + * + * @param array{class-string, string}|class-string $map * @return string */ - public static function using($class) + public static function map($map) { - return static::class.':'.$class; + return static::using('', $map); } } diff --git a/tests/Integration/Database/DatabaseCustomCastsTest.php b/tests/Integration/Database/DatabaseCustomCastsTest.php index 0337edf26dcc..2071894a4d6b 100644 --- a/tests/Integration/Database/DatabaseCustomCastsTest.php +++ b/tests/Integration/Database/DatabaseCustomCastsTest.php @@ -7,8 +7,10 @@ use Illuminate\Database\Eloquent\Casts\AsStringable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Fluent; use Illuminate\Support\Stringable; class DatabaseCustomCastsTest extends DatabaseTestCase @@ -151,6 +153,68 @@ public function test_custom_casting_nullable_values() $model->array_object_json->toArray() ); } + + public function test_as_collection_with_map_into() + { + $model = new TestEloquentModelWithCustomCasts(); + $model->mergeCasts([ + 'collection' => AsCollection::map(Fluent::class), + ]); + + $model->setRawAttributes([ + 'collection' => json_encode([['foo' => 'bar']]), + ]); + + $this->assertInstanceOf(Fluent::class, $model->collection->first()); + $this->assertSame('bar', $model->collection->first()->foo); + } + + public function test_as_custom_collection_with_map_into() + { + $model = new TestEloquentModelWithCustomCasts(); + $model->mergeCasts([ + 'collection' => AsCollection::using(CustomCollection::class, Fluent::class), + ]); + + $model->setRawAttributes([ + 'collection' => json_encode([['foo' => 'bar']]), + ]); + + $this->assertInstanceOf(CustomCollection::class, $model->collection); + $this->assertInstanceOf(Fluent::class, $model->collection->first()); + $this->assertSame('bar', $model->collection->first()->foo); + } + + public function test_as_collection_with_map_callback(): void + { + $model = new TestEloquentModelWithCustomCasts(); + $model->mergeCasts([ + 'collection' => AsCollection::map([FluentWithCallback::class, 'make']), + ]); + + $model->setRawAttributes([ + 'collection' => json_encode([['foo' => 'bar']]), + ]); + + $this->assertInstanceOf(FluentWithCallback::class, $model->collection->first()); + $this->assertSame('bar', $model->collection->first()->foo); + } + + public function test_as_custom_collection_with_map_callback(): void + { + $model = new TestEloquentModelWithCustomCasts(); + $model->mergeCasts([ + 'collection' => AsCollection::using(CustomCollection::class, [FluentWithCallback::class, 'make']), + ]); + + $model->setRawAttributes([ + 'collection' => json_encode([['foo' => 'bar']]), + ]); + + $this->assertInstanceOf(CustomCollection::class, $model->collection); + $this->assertInstanceOf(FluentWithCallback::class, $model->collection->first()); + $this->assertSame('bar', $model->collection->first()->foo); + } } class TestEloquentModelWithCustomCasts extends Model @@ -197,3 +261,15 @@ class TestEloquentModelWithCustomCastsNullable extends Model 'stringable' => AsStringable::class, ]; } + +class FluentWithCallback extends Fluent +{ + public static function make(array $array) + { + return new static($array); + } +} + +class CustomCollection extends Collection +{ +} diff --git a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php index f4a00d3c98ca..91f782e9b4bd 100644 --- a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php +++ b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php @@ -11,6 +11,7 @@ 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 +232,58 @@ 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(12) + ->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' => AsEncryptedCollection::map(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->assertInstanceOf(Fluent::class, $subject->secret_collection->first()); + $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->assertInstanceOf(Fluent::class, $subject->secret_collection->first()); + $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') From 41d5dbecd04420c8b9c7a3c29456bc5852c836f9 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Fri, 18 Apr 2025 12:09:48 -0400 Subject: [PATCH 2/3] formatting --- .../Database/Eloquent/Casts/AsCollection.php | 24 ++++++++-------- .../Eloquent/Casts/AsEncryptedCollection.php | 28 ++++++++----------- .../Database/DatabaseCustomCastsTest.php | 4 +-- .../EloquentModelEncryptedCastingTest.php | 2 +- 4 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php index 5c03c06d5563..e36b13df2184 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php @@ -45,7 +45,7 @@ public function get($model, $key, $value, $attributes) $instance = new $collectionClass($data); - if (! $this->arguments[1]) { + if (! isset($this->arguments[1]) || ! $this->arguments[1]) { return $instance; } @@ -66,29 +66,29 @@ public function set($model, $key, $value, $attributes) } /** - * Specify the collection for the cast. + * Specify the type of object each item in the collection should be mapped to. * - * @param class-string $class * @param array{class-string, string}|class-string $map * @return string */ - public static function using($class, $map = null) + public static function of($map) { - if (is_array($map) && is_callable($map)) { - $map = $map[0].'@'.$map[1]; - } - - return static::class.':'.implode(',', [$class, $map]); + return static::using('', $map); } /** - * Specify the callback to map each item. + * Specify the collection type for the cast. * + * @param class-string $class * @param array{class-string, string}|class-string $map * @return string */ - public static function map($map) + public static function using($class, $map = null) { - return static::using('', $map); + if (is_array($map) && is_callable($map)) { + $map = $map[0].'@'.$map[1]; + } + + return static::class.':'.implode(',', [$class, $map]); } } diff --git a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php index 6ee3c1ff8959..b5912fa20b10 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsEncryptedCollection.php @@ -40,11 +40,7 @@ public function get($model, $key, $value, $attributes) $instance = new $collectionClass(Json::decode(Crypt::decryptString($attributes[$key]))); - if (! $this->arguments[1]) { - return $instance; - } - - if (! $this->arguments[1]) { + if (! isset($this->arguments[1]) || ! $this->arguments[1]) { return $instance; } @@ -69,29 +65,29 @@ public function set($model, $key, $value, $attributes) } /** - * Specify the collection for the cast. + * Specify the type of object each item in the collection should be mapped to. * - * @param class-string $class * @param array{class-string, string}|class-string $map * @return string */ - public static function using($class, $map = null) + public static function of($map) { - if (is_array($map) && is_callable($map)) { - $map = $map[0].'@'.$map[1]; - } - - return static::class.':'.implode(',', [$class, $map]); + return static::using('', $map); } /** - * Specify the callback to map each item. + * Specify the collection for the cast. * + * @param class-string $class * @param array{class-string, string}|class-string $map * @return string */ - public static function map($map) + public static function using($class, $map = null) { - return static::using('', $map); + if (is_array($map) && is_callable($map)) { + $map = $map[0].'@'.$map[1]; + } + + return static::class.':'.implode(',', [$class, $map]); } } diff --git a/tests/Integration/Database/DatabaseCustomCastsTest.php b/tests/Integration/Database/DatabaseCustomCastsTest.php index 2071894a4d6b..67162552610d 100644 --- a/tests/Integration/Database/DatabaseCustomCastsTest.php +++ b/tests/Integration/Database/DatabaseCustomCastsTest.php @@ -158,7 +158,7 @@ public function test_as_collection_with_map_into() { $model = new TestEloquentModelWithCustomCasts(); $model->mergeCasts([ - 'collection' => AsCollection::map(Fluent::class), + 'collection' => AsCollection::of(Fluent::class), ]); $model->setRawAttributes([ @@ -189,7 +189,7 @@ public function test_as_collection_with_map_callback(): void { $model = new TestEloquentModelWithCustomCasts(); $model->mergeCasts([ - 'collection' => AsCollection::map([FluentWithCallback::class, 'make']), + 'collection' => AsCollection::of([FluentWithCallback::class, 'make']), ]); $model->setRawAttributes([ diff --git a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php index 91f782e9b4bd..0c87c7440022 100644 --- a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php +++ b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php @@ -249,7 +249,7 @@ public function testAsEncryptedCollectionMap() $subject = new EncryptedCast; - $subject->mergeCasts(['secret_collection' => AsEncryptedCollection::map(Fluent::class)]); + $subject->mergeCasts(['secret_collection' => AsEncryptedCollection::of(Fluent::class)]); $subject->secret_collection = new Collection([new Fluent(['key1' => 'value1'])]); $subject->secret_collection->push(new Fluent(['key2' => 'value2'])); From 2c15ae3a3e25441fbdb70c10bfdabcc0d4585301 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Fri, 18 Apr 2025 12:12:46 -0400 Subject: [PATCH 3/3] fix signature --- tests/Integration/Database/DatabaseCustomCastsTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Database/DatabaseCustomCastsTest.php b/tests/Integration/Database/DatabaseCustomCastsTest.php index 67162552610d..ec14a2bf3c49 100644 --- a/tests/Integration/Database/DatabaseCustomCastsTest.php +++ b/tests/Integration/Database/DatabaseCustomCastsTest.php @@ -264,9 +264,9 @@ class TestEloquentModelWithCustomCastsNullable extends Model class FluentWithCallback extends Fluent { - public static function make(array $array) + public static function make($attributes = []) { - return new static($array); + return new static($attributes); } }