Skip to content

Commit 87b7117

Browse files
[12.x] Adds Collection mapping into classes
1 parent 64fb35b commit 87b7117

File tree

6 files changed

+344
-2
lines changed

6 files changed

+344
-2
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Eloquent\Casts;
4+
5+
use Closure;
6+
use Illuminate\Contracts\Database\Eloquent\Castable;
7+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
8+
use Illuminate\Support\Collection;
9+
use Illuminate\Support\Str;
10+
use InvalidArgumentException;
11+
12+
class AsCollectionMap implements Castable
13+
{
14+
/**
15+
* Get the caster class to use when casting from / to this cast target.
16+
*
17+
* @template TValue
18+
*
19+
* @param array{0: class-string<TValue>|(callable(mixed):TValue), 1?: string} $arguments
20+
* @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection<array-key, TValue>, iterable<TValue>>
21+
*/
22+
public static function castUsing(array $arguments)
23+
{
24+
return new class($arguments) implements CastsAttributes
25+
{
26+
public function __construct(protected array $arguments)
27+
{
28+
}
29+
30+
public function get($model, $key, $value, $attributes)
31+
{
32+
if (! isset($attributes[$key])) {
33+
return;
34+
}
35+
36+
$data = Json::decode($attributes[$key]);
37+
38+
if (!is_array($data)) {
39+
return null;
40+
}
41+
42+
$this->arguments[0] ??= '';
43+
44+
if (is_callable($this->arguments[0])) {
45+
return Collection::make($data)->map($this->arguments[0]);
46+
}
47+
48+
[$class, $method] = Str::parseCallback($this->arguments[0]);
49+
50+
if ($method) {
51+
return Collection::make($data)->map([$class, $method]);
52+
}
53+
54+
if ($class) {
55+
return Collection::make($data)->mapInto($class);
56+
}
57+
58+
throw new InvalidArgumentException('No class or callable has been set to map the Collection.');
59+
}
60+
61+
public function set($model, $key, $value, $attributes)
62+
{
63+
return [$key => Json::encode($value)];
64+
}
65+
};
66+
}
67+
68+
/**
69+
* Specify the class to map into each item in the Collection cast.
70+
*
71+
* @param class-string $class
72+
* @return string
73+
*/
74+
public static function into($class)
75+
{
76+
return static::class.':'.$class;
77+
}
78+
79+
/**
80+
* Specify the callable to map each item in the Collection cast.
81+
*
82+
* @param callable-string|array{0: class-string, 1: string} $callback
83+
* @param string|null $method
84+
* @return string
85+
*/
86+
public static function using($callback, $method = null)
87+
{
88+
if ($callback instanceof Closure) {
89+
throw new InvalidArgumentException('The provided callback should be a callable array or string.');
90+
}
91+
92+
if (is_array($callback) && is_callable($callback)) {
93+
[$callback, $method] = [$callback[0], $callback[1]];
94+
}
95+
96+
return $method === null
97+
? static::class.':'.$callback
98+
: static::class.':'.$callback.'@'.$method;
99+
100+
}
101+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Eloquent\Casts;
4+
5+
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
6+
use Illuminate\Support\Collection;
7+
use Illuminate\Support\Facades\Crypt;
8+
use Illuminate\Support\Str;
9+
use InvalidArgumentException;
10+
11+
class AsEncryptedCollectionMap extends AsCollectionMap
12+
{
13+
/**
14+
* Get the caster class to use when casting from / to this cast target.
15+
*
16+
* @template TValue
17+
*
18+
* @param array{class-string<TValue>|(callable(mixed):TValue)} $arguments
19+
* @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection<array-key, TValue>, iterable<TValue>>
20+
*/
21+
public static function castUsing(array $arguments)
22+
{
23+
return new class($arguments) implements CastsAttributes
24+
{
25+
public function __construct(protected array $arguments)
26+
{
27+
}
28+
29+
public function get($model, $key, $value, $attributes)
30+
{
31+
if (! isset($attributes[$key])) {
32+
return;
33+
}
34+
35+
$data = Json::decode(Crypt::decryptString($attributes[$key]));
36+
37+
if (!is_array($data)) {
38+
return null;
39+
}
40+
41+
$this->arguments[0] ??= '';
42+
43+
if (is_callable($this->arguments[0])) {
44+
return Collection::make($data)->map($this->arguments[0]);
45+
}
46+
47+
[$class, $method] = Str::parseCallback($this->arguments[0]);
48+
49+
if ($method) {
50+
return Collection::make($data)->map([$class, $method]);
51+
}
52+
53+
if ($class) {
54+
return Collection::make($data)->mapInto($class);
55+
}
56+
57+
throw new InvalidArgumentException('No class or callable has been set to map the Collection.');
58+
}
59+
60+
public function set($model, $key, $value, $attributes)
61+
{
62+
if (! is_null($value)) {
63+
return [$key => Crypt::encryptString(Json::encode($value))];
64+
}
65+
66+
return null;
67+
}
68+
};
69+
}
70+
}

src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
use Illuminate\Contracts\Support\Arrayable;
1616
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
1717
use Illuminate\Database\Eloquent\Casts\AsCollection;
18+
use Illuminate\Database\Eloquent\Casts\AsCollectionMap;
1819
use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject;
1920
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection;
21+
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollectionMap;
2022
use Illuminate\Database\Eloquent\Casts\AsEnumArrayObject;
2123
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
2224
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -2237,11 +2239,11 @@ public function originalIsEquivalent($key)
22372239
} elseif ($this->hasCast($key, static::$primitiveCastTypes)) {
22382240
return $this->castAttribute($key, $attribute) ===
22392241
$this->castAttribute($key, $original);
2240-
} elseif ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class])) {
2242+
} elseif ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class, AsCollectionMap::class])) {
22412243
return $this->fromJson($attribute) === $this->fromJson($original);
22422244
} elseif ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsEnumArrayObject::class, AsEnumCollection::class])) {
22432245
return $this->fromJson($attribute) === $this->fromJson($original);
2244-
} elseif ($this->isClassCastable($key) && $original !== null && Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class])) {
2246+
} elseif ($this->isClassCastable($key) && $original !== null && Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class, AsEncryptedCollectionMap::class])) {
22452247
if (empty(static::currentEncrypter()->getPreviousKeys())) {
22462248
return $this->fromEncryptedString($attribute) === $this->fromEncryptedString($original);
22472249
}

tests/Integration/Database/DatabaseCustomCastsTest.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44

55
use Illuminate\Database\Eloquent\Casts\AsArrayObject;
66
use Illuminate\Database\Eloquent\Casts\AsCollection;
7+
use Illuminate\Database\Eloquent\Casts\AsCollectionMap;
78
use Illuminate\Database\Eloquent\Casts\AsStringable;
89
use Illuminate\Database\Eloquent\Model;
910
use Illuminate\Database\Schema\Blueprint;
1011
use Illuminate\Support\Facades\Hash;
1112
use Illuminate\Support\Facades\Schema;
13+
use Illuminate\Support\Fluent;
1214
use Illuminate\Support\Stringable;
15+
use InvalidArgumentException;
16+
use PHPUnit\Framework\Attributes\DataProvider;
1317

1418
class DatabaseCustomCastsTest extends DatabaseTestCase
1519
{
@@ -151,6 +155,74 @@ public function test_custom_casting_nullable_values()
151155
$model->array_object_json->toArray()
152156
);
153157
}
158+
159+
public function test_custom_casting_map_into_collection(): void
160+
{
161+
$model = new TestEloquentModelWithCustomCastsNullable();
162+
$model->mergeCasts(['collection' => AsCollectionMap::into(Fluent::class)]);
163+
$model->fill([
164+
'collection' => [
165+
['name' => 'Taylor']
166+
]
167+
]);
168+
169+
$fluent = $model->collection->first();
170+
171+
$this->assertInstanceOf(Fluent::class, $fluent);
172+
$this->assertSame('Taylor', $fluent->name);
173+
}
174+
175+
public static function provideMapArguments()
176+
{
177+
return [
178+
[TestCollectionMapCallable::class],
179+
[TestCollectionMapCallable::class, 'make'],
180+
[TestCollectionMapCallable::class . '@' .'make'],
181+
];
182+
}
183+
184+
#[DataProvider('provideMapArguments')]
185+
public function test_custom_casting_map_collection($class, $method = null): void
186+
{
187+
$model = new TestEloquentModelWithCustomCastsNullable();
188+
$model->mergeCasts(['collection' => AsCollectionMap::using($class, $method)]);
189+
$model->fill([
190+
'collection' => [
191+
['name' => 'Taylor']
192+
]
193+
]);
194+
195+
$result = $model->collection->first();
196+
197+
$this->assertInstanceOf(TestCollectionMapCallable::class, $result);
198+
$this->assertSame('Taylor', $result->name);
199+
}
200+
201+
public function test_custom_casting_map_collection_throw_when_no_arguments(): void
202+
{
203+
$model = new TestEloquentModelWithCustomCastsNullable();
204+
$model->mergeCasts(['collection' => AsCollectionMap::class]);
205+
$model->fill([
206+
'collection' => [
207+
['name' => 'Taylor']
208+
]
209+
]);
210+
211+
$this->expectException(InvalidArgumentException::class);
212+
$this->expectExceptionMessage('No class or callable has been set to map the Collection.');
213+
214+
$model->collection->first();
215+
}
216+
217+
public function test_custom_casting_map_collection_throw_when_using_closure(): void
218+
{
219+
$model = new TestEloquentModelWithCustomCastsNullable();
220+
221+
$this->expectException(InvalidArgumentException::class);
222+
$this->expectExceptionMessage('The provided callback should be a callable array or string.');
223+
224+
$model->mergeCasts(['collection' => AsCollectionMap::using(TestCollectionMapCallable::make(...))]);
225+
}
154226
}
155227

156228
class TestEloquentModelWithCustomCasts extends Model
@@ -197,3 +269,18 @@ class TestEloquentModelWithCustomCastsNullable extends Model
197269
'stringable' => AsStringable::class,
198270
];
199271
}
272+
273+
class TestCollectionMapCallable
274+
{
275+
public $name;
276+
277+
public function __construct($data)
278+
{
279+
$this->name = $data['name'];
280+
}
281+
282+
public static function make($data)
283+
{
284+
return new static($data);
285+
}
286+
}

tests/Integration/Database/EloquentModelEncryptedCastingTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
use Illuminate\Database\Eloquent\Casts\ArrayObject;
77
use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject;
88
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection;
9+
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollectionMap;
910
use Illuminate\Database\Eloquent\Model;
1011
use Illuminate\Database\Schema\Blueprint;
1112
use Illuminate\Support\Collection;
1213
use Illuminate\Support\Facades\Crypt;
1314
use Illuminate\Support\Facades\Schema;
15+
use Illuminate\Support\Fluent;
1416
use stdClass;
1517

1618
class EloquentModelEncryptedCastingTest extends DatabaseTestCase
@@ -231,6 +233,56 @@ public function testAsEncryptedCollection()
231233
$this->assertNull($subject->fresh()->secret_collection);
232234
}
233235

236+
public function testAsEncryptedCollectionMap()
237+
{
238+
$this->encrypter->expects('encryptString')
239+
->twice()
240+
->with('[{"key1":"value1"}]')
241+
->andReturn('encrypted-secret-collection-string-1');
242+
$this->encrypter->expects('encryptString')
243+
->times(10)
244+
->with('[{"key1":"value1"},{"key2":"value2"}]')
245+
->andReturn('encrypted-secret-collection-string-2');
246+
$this->encrypter->expects('decryptString')
247+
->once()
248+
->with('encrypted-secret-collection-string-2')
249+
->andReturn('[{"key1":"value1"},{"key2":"value2"}]');
250+
251+
$subject = new EncryptedCast;
252+
253+
$subject->mergeCasts(['secret_collection' => AsEncryptedCollectionMap::into(Fluent::class)]);
254+
255+
$subject->secret_collection = new Collection([new Fluent(['key1' => 'value1'])]);
256+
$subject->secret_collection->push(new Fluent(['key2' => 'value2']));
257+
258+
$subject->save();
259+
260+
$this->assertInstanceOf(Collection::class, $subject->secret_collection);
261+
$this->assertSame('value1', $subject->secret_collection->get(0)->key1);
262+
$this->assertSame('value2', $subject->secret_collection->get(1)->key2);
263+
$this->assertDatabaseHas('encrypted_casts', [
264+
'id' => $subject->id,
265+
'secret_collection' => 'encrypted-secret-collection-string-2',
266+
]);
267+
268+
$subject = $subject->fresh();
269+
270+
$this->assertInstanceOf(Collection::class, $subject->secret_collection);
271+
$this->assertSame('value1', $subject->secret_collection->get(0)->key1);
272+
$this->assertSame('value2', $subject->secret_collection->get(1)->key2);
273+
274+
$subject->secret_collection = null;
275+
$subject->save();
276+
277+
$this->assertNull($subject->secret_collection);
278+
$this->assertDatabaseHas('encrypted_casts', [
279+
'id' => $subject->id,
280+
'secret_collection' => null,
281+
]);
282+
283+
$this->assertNull($subject->fresh()->secret_collection);
284+
}
285+
234286
public function testAsEncryptedArrayObject()
235287
{
236288
$this->encrypter->expects('encryptString')

0 commit comments

Comments
 (0)