From 4f251811b2acdbc75a4892fc999fd8b7108d5ae2 Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Thu, 25 Jul 2024 14:22:13 +0200 Subject: [PATCH 1/4] Add initial support for auto lazy --- src/Attributes/AutoLazy.php | 10 ++ src/DataPipes/CastPropertiesDataPipe.php | 20 +++- src/Resolvers/CastPropertyResolver.php | 15 +++ src/Support/DataProperty.php | 1 + src/Support/Factories/DataClassFactory.php | 8 ++ src/Support/Factories/DataPropertyFactory.php | 23 ++-- tests/CreationTest.php | 105 ++++++++++++++++++ tests/Support/DataPropertyTest.php | 16 +++ 8 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 src/Attributes/AutoLazy.php create mode 100644 src/Resolvers/CastPropertyResolver.php diff --git a/src/Attributes/AutoLazy.php b/src/Attributes/AutoLazy.php new file mode 100644 index 000000000..af24e93b2 --- /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 000000000..4fdb310c5 --- /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 b4019e07f..ba59acd4c 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 d8bc3c559..794d39229 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 0485a4660..7521ffc20 100644 --- a/tests/Support/DataPropertyTest.php +++ b/tests/Support/DataPropertyTest.php @@ -1,5 +1,6 @@ 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('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') From 777067a29c6a802112a1b77dc83e7eaedbfd0cff Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Thu, 25 Jul 2024 14:25:39 +0200 Subject: [PATCH 2/4] Add some extra tests --- tests/Support/DataPropertyTest.php | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/Support/DataPropertyTest.php b/tests/Support/DataPropertyTest.php index 7521ffc20..f56567ab3 100644 --- a/tests/Support/DataPropertyTest.php +++ b/tests/Support/DataPropertyTest.php @@ -13,6 +13,7 @@ use Spatie\LaravelData\Casts\DateTimeInterfaceCast; use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; +use Spatie\LaravelData\Lazy; use Spatie\LaravelData\Support\DataProperty; use Spatie\LaravelData\Support\Factories\DataPropertyFactory; use Spatie\LaravelData\Tests\Fakes\CastTransformers\FakeCastTransformer; @@ -23,12 +24,19 @@ function resolveHelper( object $class, bool $hasDefaultValue = false, - mixed $defaultValue = null + mixed $defaultValue = null, + bool $autoLazyClass = false, ): DataProperty { $reflectionProperty = new ReflectionProperty($class, 'property'); $reflectionClass = new ReflectionClass($class); - return app(DataPropertyFactory::class)->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 () { @@ -198,6 +206,21 @@ public function __construct( )->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') From 19131a3c8f14ce8cb6ccd6e2c0acdcc146b308f1 Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Thu, 25 Jul 2024 14:37:53 +0200 Subject: [PATCH 3/4] Add docs --- docs/as-a-resource/lazy-properties.md | 67 +++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/as-a-resource/lazy-properties.md b/docs/as-a-resource/lazy-properties.md index bf5087ea1..d16ba647b 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: From 46f955107794fe7fb746f02f5291cd138de52574 Mon Sep 17 00:00:00 2001 From: Ruben Van Assche Date: Fri, 24 Jan 2025 16:08:24 +0100 Subject: [PATCH 4/4] Add support for inertia,closure and relational lazy when autoloading --- docs/advanced-usage/use-with-inertia.md | 35 +++++++ docs/as-a-resource/lazy-properties.md | 98 +++++++++++++++---- src/Attributes/AutoClosureLazy.php | 18 ++++ src/Attributes/AutoInertiaLazy.php | 18 ++++ src/Attributes/AutoLazy.php | 7 ++ src/Attributes/AutoWhenLoadedLazy.php | 27 +++++ src/DataPipes/CastPropertiesDataPipe.php | 15 ++- src/DataPipes/DefaultValuesDataPipe.php | 11 +++ src/Resolvers/CastPropertyResolver.php | 15 --- src/Support/DataProperty.php | 3 +- src/Support/Factories/DataClassFactory.php | 6 +- src/Support/Factories/DataPropertyFactory.php | 10 +- tests/CreationTest.php | 77 +++++++++++++++ tests/Support/DataPropertyTest.php | 41 ++++++-- 14 files changed, 328 insertions(+), 53 deletions(-) create mode 100644 src/Attributes/AutoClosureLazy.php create mode 100644 src/Attributes/AutoInertiaLazy.php create mode 100644 src/Attributes/AutoWhenLoadedLazy.php delete mode 100644 src/Resolvers/CastPropertyResolver.php diff --git a/docs/advanced-usage/use-with-inertia.md b/docs/advanced-usage/use-with-inertia.md index 62ca8a126..8fc77f645 100644 --- a/docs/advanced-usage/use-with-inertia.md +++ b/docs/advanced-usage/use-with-inertia.md @@ -52,3 +52,38 @@ router.reload((url, { only: ['title'], }); ``` + +### Auto lazy Inertia properties + +We already saw earlier that the package can automatically make properties Lazy, the same can be done for Inertia properties. + +It is possible to rewrite the previous example as follows: + +```php +use Spatie\LaravelData\Attributes\AutoClosureLazy;use Spatie\LaravelData\Attributes\AutoInertiaLazy; + +class SongData extends Data +{ + public function __construct( + #[AutoInertiaLazy] + public Lazy|string $title, + #[AutoClosureLazy] + public Lazy|string $artist, + ) { + } +} +``` + +If all the properties of a class should be either Inertia or closure lazy, you can use the attributes on the class level: + +```php +#[AutoInertiaLazy] +class SongData extends Data +{ + public function __construct( + public Lazy|string $title, + public Lazy|string $artist, + ) { + } +} +``` diff --git a/docs/as-a-resource/lazy-properties.md b/docs/as-a-resource/lazy-properties.md index d16ba647b..e02416b22 100644 --- a/docs/as-a-resource/lazy-properties.md +++ b/docs/as-a-resource/lazy-properties.md @@ -19,7 +19,8 @@ class AlbumData extends Data } ``` -This will always output a collection of songs, which can become quite large. With lazy properties, we can include properties when we want to: +This will always output a collection of songs, which can become quite large. With lazy properties, we can include +properties when we want to: ```php class AlbumData extends Data @@ -43,7 +44,8 @@ class AlbumData extends Data } ``` -The `songs` key won't be included in the resource when transforming it from a model. Because the closure that provides the data won't be called when transforming the data object unless we explicitly demand it. +The `songs` key won't be included in the resource when transforming it from a model. Because the closure that provides +the data won't be called when transforming the data object unless we explicitly demand it. Now when we transform the data object as such: @@ -69,7 +71,8 @@ AlbumData::from(Album::first())->include('songs'); Lazy properties will only be included when the `include` method is called on the data object with the property's name. -It is also possible to nest these includes. For example, let's update the `SongData` class and make all of its properties lazy: +It is also possible to nest these includes. For example, let's update the `SongData` class and make all of its +properties lazy: ```php class SongData extends Data @@ -108,7 +111,8 @@ If you want to include all the properties of a data object, you can do the follo AlbumData::from(Album::first())->include('songs.*'); ``` -Explicitly including properties of data objects also works on a single data object. For example, our `UserData` looks like this: +Explicitly including properties of data objects also works on a single data object. For example, our `UserData` looks +like this: ```php class UserData extends Data @@ -147,13 +151,15 @@ Lazy::create(fn() => SongData::collect($album->songs)); With a basic `Lazy` property, you must explicitly include it when the data object is transformed. -Sometimes you only want to include a property when a specific condition is true. This can be done with conditional lazy properties: +Sometimes you only want to include a property when a specific condition is true. This can be done with conditional lazy +properties: ```php Lazy::when(fn() => $this->is_admin, fn() => SongData::collect($album->songs)); ``` -The property will only be included when the `is_admin` property of the data object is true. It is not possible to include the property later on with the `include` method when a condition is not accepted. +The property will only be included when the `is_admin` property of the data object is true. It is not possible to +include the property later on with the `include` method when a condition is not accepted. ### Relational Lazy properties @@ -173,7 +179,8 @@ It is possible to mark a lazy property as included by default: Lazy::create(fn() => SongData::collect($album->songs))->defaultIncluded(); ``` -The property will now always be included when the data object is transformed. You can explicitly exclude properties that were default included as such: +The property will now always be included when the data object is transformed. You can explicitly exclude properties that +were default included as such: ```php AlbumData::create(Album::first())->exclude('songs'); @@ -181,7 +188,8 @@ 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. +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: @@ -204,9 +212,11 @@ class UserData extends Data } ``` -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. +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. +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: @@ -224,7 +234,8 @@ class UserData extends Data 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. +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: @@ -246,9 +257,60 @@ The auto lazy process won't be applied in the following situations: - 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 +### Auto lazy with model relations + +When you're constructing a data object from an Eloquent model, it is also possible to automatically create lazy +properties for model relations which are only resolved when the relation is loaded: + +```php +class UserData extends Data +{ + public function __construct( + public string $title, + #[AutoWhenLoadedLazy] + public Lazy|SongData $favoriteSong, + ) { + } +} +``` + +When the `favoriteSong` relation is loaded on the model, the property will be included in the data object. + +If the name of the relation doesn't match the property name, you can specify the relation name: + +```php +class UserData extends Data +{ + public function __construct( + public string $title, + #[AutoWhenLoadedLazy('favoriteSong')] + public Lazy|SongData $favorite_song, + ) { + } +} +``` + +The package will use the regular casting process when the relation is loaded, so it is also perfectly possible to create a collection of data objects: + +```php +class UserData extends Data +{ + /** + * @param Lazy|array $favoriteSongs + */ + public function __construct( + public string $title, + #[AutoWhenLoadedLazy] + public Lazy|array $favoriteSongs, + ) { + } +} +``` + ## 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: +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: ```php AlbumData::from(Album::first())->only('songs'); // will only show `songs` @@ -269,7 +331,8 @@ AlbumData::from(Album::first())->only('songs.{name, artist}'); AlbumData::from(Album::first())->except('songs.{name, artist}'); ``` -Only and except always take precedence over include and exclude, which means that when a property is hidden by `only` or `except` it is impossible to show it again using `include`. +Only and except always take precedence over include and exclude, which means that when a property is hidden by `only` or +`except` it is impossible to show it again using `include`. ### Conditionally @@ -373,7 +436,7 @@ Our JSON would look like this when we request `https://spatie.be/my-account`: ```json { - "name": "Ruben Van Assche" + "name" : "Ruben Van Assche" } ``` @@ -385,8 +448,8 @@ https://spatie.be/my-account?include=favorite_song ```json { - "name": "Ruben Van Assche", - "favorite_song": { + "name" : "Ruben Van Assche", + "favorite_song" : { "name" : "Never Gonna Give You Up", "artist" : "Rick Astley" } @@ -462,7 +525,8 @@ AlbumData::from(Album::first())->include('songs')->toArray(); // will include so AlbumData::from(Album::first())->toArray(); // will not include songs ``` -If you want to add includes/excludes/only/except to a data object and its nested chain that will be used for all future transformations, you can define them in their respective *properties methods: +If you want to add includes/excludes/only/except to a data object and its nested chain that will be used for all future +transformations, you can define them in their respective *properties methods: ```php class AlbumData extends Data diff --git a/src/Attributes/AutoClosureLazy.php b/src/Attributes/AutoClosureLazy.php new file mode 100644 index 000000000..6793ed850 --- /dev/null +++ b/src/Attributes/AutoClosureLazy.php @@ -0,0 +1,18 @@ + $castValue($value)); + } +} diff --git a/src/Attributes/AutoInertiaLazy.php b/src/Attributes/AutoInertiaLazy.php new file mode 100644 index 000000000..280337d84 --- /dev/null +++ b/src/Attributes/AutoInertiaLazy.php @@ -0,0 +1,18 @@ + $castValue($value)); + } +} diff --git a/src/Attributes/AutoLazy.php b/src/Attributes/AutoLazy.php index af24e93b2..b78c504d2 100644 --- a/src/Attributes/AutoLazy.php +++ b/src/Attributes/AutoLazy.php @@ -3,8 +3,15 @@ namespace Spatie\LaravelData\Attributes; use Attribute; +use Closure; +use Spatie\LaravelData\Lazy; +use Spatie\LaravelData\Support\DataProperty; #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)] class AutoLazy { + public function build(Closure $castValue, mixed $payload, DataProperty $property, mixed $value): Lazy + { + return Lazy::create(fn () => $castValue($value)); + } } diff --git a/src/Attributes/AutoWhenLoadedLazy.php b/src/Attributes/AutoWhenLoadedLazy.php new file mode 100644 index 000000000..6f381ecc0 --- /dev/null +++ b/src/Attributes/AutoWhenLoadedLazy.php @@ -0,0 +1,27 @@ +relation ?? $property->name; + + return Lazy::when(fn () => $payload->relationLoaded($relation), fn () => $castValue( + $payload->getRelation($relation) + )); + } +} diff --git a/src/DataPipes/CastPropertiesDataPipe.php b/src/DataPipes/CastPropertiesDataPipe.php index be9479acc..2c6bb206b 100644 --- a/src/DataPipes/CastPropertiesDataPipe.php +++ b/src/DataPipes/CastPropertiesDataPipe.php @@ -41,12 +41,17 @@ public function handle( } if ($dataProperty->autoLazy) { - $properties[$name] = Lazy::create(fn () => $this->cast( + $properties[$name] = $dataProperty->autoLazy->build( + fn (mixed $value) => $this->cast( + $dataProperty, + $value, + $properties, + $creationContext + ), + $payload, $dataProperty, - $value, - $properties, - $creationContext - )); + $value + ); continue; } diff --git a/src/DataPipes/DefaultValuesDataPipe.php b/src/DataPipes/DefaultValuesDataPipe.php index fcea69e00..827349aa2 100644 --- a/src/DataPipes/DefaultValuesDataPipe.php +++ b/src/DataPipes/DefaultValuesDataPipe.php @@ -2,6 +2,8 @@ namespace Spatie\LaravelData\DataPipes; +use Illuminate\Database\Eloquent\Model; +use Spatie\LaravelData\Attributes\AutoWhenLoadedLazy; use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Creation\CreationContext; use Spatie\LaravelData\Support\DataClass; @@ -31,6 +33,15 @@ public function handle( continue; } + if ($property->autoLazy + && $property->autoLazy instanceof AutoWhenLoadedLazy + && $property->autoLazy->relation !== null + && $payload instanceof Model + && $payload->relationLoaded($property->autoLazy->relation) + ) { + $properties[$name] = $payload->getRelation($property->autoLazy->relation); + } + if ($property->type->isNullable) { $properties[$name] = null; } diff --git a/src/Resolvers/CastPropertyResolver.php b/src/Resolvers/CastPropertyResolver.php deleted file mode 100644 index 4fdb310c5..000000000 --- a/src/Resolvers/CastPropertyResolver.php +++ /dev/null @@ -1,15 +0,0 @@ -contains( + $autoLazy = $attributes->first( fn (object $attribute) => $attribute instanceof AutoLazy ); @@ -141,7 +141,7 @@ protected function resolveProperties( ?ReflectionMethod $constructorReflectionMethod, array $mappers, array $dataIterablePropertyAnnotations, - bool $autoLazy + ?AutoLazy $autoLazy ): Collection { $defaultValues = $this->resolveDefaultValues($reflectionClass, $constructorReflectionMethod); @@ -157,7 +157,7 @@ protected function resolveProperties( $mappers['inputNameMapper'], $mappers['outputNameMapper'], $dataIterablePropertyAnnotations[$property->getName()] ?? null, - autoLazyClass: $autoLazy + classAutoLazy: $autoLazy ), ]); } diff --git a/src/Support/Factories/DataPropertyFactory.php b/src/Support/Factories/DataPropertyFactory.php index 715024cbe..4b71c86be 100644 --- a/src/Support/Factories/DataPropertyFactory.php +++ b/src/Support/Factories/DataPropertyFactory.php @@ -33,7 +33,7 @@ public function build( ?NameMapper $classInputNameMapper = null, ?NameMapper $classOutputNameMapper = null, ?DataIterableAnnotation $classDefinedDataIterableAnnotation = null, - bool $autoLazyClass = false, + ?AutoLazy $classAutoLazy = null, ): DataProperty { $attributes = collect($reflectionProperty->getAttributes()) ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) @@ -83,9 +83,13 @@ public function build( $defaultValue = null; } - $autoLazy = $attributes->contains( + $autoLazy = $attributes->first( fn (object $attribute) => $attribute instanceof AutoLazy - ) || ($autoLazyClass && $type->lazyType !== null); + ); + + if ($classAutoLazy && $type->lazyType !== null && $autoLazy === null) { + $autoLazy = $classAutoLazy; + } return new DataProperty( name: $reflectionProperty->name, diff --git a/tests/CreationTest.php b/tests/CreationTest.php index 9b5c51776..2890268f1 100644 --- a/tests/CreationTest.php +++ b/tests/CreationTest.php @@ -10,10 +10,14 @@ use Illuminate\Support\Enumerable; use Illuminate\Support\Facades\Route; use Illuminate\Validation\ValidationException; +use Inertia\LazyProp; use function Pest\Laravel\postJson; +use Spatie\LaravelData\Attributes\AutoClosureLazy; +use Spatie\LaravelData\Attributes\AutoInertiaLazy; use Spatie\LaravelData\Attributes\AutoLazy; +use Spatie\LaravelData\Attributes\AutoWhenLoadedLazy; use Spatie\LaravelData\Attributes\Computed; use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\Validation\Min; @@ -33,6 +37,8 @@ use Spatie\LaravelData\Optional; use Spatie\LaravelData\Support\Creation\CreationContext; use Spatie\LaravelData\Support\DataClass; +use Spatie\LaravelData\Support\Lazy\ClosureLazy; +use Spatie\LaravelData\Support\Lazy\InertiaLazy; use Spatie\LaravelData\Tests\Fakes\Castables\SimpleCastable; use Spatie\LaravelData\Tests\Fakes\Casts\ConfidentialDataCast; use Spatie\LaravelData\Tests\Fakes\Casts\ConfidentialDataCollectionCast; @@ -48,8 +54,11 @@ use Spatie\LaravelData\Tests\Fakes\DataWithArgumentCountErrorException; use Spatie\LaravelData\Tests\Fakes\EnumData; use Spatie\LaravelData\Tests\Fakes\Enums\DummyBackedEnum; +use Spatie\LaravelData\Tests\Fakes\FakeNestedModelData; use Spatie\LaravelData\Tests\Fakes\ModelData; use Spatie\LaravelData\Tests\Fakes\Models\DummyModel; +use Spatie\LaravelData\Tests\Fakes\Models\FakeModel; +use Spatie\LaravelData\Tests\Fakes\Models\FakeNestedModel; use Spatie\LaravelData\Tests\Fakes\MultiData; use Spatie\LaravelData\Tests\Fakes\NestedData; use Spatie\LaravelData\Tests\Fakes\NestedLazyData; @@ -1311,6 +1320,7 @@ public static function pipeline(): DataPipeline expect($data->toArray())->toBe([ 'nullableLazy' => null, ]); + expect($data->include('data', 'dataCollection', 'string', 'overwrittenLazy')->toArray())->toBe([ 'data' => ['string' => 'Hello World'], 'dataCollection' => [ @@ -1375,3 +1385,70 @@ class TestAutoLazyClassAttributeData extends Data 'regularString' => 'Hello World', ]); }); + +it('can use auto lazy to construct an inertia lazy', function () { + $dataClass = new class () extends Data { + #[AutoInertiaLazy] + public string|Lazy $string; + }; + + $data = $dataClass::from(['string' => 'Hello World']); + + expect($data->string)->toBeInstanceOf(InertiaLazy::class); + expect($data->toArray()['string'])->toBeInstanceOf(LazyProp::class); +}); + +it('can use auto lazy to construct a closure lazy', function () { + $dataClass = new class () extends Data { + #[AutoClosureLazy] + public string|Lazy $string; + }; + + $data = $dataClass::from(['string' => 'Hello World']); + + expect($data->string)->toBeInstanceOf(ClosureLazy::class); + expect($data->toArray()['string'])->toBeInstanceOf(Closure::class); +}); + + +it('can use auto lazy to construct a when loaded lazy', function () { + $dataClass = new class () extends Data { + #[AutoWhenLoadedLazy] + /** @property array */ + public array|Lazy $fakeNestedModels; + }; + + $model = FakeModel::factory() + ->has(FakeNestedModel::factory()->count(2)) + ->create(); + + expect($dataClass::from($model)->all())->toBeEmpty(); + + $model->load('fakeNestedModels'); + + expect($dataClass::from($model)->all()['fakeNestedModels']) + ->toBeArray() + ->toHaveCount(2) + ->each()->toBeInstanceOf(FakeNestedModelData::class); +}); + +it('can use auto lazy to construct a when loaded lazy with a manual defined relation', function () { + $dataClass = new class () extends Data { + #[AutoWhenLoadedLazy('fakeNestedModels')] + /** @property array */ + public array|Lazy $models; + }; + + $model = FakeModel::factory() + ->has(FakeNestedModel::factory()->count(2)) + ->create(); + + expect($dataClass::from($model)->all())->toBeEmpty(); + + $model->load('fakeNestedModels'); + + expect($dataClass::from($model)->all()['models']) + ->toBeArray() + ->toHaveCount(2) + ->each()->toBeInstanceOf(FakeNestedModelData::class); +}); diff --git a/tests/Support/DataPropertyTest.php b/tests/Support/DataPropertyTest.php index adfad2904..8664bb2a3 100644 --- a/tests/Support/DataPropertyTest.php +++ b/tests/Support/DataPropertyTest.php @@ -1,6 +1,9 @@ autoLazy - )->toBeFalse(); + )->toBeNull(); expect( resolveHelper(new class () { #[AutoLazy] public string $property; })->autoLazy - )->toBeTrue(); + )->toBeInstanceOf(AutoLazy::class); + + expect( + resolveHelper(new class () { + #[AutoInertiaLazy] + public string|Lazy $property; + })->autoLazy + )->toBeInstanceOf(AutoInertiaLazy::class); + + expect( + resolveHelper(new class () { + #[AutoWhenLoadedLazy('relation')] + public string|Lazy $property; + })->autoLazy + )->toBeInstanceOf(AutoWhenLoadedLazy::class); + + expect( + resolveHelper(new class () { + #[AutoClosureLazy] + public string $property; + })->autoLazy + )->toBeInstanceOf(AutoClosureLazy::class); }); 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(); + }, classAutoLazy: new AutoLazy())->autoLazy + )->toBeNull(); expect( resolveHelper(new class () { public string|Lazy $property; - }, autoLazyClass: true)->autoLazy - )->toBeTrue(); + }, classAutoLazy: new AutoLazy())->autoLazy + )->toBeInstanceOf(AutoLazy::class); }); - 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')