diff --git a/docs/as-a-resource/lazy-properties.md b/docs/as-a-resource/lazy-properties.md index bf5087ea..d16ba647 100644 --- a/docs/as-a-resource/lazy-properties.md +++ b/docs/as-a-resource/lazy-properties.md @@ -179,6 +179,73 @@ The property will now always be included when the data object is transformed. Yo AlbumData::create(Album::first())->exclude('songs'); ``` +## Auto Lazy + +Writing Lazy properties can be a bit cumbersome. It is often a repetitive task to write the same code over and over again while the package can infer almost everything. + +Let's take a look at our previous example: + +```php +class UserData extends Data +{ + public function __construct( + public string $title, + public Lazy|SongData $favorite_song, + ) { + } + + public static function fromModel(User $user): self + { + return new self( + $user->title, + Lazy::create(fn() => SongData::from($user->favorite_song)) + ); + } +} +``` + +The package knows how to get the property from the model and wrap it into a data object, but since we're using a lazy property, we need to write our own magic creation method with a lot of repetitive code. + +In such a situation auto lazy might be a good fit, instead of casting the property directly into the data object, the casting process is wrapped in a lazy Closure. + +This makes it possible to rewrite the example as such: + +```php +#[AutoLazy] +class UserData extends Data +{ + public function __construct( + public string $title, + public Lazy|SongData $favorite_song, + ) { + } +} +``` + +While achieving the same result! + +Auto Lazy wraps the casting process of a value for every property typed as `Lazy` into a Lazy Closure when the `AutoLazy` attribute is present on the class. + +It is also possible to use the `AutoLazy` attribute on a property level: + +```php +class UserData extends Data +{ + public function __construct( + public string $title, + #[AutoLazy] + public Lazy|SongData $favorite_song, + ) { + } +} +``` + +The auto lazy process won't be applied in the following situations: + +- When a null value is passed to the property +- When the property value isn't present in the input payload and the property typed as `Optional` +- When a Lazy Closure is passed to the property + ## Only and Except Lazy properties are great for reducing payloads sent over the wire. However, when you completely want to remove a property Laravel's `only` and `except` methods can be used: diff --git a/src/Attributes/AutoLazy.php b/src/Attributes/AutoLazy.php new file mode 100644 index 00000000..af24e93b --- /dev/null +++ b/src/Attributes/AutoLazy.php @@ -0,0 +1,10 @@ +cast($dataProperty, $value, $properties, $creationContext); + if ($dataProperty->autoLazy) { + $properties[$name] = Lazy::create(fn () => $this->cast( + $dataProperty, + $value, + $properties, + $creationContext + )); + + continue; + } + + $properties[$name] = $this->cast( + $dataProperty, + $value, + $properties, + $creationContext + ); } return $properties; @@ -175,7 +191,7 @@ protected function castIterableItems( array $properties, CreationContext $creationContext ): array { - if(empty($values)) { + if (empty($values)) { return $values; } diff --git a/src/Resolvers/CastPropertyResolver.php b/src/Resolvers/CastPropertyResolver.php new file mode 100644 index 00000000..4fdb310c --- /dev/null +++ b/src/Resolvers/CastPropertyResolver.php @@ -0,0 +1,15 @@ +contains( + fn (object $attribute) => $attribute instanceof AutoLazy + ); + $properties = $this->resolveProperties( $reflectionClass, $constructorReflectionMethod, NameMappersResolver::create(ignoredMappers: [ProvidedNameMapper::class])->execute($attributes), $dataIterablePropertyAnnotations, + $autoLazy ); $responsable = $reflectionClass->implementsInterface(ResponsableData::class); @@ -136,6 +142,7 @@ protected function resolveProperties( ?ReflectionMethod $constructorReflectionMethod, array $mappers, array $dataIterablePropertyAnnotations, + bool $autoLazy ): Collection { $defaultValues = $this->resolveDefaultValues($reflectionClass, $constructorReflectionMethod); @@ -151,6 +158,7 @@ protected function resolveProperties( $mappers['inputNameMapper'], $mappers['outputNameMapper'], $dataIterablePropertyAnnotations[$property->getName()] ?? null, + autoLazyClass: $autoLazy ), ]); } diff --git a/src/Support/Factories/DataPropertyFactory.php b/src/Support/Factories/DataPropertyFactory.php index b4019e07..ba59acd4 100644 --- a/src/Support/Factories/DataPropertyFactory.php +++ b/src/Support/Factories/DataPropertyFactory.php @@ -5,6 +5,7 @@ use ReflectionAttribute; use ReflectionClass; use ReflectionProperty; +use Spatie\LaravelData\Attributes\AutoLazy; use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\GetsCast; use Spatie\LaravelData\Attributes\Hidden; @@ -31,11 +32,20 @@ public function build( ?NameMapper $classInputNameMapper = null, ?NameMapper $classOutputNameMapper = null, ?DataIterableAnnotation $classDefinedDataIterableAnnotation = null, + bool $autoLazyClass = false, ): DataProperty { $attributes = collect($reflectionProperty->getAttributes()) ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) ->map(fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance()); + $type = $this->typeFactory->buildProperty( + $reflectionProperty->getType(), + $reflectionClass, + $reflectionProperty, + $attributes, + $classDefinedDataIterableAnnotation + ); + $mappers = NameMappersResolver::create()->execute($attributes); $inputMappedName = match (true) { @@ -62,21 +72,20 @@ public function build( fn (object $attribute) => $attribute instanceof WithoutValidation ) && ! $computed; + $autoLazy = $attributes->contains( + fn (object $attribute) => $attribute instanceof AutoLazy + ) || ($autoLazyClass && $type->lazyType !== null); + return new DataProperty( name: $reflectionProperty->name, className: $reflectionProperty->class, - type: $this->typeFactory->buildProperty( - $reflectionProperty->getType(), - $reflectionClass, - $reflectionProperty, - $attributes, - $classDefinedDataIterableAnnotation - ), + type: $type, validate: $validate, computed: $computed, hidden: $hidden, isPromoted: $reflectionProperty->isPromoted(), isReadonly: $reflectionProperty->isReadOnly(), + autoLazy: $autoLazy, hasDefaultValue: $reflectionProperty->isPromoted() ? $hasDefaultValue : $reflectionProperty->hasDefaultValue(), defaultValue: $reflectionProperty->isPromoted() ? $defaultValue : $reflectionProperty->getDefaultValue(), cast: $attributes->first(fn (object $attribute) => $attribute instanceof GetsCast)?->get(), diff --git a/tests/CreationTest.php b/tests/CreationTest.php index d8bc3c55..794d3922 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -13,6 +13,7 @@ use function Pest\Laravel\postJson; +use Spatie\LaravelData\Attributes\AutoLazy; use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\Validation\Min; @@ -1227,3 +1228,107 @@ public static function pipeline(): DataPipeline [10, SimpleData::from('Hello World')] ); })->todo(); + +it('can create a data object with auto lazy properties', function () { + $dataClass = new class () extends Data { + #[AutoLazy] + public Lazy|SimpleData $data; + + /** @var Lazy|Collection */ + #[AutoLazy] + public Lazy|Collection $dataCollection; + + #[AutoLazy] + public Lazy|string $string; + + #[AutoLazy] + public Lazy|string $overwrittenLazy; + + #[AutoLazy] + public Optional|Lazy|string $optionalLazy; + + #[AutoLazy] + public null|string|Lazy $nullableLazy; + }; + + $data = $dataClass::from([ + 'data' => 'Hello World', + 'dataCollection' => ['Hello', 'World'], + 'string' => 'Hello World', + 'overwrittenLazy' => Lazy::create(fn () => 'Overwritten Lazy'), + ]); + + expect($data->data)->toBeInstanceOf(Lazy::class); + expect($data->dataCollection)->toBeInstanceOf(Lazy::class); + expect($data->string)->toBeInstanceOf(Lazy::class); + expect($data->overwrittenLazy)->toBeInstanceOf(Lazy::class); + expect($data->optionalLazy)->toBeInstanceOf(Optional::class); + expect($data->nullableLazy)->toBeNull(); + + expect($data->toArray())->toBe([ + 'nullableLazy' => null, + ]); + expect($data->include('data', 'dataCollection', 'string', 'overwrittenLazy')->toArray())->toBe([ + 'data' => ['string' => 'Hello World'], + 'dataCollection' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + 'string' => 'Hello World', + 'overwrittenLazy' => 'Overwritten Lazy', + 'nullableLazy' => null, + ]); +}); + +it('can create an auto-lazy class level attribute class', function () { + #[AutoLazy] + class TestAutoLazyClassAttributeData extends Data + { + public Lazy|SimpleData $data; + + /** @var Lazy|Collection */ + public Lazy|Collection $dataCollection; + + public Lazy|string $string; + + public Lazy|string $overwrittenLazy; + + public Optional|Lazy|string $optionalLazy; + + public null|string|Lazy $nullableLazy; + + public string $regularString; + } + + $data = TestAutoLazyClassAttributeData::from([ + 'data' => 'Hello World', + 'dataCollection' => ['Hello', 'World'], + 'string' => 'Hello World', + 'overwrittenLazy' => Lazy::create(fn () => 'Overwritten Lazy'), + 'regularString' => 'Hello World', + ]); + + expect($data->data)->toBeInstanceOf(Lazy::class); + expect($data->dataCollection)->toBeInstanceOf(Lazy::class); + expect($data->string)->toBeInstanceOf(Lazy::class); + expect($data->overwrittenLazy)->toBeInstanceOf(Lazy::class); + expect($data->optionalLazy)->toBeInstanceOf(Optional::class); + expect($data->nullableLazy)->toBeNull(); + expect($data->regularString)->toBe('Hello World'); + + expect($data->toArray())->toBe([ + 'nullableLazy' => null, + 'regularString' => 'Hello World', + ]); + expect($data->include('data', 'dataCollection', 'string', 'overwrittenLazy')->toArray())->toBe([ + 'data' => ['string' => 'Hello World'], + 'dataCollection' => [ + ['string' => 'Hello'], + ['string' => 'World'], + ], + 'string' => 'Hello World', + 'overwrittenLazy' => 'Overwritten Lazy', + 'nullableLazy' => null, + 'regularString' => 'Hello World', + ]); +}); diff --git a/tests/Support/DataPropertyTest.php b/tests/Support/DataPropertyTest.php index 0485a466..f56567ab 100644 --- a/tests/Support/DataPropertyTest.php +++ b/tests/Support/DataPropertyTest.php @@ -1,5 +1,6 @@ build($reflectionProperty, $reflectionClass, $hasDefaultValue, $defaultValue); + return app(DataPropertyFactory::class)->build( + $reflectionProperty, + $reflectionClass, + $hasDefaultValue, + $defaultValue, + autoLazyClass: $autoLazyClass + ); } it('can get the cast attribute with arguments', function () { @@ -182,6 +191,36 @@ public function __construct( )->toBeTrue(); }); +it('can check if a property is auto-lazy', function () { + expect( + resolveHelper(new class () { + public string $property; + })->autoLazy + )->toBeFalse(); + + expect( + resolveHelper(new class () { + #[AutoLazy] + public string $property; + })->autoLazy + )->toBeTrue(); +}); + +it('will set a property as auto-lazy when the class is auto-lazy and a lazy type is allowed', function () { + expect( + resolveHelper(new class () { + public string $property; + }, autoLazyClass: true)->autoLazy + )->toBeFalse(); + + expect( + resolveHelper(new class () { + public string|Lazy $property; + }, autoLazyClass: true)->autoLazy + )->toBeTrue(); +}); + + it('wont throw an error if non existing attribute is used on a data class property', function () { expect(NonExistingPropertyAttributeData::from(['property' => 'hello'])->property)->toEqual('hello') ->and(PhpStormAttributeData::from(['property' => 'hello'])->property)->toEqual('hello')