Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto lazy #831

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/as-a-resource/lazy-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions src/Attributes/AutoLazy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Spatie\LaravelData\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
class AutoLazy
{
}
20 changes: 18 additions & 2 deletions src/DataPipes/CastPropertiesDataPipe.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,23 @@ public function handle(
continue;
}

$properties[$name] = $this->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;
Expand Down Expand Up @@ -175,7 +191,7 @@ protected function castIterableItems(
array $properties,
CreationContext $creationContext
): array {
if(empty($values)) {
if (empty($values)) {
return $values;
}

Expand Down
15 changes: 15 additions & 0 deletions src/Resolvers/CastPropertyResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Spatie\LaravelData\Resolvers;

use Spatie\LaravelData\Support\DataConfig;

class CastPropertyResolver
{
public function __construct(
protected DataConfig $dataConfig,
) {
}


}
1 change: 1 addition & 0 deletions src/Support/DataProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public function __construct(
public readonly bool $hidden,
public readonly bool $isPromoted,
public readonly bool $isReadonly,
public readonly bool $autoLazy,
public readonly bool $hasDefaultValue,
public readonly mixed $defaultValue,
public readonly ?Cast $cast,
Expand Down
8 changes: 8 additions & 0 deletions src/Support/Factories/DataClassFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use ReflectionMethod;
use ReflectionParameter;
use ReflectionProperty;
use Spatie\LaravelData\Attributes\AutoLazy;
use Spatie\LaravelData\Contracts\AppendableData;
use Spatie\LaravelData\Contracts\EmptyData;
use Spatie\LaravelData\Contracts\IncludeableData;
Expand Down Expand Up @@ -54,11 +55,16 @@ public function build(ReflectionClass $reflectionClass): DataClass
);
}

$autoLazy = $attributes->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);
Expand Down Expand Up @@ -136,6 +142,7 @@ protected function resolveProperties(
?ReflectionMethod $constructorReflectionMethod,
array $mappers,
array $dataIterablePropertyAnnotations,
bool $autoLazy
): Collection {
$defaultValues = $this->resolveDefaultValues($reflectionClass, $constructorReflectionMethod);

Expand All @@ -151,6 +158,7 @@ protected function resolveProperties(
$mappers['inputNameMapper'],
$mappers['outputNameMapper'],
$dataIterablePropertyAnnotations[$property->getName()] ?? null,
autoLazyClass: $autoLazy
),
]);
}
Expand Down
23 changes: 16 additions & 7 deletions src/Support/Factories/DataPropertyFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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(),
Expand Down
105 changes: 105 additions & 0 deletions tests/CreationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, Spatie\LaravelData\Tests\Fakes\SimpleData> */
#[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<int, Spatie\LaravelData\Tests\Fakes\SimpleData> */
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',
]);
});
Loading
Loading