Skip to content

Commit a5602a2

Browse files
committed
Add support for first-class callable syntax in cast attribute
1 parent f653a3f commit a5602a2

File tree

4 files changed

+54
-6
lines changed

4 files changed

+54
-6
lines changed

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,9 @@ The `Describe` attribute can accept these arguments.
289289
// Runs before 'cast'
290290
'pre' => [MyClass::class, 'preHook']
291291
// Targets the static method: `MyClass::methodName()`
292-
'cast' => [MyClass::class, 'castMethod'],
292+
'cast' => [MyClass::class, 'castMethod'],
293293
// 'cast' => 'my_func', // alternately target a function
294+
// 'cast' => MyClass::castMethod(...), // or a first-class callable (PHP 8.5+)
294295
// Runs after 'cast' passing the resolved value as `$value`
295296
'post' => [MyClass::class, 'postHook']
296297
'default' => 'value',
@@ -324,12 +325,15 @@ class User
324325
use \Zerotoprod\DataModel\DataModel;
325326

326327
#[Describe(['cast' => [self::class, 'firstName'], 'function' => 'strtoupper'])]
328+
// Or with first-class callable (PHP 8.5+):
329+
// #[Describe(['cast' => self::firstName(...), 'function' => 'strtoupper'])]
327330
public string $first_name;
328-
331+
329332
#[Describe(['cast' => 'uppercase'])]
330333
public string $last_name;
331334

332335
#[Describe(['cast' => [self::class, 'fullName']])]
336+
// Or: #[Describe(['cast' => self::fullName(...)])]
333337
public string $full_name;
334338

335339
private static function firstName(mixed $value, array $context, ?\ReflectionAttribute $ReflectionAttribute, \ReflectionProperty $ReflectionProperty): string
@@ -748,6 +752,7 @@ class User
748752
/** @var Alias[] $Aliases */
749753
#[Describe([
750754
'cast' => [self::class, 'mapOf'], // Use the mapOf helper method
755+
// 'cast' => self::mapOf(...), // Or use first-class callable (PHP 8.5+)
751756
'type' => Alias::class, // Target type for each item
752757
])]
753758
public array $Aliases;
@@ -791,7 +796,7 @@ class User
791796

792797
/** @var Collection<int, Alias> $Aliases */
793798
#[Describe([
794-
'cast' => [self::class, 'mapOf'],
799+
'cast' => [self::class, 'mapOf'], // Or: self::mapOf(...) on PHP 8.5+
795800
'type' => Alias::class,
796801
])]
797802
public \Illuminate\Support\Collection $Aliases;

src/DataModel.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use ReflectionFunction;
99
use ReflectionMethod;
1010
use ReflectionUnionType;
11+
use Closure;
1112
use UnitEnum;
1213

1314
use function is_array;
@@ -44,6 +45,7 @@
4445
* // Targets the static method: `MyClass::methodName()`
4546
* 'cast' => [MyClass::class, 'castMethod'],
4647
* // 'cast' => 'my_func', // alternately target a function
48+
* // 'cast' => MyClass::castMethod(...), // or a first-class callable (PHP 8.5+)
4749
* // Runs after 'cast' passing the resolved value as `$value`
4850
* 'post' => [MyClass::class, 'postHook']
4951
* 'default' => 'value',
@@ -253,7 +255,9 @@ public static function from(array|object|null|string $context = [], mixed $insta
253255

254256
/** Property-level Cast */
255257
if (isset($Describe->cast)) {
256-
$param_count = (new (is_array($Describe->cast) ? ReflectionMethod::class : ReflectionFunction::class)(...(array)$Describe->cast))
258+
$param_count = ($Describe->cast instanceof Closure
259+
? new ReflectionFunction($Describe->cast)
260+
: new (is_array($Describe->cast) ? ReflectionMethod::class : ReflectionFunction::class)(...(array)$Describe->cast))
257261
->getNumberOfParameters();
258262

259263
$self->{$property_name} = $param_count === 1

src/Describe.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Zerotoprod\DataModel;
44

55
use Attribute;
6+
use Closure;
67

78
use function is_bool;
89
use function is_string;
@@ -20,12 +21,15 @@
2021
* use DataModel;
2122
*
2223
* #[Describe(['cast' => [__CLASS__, 'firstName'], 'required' => true])]
24+
* // Or with first-class callable (PHP 8.5+):
25+
* // #[Describe(['cast' => self::firstName(...), 'required' => true])]
2326
* public string $first_name;
2427
*
2528
* #[Describe(['cast' => 'uppercase'])]
2629
* public string $last_name;
2730
*
2831
* #[Describe(['cast' => [__CLASS__, 'fullName']])]
32+
* // Or: #[Describe(['cast' => self::fullName(...)])]
2933
* public string $full_name;
3034
*
3135
*
@@ -126,7 +130,7 @@ class Describe
126130
/**
127131
* @link https://github.com/zero-to-prod/data-model
128132
*/
129-
public string|array $cast;
133+
public string|array|Closure $cast;
130134
/**
131135
* @see $required
132136
* @link https://github.com/zero-to-prod/data-model
@@ -204,12 +208,15 @@ class Describe
204208
* use DataModel;
205209
*
206210
* #[Describe(['cast' => [__CLASS__, 'firstName'], 'function' => 'strtoupper'])]
211+
* // Or with first-class callable (PHP 8.5+):
212+
* // #[Describe(['cast' => self::firstName(...), 'function' => 'strtoupper'])]
207213
* public string $first_name;
208214
*
209215
* #[Describe(['cast' => 'uppercase'])]
210216
* public string $last_name;
211217
*
212218
* #[Describe(['cast' => [__CLASS__, 'fullName'], 'required' => true])]
219+
* // Or: #[Describe(['cast' => self::fullName(...), 'required' => true])]
213220
* public string $full_name;
214221
*
215222
* private static function firstName(mixed $value, array $context, ?\ReflectionAttribute $ReflectionAttribute, \ReflectionProperty $ReflectionProperty): string
@@ -279,7 +286,7 @@ class Describe
279286
* }
280287
* ```
281288
*
282-
* @param string|array{'from'?: string, 'pre'?: string|string[], 'cast'?: string|string[], 'post'?: string|string[], 'required'?: bool, 'default'?: mixed, 'nullable'?: bool,
289+
* @param string|array{'from'?: string, 'pre'?: string|string[], 'cast'?: string|array|\Closure, 'post'?: string|string[], 'required'?: bool, 'default'?: mixed, 'nullable'?: bool,
283290
* 'ignore'?: bool, 'via'?: string}|null $attributes
284291
*
285292
* @link https://github.com/zero-to-prod/data-model
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Tests\Unit\Describe\CastClosure;
4+
5+
use Closure;
6+
use PHPUnit\Framework\Attributes\Test;
7+
use ReflectionFunction;
8+
use Tests\TestCase;
9+
use Zerotoprod\DataModel\Describe;
10+
11+
class CastClosureTest extends TestCase
12+
{
13+
#[Test]
14+
public function cast_with_single_argument_closure(): void
15+
{
16+
$describe = new Describe(['cast' => fn($value) => strtolower($value)]);
17+
18+
$this->assertInstanceOf(Closure::class, $describe->cast);
19+
$this->assertEquals(1, (new ReflectionFunction($describe->cast))->getNumberOfParameters());
20+
$this->assertEquals('hello', ($describe->cast)('HELLO'));
21+
}
22+
23+
#[Test]
24+
public function cast_with_four_argument_closure(): void
25+
{
26+
$describe = new Describe(['cast' => fn($value, $context, $attribute, $property) => strtolower($value)]);
27+
28+
$this->assertInstanceOf(Closure::class, $describe->cast);
29+
$this->assertEquals(4, (new ReflectionFunction($describe->cast))->getNumberOfParameters());
30+
$this->assertEquals('hello', ($describe->cast)('HELLO', [], null, null));
31+
}
32+
}

0 commit comments

Comments
 (0)