diff --git a/docs/LazyData.md b/docs/LazyData.md new file mode 100644 index 0000000..8c73da6 --- /dev/null +++ b/docs/LazyData.md @@ -0,0 +1,80 @@ +Lazy Data += + +Out of the box, extending `Data` will give you the ability to create [Lazy objects](https://www.php.net/manual/en/language.oop5.lazy-objects.php). +You can achieve that by calling either `createLazy` or `createLazyUsing` methods, depending +on whether you want to pass properties from the get-go or not. + +Let's take for example `UserData` class: + +```php +use Nuxtifyts\PhpDto\Data; + +final readonly class UserData extends Data +{ + public function __construct( + public int $id, + public string $firstName, + public string $lastName + ) {} +} +``` + +We can create a lazy object like this: + +```php +$user = UserData::createLazy( + id: 1, + firstName: 'John', + lastName: 'Doe' +); +``` + +Or, if we have more complex logic to run before creating the object, we can do: + +```php +// Supposedly, we know the user id. +$userId = 1; + +$user = UserData::createLazyUsing( + static function () use($userId): UserData { + // Fetch user data from the database. then create the DTO. + return UserData::from(UserModel::find($userId)); + } +) +``` + +The `createLazyUsing` method accepts a closure that returns the object. +This closure will be called only once, and the object will be cached for future calls. + +> For more information about lazy objects. Please refer to the [PHP documentation](https://www.php.net/manual/en/language.oop5.lazy-objects.php). + +Lazy Data Attribute +- + +Sometimes we may want to enable lazy data for a specific `Data` class. +In order to do that, we can user the `Lazy` attribute. + +```php +use Nuxtifyts\PhpDto\Data; +use Nuxtifyts\PhpDto\Attributes\Class\Lazy; + +#[Lazy] +final readonly class UserData extends Data +{ + public function __construct( + public int $id, + public string $firstName, + public string $lastName + ) {} +} +``` + +This will enable lazy data for all the "essential" functions that `Data` provides: +[create](https://github.com/nuxtifyts/php-dto/blob/main/docs/DefaultValues.md), +[from](https://github.com/nuxtifyts/php-dto/blob/main/docs/Quickstart.md), +[empty](https://github.com/nuxtifyts/php-dto/blob/main/docs/EmptyData.md) +and [clone](https://github.com/nuxtifyts/php-dto/blob/main/docs/CloneableData.md). + + + diff --git a/docs/Quickstart.md b/docs/Quickstart.md index 7d8ba33..ebd291e 100644 --- a/docs/Quickstart.md +++ b/docs/Quickstart.md @@ -82,4 +82,5 @@ can be found here: - [Data Refiners](https://github.com/nuxtifyts/php-dto/blob/main/docs/DataRefiners.md) - [Empty Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/EmptyData.md) - [Cloneable Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/CloneableData.md) +- [Lazy Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/LazyData.md) - [Data Configuration](https://github.com/nuxtifyts/php-dto/blob/main/docs/DataConfiguration.md) diff --git a/src/Attributes/Class/Lazy.php b/src/Attributes/Class/Lazy.php new file mode 100644 index 0000000..aace9b7 --- /dev/null +++ b/src/Attributes/Class/Lazy.php @@ -0,0 +1,10 @@ +sendThenReturn(new DeserializePipelinePassable( - classContext: $context, - data: $value - )) - ->data; - - return static::instanceWithConstructorCallFrom($context, $data); + $dataCreationClosure = static function () use ($context, $value): static { + $data = DeserializePipeline::createFromArray() + ->sendThenReturn(new DeserializePipelinePassable( + classContext: $context, + data: $value + )) + ->data; + + return $context->constructFromArray($data); + }; + + return $context->isLazy + ? $context->newLazyProxy($dataCreationClosure) + : $dataCreationClosure(); } catch (Throwable $e) { throw DataCreationException::unableToCreateInstance(static::class, $e); } @@ -59,16 +65,22 @@ final public static function from(mixed $value): static throw DeserializeException::invalidValue(); } - $data = DeserializePipeline::hydrateFromArray() - ->sendThenReturn(new DeserializePipelinePassable( - classContext: $context, - data: $value - )) - ->data; - - return $context->hasComputedProperties - ? static::instanceWithConstructorCallFrom($context, $data) - : static::instanceWithoutConstructorFrom($context, $data); + $dataCreationClosure = static function () use ($context, $value): static { + $data = DeserializePipeline::hydrateFromArray() + ->sendThenReturn(new DeserializePipelinePassable( + classContext: $context, + data: $value + )) + ->data; + + return $context->hasComputedProperties + ? $context->constructFromArray($data) + : static::instanceWithoutConstructorFrom($context, $data); + }; + + return $context->isLazy + ? $context->newLazyProxy($dataCreationClosure) + : $dataCreationClosure(); } catch (Throwable $e) { throw DeserializeException::generic($e); } @@ -93,30 +105,6 @@ protected static function instanceWithoutConstructorFrom(ClassContext $context, return $instance; } - /** - * @param ClassContext $context - * @param array $value - * - * @throws Throwable - */ - protected static function instanceWithConstructorCallFrom(ClassContext $context, array $value): static - { - /** @var array $args */ - $args = []; - - foreach ($context->constructorParams as $paramName) { - $propertyContext = $context->properties[$paramName] ?? null; - - if (!$propertyContext) { - throw DeserializeException::invalidParamsPassed(); - } - - $args[$paramName] = $propertyContext->deserializeFrom($value); - } - - return $context->newInstanceWithConstructorCall(...$args); - } - /** * @return array * diff --git a/src/Concerns/CloneableData.php b/src/Concerns/CloneableData.php index 724b82c..44588b3 100644 --- a/src/Concerns/CloneableData.php +++ b/src/Concerns/CloneableData.php @@ -32,9 +32,13 @@ public function with(mixed ...$args): static throw DataCreationException::invalidParamsPassed(static::class); } - return $context->hasComputedProperties + $cloneDataClosure = fn (): static => $context->hasComputedProperties ? $this->cloneInstanceWithConstructorCall($context, $value) : $this->cloneInstanceWithoutConstructorCall($context, $value); + + return $context->isLazy + ? $context->newLazyProxy($cloneDataClosure) + : $cloneDataClosure(); } catch (Throwable $t) { throw DataCreationException::unableToCloneInstanceWithNewData(static::class, $t); } diff --git a/src/Concerns/LazyData.php b/src/Concerns/LazyData.php new file mode 100644 index 0000000..93c7eb5 --- /dev/null +++ b/src/Concerns/LazyData.php @@ -0,0 +1,67 @@ + $context */ + $context = ClassContext::getInstance(static::class); + + $value = static::normalizeValue($args, static::class, $context->normalizers) + ?: static::normalizeValue($args[0] ?? [], static::class, $context->normalizers); + + if ($value === false) { + throw DataCreationException::invalidParamsPassed(static::class); + } + + return $context->newLazyProxy( + static function () use($context, $value): static { + $data = DeserializePipeline::createFromArray() + ->sendThenReturn(new DeserializePipelinePassable( + classContext: $context, + data: $value + )) + ->data; + + return $context->constructFromArray($data); + } + ); + } catch (Throwable $e) { + throw DataCreationException::unableToCreateLazyInstance(static::class, $e); + } + } + + /** + * @param callable(static $data): static $callable + * + * @throws DataCreationException + */ + public static function createLazyUsing(callable $callable): static + { + try { + /** @var ClassContext $context */ + $context = ClassContext::getInstance(static::class); + + return $context->newLazyProxy($callable); + // @codeCoverageIgnoreStart + } catch (Throwable $e) { + throw DataCreationException::unableToCreateLazyInstance(static::class, $e); + } + // @codeCoverageIgnoreEnd + } +} diff --git a/src/Contexts/ClassContext.php b/src/Contexts/ClassContext.php index c7cde2e..174cefe 100644 --- a/src/Contexts/ClassContext.php +++ b/src/Contexts/ClassContext.php @@ -2,6 +2,8 @@ namespace Nuxtifyts\PhpDto\Contexts; +use Exception; +use Nuxtifyts\PhpDto\Attributes\Class\Lazy; use Nuxtifyts\PhpDto\Attributes\Class\MapName; use Nuxtifyts\PhpDto\Attributes\Class\WithNormalizer; use Nuxtifyts\PhpDto\Contexts\ClassContext\NameMapperConfig; @@ -40,6 +42,8 @@ class ClassContext private(set) ?NameMapperConfig $nameMapperConfig = null; + private(set) bool $isLazy = false; + /** * @param ReflectionClass $reflection * @@ -134,6 +138,8 @@ private function syncClassAttributes(): void to: $instance->to ); } + + $this->isLazy = !empty($this->reflection->getAttributes(Lazy::class)); } /** @@ -157,13 +163,15 @@ public function newInstanceWithConstructorCall(mixed ...$args): mixed } /** + * @desc Creates an instance from an array of values using the constructor + * + * @param array $value + * * @return T * - * @throws ReflectionException - * @throws UnsupportedTypeException - * @throws DataCreationException + * @throws Exception */ - public function emptyValue(): mixed + public function constructFromArray(array $value): mixed { /** @var array $args */ $args = []; @@ -172,12 +180,54 @@ public function emptyValue(): mixed $propertyContext = $this->properties[$paramName] ?? null; if (!$propertyContext) { - throw DataCreationException::invalidProperty(); + throw new Exception('invalid_params_passed'); } - $args[$paramName] = $propertyContext->emptyValue(); + $args[$paramName] = $propertyContext->deserializeFrom($value); } return $this->newInstanceWithConstructorCall(...$args); } + + /** + * @param callable(T $object): T $lazyProxyCallable + * + * @return T + */ + public function newLazyProxy(callable $lazyProxyCallable): mixed + { + /** @phpstan-ignore-next-line */ + return $this->reflection->newLazyProxy($lazyProxyCallable); + } + + /** + * @return T + * + * @throws ReflectionException + * @throws UnsupportedTypeException + * @throws DataCreationException + */ + public function emptyValue(): mixed + { + $emptyValueCreationClosure = function () { + /** @var array $args */ + $args = []; + + foreach ($this->constructorParams as $paramName) { + $propertyContext = $this->properties[$paramName] ?? null; + + if (!$propertyContext) { + throw DataCreationException::invalidProperty(); + } + + $args[$paramName] = $propertyContext->emptyValue(); + } + + return $this->newInstanceWithConstructorCall(...$args); + }; + + return $this->isLazy + ? $this->newLazyProxy($emptyValueCreationClosure) + : $emptyValueCreationClosure(); + } } diff --git a/src/Contracts/LazyData.php b/src/Contracts/LazyData.php new file mode 100644 index 0000000..be57a09 --- /dev/null +++ b/src/Contracts/LazyData.php @@ -0,0 +1,21 @@ +isLazy); + + $lazyDummyData = LazyDummyData::create(propertyA: 'a', propertyB: 'b'); + + self::assertEquals('a', $lazyDummyData->propertyA); + self::assertEquals('b', $lazyDummyData->propertyB); + + $lazyDummyData = LazyDummyData::from(['propertyA' => 'a', 'propertyB' => 'b']); + self::assertEquals('a', $lazyDummyData->propertyA); + self::assertEquals('b', $lazyDummyData->propertyB); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_be_able_to_create_empty_instance_of_lazy_data(): void + { + $lazyDummyData = LazyDummyData::empty(); + + self::assertInstanceOf(LazyDummyData::class, $lazyDummyData); + self::assertEquals('', $lazyDummyData->propertyA); + self::assertEquals('', $lazyDummyData->propertyB); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_be_able_to_clone_instance_of_lazy_data(): void + { + $lazyDummyData = LazyDummyData::create(propertyA: 'a', propertyB: 'b'); + $clonedLazyDummyData = $lazyDummyData->with(propertyA: 'c', propertyB: 'd'); + + self::assertEquals('c', $clonedLazyDummyData->propertyA); + self::assertEquals('d', $clonedLazyDummyData->propertyB); + } +} diff --git a/tests/Unit/Concerns/LazyDataTest.php b/tests/Unit/Concerns/LazyDataTest.php new file mode 100644 index 0000000..b72fa43 --- /dev/null +++ b/tests/Unit/Concerns/LazyDataTest.php @@ -0,0 +1,85 @@ +fullName); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_create_lazy_instance_using_callback(): void + { + $person = PersonData::createLazyUsing( + static fn () => PersonData::create( + firstName: 'John', + lastName: 'Doe' + ) + ); + + self::assertInstanceOf(PersonData::class, $person); + self::assertEquals('John Doe', $person->fullName); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_data_creation_exception_if_params_are_invalid(): void + { + self::expectException(DataCreationException::class); + + PersonData::createLazy( + 'John', + 'Doe' + ); + } + + /** + * @throws Throwable + */ + #[Test] + public function will_throw_deserializing_exception_if_properties_are_missing(): void + { + $person = PersonData::createLazy( + firstName: 'John', + ); + + $this->assertInstanceOf(PersonData::class, $person); + self::expectException(DeserializeException::class); + + /** @phpstan-ignore-next-line */ + $person->fullName; + } +}