From 952d311cc3243d9d40ce3ad576cefb5b8c01f155 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:13:38 +0000 Subject: [PATCH 01/35] Enhance contextual container construction Make contextual attribute results authoritative, including null, so constructor resolution cannot silently fall through to defaults, contextual bindings, or fabricated class instances. Keep public build() and buildWith() as raw construction APIs while retaining SelfBuilding dispatch on normal container resolution. Add request-attribute injection, reusable route and authenticated-user property extraction, and declaration-ordered BindWhen support with reevaluation of conditional misses. Document the worker-lifetime BindWhen constraint and Laravel null-resolution difference, declare the Collections dependency used by property extraction, and cover direct construction, binding precedence, contextual stacks, execution scoping, interleaving, extraction failures, and PHP 8.5 conditional bindings. --- src/container/README.md | 4 + src/container/composer.json | 1 + .../src/Attributes/Authenticated.php | 19 +- src/container/src/Attributes/BindWhen.php | 43 +++ .../Concerns/ExtractsPropertyValue.php | 33 ++ .../src/Attributes/RequestAttribute.php | 37 +++ .../src/Attributes/RouteParameter.php | 16 +- src/container/src/Container.php | 124 +++++--- src/docs/container.md | 38 ++- src/docs/porting-from-laravel.md | 5 + tests/Container/ContainerTest.php | 296 +++++++++++++++++- .../ContextualAttributeBindingTest.php | 234 ++++++++++++++ .../Fixtures/ContainerBindWhenFixtures.php | 159 ++++++++++ 13 files changed, 949 insertions(+), 60 deletions(-) create mode 100644 src/container/src/Attributes/BindWhen.php create mode 100644 src/container/src/Attributes/Concerns/ExtractsPropertyValue.php create mode 100644 src/container/src/Attributes/RequestAttribute.php create mode 100644 tests/Container/Fixtures/ContainerBindWhenFixtures.php diff --git a/src/container/README.md b/src/container/README.md index 0e7a341c0..04104c016 100644 --- a/src/container/README.md +++ b/src/container/README.md @@ -9,4 +9,8 @@ Documentation: https://hypervel.org/docs/container Hypervel supports Laravel's named container APIs, but not container ArrayAccess or dynamic service properties. Use `make()` / `get()`, `bound()` / `has()`, `bind()`, and `instance()`. For temporary instance overrides, use `forgetInstance()` to restore the original binding. Hypervel does not expose arbitrary binding removal because registrations are worker-wide boot-time state. +A contextual attribute's resolved value is authoritative, including `null`. Unlike Laravel, Hypervel does not fall through to class or primitive resolution, contextual bindings, or declared defaults after a contextual resolver returns `null`. + +`#[BindWhen]` conditions must depend only on boot-stable state. The first matching condition becomes a normal worker-lifetime binding; unmatched conditions remain eligible for reevaluation on later resolutions. + Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Container diff --git a/src/container/composer.json b/src/container/composer.json index 55f65552d..557428ebc 100644 --- a/src/container/composer.json +++ b/src/container/composer.json @@ -26,6 +26,7 @@ "php": "^8.4", "ext-swoole": "^6.2.2", "psr/container": "^2.0.1", + "hypervel/collections": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/reflection": "^0.4" diff --git a/src/container/src/Attributes/Authenticated.php b/src/container/src/Attributes/Authenticated.php index 406d0bebe..7f2516413 100644 --- a/src/container/src/Attributes/Authenticated.php +++ b/src/container/src/Attributes/Authenticated.php @@ -5,7 +5,7 @@ namespace Hypervel\Container\Attributes; use Attribute; -use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Container\Attributes\Concerns\ExtractsPropertyValue; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ExecutionScopedAttribute; use UnitEnum; @@ -13,11 +13,18 @@ #[Attribute(Attribute::TARGET_PARAMETER)] class Authenticated implements ExecutionScopedAttribute { + use ExtractsPropertyValue; + /** * Create a new class instance. + * + * Property paths use data_get() and may invoke object accessors or lazy-load + * Eloquent relationships. */ - public function __construct(public UnitEnum|string|null $guard = null) - { + public function __construct( + public UnitEnum|string|null $guard = null, + public ?string $property = null, + ) { } /** @@ -31,8 +38,10 @@ public function isExecutionScoped(): bool /** * Resolve the currently authenticated user. */ - public static function resolve(self $attribute, Container $container): ?Authenticatable + public static function resolve(self $attribute, Container $container): mixed { - return call_user_func($container->make('auth')->userResolver(), $attribute->guard); + $value = call_user_func($container->make('auth')->userResolver(), $attribute->guard); + + return $attribute->extractPropertyValue($value, $attribute->property); } } diff --git a/src/container/src/Attributes/BindWhen.php b/src/container/src/Attributes/BindWhen.php new file mode 100644 index 000000000..4b82aca2d --- /dev/null +++ b/src/container/src/Attributes/BindWhen.php @@ -0,0 +1,43 @@ +concrete = $concrete; + $this->condition = $condition; + } +} diff --git a/src/container/src/Attributes/Concerns/ExtractsPropertyValue.php b/src/container/src/Attributes/Concerns/ExtractsPropertyValue.php new file mode 100644 index 000000000..f5c6a94d0 --- /dev/null +++ b/src/container/src/Attributes/Concerns/ExtractsPropertyValue.php @@ -0,0 +1,33 @@ +make('request')->attributes->get($attribute->parameter); + } +} diff --git a/src/container/src/Attributes/RouteParameter.php b/src/container/src/Attributes/RouteParameter.php index 409a62621..dac354e97 100644 --- a/src/container/src/Attributes/RouteParameter.php +++ b/src/container/src/Attributes/RouteParameter.php @@ -5,6 +5,7 @@ namespace Hypervel\Container\Attributes; use Attribute; +use Hypervel\Container\Attributes\Concerns\ExtractsPropertyValue; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Container\ExecutionScopedAttribute; use ReflectionParameter; @@ -12,11 +13,18 @@ #[Attribute(Attribute::TARGET_PARAMETER)] class RouteParameter implements ExecutionScopedAttribute { + use ExtractsPropertyValue; + /** * Create a new class instance. + * + * Property paths use data_get() and may invoke object accessors or lazy-load + * Eloquent relationships. */ - public function __construct(public ?string $parameter = null) - { + public function __construct( + public ?string $parameter = null, + public ?string $property = null, + ) { } /** @@ -32,6 +40,8 @@ public function isExecutionScoped(): bool */ public static function resolve(self $attribute, Container $container, ReflectionParameter $parameter): mixed { - return $container->make('request')->route($attribute->parameter ?? $parameter->getName()); + $value = $container->make('request')->route($attribute->parameter ?? $parameter->getName()); + + return $attribute->extractPropertyValue($value, $attribute->property); } } diff --git a/src/container/src/Container.php b/src/container/src/Container.php index 537777d56..769c18ea7 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -7,6 +7,7 @@ use Closure; use Exception; use Hypervel\Container\Attributes\Bind; +use Hypervel\Container\Attributes\BindWhen; use Hypervel\Container\Attributes\Scoped; use Hypervel\Container\Attributes\Singleton; use Hypervel\Context\CoroutineContext; @@ -541,7 +542,7 @@ protected function getClosure(string $abstract, string $concrete): Closure { return function ($container, $parameters = []) use ($abstract, $concrete) { if ($abstract === $concrete) { - return $container->build($concrete); + return $container->buildForResolution($concrete); } return $container->resolve( @@ -1235,7 +1236,7 @@ protected function resolve(string $abstract, array $parameters = [], bool $raise // the binding. This will instantiate the types, as well as resolve any of // its "nested" dependencies recursively until all have gotten resolved. $object = $this->isBuildable($concrete, $abstract) - ? $this->build($concrete) + ? $this->buildForResolution($concrete) : $this->make($concrete); // If we defined any extenders for this type, we'll need to spin through them @@ -1453,8 +1454,7 @@ protected function getConcrete(string $abstract): mixed return $this->bindings[$abstract]['concrete']; } - if ($this->environmentResolver === null - || ($this->checkedForAttributeBindings[$abstract] ?? false)) { + if ($this->checkedForAttributeBindings[$abstract] ?? false) { return $abstract; } @@ -1462,7 +1462,7 @@ protected function getConcrete(string $abstract): mixed } /** - * Get the concrete binding for an abstract from the Bind attribute. + * Get the concrete binding for an abstract from the BindWhen or Bind attributes. */ protected function getConcreteBindingFromAttributes(string $abstract): mixed { @@ -1474,45 +1474,65 @@ protected function getConcreteBindingFromAttributes(string $abstract): mixed return $abstract; } - $bindAttributes = $reflected->getAttributes(Bind::class); + $concrete = $this->resolveConcreteFromAttributes($reflected); + + if ($concrete === null) { + if ($reflected->getAttributes(BindWhen::class) !== [] + || ($this->environmentResolver === null && $reflected->getAttributes(Bind::class) !== [])) { + unset($this->checkedForAttributeBindings[$abstract]); + } - if ($bindAttributes === []) { return $abstract; } - $concrete = $maybeConcrete = null; + match ($this->getScopedType($reflected)) { + 'scoped' => $this->scoped($abstract, $concrete), + 'singleton' => $this->singleton($abstract, $concrete), + null => $this->bind($abstract, $concrete), + }; + + return $this->bindings[$abstract]['concrete']; + } + + /** + * Resolve the concrete from the Bind and BindWhen attributes in declaration order. + * + * @param ReflectionClass $reflected + * @return null|class-string + */ + protected function resolveConcreteFromAttributes(ReflectionClass $reflected): ?string + { + $wildcard = null; + + foreach ($reflected->getAttributes() as $reflectedAttribute) { + $name = $reflectedAttribute->getName(); - foreach ($bindAttributes as $reflectedAttribute) { - $instance = $reflectedAttribute->newInstance(); + if ($name === BindWhen::class) { + $instance = $reflectedAttribute->newInstance(); - if ($instance->environments === ['*']) { - $maybeConcrete = $instance->concrete; + if (($instance->condition)($this)) { + return $instance->concrete; + } continue; } - if ($this->currentEnvironmentIs($instance->environments)) { - $concrete = $instance->concrete; + if ($name === Bind::class && $this->environmentResolver !== null) { + $instance = $reflectedAttribute->newInstance(); - break; - } - } + if ($instance->environments === ['*']) { + $wildcard ??= $instance->concrete; - if ($maybeConcrete !== null && $concrete === null) { - $concrete = $maybeConcrete; - } + continue; + } - if ($concrete === null) { - return $abstract; + if ($this->currentEnvironmentIs($instance->environments)) { + return $instance->concrete; + } + } } - match ($this->getScopedType($reflected)) { - 'scoped' => $this->scoped($abstract, $concrete), - 'singleton' => $this->singleton($abstract, $concrete), - null => $this->bind($abstract, $concrete), - }; - - return $this->bindings[$abstract]['concrete']; + return $wildcard; } /** @@ -1816,11 +1836,6 @@ public function build(Closure|string $concrete): mixed return $this->notInstantiable($concrete); } - if (is_a($concrete, SelfBuilding::class, true) - && ! in_array($concrete, $this->getBuildStack(), true)) { - return $this->buildSelfBuildingInstance($concrete, $recipe); - } - $this->pushBuildStack($concrete); try { @@ -1860,6 +1875,27 @@ public function build(Closure|string $concrete): mixed return $instance; } + /** + * Build a concrete as part of container resolution. + */ + protected function buildForResolution(Closure|string $concrete): mixed + { + if ($concrete instanceof Closure + || ! is_a($concrete, SelfBuilding::class, true) + || in_array($concrete, $this->getBuildStack(), true) + ) { + return $this->build($concrete); + } + + $recipe = $this->getBuildRecipe($concrete); + + if (! $recipe->classExists || ! $recipe->isInstantiable) { + return $this->build($concrete); + } + + return $this->buildSelfBuildingInstance($concrete, $recipe); + } + /** * Instantiate a concrete instance of the given self building type. * @@ -1910,24 +1946,22 @@ protected function resolveRecipeParameters(BuildRecipe $recipe): array continue; } - $result = null; - - // Contextual attributes are checked BEFORE class/primitive resolution. - // This is critical for #[Config], #[Give], etc. to work correctly. if ($paramRecipe->contextualAttribute !== null) { + // A contextual result is authoritative even when it is null, matching + // method injection and preventing fallback dependency construction. $result = $this->resolveFromAttribute( $paramRecipe->contextualAttribute, $paramRecipe->getReflectionParameter(), ); + } else { + // If the class is null, it means the dependency is a string or some other + // primitive type which we can not resolve since it is not a class and + // we will just bomb out with an error since we have no-where to go. + $result = ($paramRecipe->className === null) + ? $this->resolvePrimitive($paramRecipe) + : $this->resolveClass($paramRecipe); } - // If the class is null, it means the dependency is a string or some other - // primitive type which we can not resolve since it is not a class and - // we will just bomb out with an error since we have no-where to go. - $result ??= ($paramRecipe->className === null) - ? $this->resolvePrimitive($paramRecipe) - : $this->resolveClass($paramRecipe); - if ($paramRecipe->attributes !== []) { $this->fireAfterResolvingAttributeCallbacks($paramRecipe->attributes, $result); } diff --git a/src/docs/container.md b/src/docs/container.md index 73472a77b..5d162048d 100644 --- a/src/docs/container.md +++ b/src/docs/container.md @@ -385,6 +385,32 @@ interface EventPusher } ``` + +#### Conditional Bind Attribute + +On PHP 8.5 and later, the `BindWhen` attribute may select an implementation using a closure. Conditional and environment-specific bindings are checked in declaration order. The first unconditional `Bind` remains the fallback when none of them match: + +```php + $container->bound('redis'), +)] +interface EventPusher +{ + // ... +} +``` + +`BindWhen` conditions must depend only on state established during application boot. Once a condition matches, Hypervel registers a normal binding that remains in the worker for its lifetime. A condition that has not matched may be evaluated again on a later resolution. + ### Contextual Binding @@ -435,7 +461,7 @@ class PhotoController extends Controller } ``` -In addition to the `Storage` attribute, Hypervel offers `Auth`, `Cache`, `Config`, `Context`, `Database` (with `DB` as a short alias), `Give`, `Log`, `RouteParameter`, and [Tag](#tagging) attributes: +In addition to the `Storage` attribute, Hypervel offers `Auth`, `Authenticated`, `Cache`, `Config`, `Context`, `CurrentUser`, `Database` (with `DB` as a short alias), `Give`, `Log`, `RequestAttribute`, `RouteParameter`, and [Tag](#tagging) attributes: ```php diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 041bad7b8..d89452c5a 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -414,6 +414,10 @@ Container lifecycles are adapted for Swoole: > [!WARNING] > Unbound concrete classes are automatically cached for the worker lifetime after their first resolution. If an unbound class captures the current user, tenant, request, or other mutable per-request data in its constructor, ordinary tests may pass while concurrent requests receive another request's state. Register the class with `bind()` for a fresh instance, use `scoped()` for one instance per request or job coroutine, construct a fresh instance with `build()`, or implement `Transient` when every subclass must always be fresh. Eloquent models already implement `Transient`. +Hypervel treats a contextual attribute's resolved value as authoritative, including `null`. Laravel constructor injection may fall through from `null` to class or primitive resolution, a contextual binding, or a declared default. Move that fallback into the attribute resolver when porting code that relies on this behavior. + +On PHP 8.5 and later, `#[BindWhen]` conditions must depend only on boot-stable state. A matching condition becomes a normal worker-lifetime binding in Hypervel, while an unmatched condition may be evaluated again on a later resolution. Do not read the current request, user, or tenant from the condition. + ### Coroutine-Aware Dependencies @@ -711,6 +715,7 @@ When reviewing a Laravel port, confirm the following: - Code that receives framework-created dates handles immutable Carbon instances correctly. - Service providers extend `Hypervel\Support\ServiceProvider`, keep bindings in `register`, and do not use `DeferrableProvider`. - Request-specific state is not stored on static properties, singleton services, service providers, managers, or unbound concrete services. +- Contextual attribute null fallbacks and `BindWhen` conditions have been adapted to Hypervel's worker-lifetime container behavior. - Per-request values use context, coroutine context, scoped bindings, or fresh objects, while static caches contain only worker-safe immutable data. - Runtime configuration mutation has been removed or replaced with request-scoped state. - Third-party I/O and PHP extensions are coroutine-aware or deliberately isolated in a separate process. diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index 16ab6eeab..a7b315e91 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -18,9 +18,30 @@ use Hypervel\Contracts\Container\SelfBuilding; use Hypervel\Contracts\Container\Transient; use Hypervel\Foundation\Application; +use Hypervel\Tests\Container\Fixtures\BindBeforeBindWhenInterface; +use Hypervel\Tests\Container\Fixtures\BindBeforeConcrete; +use Hypervel\Tests\Container\Fixtures\BindFallbackConcrete; +use Hypervel\Tests\Container\Fixtures\BindWhenAndBindInterface; +use Hypervel\Tests\Container\Fixtures\BindWhenCondition; +use Hypervel\Tests\Container\Fixtures\BindWhenConditionalConcrete; +use Hypervel\Tests\Container\Fixtures\BindWhenConditionalInterface; +use Hypervel\Tests\Container\Fixtures\BindWhenFallbackInterface; +use Hypervel\Tests\Container\Fixtures\BindWhenInterface; +use Hypervel\Tests\Container\Fixtures\BindWhenMaterializedConcrete; +use Hypervel\Tests\Container\Fixtures\BindWhenMaterializedInterface; +use Hypervel\Tests\Container\Fixtures\BindWhenNoMatchInterface; +use Hypervel\Tests\Container\Fixtures\BindWhenScopedInterface; +use Hypervel\Tests\Container\Fixtures\BindWhenSingletonConcrete; +use Hypervel\Tests\Container\Fixtures\BindWhenSingletonInterface; +use Hypervel\Tests\Container\Fixtures\BindWhenState; +use Hypervel\Tests\Container\Fixtures\BindWhenTrueConcrete; +use Hypervel\Tests\Container\Fixtures\BindWhenWinsConcrete; +use Hypervel\Tests\Container\Fixtures\FirstWildcardConcrete; +use Hypervel\Tests\Container\Fixtures\MultipleWildcardBindInterface; use Hypervel\Tests\TestCase; use InvalidArgumentException; use LogicException; +use PHPUnit\Framework\Attributes\RequiresPhp; use Psr\Container\ContainerExceptionInterface; use ReflectionClass; use ReflectionProperty; @@ -32,6 +53,15 @@ class ContainerTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + + if (version_compare(PHP_VERSION, '8.5.0', '>=')) { + require_once __DIR__ . '/Fixtures/ContainerBindWhenFixtures.php'; + } + } + public function testContainerSingleton() { $container = Container::setInstance(new Container); @@ -933,6 +963,19 @@ public function testCurrentlyResolving() $this->assertEquals(ContainerCurrentResolvingConcrete::class, $resolved->currentlyResolving); } + public function testContextualNullTakesPrecedenceOverPrimitiveBindingAndDefault(): void + { + $container = new Container; + $container->when(ContainerContextualNullFallbacks::class) + ->needs('$bound') + ->give('bound value'); + + $resolved = $container->make(ContainerContextualNullFallbacks::class); + + $this->assertNull($resolved->bound); + $this->assertNull($resolved->default); + } + public function testGetAliasRecursive() { $container = new Container; @@ -1346,6 +1389,137 @@ public function testFlushResetsEnvironmentResolverAndCheckedBindings(): void $this->assertInstanceOf(DevConcrete::class, $second); } + #[RequiresPhp('>= 8.5.0')] + public function testBindWhenBindsFirstConditionThatPasses(): void + { + $container = new Container; + + $this->assertInstanceOf(BindWhenTrueConcrete::class, $container->make(BindWhenInterface::class)); + } + + #[RequiresPhp('>= 8.5.0')] + public function testBindWhenSingletonAttribute(): void + { + $container = new Container; + + $first = $container->make(BindWhenSingletonInterface::class); + $second = $container->make(BindWhenSingletonInterface::class); + + $this->assertInstanceOf(BindWhenSingletonConcrete::class, $first); + $this->assertSame($first, $second); + } + + #[RequiresPhp('>= 8.5.0')] + public function testBindWhenScopedAttribute(): void + { + $container = new Container; + + $first = $container->make(BindWhenScopedInterface::class); + + $this->assertSame($first, $container->make(BindWhenScopedInterface::class)); + + $container->forgetScopedInstances(); + + $this->assertNotSame($first, $container->make(BindWhenScopedInterface::class)); + } + + #[RequiresPhp('>= 8.5.0')] + public function testBindWhenThrowsWhenNoConditionPasses(): void + { + $this->expectException(BindingResolutionException::class); + + (new Container)->make(BindWhenNoMatchInterface::class); + } + + #[RequiresPhp('>= 8.5.0')] + public function testBindWhenIsReevaluatedAfterAnInitialMiss(): void + { + $container = new Container; + + try { + $container->make(BindWhenConditionalInterface::class); + + $this->fail('Expected binding resolution to fail when the BindWhen condition does not match.'); + } catch (BindingResolutionException) { + // Continue after the expected first resolution failure. + } + + $container->instance(BindWhenCondition::class, new BindWhenCondition); + + $this->assertInstanceOf( + BindWhenConditionalConcrete::class, + $container->make(BindWhenConditionalInterface::class), + ); + } + + #[RequiresPhp('>= 8.5.0')] + public function testBindWhenTakesPrecedenceOverBind(): void + { + $container = new Container; + $container->resolveEnvironmentUsing(fn (): bool => true); + + $this->assertInstanceOf( + BindWhenWinsConcrete::class, + $container->make(BindWhenAndBindInterface::class), + ); + } + + #[RequiresPhp('>= 8.5.0')] + public function testBindWhenFallsThroughToBind(): void + { + $container = new Container; + $container->resolveEnvironmentUsing(fn (): bool => true); + + $this->assertInstanceOf( + BindFallbackConcrete::class, + $container->make(BindWhenFallbackInterface::class), + ); + } + + #[RequiresPhp('>= 8.5.0')] + public function testBindAndBindWhenResolveInDeclarationOrder(): void + { + $container = new Container; + $container->resolveEnvironmentUsing(fn (array $environments): bool => in_array('foobar', $environments, true)); + + $this->assertInstanceOf( + BindBeforeConcrete::class, + $container->make(BindBeforeBindWhenInterface::class), + ); + } + + #[RequiresPhp('>= 8.5.0')] + public function testFirstWildcardBindWinsDuringAttributeResolution(): void + { + $container = new Container; + $container->resolveEnvironmentUsing(fn (): bool => false); + + $this->assertInstanceOf( + FirstWildcardConcrete::class, + $container->make(MultipleWildcardBindInterface::class), + ); + } + + #[RequiresPhp('>= 8.5.0')] + public function testMatchingBindWhenConditionMaterializesWorkerLifetimeBinding(): void + { + $container = new Container; + $state = new BindWhenState(true); + $container->instance(BindWhenState::class, $state); + + $this->assertInstanceOf( + BindWhenMaterializedConcrete::class, + $container->make(BindWhenMaterializedInterface::class), + ); + + $state->enabled = false; + + $this->assertInstanceOf( + BindWhenMaterializedConcrete::class, + $container->make(BindWhenMaterializedInterface::class), + ); + } + public function testNoMatchingEnvironmentAndNoWildcardThrowsBindingResolutionException(): void { $this->expectException(BindingResolutionException::class); @@ -1890,6 +2064,71 @@ public function testSelfBuildingClassCanBeExplicitlySingletoned() unset($_SERVER['__selfBuilding.counter']); } + + public function testSelfBuildingClassCanBeExplicitlyBound(): void + { + $container = new Container; + + $container->bind(SelfBuildingBuildStub::class); + + $first = $container->make(SelfBuildingBuildStub::class); + $second = $container->make(SelfBuildingBuildStub::class); + + $this->assertSame('factory', $first->value); + $this->assertSame('factory', $second->value); + $this->assertNotSame($first, $second); + } + + public function testExplicitClosureBindingTakesPrecedenceOverSelfBuildingFactory(): void + { + $container = new Container; + + $container->bind( + SelfBuildingBuildStub::class, + fn () => new SelfBuildingBuildStub('closure') + ); + + $this->assertSame('closure', $container->make(SelfBuildingBuildStub::class)->value); + } + + public function testInterfaceBindingDispatchesSelfBuildingFactory(): void + { + $container = new Container; + + $container->bind(SelfBuildingContractStub::class, SelfBuildingBuildStub::class); + + $this->assertSame('factory', $container->make(SelfBuildingContractStub::class)->value); + } + + public function testBuildDirectlyConstructsSelfBuildingClass(): void + { + $container = new Container; + + $instance = $container->build(SelfBuildingBuildStub::class); + + $this->assertSame('constructor', $instance->value); + } + + public function testBuildWithDirectlyConstructsSelfBuildingClassWithOverrides(): void + { + $container = new Container; + + $instance = $container->buildWith(SelfBuildingBuildStub::class, ['value' => 'override']); + + $this->assertSame('override', $instance->value); + } + + public function testBuildWithKeepsSelfBuildingClassOnContextualBuildStack(): void + { + $container = new Container; + $container->when(SelfBuildingContextualBuildStub::class) + ->needs('$value') + ->give('contextual'); + + $instance = $container->buildWith(SelfBuildingContextualBuildStub::class); + + $this->assertSame('contextual', $instance->value); + } } class CircularAStub @@ -2020,16 +2259,13 @@ public function work(IContainerContractStub $stub) } #[Attribute(Attribute::TARGET_PARAMETER)] -class ContainerCurrentResolvingAttribute implements ContextualAttribute +class ContainerCurrentResolvingAttribute { - public function resolve() - { - } } class ContainerCurrentResolvingConcrete { - public $currentlyResolving; + public string $currentlyResolving; public function __construct( #[ContainerCurrentResolvingAttribute] @@ -2039,6 +2275,26 @@ public function __construct( } } +#[Attribute(Attribute::TARGET_PARAMETER)] +class ContainerNullContextualAttribute implements ContextualAttribute +{ + public static function resolve(): mixed + { + return null; + } +} + +class ContainerContextualNullFallbacks +{ + public function __construct( + #[ContainerNullContextualAttribute] + public ?string $bound, + #[ContainerNullContextualAttribute] + public ?string $default = 'default value', + ) { + } +} + #[Singleton] class ContainerSingletonAttribute { @@ -2336,6 +2592,10 @@ class TransientChildStub extends TransientStub { } +interface SelfBuildingContractStub +{ +} + class SelfBuildingCounterStub implements SelfBuilding { public function __construct( @@ -2349,6 +2609,32 @@ public static function newInstance(): self } } +class SelfBuildingBuildStub implements SelfBuilding, SelfBuildingContractStub +{ + public function __construct( + public readonly string $value = 'constructor', + ) { + } + + public static function newInstance(): self + { + return new self('factory'); + } +} + +class SelfBuildingContextualBuildStub implements SelfBuilding +{ + public function __construct( + public readonly string $value, + ) { + } + + public static function newInstance(): self + { + return new self('factory'); + } +} + class ContainerObjectDefaultDependency { } diff --git a/tests/Container/ContextualAttributeBindingTest.php b/tests/Container/ContextualAttributeBindingTest.php index 6faea68c7..210ad0e8d 100644 --- a/tests/Container/ContextualAttributeBindingTest.php +++ b/tests/Container/ContextualAttributeBindingTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Container; +use ArrayObject; use Attribute; use Hypervel\Auth\AuthManager; +use Hypervel\Auth\GenericUser; use Hypervel\Cache\CacheManager; use Hypervel\Cache\Repository as CacheRepository; use Hypervel\Config\Repository; @@ -18,13 +20,16 @@ use Hypervel\Container\Attributes\Database; use Hypervel\Container\Attributes\Give; use Hypervel\Container\Attributes\Log; +use Hypervel\Container\Attributes\RequestAttribute; use Hypervel\Container\Attributes\RouteParameter; use Hypervel\Container\Attributes\Storage; use Hypervel\Container\Attributes\Tag; use Hypervel\Container\Container; use Hypervel\Container\RewindableGenerator; +use Hypervel\Context\RequestContext; use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; use Hypervel\Contracts\Auth\Guard as GuardContract; +use Hypervel\Contracts\Container\BindingResolutionException; use Hypervel\Contracts\Container\ContextualAttribute; use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\Database\ConnectionInterface; @@ -40,6 +45,9 @@ use Psr\Log\LoggerInterface; use ReflectionParameter; use RuntimeException; +use TypeError; + +use function Hypervel\Coroutine\parallel; class ContextualAttributeBindingTest extends TestCase { @@ -171,6 +179,43 @@ public function testAuthedAttribute() $container->make(AuthedTest::class); } + public function testAuthenticatedAttributesCanExtractPropertyPaths(): void + { + $container = new Container; + $user = new GenericUser([ + 'id' => 10, + 'profile' => ['id' => 20], + ]); + + $manager = m::mock(AuthManager::class); + $manager->shouldReceive('userResolver')->twice()->andReturn(fn () => $user); + $container->singleton('auth', fn () => $manager); + + $resolved = $container->make(AuthenticatedPropertyTest::class); + + $this->assertTrue($container->isScoped(AuthenticatedPropertyTest::class)); + $this->assertSame(10, $resolved->userId); + $this->assertSame(20, $resolved->profileId); + } + + public function testAuthenticatedNullIsAuthoritativeForMakeAndCall(): void + { + $container = new Container; + $manager = m::mock(AuthManager::class); + $manager->shouldReceive('userResolver')->times(3)->andReturn(fn () => null); + $container->singleton('auth', fn () => $manager); + + $withoutDefault = $container->make(NullableAuthenticatedWithoutDefault::class); + $withDefault = $container->make(NullableAuthenticatedWithDefault::class); + $called = $container->call( + fn (#[Authenticated] ?AuthenticatableContract $user): ?AuthenticatableContract => $user, + ); + + $this->assertNull($withoutDefault->user); + $this->assertNull($withDefault->user); + $this->assertNull($called); + } + public function testCacheAttribute() { $container = new Container; @@ -310,6 +355,98 @@ public function testRouteParameterAttributeWithoutParameterName(): void $container->make(RouteParameterTestWithoutParameterName::class); } + public function testRouteParameterCanExtractSupportedPropertyPaths(): void + { + $container = new Container; + $model = new ContextualRouteModel; + $model->setAttribute('id', 40); + + $request = m::mock(Request::class); + $request->shouldReceive('route')->with('array')->andReturn(['nested' => ['id' => 10]]); + $request->shouldReceive('route')->with('array-access')->andReturn(new ArrayObject(['id' => 20])); + $request->shouldReceive('route')->with('object')->andReturn((object) ['nested' => (object) ['id' => 30]]); + $request->shouldReceive('route')->with('model')->andReturn($model); + $request->shouldReceive('route')->with('missing')->andReturn(['other' => 50]); + $request->shouldReceive('route')->with('null')->andReturnNull(); + $container->singleton('request', fn () => $request); + + $resolved = $container->make(RouteParameterPropertyTest::class); + + $this->assertTrue($container->isScoped(RouteParameterPropertyTest::class)); + $this->assertSame(10, $resolved->arrayId); + $this->assertSame(20, $resolved->arrayAccessId); + $this->assertSame(30, $resolved->objectId); + $this->assertSame(40, $resolved->modelId); + $this->assertNull($resolved->missingId); + $this->assertNull($resolved->nullId); + } + + public function testRouteParameterPropertyRejectsScalarValues(): void + { + $container = new Container; + $request = m::mock(Request::class); + $request->shouldReceive('route')->with('post')->andReturn(123); + $container->singleton('request', fn () => $request); + + $this->expectException(BindingResolutionException::class); + $this->expectExceptionMessage('Cannot extract property path [id] from scalar [int] resolved by [Hypervel\Container\Attributes\RouteParameter].'); + + $container->make(ScalarRouteParameterPropertyTest::class); + } + + public function testMissingRouteParameterDoesNotConstructAnEmptyModel(): void + { + $container = new Container; + $request = m::mock(Request::class); + $request->shouldReceive('route')->with('post')->andReturnNull(); + $container->singleton('request', fn () => $request); + + $this->expectException(TypeError::class); + + $container->make(MissingRouteModelTest::class); + } + + public function testRequestAttributeResolvesSelectedBagValue(): void + { + $container = new Container; + $request = Request::create('/'); + $request->attributes->set('tenant', 'acme'); + $container->singleton('request', fn () => $request); + + $this->assertTrue($container->isScoped(RequestAttributeTest::class)); + $this->assertSame('acme', $container->make(RequestAttributeTest::class)->tenant); + } + + public function testRouteAndAuthenticatedPropertyExtractionIsIsolatedBetweenExecutions(): void + { + $auth = $this->app->make('auth'); + + $resolve = function (string $routeId, int $userId) use ($auth): array { + $request = Request::create('/'); + $request->setRouteResolver(fn () => new ContextualAttributeTestRoute([ + 'team' => (object) ['id' => $routeId], + ])); + RequestContext::set($request); + $auth->resolveUsersUsing(fn () => new GenericUser(['id' => $userId])); + + usleep(5000); + + $resolved = $this->app->make(InterleavedContextualPropertyTest::class); + + return [$resolved->routeId, $resolved->userId]; + }; + + $results = parallel([ + fn () => $resolve('route-a', 10), + fn () => $resolve('route-b', 20), + ]); + + $this->assertSame([ + ['route-a', 10], + ['route-b', 20], + ], $results); + } + public function testContextAttribute() { $container = new Container; @@ -654,6 +791,31 @@ public function __construct( } } +final class AuthenticatedPropertyTest +{ + public function __construct( + #[Authenticated(property: 'id')] + public int $userId, + #[CurrentUser(property: 'profile.id')] + public int $profileId, + ) { + } +} + +final class NullableAuthenticatedWithoutDefault +{ + public function __construct(#[Authenticated] public ?AuthenticatableContract $user) + { + } +} + +final class NullableAuthenticatedWithDefault +{ + public function __construct(#[Authenticated] public ?AuthenticatableContract $user = null) + { + } +} + final class CacheTest { public function __construct( @@ -773,6 +935,78 @@ public function __construct(#[RouteParameter] Model $foo, #[RouteParameter] stri } } +final class RouteParameterPropertyTest +{ + public function __construct( + #[RouteParameter('array', 'nested.id')] + public int $arrayId, + #[RouteParameter('array-access', 'id')] + public int $arrayAccessId, + #[RouteParameter('object', 'nested.id')] + public int $objectId, + #[RouteParameter('model', 'id')] + public int $modelId, + #[RouteParameter('missing', 'id')] + public ?int $missingId, + #[RouteParameter('null', 'id')] + public ?int $nullId, + ) { + } +} + +final class ScalarRouteParameterPropertyTest +{ + public function __construct(#[RouteParameter('post', 'id')] public ?int $postId) + { + } +} + +final class ContextualRouteModel extends Model +{ +} + +final class MissingRouteModelTest +{ + public function __construct(#[RouteParameter('post')] public ContextualRouteModel $post) + { + } +} + +final class RequestAttributeTest +{ + public function __construct(#[RequestAttribute('tenant')] public string $tenant) + { + } +} + +final class InterleavedContextualPropertyTest +{ + public function __construct( + #[RouteParameter('team', 'id')] + public string $routeId, + #[CurrentUser(property: 'id')] + public int $userId, + ) { + } +} + +final class ContextualAttributeTestRoute +{ + public function __construct(private array $parameters) + { + } + + public function hasParameters(): bool + { + return true; + } + + public function parameter(string $name, mixed $default = null): mixed + { + return $this->parameters[$name] ?? $default; + } +} + final class StorageTest { public function __construct( diff --git a/tests/Container/Fixtures/ContainerBindWhenFixtures.php b/tests/Container/Fixtures/ContainerBindWhenFixtures.php new file mode 100644 index 000000000..fdc26cac8 --- /dev/null +++ b/tests/Container/Fixtures/ContainerBindWhenFixtures.php @@ -0,0 +1,159 @@ +bound(BindWhenCondition::class); +})] +interface BindWhenConditionalInterface +{ +} + +class BindWhenCondition +{ +} + +class BindWhenConditionalConcrete implements BindWhenConditionalInterface +{ +} + +#[BindWhen(BindWhenMaterializedConcrete::class, static function (ContainerContract $container): bool { + return $container->make(BindWhenState::class)->enabled; +})] +interface BindWhenMaterializedInterface +{ +} + +class BindWhenMaterializedConcrete implements BindWhenMaterializedInterface +{ +} + +class BindWhenState +{ + public function __construct(public bool $enabled) + { + } +} + +#[BindWhen(BindWhenWinsConcrete::class, static function (): bool { + return true; +})] +#[Bind(BindLosesConcrete::class)] +interface BindWhenAndBindInterface +{ +} + +class BindWhenWinsConcrete implements BindWhenAndBindInterface +{ +} + +class BindLosesConcrete implements BindWhenAndBindInterface +{ +} + +#[BindWhen(BindWhenSkippedConcrete::class, static function (): bool { + return false; +})] +#[Bind(BindFallbackConcrete::class)] +interface BindWhenFallbackInterface +{ +} + +class BindWhenSkippedConcrete implements BindWhenFallbackInterface +{ +} + +class BindFallbackConcrete implements BindWhenFallbackInterface +{ +} + +#[Bind(BindBeforeConcrete::class, environments: 'foobar')] +#[BindWhen(BindWhenAfterConcrete::class, static function (): bool { + return true; +})] +interface BindBeforeBindWhenInterface +{ +} + +class BindBeforeConcrete implements BindBeforeBindWhenInterface +{ +} + +class BindWhenAfterConcrete implements BindBeforeBindWhenInterface +{ +} + +#[Bind(FirstWildcardConcrete::class)] +#[Bind(SecondWildcardConcrete::class)] +interface MultipleWildcardBindInterface +{ +} + +class FirstWildcardConcrete implements MultipleWildcardBindInterface +{ +} + +class SecondWildcardConcrete implements MultipleWildcardBindInterface +{ +} From 768c2151de7b67b6d92a2c92786e271bbcbb7103 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:13:55 +0000 Subject: [PATCH 02/35] Extract reusable unknown-field validation Move FormRequest's unknown-input check into a Validation-owned helper that reads the validator's effective rules, confirmation fields, declared array subtrees, exact additions, and whole-segment wildcard allowances. Walk the original nested input without flattening literal dot or asterisk keys, retain unescaped public error paths, and fail closed at unsupported escape boundaries. Keep FormRequest's body-versus-query behavior while allowing contents of genuinely free-form array rules and preserving strict structured descendants. Add focused helper and FormRequest regressions for exact, wildcard, escaped, confirmation, opaque-subtree, and structured-array behavior. --- src/foundation/src/Http/FormRequest.php | 47 +-- src/validation/src/UnknownFields.php | 336 ++++++++++++++++++ .../Foundation/FoundationFormRequestTest.php | 48 ++- tests/Validation/UnknownFieldsTest.php | 333 +++++++++++++++++ 4 files changed, 714 insertions(+), 50 deletions(-) create mode 100644 src/validation/src/UnknownFields.php create mode 100644 tests/Validation/UnknownFieldsTest.php diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php index 64382e93e..002b5ce1b 100644 --- a/src/foundation/src/Http/FormRequest.php +++ b/src/foundation/src/Http/FormRequest.php @@ -19,10 +19,9 @@ use Hypervel\Foundation\Http\Traits\HasCasts; use Hypervel\Http\Request; use Hypervel\Routing\Redirector; -use Hypervel\Support\Arr; use Hypervel\Support\ValidatedInput; +use Hypervel\Validation\UnknownFields; use Hypervel\Validation\ValidatesWhenResolvedTrait; -use Hypervel\Validation\ValidationRuleParser; use ReflectionClass; class FormRequest extends Request implements SelfBuilding, ValidatesWhenResolved @@ -265,51 +264,9 @@ protected function shouldFailOnUnknownFields(): bool */ protected function validateNoUnknownFields(Validator $validator): void { - $knownFields = $this->knownFields($validator); $input = $this->isJson() ? $this->json()->all() : $this->request->all(); - foreach (array_keys(Arr::dot($input)) as $inputKey) { - if (! isset($knownFields[$inputKey])) { - $message = $validator->getTranslator()->string('validation.prohibited', [ - 'attribute' => str_replace('_', ' ', $inputKey), - ]); - - $validator->errors()->add($inputKey, $message); - } - } - } - - /** - * Get the known input fields from the validator's effective rules. - * - * @return array - */ - protected function knownFields(Validator $validator): array - { - $fields = []; - $rulesWithoutPlaceholders = $validator->getRulesWithoutPlaceholders(); - - if ($this->unfilteredValidationRules !== null) { - $rulesWithoutPlaceholders = array_replace($this->unfilteredValidationRules, $rulesWithoutPlaceholders); - } - - foreach ($rulesWithoutPlaceholders as $attribute => $rules) { - $attribute = (string) $attribute; - $fields[$attribute] = true; - - /** @var array $rules */ - $rules = (array) $rules; - - foreach ($rules as $rule) { - [$rule, $parameters] = ValidationRuleParser::parse($rule); - - if ($rule === 'Confirmed') { - $fields[(string) ($parameters[0] ?? $attribute . '_confirmation')] = true; - } - } - } - - return $fields; + UnknownFields::validate($validator, $input, $this->unfilteredValidationRules); } /** diff --git a/src/validation/src/UnknownFields.php b/src/validation/src/UnknownFields.php new file mode 100644 index 000000000..0e22b75fa --- /dev/null +++ b/src/validation/src/UnknownFields.php @@ -0,0 +1,336 @@ +> $unfilteredRules + * @param list $additionalFields + * @param list $allowedSubtrees + */ + public static function validate( + Validator $validator, + array $input, + ?array $unfilteredRules = null, + array $additionalFields = [], + array $allowedSubtrees = [], + ): void { + $rules = $unfilteredRules === null + ? $validator->getRulesWithoutPlaceholders() + : array_replace($unfilteredRules, $validator->getRulesWithoutPlaceholders()); + + [$knownFields, $knownSubtrees, $wildcardFields, $wildcardSubtrees] = static::resolveKnownFields( + $rules, + $additionalFields, + $allowedSubtrees, + ); + + static::validateInput( + $validator, + $input, + $knownFields, + $knownSubtrees, + $wildcardFields, + $wildcardSubtrees, + inputSegments: $wildcardFields === [] && $wildcardSubtrees === [] ? null : [], + ); + } + + /** + * Validate input leaves against the known field paths. + * + * @param array $knownFields + * @param array $knownSubtrees + * @param list> $wildcardFields + * @param list> $wildcardSubtrees + * @param null|list $inputSegments + */ + private static function validateInput( + Validator $validator, + array $input, + array $knownFields, + array $knownSubtrees, + array $wildcardFields, + array $wildcardSubtrees, + string $comparisonPrefix = '', + string $displayPrefix = '', + ?array $inputSegments = null, + ): void { + foreach ($input as $key => $value) { + $key = (string) $key; + $comparisonKey = $comparisonPrefix + . str_replace(['.', '*'], ['\\.', '\\*'], $key); + $displayKey = $displayPrefix . $key; + $currentInputSegments = $inputSegments; + + if ($currentInputSegments !== null) { + $currentInputSegments[] = $key; + } + + if (is_array($value) && $value !== []) { + static::validateInput( + $validator, + $value, + $knownFields, + $knownSubtrees, + $wildcardFields, + $wildcardSubtrees, + $comparisonKey . '.', + $displayKey . '.', + $currentInputSegments, + ); + + continue; + } + + if (static::isKnownField( + $comparisonKey, + $currentInputSegments, + $knownFields, + $knownSubtrees, + $wildcardFields, + $wildcardSubtrees, + )) { + continue; + } + + $message = $validator->getTranslator()->string('validation.prohibited', [ + 'attribute' => str_replace('_', ' ', $displayKey), + ]); + + $validator->errors()->add($displayKey, $message); + } + } + + /** + * Resolve exact fields and opaque array subtrees from effective rules. + * + * @param array> $rules + * @param list $additionalFields + * @param list $allowedSubtrees + * @return array{ + * array, + * array, + * list>, + * list> + * } + */ + private static function resolveKnownFields( + array $rules, + array $additionalFields, + array $allowedSubtrees, + ): array { + [$knownFields, $wildcardFields] = static::resolveAuxiliaryPaths($additionalFields); + [$opaqueSubtrees, $wildcardSubtrees] = static::resolveAuxiliaryPaths($allowedSubtrees); + $fieldsWithDescendants = []; + + foreach (array_keys($rules) as $attribute) { + $attribute = (string) $attribute; + $knownFields[$attribute] = true; + + foreach (static::parentPaths($attribute) as $parent) { + $fieldsWithDescendants[$parent] = true; + } + } + + foreach ($rules as $attribute => $attributeRules) { + $attribute = (string) $attribute; + + foreach ($attributeRules as $rule) { + [$rule, $parameters] = ValidationRuleParser::parse($rule); + + if ($rule === 'Confirmed') { + $knownFields[(string) ($parameters[0] ?? $attribute . '_confirmation')] = true; + } + + if ($rule === 'Array' && ! isset($fieldsWithDescendants[$attribute])) { + $opaqueSubtrees[$attribute] = true; + } + } + } + + return [$knownFields, $opaqueSubtrees, $wildcardFields, $wildcardSubtrees]; + } + + /** + * Resolve exact and whole-segment wildcard auxiliary paths. + * + * @param list $paths + * @return array{array, list>} + */ + private static function resolveAuxiliaryPaths(array $paths): array + { + $exactPaths = []; + $wildcardPaths = []; + + foreach ($paths as $path) { + $segments = static::parseAuxiliaryPath($path); + + if ($segments === null) { + continue; + } + + if (in_array(null, $segments, true)) { + $wildcardPaths[] = $segments; + } else { + $exactPaths[$path] = true; + } + } + + return [$exactPaths, $wildcardPaths]; + } + + /** + * Determine whether an input path is exact or inside an allowed subtree. + * + * @param null|list $inputSegments + * @param array $knownFields + * @param array $allowedSubtrees + * @param list> $wildcardFields + * @param list> $wildcardSubtrees + */ + private static function isKnownField( + string $inputKey, + ?array $inputSegments, + array $knownFields, + array $allowedSubtrees, + array $wildcardFields, + array $wildcardSubtrees, + ): bool { + if (isset($knownFields[$inputKey]) || isset($allowedSubtrees[$inputKey])) { + return true; + } + + foreach (static::parentPaths($inputKey) as $parent) { + if (isset($allowedSubtrees[$parent])) { + return true; + } + } + + if ($wildcardFields === [] && $wildcardSubtrees === []) { + return false; + } + + /** @var list $inputSegments */ + foreach ($wildcardFields as $pattern) { + if (static::matchesPathPattern($pattern, $inputSegments)) { + return true; + } + } + + foreach ($wildcardSubtrees as $pattern) { + if (static::matchesPathPattern($pattern, $inputSegments, allowsDescendants: true)) { + return true; + } + } + + return false; + } + + /** + * Determine whether input segments match an auxiliary path pattern. + * + * @param list $pattern + * @param list $inputSegments + */ + private static function matchesPathPattern( + array $pattern, + array $inputSegments, + bool $allowsDescendants = false, + ): bool { + $patternLength = count($pattern); + $inputLength = count($inputSegments); + + if ($inputLength < $patternLength + || (! $allowsDescendants && $inputLength !== $patternLength) + ) { + return false; + } + + foreach ($pattern as $index => $segment) { + if ($segment !== null && $segment !== $inputSegments[$index]) { + return false; + } + } + + return true; + } + + /** + * Parse an auxiliary path or reject a partial wildcard segment. + * + * @return null|list + */ + private static function parseAuxiliaryPath(string $path): ?array + { + $segments = []; + $segment = ''; + $hasUnescapedAsterisk = false; + $length = strlen($path); + + for ($position = 0; $position < $length; ++$position) { + $character = $path[$position]; + + if ($character === '\\' + && $position + 1 < $length + && in_array($path[$position + 1], ['.', '*'], true) + ) { + $segment .= $path[++$position]; + + continue; + } + + if ($character === '.') { + if ($hasUnescapedAsterisk && $segment !== '*') { + return null; + } + + $segments[] = $hasUnescapedAsterisk ? null : $segment; + $segment = ''; + $hasUnescapedAsterisk = false; + + continue; + } + + $hasUnescapedAsterisk = $hasUnescapedAsterisk || $character === '*'; + $segment .= $character; + } + + if ($hasUnescapedAsterisk && $segment !== '*') { + return null; + } + + $segments[] = $hasUnescapedAsterisk ? null : $segment; + + return $segments; + } + + /** + * Get parent paths using Validator's escaped-dot notation. + * + * @return list + */ + private static function parentPaths(string $path): array + { + $parents = []; + + // Validator does not escape backslashes themselves, so a raw key ending + // in a backslash before a child is interpreted as escaped and fails closed. + for ($position = strlen($path) - 1; $position >= 0; --$position) { + if ($path[$position] !== '.' || ($position > 0 && $path[$position - 1] === '\\')) { + continue; + } + + $parents[] = substr($path, 0, $position); + } + + return $parents; + } +} diff --git a/tests/Foundation/FoundationFormRequestTest.php b/tests/Foundation/FoundationFormRequestTest.php index 4efa7c8c9..1de57bdbf 100644 --- a/tests/Foundation/FoundationFormRequestTest.php +++ b/tests/Foundation/FoundationFormRequestTest.php @@ -361,7 +361,7 @@ public function testFailOnUnknownFieldsPassesForInputMatchingWildcardRulesOnly() ); } - public function testFailOnUnknownFieldsWildcardMatchesSingleSegmentOnly(): void + public function testFailOnUnknownFieldsAllowsContentsOfWildcardArrayRules(): void { $request = $this->createRequest( [ @@ -373,11 +373,32 @@ public function testFailOnUnknownFieldsWildcardMatchesSingleSegmentOnly(): void 'POST' ); - $exception = $this->catchException(ValidationException::class, function () use ($request) { - $request->validateResolved(); - }); + $request->validateResolved(); - $this->assertTrue($exception->validator->errors()->has('items.0.name')); + $this->assertSame([ + 'items' => [ + ['name' => 'a'], + ], + ], $request->validated()); + } + + public function testFailOnUnknownFieldsAllowsContentsOfDeclaredArrays(): void + { + $request = $this->createRequest( + [ + 'meta' => ['source' => 'import'], + 'tags' => ['framework', 'php'], + ], + FoundationTestFormRequestFailOnUnknownFieldsWithOpaqueArraysStub::class, + 'POST' + ); + + $request->validateResolved(); + + $this->assertSame([ + 'meta' => ['source' => 'import'], + 'tags' => ['framework', 'php'], + ], $request->validated()); } public function testFailOnUnknownFieldsRejectsMultipleUnknownKeys(): void @@ -1009,6 +1030,23 @@ public function authorize(): bool } } +#[FailOnUnknownFields] +class FoundationTestFormRequestFailOnUnknownFieldsWithOpaqueArraysStub extends FormRequest +{ + public function rules(): array + { + return [ + 'meta' => 'array', + 'tags' => 'array', + ]; + } + + public function authorize(): bool + { + return true; + } +} + #[FailOnUnknownFields] class FoundationTestFormRequestFailOnUnknownFieldsNestedStub extends FormRequest { diff --git a/tests/Validation/UnknownFieldsTest.php b/tests/Validation/UnknownFieldsTest.php new file mode 100644 index 000000000..04e6c8404 --- /dev/null +++ b/tests/Validation/UnknownFieldsTest.php @@ -0,0 +1,333 @@ +validator( + ['name' => 'Taylor'], + ['name' => 'required'], + ['name' => 'Taylor', 'role' => 'admin'], + ); + + $this->assertTrue($validator->fails()); + $this->assertTrue($validator->errors()->has('role')); + } + + public function testItAllowsDefaultAndCustomConfirmationFields(): void + { + $validator = $this->validator( + [ + 'password' => 'secret', + 'password_confirmation' => 'secret', + 'pin' => '1234', + 'repeat_pin' => '1234', + ], + [ + 'password' => 'confirmed', + 'pin' => 'confirmed:repeat_pin', + ], + [ + 'password' => 'secret', + 'password_confirmation' => 'secret', + 'pin' => '1234', + 'repeat_pin' => '1234', + ], + ); + + $this->assertFalse($validator->fails()); + } + + public function testItAllowsContentsOfLeafArrayRules(): void + { + $validator = $this->validator( + [ + 'meta' => ['source' => 'import'], + 'tags' => ['framework', 'php'], + 'items' => [['name' => 'first']], + ], + [ + 'meta' => 'array', + 'tags' => 'array', + 'items.*' => 'array', + ], + [ + 'meta' => ['source' => 'import'], + 'tags' => ['framework', 'php'], + 'items' => [['name' => 'first']], + ], + ); + + $this->assertFalse($validator->fails()); + } + + public function testArrayRulesWithDescendantsRemainStructured(): void + { + $validator = $this->validator( + ['items' => [['id' => 1, 'name' => 'first']]], + [ + 'items' => 'array', + 'items.*.id' => 'required|integer', + ], + ['items' => [['id' => 1, 'name' => 'first']]], + ); + + $this->assertTrue($validator->fails()); + $this->assertTrue($validator->errors()->has('items.0.name')); + } + + public function testItAcceptsAdditionalExactFieldsAndExplicitSubtrees(): void + { + $validator = $this->validator( + [], + [], + [ + 'context' => 'server-owned', + 'meta' => ['source' => 'import'], + ], + additionalFields: ['context'], + allowedSubtrees: ['meta'], + ); + + $this->assertFalse($validator->fails()); + } + + public function testAdditionalFieldsDoNotAllowDescendants(): void + { + $validator = $this->validator( + [], + [], + ['context' => ['id' => 1]], + additionalFields: ['context'], + ); + + $this->assertTrue($validator->fails()); + $this->assertTrue($validator->errors()->has('context.id')); + } + + public function testItAcceptsWildcardAdditionalFields(): void + { + $validator = $this->validator( + [], + [], + ['items' => [['serverUser' => 1]]], + additionalFields: ['items.*.serverUser'], + ); + + $this->assertFalse($validator->fails()); + } + + public function testWildcardAdditionalFieldsDoNotAllowDescendants(): void + { + $validator = $this->validator( + [], + [], + ['items' => [['context' => ['id' => 1]]]], + additionalFields: ['items.*.context'], + ); + + $this->assertTrue($validator->fails()); + $this->assertTrue($validator->errors()->has('items.0.context.id')); + } + + public function testItAcceptsWildcardSubtreesAndTheirOwnEmptyLeaf(): void + { + $validator = $this->validator( + [], + [], + [ + 'items' => [ + ['meta' => ['source' => 'import']], + ['meta' => []], + ], + ], + allowedSubtrees: ['items.*.meta'], + ); + + $this->assertFalse($validator->fails()); + } + + public function testItTreatsEscapedAsterisksAsLiteralAuxiliarySegments(): void + { + $validator = $this->validator( + [], + [], + ['items' => [['literal*' => 'value']]], + additionalFields: ['items.*.literal\*'], + ); + + $this->assertFalse($validator->fails()); + } + + public function testPartialAsteriskAuxiliarySegmentsFailClosed(): void + { + $validator = $this->validator( + [], + [], + [ + 'items' => [ + 'a*' => ['value' => 1], + 'alpha' => ['value' => 2], + ], + ], + additionalFields: ['items.a*.value'], + ); + + $this->assertTrue($validator->fails()); + $this->assertArrayHasKey('items.a*.value', $validator->errors()->messages()); + $this->assertArrayHasKey('items.alpha.value', $validator->errors()->messages()); + } + + public function testItMergesUnfilteredRulesWithEffectiveRules(): void + { + $validator = $this->validator( + ['name' => 'Taylor'], + ['name' => 'required'], + [ + 'name' => 'Taylor', + 'email' => 'taylor@example.com', + ], + unfilteredRules: [ + 'name' => ['required'], + 'email' => ['required', 'email'], + ], + ); + + $this->assertFalse($validator->fails()); + } + + public function testItAllowsLiteralDotAndAsteriskKeys(): void + { + $input = [ + 'maps' => ['example.com' => ['id' => 1]], + 'labels' => ['literal*' => 'value'], + ]; + + $validator = $this->validator( + $input, + [ + 'maps.example\.com.id' => 'required|integer', + 'labels.literal\*' => 'required|string', + ], + $input, + ); + + $this->assertFalse($validator->fails()); + } + + public function testItAllowsWildcardExpandedLiteralAsteriskKeys(): void + { + $input = ['items' => ['literal*' => ['id' => 1]]]; + + $validator = $this->validator( + $input, + ['items.*.id' => 'required|integer'], + $input, + ); + + $this->assertFalse($validator->fails()); + } + + public function testItPreservesUnescapedPublicErrorPaths(): void + { + $validator = $this->validator( + [], + [], + ['unknown.key' => ['child*' => 'value']], + ); + + $this->assertTrue($validator->fails()); + $this->assertTrue($validator->errors()->has('unknown.key.child*')); + $this->assertFalse($validator->errors()->has('unknown\.key.child\*')); + } + + public function testItMatchesAllowedSubtreesAcrossEscapedSegments(): void + { + $validator = $this->validator( + [], + [], + ['maps' => ['example.com' => ['source' => 'import']]], + allowedSubtrees: ['maps.example\.com'], + ); + + $this->assertFalse($validator->fails()); + } + + public function testEscapedSegmentsCannotMatchADifferentTrailingBackslashSubtree(): void + { + $validator = $this->validator( + [], + [], + ['maps' => ['example.com' => ['source' => 'import']]], + allowedSubtrees: ['maps.example\\'], + ); + + $this->assertTrue($validator->fails()); + $this->assertTrue($validator->errors()->has('maps.example.com.source')); + } + + public function testTrailingBackslashSubtreeBeforeAChildFailsClosed(): void + { + $validator = $this->validator( + [], + [], + ['maps' => ['back\\' => ['source' => 'import']]], + allowedSubtrees: ['maps.back\\'], + ); + + $this->assertTrue($validator->fails()); + $this->assertTrue($validator->errors()->has('maps.back\.source')); + } + + /** + * Create a validator with unknown-field checking attached. + * + * @param array $validationData + * @param array $rules + * @param array $input + * @param null|array> $unfilteredRules + * @param list $additionalFields + * @param list $allowedSubtrees + */ + private function validator( + array $validationData, + array $rules, + array $input, + ?array $unfilteredRules = null, + array $additionalFields = [], + array $allowedSubtrees = [], + ): Validator { + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + $validationData, + $rules, + ); + + $validator->after(static function (Validator $validator) use ( + $input, + $unfilteredRules, + $additionalFields, + $allowedSubtrees, + ): void { + UnknownFields::validate( + $validator, + $input, + $unfilteredRules, + $additionalFields, + $allowedSubtrees, + ); + }); + + return $validator; + } +} From f3aff0e004f53a56036bbacfbf109ecf35986e85 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:14:14 +0000 Subject: [PATCH 03/35] Harden compiled validation rule execution Normalize rules before wildcard merging, preserve first-declared wildcard identity, clear implicit state when rule graphs are replaced, and support literal asterisks plus Laravel-compatible partial-segment wildcards without weakening missing-leaf validation. Count immutable presence-check consumers in compiled plans so exact Exists and Unique rules can use the existing guarded batch path alongside wildcard rules. Retain ordinary execution for callbacks, unsafe query shapes, mutation-sensitive cases, custom validators and verifiers, exclusions, uploads, and stop-on-first-failure. Make string-reducing rule detection reusable by Data's conservative comparisons, reject unsupported email validation modes instead of silently changing semantics, align NotIn's native input type with In, and clean the Can constructor. Cover parser precedence, stale rule replacement, wildcard identity and dependent substitution, literal keys, partial patterns, exact database batching, mutation-aware fact reuse, fallbacks, repeated passes, and strict email diagnostics. --- src/docs/validation.md | 2 + src/validation/README.md | 1 + src/validation/src/AttributePlan.php | 2 + src/validation/src/BatchDatabaseChecker.php | 2 +- .../src/Concerns/ValidatesAttributes.php | 6 +- src/validation/src/RuleCompiler.php | 40 ++- src/validation/src/Rules/Can.php | 9 +- src/validation/src/Rules/NotIn.php | 4 +- src/validation/src/ValidationRuleParser.php | 48 ++- src/validation/src/Validator.php | 75 +++-- ...ValidationBatchDatabaseCheckerTestCase.php | 314 +++++++++++++++++- tests/Validation/ValidationNotInRuleTest.php | 10 + tests/Validation/ValidationRuleParserTest.php | 81 +++++ tests/Validation/ValidationValidatorTest.php | 186 +++++++++++ .../ValidationWildcardExpansionTest.php | 51 +++ 15 files changed, 760 insertions(+), 71 deletions(-) diff --git a/src/docs/validation.md b/src/docs/validation.md index a6a819c71..9334c42c9 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -1947,6 +1947,8 @@ The example above will apply the `RFCValidation` and `DNSCheckValidation` valida +If you specify an unsupported validation style, Hypervel will throw an `InvalidArgumentException` instead of falling back to another style. + For convenience, email validation rules may be built using the fluent rule builder: ```php diff --git a/src/validation/README.md b/src/validation/README.md index bb88dc39a..7a06d1a62 100644 --- a/src/validation/README.md +++ b/src/validation/README.md @@ -9,5 +9,6 @@ Documentation: https://hypervel.org/docs/validation - Scalar `in` and `not_in` rules compare the submitted value with the rule's literal values as strings. Numeric strings are not loosely coerced. - Date comparison rules allow a referenced field to be missing or `null` unless it is also required. Unparseable date strings and invalid referenced values fail validation instead of being compared with `null`. +- Rule keys may escape a literal asterisk as `\*`, matching the existing `\.` literal-dot syntax. Ported from: https://github.com/laravel/framework diff --git a/src/validation/src/AttributePlan.php b/src/validation/src/AttributePlan.php index 2d1996f95..38444d71c 100644 --- a/src/validation/src/AttributePlan.php +++ b/src/validation/src/AttributePlan.php @@ -20,6 +20,8 @@ final class AttributePlan public bool $sometimes = false; + public int $databasePresenceCheckCount = 0; + /** @var list */ public array $checks = []; } diff --git a/src/validation/src/BatchDatabaseChecker.php b/src/validation/src/BatchDatabaseChecker.php index 4dbc0f3b7..c37746707 100644 --- a/src/validation/src/BatchDatabaseChecker.php +++ b/src/validation/src/BatchDatabaseChecker.php @@ -5,7 +5,7 @@ namespace Hypervel\Validation; /** - * Query wildcard database-presence candidates in groups. + * Query database-presence candidates in groups. * * The validator owns rule interpretation and ordered candidate selection. This * class turns each complete query shape into database-proven execution-local diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index da49e23fd..6e36cc28f 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -900,13 +900,17 @@ public function validateEmail(string $attribute, mixed $value, mixed $parameters $validations = (new Collection($parameters)) ->unique() ->map(fn ($validation) => match (true) { + $validation === 'rfc' => new RFCValidation, $validation === 'strict' => new NoRFCWarningsValidation, $validation === 'dns' => new DNSCheckValidation(static::$fakeDnsLookups ? new FakeDnsGetRecordWrapper : null), $validation === 'spoof' => new SpoofCheckValidation, $validation === 'filter' => new FilterEmailValidation, $validation === 'filter_unicode' => FilterEmailValidation::unicode(), is_string($validation) && class_exists($validation) => $this->container->make($validation), - default => new RFCValidation, + default => throw new InvalidArgumentException(sprintf( + 'Validation rule email parameter [%s] is not supported.', + is_string($validation) ? $validation : get_debug_type($validation), + )), }) ->values() ->all() ?: [new RFCValidation]; diff --git a/src/validation/src/RuleCompiler.php b/src/validation/src/RuleCompiler.php index eece5c106..bd231a62c 100644 --- a/src/validation/src/RuleCompiler.php +++ b/src/validation/src/RuleCompiler.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Validation\Enums\CheckType; +use Hypervel\Validation\Rules\Exists; +use Hypervel\Validation\Rules\Unique; /** * Compile pipe-string or array rules into an AttributePlan. @@ -157,7 +159,8 @@ private static function compileRule(mixed $rule, array $parsedRule, AttributePla return; } - $plan->checks[] = new DelegatedCheck( + self::appendDelegatedCheck( + $plan, ruleName: $ruleName, parameters: $parameters, originalRule: $rule, @@ -197,13 +200,46 @@ private static function compileRuleDelegated(mixed $rule, AttributePlan $plan, ? $plan->sometimes = true; } - $plan->checks[] = new DelegatedCheck( + self::appendDelegatedCheck( + $plan, ruleName: $ruleName, parameters: $parameters, originalRule: $rule, ); } + /** + * Append a parsed delegated check to the plan. + */ + private static function appendDelegatedCheck( + AttributePlan $plan, + string $ruleName, + array $parameters, + mixed $originalRule, + ): void { + $check = new DelegatedCheck($ruleName, $parameters, $originalRule); + $plan->checks[] = $check; + + if (self::canConsumePrecomputedPresenceLookup($check)) { + ++$plan->databasePresenceCheckCount; + } + } + + /** + * Determine if a check can consume a precomputed database-presence lookup. + */ + private static function canConsumePrecomputedPresenceLookup(DelegatedCheck $check): bool + { + if (! $check->parametersAreScalar + || ($check->ruleName !== 'Exists' && $check->ruleName !== 'Unique') + ) { + return false; + } + + return ! (($check->originalRule instanceof Exists || $check->originalRule instanceof Unique) + && $check->originalRule->queryCallbacks() !== []); + } + /** * Attempt to compile a parsed string rule as an InlineCheck. * diff --git a/src/validation/src/Rules/Can.php b/src/validation/src/Rules/Can.php index 7d6c86241..10c15d0d2 100644 --- a/src/validation/src/Rules/Can.php +++ b/src/validation/src/Rules/Can.php @@ -17,17 +17,12 @@ class Can implements Rule, ValidatorAwareRule protected ?Validator $validator = null; /** - * Constructor. - * - * @param string $ability the ability to check - * @param array $arguments the arguments to pass to the authorization check + * Create a new can validation rule. */ public function __construct( protected string $ability, - protected array $arguments = [] + protected array $arguments = [], ) { - $this->ability = $ability; - $this->arguments = $arguments; } /** diff --git a/src/validation/src/Rules/NotIn.php b/src/validation/src/Rules/NotIn.php index 4f5f312b6..95581e3ba 100644 --- a/src/validation/src/Rules/NotIn.php +++ b/src/validation/src/Rules/NotIn.php @@ -24,10 +24,8 @@ class NotIn implements Stringable /** * Create a new "not in" rule instance. - * - * @param array|Arrayable|string|UnitEnum $values */ - public function __construct($values) + public function __construct(array|Arrayable|UnitEnum|string $values) { if ($values instanceof Arrayable) { $values = $values->toArray(); diff --git a/src/validation/src/ValidationRuleParser.php b/src/validation/src/ValidationRuleParser.php index 602e037ae..833291cf8 100644 --- a/src/validation/src/ValidationRuleParser.php +++ b/src/validation/src/ValidationRuleParser.php @@ -121,15 +121,11 @@ protected function prepareRule(mixed $rule, string $attribute): mixed $rule = InvokableValidationRule::make($rule); } - if (! is_object($rule) - || $rule instanceof RuleContract - || ($rule instanceof Exists && $rule->queryCallbacks()) - || ($rule instanceof Unique && $rule->queryCallbacks()) - ) { - return $rule; + if (static::ruleReducesToString($rule)) { + return (string) $rule; } - if ($rule instanceof CompilableRules) { + if ($rule instanceof CompilableRules && ! $rule instanceof RuleContract) { return $rule->compile( $attribute, $this->data[$attribute] ?? null, @@ -138,7 +134,22 @@ protected function prepareRule(mixed $rule, string $attribute): mixed )->rules[$attribute]; } - return (string) $rule; + return $rule; + } + + /** + * Determine if the parser reduces a rule object to its string form. + */ + public static function ruleReducesToString(mixed $rule): bool + { + return is_object($rule) + && ! $rule instanceof Closure + && ! $rule instanceof InvokableRule + && ! $rule instanceof ValidationRule + && ! $rule instanceof RuleContract + && ! $rule instanceof CompilableRules + && ! (($rule instanceof Exists || $rule instanceof Unique) + && $rule->queryCallbacks() !== []); } /** @@ -176,7 +187,10 @@ protected function explodeWildcardRules(array $results, string $attribute, array foreach ($keys as $key) { $this->implicitAttributes[$attribute][] = $key; - $results[$key] = array_merge($results[$key] ?? [], $explodedRules); + $results[$key] = array_merge( + isset($results[$key]) ? $this->explodeExplicitRule($results[$key], $key) : [], + $explodedRules, + ); } return $results; @@ -327,6 +341,22 @@ protected function traverseWildcardSegments(array $segments, int $index, mixed $ return; } + if (str_contains($segment, '*')) { + if (! is_array($data)) { + return; + } + + $pattern = '/^' . str_replace('\*', '[^\.]*', preg_quote($segment, '/')) . '\z/'; + + foreach ($data as $key => $value) { + if (preg_match($pattern, (string) $key) === 1) { + $this->traverseWildcardSegments($segments, $index + 1, $value, $prefix . $key . '.', $results); + } + } + + return; + } + $nextData = is_array($data) && array_key_exists($segment, $data) ? $data[$segment] : null; $this->traverseWildcardSegments($segments, $index + 1, $nextData, $prefix . $segment . '.', $results); diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 723ee241f..a4fc1e640 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -98,8 +98,8 @@ class Validator implements ValidatorContract /** * Reverse lookup from concrete expanded attribute to its wildcard pattern. * - * Built lazily by getImplicitAttributeMap(). Invalidated when - * addRules() or sometimes() modify $implicitAttributes. + * Built lazily by getImplicitAttributeMap(). Invalidated whenever + * the implicit attribute graph changes. * * @var null|array */ @@ -122,6 +122,11 @@ class Validator implements ValidatorContract */ protected array $compiledPlans = []; + /** + * Database-presence checks that can consume precomputed lookups during the current pass. + */ + private int $databasePresenceCheckCount = 0; + /** * Parsed presence-rule tables for the current passes() invocation. * @@ -410,12 +415,16 @@ protected function replacePlaceholderInString(string $value): string } /** - * Replace each field parameter dot placeholder with dot. + * Replace each field parameter key placeholder. */ protected function replaceDotPlaceholderInParameters(array $parameters): array { return array_map(function ($field) { - return str_replace('__dot__' . static::$placeholderHash, '.', $field); + return str_replace( + ['__dot__' . static::$placeholderHash, '__asterisk__' . static::$placeholderHash], + ['.', '*'], + $field, + ); }, $parameters); } @@ -460,6 +469,7 @@ public function passes(): bool if ($activeVerifier !== null && $activeVerifier::class === DatabasePresenceVerifier::class && ! $this->stopOnFirstFailure + && $this->databasePresenceCheckCount >= 2 ) { $this->maybeBatchDatabaseChecks( $activeVerifier, @@ -509,6 +519,7 @@ protected function compileRules(): array { $plans = []; $isBaseValidator = static::class === self::class; + $this->databasePresenceCheckCount = 0; foreach ($this->rules as $attribute => $rules) { $attribute = (string) $attribute; @@ -517,23 +528,20 @@ protected function compileRules(): array $rules = [$rules]; } - if ($isBaseValidator) { - $cached = RulePlanCache::get($rules); - if ($cached !== null) { - $plans[$attribute] = $cached; - continue; - } - } + $plan = $isBaseValidator ? RulePlanCache::get($rules) : null; - $plan = $isBaseValidator - ? RuleCompiler::compile($rules, $this->defaultNumericRules) - : RuleCompiler::compileAllDelegated($rules); + if ($plan === null) { + $plan = $isBaseValidator + ? RuleCompiler::compile($rules, $this->defaultNumericRules) + : RuleCompiler::compileAllDelegated($rules); - if ($isBaseValidator) { - RulePlanCache::put($rules, $plan); + if ($isBaseValidator) { + RulePlanCache::put($rules, $plan); + } } $plans[$attribute] = $plan; + $this->databasePresenceCheckCount += $plan->databasePresenceCheckCount; } return $plans; @@ -709,7 +717,7 @@ protected function preEvaluateExclusions(): array } /** - * Batch safe wildcard database-presence candidates by query shape. + * Batch safe database-presence candidates by query shape. * * @param array $preExcludedAttributes * @param array $unresolvedExclusionAttributes @@ -719,26 +727,11 @@ protected function maybeBatchDatabaseChecks( array $preExcludedAttributes, array $unresolvedExclusionAttributes, ): void { - if ($this->implicitAttributes === []) { - return; - } - - $wildcardAttributes = array_merge(...array_values($this->implicitAttributes)); - if ($wildcardAttributes === []) { - return; - } - - $wildcardAttributeSet = array_flip($wildcardAttributes); - $groups = []; foreach ($this->compiledPlans as $attribute => $plan) { $attribute = (string) $attribute; - if (! isset($wildcardAttributeSet[$attribute])) { - continue; - } - $firstPresenceIndex = null; foreach ($plan->checks as $index => $check) { @@ -1226,7 +1219,7 @@ private function getImplicitAttributeMap(): array foreach ($this->implicitAttributes as $pattern => $concreteAttributes) { foreach ($concreteAttributes as $concrete) { - $this->implicitAttributeMap[$concrete] = $pattern; + $this->implicitAttributeMap[$concrete] ??= $pattern; } } } @@ -1235,7 +1228,7 @@ private function getImplicitAttributeMap(): array } /** - * Replace each field parameter which has an escaped dot with the dot placeholder. + * Replace each escaped field parameter separator with its placeholder. */ protected function replaceDotInParameters(array $parameters): array { @@ -1670,6 +1663,8 @@ public function setRules(array $rules): static $this->initialRules = $rules; $this->rules = []; + $this->implicitAttributes = []; + $this->implicitAttributeMap = null; $this->addRules($rules); @@ -2044,7 +2039,11 @@ protected function callClassBasedExtension(string $callback, array $parameters): */ protected static function encodeAttributeWithPlaceholder(string $attribute): string { - return str_replace('\.', '__dot__' . static::$placeholderHash, $attribute); + return str_replace( + ['\.', '\*'], + ['__dot__' . static::$placeholderHash, '__asterisk__' . static::$placeholderHash], + $attribute, + ); } /** @@ -2052,7 +2051,11 @@ protected static function encodeAttributeWithPlaceholder(string $attribute): str */ protected static function decodeAttributeWithPlaceholder(string $attribute): string { - return str_replace('__dot__' . static::$placeholderHash, '\.', $attribute); + return str_replace( + ['__dot__' . static::$placeholderHash, '__asterisk__' . static::$placeholderHash], + ['\.', '\*'], + $attribute, + ); } /** diff --git a/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php b/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php index dcd12fd32..c42f2db10 100644 --- a/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php +++ b/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php @@ -114,6 +114,184 @@ public function testBatchingActivatesEndToEndForStringFormExists(): void $this->assertCount(1, $existsQueries); } + public function testIsolatedPresenceChecksUseTheOrdinaryVerifier(): void + { + $exact = $this->makeValidator( + ['email' => 'user1@example.com'], + ['email' => 'exists:batch_test_users,email'], + ); + $wildcard = $this->makeValidator( + ['items' => [['email' => 'user1@example.com']]], + ['items.*.email' => 'exists:batch_test_users,email'], + ); + + foreach ([$exact, $wildcard] as $validator) { + $validator->after(function (Validator $validator): void { + $this->assertNotInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); + + $this->assertTrue($validator->passes()); + } + } + + public function testExactPresenceChecksBatchAcrossCachedPlans(): void + { + $validator = $this->makeValidator( + [ + 'primary_email' => 'user1@example.com', + 'secondary_email' => 'user2@example.com', + ], + [ + 'primary_email' => 'exists:batch_test_users,email', + 'secondary_email' => 'exists:batch_test_users,email', + ], + ); + $validator->after(function (Validator $validator): void { + $this->assertInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testExactPresenceChecksBatchDifferentQueryShapesIndependently(): void + { + $validator = $this->makeValidator( + [ + 'email' => 'user1@example.com', + 'external_id' => 1, + ], + [ + 'email' => 'exists:batch_test_users,email', + 'external_id' => 'exists:batch_test_users,external_id', + ], + ); + $validator->after(function (Validator $validator): void { + $this->assertInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertTrue($result); + $this->assertCount(2, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testPresenceCountIsRecomputedAfterWildcardExpansionChanges(): void + { + $validator = $this->makeValidator( + ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ]], + ['items.*.email' => 'exists:batch_test_users,email'], + ); + $verifierClasses = []; + $validator->after(function (Validator $validator) use (&$verifierClasses): void { + $verifierClasses[] = $validator->getPresenceVerifier()::class; + }); + + $this->assertTrue($validator->passes()); + + $validator->setData(['items' => [['email' => 'user3@example.com']]]); + + $this->assertTrue($validator->passes()); + $this->assertSame([ + PrecomputedPresenceVerifier::class, + DatabasePresenceVerifier::class, + ], $verifierClasses); + } + + public function testExistsAndUniqueOnOneAttributeCountAsTwoPresenceConsumers(): void + { + $validator = $this->makeValidator( + ['email' => 'user1@example.com'], + ['email' => 'exists:batch_test_users,email|unique:batch_test_users,email'], + ); + $validator->after(function (Validator $validator): void { + $this->assertInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('email')); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + + public function testCallbackPresenceDoesNotMakeAnIsolatedExactCheckBatchable(): void + { + $callbackCalls = 0; + $callbackRule = (new Exists('batch_test_users', 'email'))->where( + function ($query) use (&$callbackCalls): void { + ++$callbackCalls; + $query->where('status', 'active'); + }, + ); + $validator = $this->makeValidator( + [ + 'ordinary_email' => 'user1@example.com', + 'callback_email' => 'user2@example.com', + ], + [ + 'ordinary_email' => 'exists:batch_test_users,email', + 'callback_email' => $callbackRule, + ], + ); + $validator->after(function (Validator $validator): void { + $this->assertNotInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); + + $this->assertTrue($validator->passes()); + $this->assertSame(1, $callbackCalls); + } + public function testStringableCandidateUsesOrdinaryPresenceQueryWithoutDisablingSafeBatch(): void { $stringable = new ValidationPresenceStringable('user1@example.com'); @@ -240,14 +418,26 @@ public function testBatchedPresenceMatchesOrdinaryVerifierDatabaseEquality(): vo foreach ($probes as [$column, $value]) { foreach (['exists', 'unique'] as $rule) { - $ordinary = $this->makeValidator( + $ordinary = $this->makeOrdinaryValidator( ['value' => $value], ['value' => "{$rule}:batch_test_users,{$column}"], ); $batched = $this->makeValidator( - ['items' => [['value' => $value]]], + ['items' => [['value' => $value], ['value' => $value]]], ['items.*.value' => "{$rule}:batch_test_users,{$column}"], ); + $ordinary->after(function (Validator $validator): void { + $this->assertNotInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); + $batched->after(function (Validator $validator): void { + $this->assertInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); $this->assertSame( $ordinary->passes(), @@ -280,12 +470,12 @@ public function testDateTimeCandidatesUseTheOrdinaryVerifierBindingConversion(): public function testIntegerCandidateAgainstTextUsesOnePrecomputedQueryWhereSupported(): void { foreach (['exists', 'unique'] as $rule) { - $ordinary = $this->makeValidator( + $ordinary = $this->makeOrdinaryValidator( ['value' => 1], ['value' => "{$rule}:batch_test_users,lookup_value"], ); $batched = $this->makeValidator( - ['items' => [['value' => 1]]], + ['items' => [['value' => 1], ['value' => 1]]], ['items.*.value' => "{$rule}:batch_test_users,lookup_value"], ); @@ -342,11 +532,11 @@ public function testMixedStringAndIntegerCandidatesPreserveOrdinaryMySqlTextComp ->where('external_id', 100) ->update(['lookup_value' => '01']); - $ordinaryString = $this->makeValidator( + $ordinaryString = $this->makeOrdinaryValidator( ['value' => '1'], ['value' => 'exists:batch_test_users,lookup_value'], ); - $ordinaryInteger = $this->makeValidator( + $ordinaryInteger = $this->makeOrdinaryValidator( ['value' => 1], ['value' => 'exists:batch_test_users,lookup_value'], ); @@ -378,7 +568,10 @@ public function testMixedStringAndIntegerCandidatesPreserveOrdinaryMySqlTextComp public function testCaseInsensitiveUniqueUsesDatabaseEquality(): void { $validator = $this->makeValidator( - ['items' => [['email' => 'USER1@EXAMPLE.COM']]], + ['items' => [ + ['email' => 'USER1@EXAMPLE.COM'], + ['email' => 'USER1@EXAMPLE.COM'], + ]], ['items.*.email' => 'required|unique:batch_test_users,email'], ); @@ -418,14 +611,23 @@ public function testArrayPresenceUsesDatabaseDistinctEquivalenceClasses(): void null, )); - $ordinary = $this->makeValidator( + $ordinary = $this->makeOrdinaryValidator( ['values' => ['Case', 'case']], ['values' => 'array|exists:batch_test_users,lookup_value'], ); $batched = $this->makeValidator( - ['items' => [['values' => ['Case', 'case']]]], + ['items' => [ + ['values' => ['Case', 'case']], + ['values' => ['Case', 'case']], + ]], ['items.*.values' => 'array|exists:batch_test_users,lookup_value'], ); + $batched->after(function (Validator $validator): void { + $this->assertInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); $this->assertFalse($ordinary->passes()); $this->assertFalse($batched->passes()); @@ -469,7 +671,7 @@ public function testInvalidTypedValuesFailBeforeAnyPresenceQuery(): void #[RequiresDatabase('pgsql')] public function testPostgresPreservesIntegerToTextBindingErrorsOnOrdinaryAndBatchedPaths(): void { - $ordinary = $this->makeValidator( + $ordinary = $this->makeOrdinaryValidator( ['value' => 1], ['value' => 'exists:batch_test_users,lookup_value'], ); @@ -493,7 +695,10 @@ public function testOriginalPresenceVerifierIsRestoredAfterExceptionDuringBatche { $validator = $this->makeValidator( [ - 'items' => [['email' => 'user1@example.com']], + 'items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ], 'boom' => 'trigger', ], [ @@ -505,15 +710,23 @@ public function testOriginalPresenceVerifierIsRestoredAfterExceptionDuringBatche ); $originalVerifier = $validator->getPresenceVerifier(); + DB::enableQueryLog(); try { $validator->passes(); $this->fail('Expected RuntimeException was not thrown.'); } catch (RuntimeException $e) { $this->assertSame('boom', $e->getMessage()); + } finally { + $queryLog = DB::getQueryLog(); + DB::disableQueryLog(); } $this->assertSame($originalVerifier, $validator->getPresenceVerifier()); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); } public function testDifferentWildcardQueryShapesOnSameTableColumnBatchIndependently(): void @@ -530,6 +743,12 @@ public function testDifferentWildcardQueryShapesOnSameTableColumnBatchIndependen 'items.*.any_email' => 'required|exists:batch_test_users,email', ], ); + $validator->after(function (Validator $validator): void { + $this->assertInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); DB::enableQueryLog(); @@ -1189,7 +1408,7 @@ public function testStopOnFirstFailureSkipsSpeculativePresenceBatching(): void public function testPresenceBatchingRemainsEnabledWithoutStopOnFirstFailure(): void { $validator = $this->makeValidator( - ['items' => [['value' => 'Case']]], + ['items' => [['value' => 'Case'], ['value' => 'Case']]], [ 'name' => 'required', 'items.*.value' => 'exists:batch_test_users,lookup_value', @@ -1429,6 +1648,67 @@ public function message(): string )); } + public function testExactValidatorAwareMutationCanConsumeAnotherSubmittedValueFact(): void + { + $validator = $this->makeValidator( + [ + 'submitted_email' => 'user2@example.com', + 'mutated_email' => 'user1@example.com', + ], + [ + 'submitted_email' => 'required|unique:batch_test_users,email', + 'mutated_email' => [ + new class implements Rule, ValidatorAwareRule { + private Validator $validator; + + public function setValidator(Validator $validator): static + { + $this->validator = $validator; + + return $this; + } + + public function passes(string $attribute, mixed $value): bool + { + $this->validator->setValue($attribute, 'user2@example.com'); + + return true; + } + + public function message(): string + { + return 'The value could not be prepared.'; + } + }, + 'unique:batch_test_users,email', + ], + ], + ); + $validator->after(function (Validator $validator): void { + $this->assertInstanceOf( + PrecomputedPresenceVerifier::class, + $validator->getPresenceVerifier(), + ); + }); + + DB::enableQueryLog(); + + try { + $result = $validator->passes(); + $queryLog = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertFalse($result); + $this->assertTrue($validator->errors()->has('submitted_email')); + $this->assertTrue($validator->errors()->has('mutated_email')); + $this->assertCount(1, array_filter( + $queryLog, + static fn (array $entry): bool => str_contains($entry['query'], 'batch_test_users'), + )); + } + private function makeValidator(array $data, array $rules): Validator { $translator = new Translator(new ArrayLoader, 'en'); @@ -1437,6 +1717,16 @@ private function makeValidator(array $data, array $rules): Validator return $validator; } + + private function makeOrdinaryValidator(array $data, array $rules): Validator + { + $validator = $this->makeValidator($data, $rules); + $database = $this->app->make('db'); + $validator->setPresenceVerifier(new class($database) extends DatabasePresenceVerifier { + }); + + return $validator; + } } class BatchTestUser extends Model diff --git a/tests/Validation/ValidationNotInRuleTest.php b/tests/Validation/ValidationNotInRuleTest.php index 261aa62ff..9b5c80d43 100644 --- a/tests/Validation/ValidationNotInRuleTest.php +++ b/tests/Validation/ValidationNotInRuleTest.php @@ -9,13 +9,23 @@ use Hypervel\Translation\ArrayLoader; use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; +use Hypervel\Validation\Rules\In; use Hypervel\Validation\Rules\NotIn; use Hypervel\Validation\Validator; +use ReflectionMethod; include_once 'Enums.php'; class ValidationNotInRuleTest extends TestCase { + public function testConstructorUsesTheSameInputTypeAsTheInRule(): void + { + $inType = (new ReflectionMethod(In::class, '__construct'))->getParameters()[0]->getType(); + $notInType = (new ReflectionMethod(NotIn::class, '__construct'))->getParameters()[0]->getType(); + + $this->assertSame((string) $inType, (string) $notInType); + } + public function testItCorrectlyFormatsAStringVersionOfTheRule() { $rule = new NotIn(['Laravel', 'Framework', 'PHP']); diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php index 83dfe2079..49816d28f 100644 --- a/tests/Validation/ValidationRuleParserTest.php +++ b/tests/Validation/ValidationRuleParserTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Support\Fluent; use Hypervel\Tests\TestCase; use Hypervel\Validation\Rule; @@ -566,6 +567,38 @@ public function testExplodePreservesCallbackBearingPresenceRules(): void $this->assertSame([$exists, $unique], $results->rules['email']); } + public function testIdentifiesRulesReducedToStringsByTheParser(): void + { + $callbackExists = Rule::exists('users', 'email')->where( + static fn ($query) => $query, + ); + $contractRule = new class implements RuleContract { + public function passes(string $attribute, mixed $value): bool + { + return true; + } + + public function message(): string + { + return 'Invalid value.'; + } + }; + + $this->assertTrue(ValidationRuleParser::ruleReducesToString( + Rule::exists('users', 'email'), + )); + $this->assertTrue(ValidationRuleParser::ruleReducesToString(Rule::date())); + $this->assertFalse(ValidationRuleParser::ruleReducesToString($callbackExists)); + $this->assertFalse(ValidationRuleParser::ruleReducesToString( + Rule::forEach(static fn (): array => []), + )); + $this->assertFalse(ValidationRuleParser::ruleReducesToString( + static function (): void { + }, + )); + $this->assertFalse(ValidationRuleParser::ruleReducesToString($contractRule)); + } + public function testExplodeEvaluatesConditionalFluentRuleOnce(): void { $calls = 0; @@ -610,6 +643,54 @@ public function testExplodeExpandsWildcardStringRules(): void ], $results->implicitAttributes); } + #[DataProvider('overlappingWildcardAndExactRuleProvider')] + public function testExplodePreservesOverlappingWildcardAndExactRulePrecedence( + array $rules, + array $expectedRules, + ): void { + $results = (new ValidationRuleParser([ + 'items' => [ + ['code' => 'A'], + ['code' => 'B'], + ], + ]))->explode($rules); + + $this->assertSame($expectedRules, $results->rules); + $this->assertSame([ + 'items.*.code' => ['items.0.code', 'items.1.code'], + ], $results->implicitAttributes); + } + + public static function overlappingWildcardAndExactRuleProvider(): array + { + return [ + 'wildcard then exact string' => [ + ['items.*.code' => 'string', 'items.0.code' => 'min:1'], + ['items.0.code' => ['min:1'], 'items.1.code' => ['string']], + ], + 'wildcard then exact array' => [ + ['items.*.code' => 'string', 'items.0.code' => ['min:1']], + ['items.0.code' => ['min:1'], 'items.1.code' => ['string']], + ], + 'exact string then wildcard' => [ + ['items.0.code' => 'min:1', 'items.*.code' => 'string'], + ['items.0.code' => ['min:1', 'string'], 'items.1.code' => ['string']], + ], + 'exact array then wildcard' => [ + ['items.0.code' => ['min:1'], 'items.*.code' => 'string'], + ['items.0.code' => ['min:1', 'string'], 'items.1.code' => ['string']], + ], + 'empty marker then exact string' => [ + ['items.*.code' => [], 'items.0.code' => 'min:1'], + ['items.0.code' => ['min:1'], 'items.1.code' => []], + ], + 'exact string then empty marker' => [ + ['items.0.code' => 'min:1', 'items.*.code' => []], + ['items.0.code' => ['min:1'], 'items.1.code' => []], + ], + ]; + } + public function testExplodeExpandsDeeplyNestedWildcardStringRules(): void { $parser = new ValidationRuleParser([ diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 34ad49506..8d0a95a89 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -4480,6 +4480,28 @@ public function testValidateDistinct() $this->assertTrue($v->passes()); } + public function testFirstDeclaredOverlappingWildcardDefinesDistinctScope(): void + { + $validator = new Validator($this->getArrayTranslator(), [ + 'groups' => [ + ['children' => [ + ['name' => 'shared'], + ['name' => 'primary'], + ]], + ['children' => [ + ['name' => 'shared'], + ['name' => 'secondary'], + ]], + ], + ], [ + 'groups.*.children.*.name' => ['distinct'], + 'groups.0.children.*.name' => [], + 'groups.1.children.*.name' => [], + ]); + + $this->assertFalse($validator->passes()); + } + public function testValidateDistinctForTopLevelArrays() { $trans = $this->getArrayTranslator(); @@ -4959,6 +4981,50 @@ public function testValidateEmailWithCustomClassCheck() $this->assertFalse($v->passes()); } + /** + * Test unsupported email validation modes fail clearly. + */ + #[DataProvider('invalidEmailValidationModes')] + public function testValidateEmailRejectsUnsupportedModes(string $rule): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Validation rule email parameter [unsupported] is not supported.'); + + $validator = new Validator( + $this->getArrayTranslator(), + ['x' => 'example@example.com'], + ['x' => $rule], + ); + + $validator->passes(); + } + + /** + * Provide unsupported email validation modes. + */ + public static function invalidEmailValidationModes(): iterable + { + yield ['email:unsupported']; + yield ['email:rfc,unsupported']; + } + + /** + * Test non-string email validation modes retain an actionable diagnostic. + */ + public function testValidateEmailRejectsNonStringModes(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Validation rule email parameter [stdClass] is not supported.'); + + $validator = new Validator( + $this->getArrayTranslator(), + ['x' => 'example@example.com'], + ['x' => [['email', new stdClass]]], + ); + + $validator->passes(); + } + public function testValidateUrlWithProtocols() { $trans = $this->getArrayTranslator(); @@ -7539,6 +7605,31 @@ public function testParsingArrayKeysWithDot() $this->assertTrue($v->fails()); } + public function testParsingArrayKeysWithAsterisk(): void + { + $translator = $this->getArrayTranslator(); + + $validator = new Validator( + $translator, + ['foo*bar' => 'valid'], + ['foo\*bar' => 'required|in:valid'], + ); + + $this->assertTrue($validator->passes()); + $this->assertArrayHasKey('foo\*bar', $validator->getRulesWithoutPlaceholders()); + $this->assertArrayHasKey('foo*bar', $validator->validated()); + + $validator = new Validator( + $translator, + ['items' => ['literal*' => ['value' => 'invalid']]], + ['items.*.value' => 'integer'], + ); + + $this->assertArrayHasKey('items.literal\*.value', $validator->getRulesWithoutPlaceholders()); + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('items.literal*.value')); + } + public function testParsingArrayKeysWithDotWhenTestingExistence() { $trans = $this->getArrayTranslator(); @@ -7643,6 +7734,34 @@ public function testDotPlaceholdersInParametersAreReplacedIn() $this->assertSame('The name field is required when user.name / admin.name is not present.', $v->messages()->first()); } + public function testAsteriskPlaceholdersInParametersAreReplaced(): void + { + $translator = $this->getArrayTranslator(); + $translator->addLines([ + 'validation.required_without' => 'The :attribute field is required when :values is not present.', + ], 'en'); + + $validator = new Validator( + $translator, + [ + 'name' => 'admin', + 'user' => ['role*' => 'admin'], + ], + ['name' => 'same:user.role\*'], + ); + + $this->assertTrue($validator->passes()); + + $validator = new Validator( + $translator, + [], + ['name' => 'required_without:user.role\*'], + ); + + $this->assertTrue($validator->fails()); + $this->assertSame('The name field is required when user.role* is not present.', $validator->messages()->first()); + } + public function testCoveringEmptyKeys() { $trans = $this->getArrayTranslator(); @@ -7861,6 +7980,45 @@ public function testValidateImplicitEachWithAsterisksSame() $this->assertTrue($v->messages()->has('foo.0.bar.1.name')); } + public function testFirstDeclaredOverlappingWildcardDefinesDependentRuleKeys(): void + { + $validator = new Validator($this->getArrayTranslator(), [ + 'groups' => [ + ['children' => [ + ['name' => 'reference', 'other' => 'reference'], + ['name' => 'reference', 'other' => 'unused'], + ]], + ['children' => [ + ['name' => 'different', 'other' => 'different'], + ]], + ], + ], [ + 'groups.*.children.*.name' => ['same:groups.*.children.0.other'], + 'groups.0.children.*.name' => [], + ]); + + $this->assertTrue($validator->passes()); + } + + public function testFirstDeclaredOverlappingWildcardPreservesDependentRuleArity(): void + { + $validator = new Validator($this->getArrayTranslator(), [ + 'groups' => [ + ['children' => [ + ['name' => 'first', 'other' => 'first'], + ]], + ['children' => [ + ['name' => 'second', 'other' => 'second'], + ]], + ], + ], [ + 'groups.*.children.*.name' => ['same:groups.*.children.*.other'], + 'groups.0.children.*.name' => [], + ]); + + $this->assertTrue($validator->passes()); + } + public function testValidateImplicitEachWithAsterisksRequired() { $trans = $this->getArrayTranslator(); @@ -8330,6 +8488,34 @@ public function testUsingSettersWithImplicitRules() $this->assertFalse($v->passes()); } + public function testSetRulesClearsPreviousImplicitAttributeIdentity(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['foo' => ['duplicate', 'duplicate']], + ['foo.*' => 'distinct'], + ); + + $validator->setRules(['foo.0' => 'distinct']); + + $this->assertTrue($validator->passes()); + } + + public function testSetDataClearsImplicitAttributesWhenWildcardExpansionBecomesEmpty(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['foo' => ['duplicate', 'duplicate']], + ['foo.*' => 'distinct'], + ); + + $validator->setData(['foo' => []]); + $validator->addRules(['foo.0' => 'distinct']); + $validator->setValue('foo', ['duplicate', 'duplicate']); + + $this->assertTrue($validator->passes()); + } + public function testInvalidMethod() { $trans = $this->getArrayTranslator(); diff --git a/tests/Validation/ValidationWildcardExpansionTest.php b/tests/Validation/ValidationWildcardExpansionTest.php index 9d6171b34..1443b4121 100644 --- a/tests/Validation/ValidationWildcardExpansionTest.php +++ b/tests/Validation/ValidationWildcardExpansionTest.php @@ -113,6 +113,57 @@ public function testMixedStringAndNumericKeysReportCorrectPath() $this->assertFalse($v->errors()->has('settings.layout.color')); } + public function testPartialSegmentWildcardMatchesChildKeys(): void + { + $validator = $this->makeValidator( + [ + 'items' => [ + 'alpha' => ['value' => 'invalid'], + 'beta' => ['value' => 'ignored'], + ], + ], + ['items.a*.value' => 'integer'], + ); + + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('items.alpha.value')); + $this->assertFalse($validator->errors()->has('items.beta.value')); + } + + public function testPartialSegmentWildcardDoesNotMatchOtherChildKeys(): void + { + $validator = $this->makeValidator( + ['items' => ['beta' => ['value' => 'ignored']]], + ['items.a*.value' => 'integer'], + ); + + $this->assertSame([], $validator->getRulesWithoutPlaceholders()); + $this->assertTrue($validator->passes()); + } + + public function testPartialSegmentWildcardSkipsAbsentParent(): void + { + $validator = $this->makeValidator( + ['other' => 'value'], + ['items.a*.value' => 'integer'], + ); + + $this->assertSame([], $validator->getRulesWithoutPlaceholders()); + $this->assertTrue($validator->passes()); + } + + public function testBareWildcardEmitsRequiredRuleForMissingNestedLeaf(): void + { + $validator = $this->makeValidator( + ['items' => [[]]], + ['items.*.value' => 'required'], + ); + + $this->assertArrayHasKey('items.0.value', $validator->getRulesWithoutPlaceholders()); + $this->assertFalse($validator->passes()); + $this->assertTrue($validator->errors()->has('items.0.value')); + } + public function testWildcardWithMultipleRuleTypes() { $items = []; From 23fb3aa7267372b53073705fae172fd97a136a09 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:16:55 +0000 Subject: [PATCH 04/35] Add the Hypervel Data component foundation Register hypervel/data in the component monorepo and subtree metadata, retain the upstream MIT attribution, and define the package's Data, DTO, resource, collection, validation, transformation, and wrapping capability contracts. Build one typed DataConfig at provider boot from required shallow-merged configuration. Validate mapper, cast, transformer, and normalizer extension classes eagerly; keep only immutable recipes and scalar settings; and provide an atomic boot-only morph alias map with forward and reverse collision checks. Ship familiar OnlyRequests defaults, explicit input and output mapping settings, optional depth and wrapping settings, provider discovery, publishing metadata, and focused configuration, morph-map, discovery, and dependency tests. --- composer.json | 3 + src/data/LICENSE.md | 23 ++ src/data/README.md | 15 + src/data/composer.json | 69 ++++ src/data/config/data.php | 71 ++++ src/data/src/Casts/Cast.php | 22 ++ src/data/src/Contracts/AppendableData.php | 23 ++ src/data/src/Contracts/BaseData.php | 63 ++++ .../src/Contracts/BaseDataCollectable.php | 23 ++ src/data/src/Contracts/EmptyData.php | 13 + src/data/src/Contracts/IncludeableData.php | 76 ++++ .../src/Contracts/PropertyMorphableData.php | 15 + src/data/src/Contracts/ResponsableData.php | 37 ++ src/data/src/Contracts/TransformableData.php | 52 +++ src/data/src/Contracts/ValidateableData.php | 31 ++ src/data/src/Contracts/WrappableData.php | 25 ++ src/data/src/DataServiceProvider.php | 45 +++ src/data/src/Mappers/NameMapper.php | 13 + .../src/Normalizers/Normalized/Normalized.php | 15 + src/data/src/Normalizers/Normalizer.php | 15 + .../Support/Creation/ValidationStrategy.php | 12 + src/data/src/Support/DataConfig.php | 338 ++++++++++++++++++ src/data/src/Transformers/Transformer.php | 16 + tests/Data/DataConfigTest.php | 259 ++++++++++++++ tests/Data/DataServiceProviderTest.php | 36 ++ tests/Data/PackageMetadataTest.php | 65 ++++ 26 files changed, 1375 insertions(+) create mode 100644 src/data/LICENSE.md create mode 100644 src/data/README.md create mode 100644 src/data/composer.json create mode 100644 src/data/config/data.php create mode 100644 src/data/src/Casts/Cast.php create mode 100644 src/data/src/Contracts/AppendableData.php create mode 100644 src/data/src/Contracts/BaseData.php create mode 100644 src/data/src/Contracts/BaseDataCollectable.php create mode 100644 src/data/src/Contracts/EmptyData.php create mode 100644 src/data/src/Contracts/IncludeableData.php create mode 100644 src/data/src/Contracts/PropertyMorphableData.php create mode 100644 src/data/src/Contracts/ResponsableData.php create mode 100644 src/data/src/Contracts/TransformableData.php create mode 100644 src/data/src/Contracts/ValidateableData.php create mode 100644 src/data/src/Contracts/WrappableData.php create mode 100644 src/data/src/DataServiceProvider.php create mode 100644 src/data/src/Mappers/NameMapper.php create mode 100644 src/data/src/Normalizers/Normalized/Normalized.php create mode 100644 src/data/src/Normalizers/Normalizer.php create mode 100644 src/data/src/Support/Creation/ValidationStrategy.php create mode 100644 src/data/src/Support/DataConfig.php create mode 100644 src/data/src/Transformers/Transformer.php create mode 100644 tests/Data/DataConfigTest.php create mode 100644 tests/Data/DataServiceProviderTest.php create mode 100644 tests/Data/PackageMetadataTest.php diff --git a/composer.json b/composer.json index 19d9a7b47..4e94baeea 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "Hypervel\\Context\\": "src/context/src/", "Hypervel\\Coordinator\\": "src/coordinator/src/", "Hypervel\\Cookie\\": "src/cookie/src/", + "Hypervel\\Data\\": "src/data/src/", "Hypervel\\Database\\": "src/database/src/", "Hypervel\\Concurrency\\": "src/concurrency/src/", "Hypervel\\Coroutine\\": "src/coroutine/src/", @@ -236,6 +237,7 @@ "hypervel/coordinator": "self.version", "hypervel/cookie": "self.version", "hypervel/coroutine": "self.version", + "hypervel/data": "self.version", "hypervel/database": "self.version", "hypervel/di": "self.version", "hypervel/docs": "self.version", @@ -341,6 +343,7 @@ "Hypervel\\Concurrency\\ConcurrencyServiceProvider", "Hypervel\\Console\\ConsoleServiceProvider", "Hypervel\\Cookie\\CookieServiceProvider", + "Hypervel\\Data\\DataServiceProvider", "Hypervel\\Database\\DatabaseServiceProvider", "Hypervel\\Encryption\\EncryptionServiceProvider", "Hypervel\\Engine\\EngineServiceProvider", diff --git a/src/data/LICENSE.md b/src/data/LICENSE.md new file mode 100644 index 000000000..6cf9c88c4 --- /dev/null +++ b/src/data/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) spatie + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/data/README.md b/src/data/README.md new file mode 100644 index 000000000..6c347b27d --- /dev/null +++ b/src/data/README.md @@ -0,0 +1,15 @@ +# Hypervel Data + +Documentation: https://hypervel.org/docs/data-objects + +## Differences From Laravel + +Hypervel Data keeps the familiar `spatie/laravel-data` vocabulary with fixed, coroutine-safe internals for long-lived workers. Metadata is analyzed once per used class and retained in worker memory; there is no discovery or deploy cache command. + +`Data`, `Dto`, and `Resource` validate request input by default. Each `factory()` call starts a fresh operation, omitted nullable properties become `null`, and a declared `Optional` union always preserves absence. + +Constructor injection uses Hypervel contextual attributes. Their resolved value always wins over payload input, including `null`; use a named factory or creation hook when payload values should take precedence. Hypervel's compiled wildcard validation is used for uniform nested collections, with concrete indexed rules for dynamic shapes. + +Deprecated collection forwarding, Livewire integration, and TypeScript generation are not included. Use `toCollection()` for collection operations; TypeScript generation belongs in a general transformer package. + +Ported from: https://github.com/spatie/laravel-data diff --git a/src/data/composer.json b/src/data/composer.json new file mode 100644 index 000000000..734a83a1c --- /dev/null +++ b/src/data/composer.json @@ -0,0 +1,69 @@ +{ + "name": "hypervel/data", + "type": "library", + "description": "Powerful data objects for Hypervel applications.", + "license": "MIT", + "keywords": [ + "php", + "swoole", + "data", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Data\\": "src/" + } + }, + "require": { + "ext-tokenizer": "*", + "php": "^8.4", + "hypervel/auth": "^0.4", + "hypervel/collections": "^0.4", + "hypervel/console": "^0.4", + "hypervel/container": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/database": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/http": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/pagination": "^0.4", + "hypervel/reflection": "^0.4", + "hypervel/support": "^0.4", + "hypervel/validation": "^0.4", + "laravel/serializable-closure": "^2.0.10", + "nesbot/carbon": "^3.13.1", + "phpstan/phpdoc-parser": "^2.3", + "symfony/console": "^8.1", + "symfony/var-dumper": "^8.1" + }, + "suggest": { + "hypervel/inertia": "Provides lazy and deferred Inertia properties." + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "0.4-dev" + }, + "hypervel": { + "providers": [ + "Hypervel\\Data\\DataServiceProvider" + ] + } + } +} diff --git a/src/data/config/data.php b/src/data/config/data.php new file mode 100644 index 000000000..fb55f2d75 --- /dev/null +++ b/src/data/config/data.php @@ -0,0 +1,71 @@ + DATE_ATOM, + + /* + * When transforming or casting dates, the following timezone will be used to + * convert the date to the correct timezone. If set to null no timezone will + * be passed. + */ + 'date_timezone' => null, + + /* + * Custom global transformers override the package's fixed transformation for + * their declared types. + */ + 'transformers' => [], + + /* + * Custom global casts override the package's fixed casting for their declared + * types. + */ + 'casts' => [], + + /* + * Custom global normalizers run after normalizers declared by the data class + * and before the package's fixed source normalization. + */ + 'normalizers' => [], + + /* + * Data objects can be wrapped into a key like 'data' when used as a resource, + * this key can be set globally here for all data objects. You can pass in + * `null` if you want to disable wrapping. + */ + 'wrap' => null, + + /* + * A data object can be validated when created using a factory or when calling the from + * method. By default, only when a request is passed the data is being validated. This + * behaviour can be changed to always validate or to completely disable validation. + */ + 'validation_strategy' => ValidationStrategy::OnlyRequests->value, + + /* + * A data object can map the names of its properties when transforming (output) or when + * creating (input). By default, the package will not map any names. You can set a + * global strategy here, or override it on a specific data object. + */ + 'name_mapping_strategy' => [ + 'input' => null, + 'output' => null, + ], + + /* + * When transforming a nested chain of data objects, the package can end up in an infinite + * loop when including a recursive relationship. The max transformation depth can be + * set as a safety measure to prevent this from happening. When set to null, the + * package will not enforce a maximum depth. + */ + 'max_transformation_depth' => null, +]; diff --git a/src/data/src/Casts/Cast.php b/src/data/src/Casts/Cast.php new file mode 100644 index 000000000..2c1b06799 --- /dev/null +++ b/src/data/src/Casts/Cast.php @@ -0,0 +1,22 @@ +|Collection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|PaginatorContract $items + * + * @return ($into is 'array' ? array : ($into is class-string ? Collection : ($into is class-string ? Collection : ($into is class-string ? LazyCollection : ($into is class-string ? DataCollection : ($into is class-string ? PaginatedDataCollection : ($into is class-string ? CursorPaginatedDataCollection : ($items is EloquentCollection ? Collection : ($items is Collection ? Collection : ($items is LazyCollection ? LazyCollection : ($items is Enumerable ? Enumerable : ($items is array ? array : ($items is AbstractPaginator ? AbstractPaginator : ($items is PaginatorContract ? PaginatorContract : ($items is AbstractCursorPaginator ? AbstractCursorPaginator : ($items is CursorPaginatorContract ? CursorPaginatorContract : ($items is DataCollection ? DataCollection : ($items is CursorPaginator ? CursorPaginatedDataCollection : ($items is Paginator ? PaginatedDataCollection : DataCollection))))))))))))))))))) + */ + public static function collect(mixed $items, ?string $into = null): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection; + + /** + * Create a data construction factory. + * + * @return CreationContextFactory + */ + public static function factory(): CreationContextFactory; + + /** + * Get the data normalizers. + * + * @return list> + */ + public static function normalizers(): array; +} diff --git a/src/data/src/Contracts/BaseDataCollectable.php b/src/data/src/Contracts/BaseDataCollectable.php new file mode 100644 index 000000000..f0cc8fa91 --- /dev/null +++ b/src/data/src/Contracts/BaseDataCollectable.php @@ -0,0 +1,23 @@ + + */ +interface BaseDataCollectable extends IteratorAggregate +{ + /** + * Get the data class stored by the collection. + * + * @return class-string + */ + public function getDataClass(): string; +} diff --git a/src/data/src/Contracts/EmptyData.php b/src/data/src/Contracts/EmptyData.php new file mode 100644 index 000000000..0a7596484 --- /dev/null +++ b/src/data/src/Contracts/EmptyData.php @@ -0,0 +1,13 @@ + + */ + public static function morph(array $properties): ?string; +} diff --git a/src/data/src/Contracts/ResponsableData.php b/src/data/src/Contracts/ResponsableData.php new file mode 100644 index 000000000..e681121d8 --- /dev/null +++ b/src/data/src/Contracts/ResponsableData.php @@ -0,0 +1,37 @@ + + */ +interface TransformableData extends JsonSerializable, Jsonable, Arrayable, EloquentCastable +{ + /** + * Transform the data object to an array. + */ + public function transform( + null|TransformationContextFactory|TransformationContext $transformationContext = null, + ): array; + + /** + * Get all visible data properties. + */ + public function all(): array; + + /** + * Get the data object as an array. + */ + public function toArray(): array; + + /** + * Convert the data object to its JSON representation. + */ + public function toJson(int $options = 0): string; + + /** + * Get the data that should be serialized to JSON. + */ + public function jsonSerialize(): array; + + /** + * Get the Eloquent caster for the data object. + */ + public static function castUsing(array $arguments): CastsAttributes|CastsInboundAttributes|string; +} diff --git a/src/data/src/Contracts/ValidateableData.php b/src/data/src/Contracts/ValidateableData.php new file mode 100644 index 000000000..8ad1fe2c2 --- /dev/null +++ b/src/data/src/Contracts/ValidateableData.php @@ -0,0 +1,31 @@ +mergeConfigFrom( + dirname(__DIR__) . '/config/data.php', + 'data', + ); + + $this->app->singleton( + DataConfig::class, + fn (Container $container): DataConfig => new DataConfig( + $container->make(Repository::class), + ), + ); + } + + /** + * Bootstrap data services. + */ + public function boot(): void + { + $this->app->make(DataConfig::class); + + if ($this->app->runningInConsole()) { + $this->publishes([ + dirname(__DIR__) . '/config/data.php' => config_path('data.php'), + ], 'data-config'); + } + } +} diff --git a/src/data/src/Mappers/NameMapper.php b/src/data/src/Mappers/NameMapper.php new file mode 100644 index 000000000..e70197e68 --- /dev/null +++ b/src/data/src/Mappers/NameMapper.php @@ -0,0 +1,13 @@ + + */ + public readonly array $dateFormats; + + /** + * The timezone applied while casting and transforming dates. + */ + public readonly ?string $dateTimezone; + + /** + * The default validation strategy. + */ + public readonly ValidationStrategy $validationStrategy; + + /** + * The default input name mapper. + * + * @var null|class-string + */ + public readonly ?string $inputNameMapper; + + /** + * The default output name mapper. + * + * @var null|class-string + */ + public readonly ?string $outputNameMapper; + + /** + * The configured cast overrides. + * + * @var array> + */ + public readonly array $casts; + + /** + * The configured transformer overrides. + * + * @var array> + */ + public readonly array $transformers; + + /** + * The configured global normalizers. + * + * @var list> + */ + public readonly array $normalizers; + + /** + * The default resource wrapper. + */ + public readonly ?string $wrap; + + /** + * The maximum nested transformation depth. + */ + public readonly ?int $maxTransformationDepth; + + /** @var array> */ + protected array $morphMap = []; + + /** @var array, string> */ + protected array $reversedMorphMap = []; + + /** + * Create a new data configuration. + */ + public function __construct(Repository $config) + { + $this->dateFormats = self::normalizeDateFormats($config->get('data.date_format')); + $this->dateTimezone = self::nullableString($config, 'data.date_timezone'); + $this->validationStrategy = ValidationStrategy::from($config->string('data.validation_strategy')); + $this->inputNameMapper = self::nameMapper($config, 'data.name_mapping_strategy.input'); + $this->outputNameMapper = self::nameMapper($config, 'data.name_mapping_strategy.output'); + $this->casts = self::extensionMap($config->array('data.casts'), Cast::class, 'data.casts'); + $this->transformers = self::extensionMap( + $config->array('data.transformers'), + Transformer::class, + 'data.transformers', + ); + $this->normalizers = self::extensionList( + $config->array('data.normalizers'), + Normalizer::class, + 'data.normalizers', + ); + $this->wrap = self::nullableString($config, 'data.wrap'); + $this->maxTransformationDepth = self::nullablePositiveInteger( + $config, + 'data.max_transformation_depth', + ); + } + + /** + * Register the enforced data morph map. + * + * Boot-only. The aliases persist on the worker-lifetime configuration and + * affect every subsequent data cast in the worker. + * + * @param array> $map + */ + public function enforceMorphMap(array $map): void + { + $morphMap = $this->morphMap; + $reversedMorphMap = $this->reversedMorphMap; + + foreach ($map as $alias => $class) { + if (! is_string($alias) || $alias === '') { + throw new InvalidArgumentException('Data morph aliases must be non-empty strings.'); + } + + if (! is_string($class) || ! is_a($class, BaseData::class, true)) { + throw new InvalidArgumentException(sprintf( + 'Data morph class [%s] must implement [%s].', + is_string($class) ? $class : get_debug_type($class), + BaseData::class, + )); + } + + if (isset($morphMap[$alias]) && $morphMap[$alias] !== $class) { + throw new InvalidArgumentException(sprintf( + 'Data morph alias [%s] is already mapped to [%s].', + $alias, + $morphMap[$alias], + )); + } + + if (isset($reversedMorphMap[$class]) && $reversedMorphMap[$class] !== $alias) { + throw new InvalidArgumentException(sprintf( + 'Data morph class [%s] is already mapped to alias [%s].', + $class, + $reversedMorphMap[$class], + )); + } + + $morphMap[$alias] = $class; + $reversedMorphMap[$class] = $alias; + } + + $this->morphMap = $morphMap; + $this->reversedMorphMap = $reversedMorphMap; + } + + /** + * Get the data class registered for a morph alias. + * + * @return null|class-string + */ + public function getMorphedDataClass(string $alias): ?string + { + return $this->morphMap[$alias] ?? null; + } + + /** + * Get the morph alias registered for a data class. + * + * @param class-string $class + */ + public function getDataClassAlias(string $class): ?string + { + return $this->reversedMorphMap[$class] ?? null; + } + + /** + * Normalize configured date formats. + * + * @return non-empty-list + */ + private static function normalizeDateFormats(mixed $formats): array + { + if (is_string($formats)) { + return [$formats]; + } + + if (! is_array($formats) || $formats === []) { + throw new InvalidArgumentException( + 'Configuration [data.date_format] must be a string or a non-empty array of strings.', + ); + } + + foreach ($formats as $format) { + if (! is_string($format)) { + throw new InvalidArgumentException( + 'Configuration [data.date_format] must be a string or a non-empty array of strings.', + ); + } + } + + return array_values($formats); + } + + /** + * Get a nullable string configuration value. + */ + private static function nullableString(Repository $config, string $key): ?string + { + if (! $config->has($key)) { + throw new InvalidArgumentException("Configuration [{$key}] is required."); + } + + $value = $config->get($key); + + if ($value !== null && ! is_string($value)) { + throw new InvalidArgumentException(sprintf( + 'Configuration [%s] must be a string or null.', + $key, + )); + } + + return $value; + } + + /** + * Get a nullable positive integer configuration value. + */ + private static function nullablePositiveInteger(Repository $config, string $key): ?int + { + if (! $config->has($key)) { + throw new InvalidArgumentException("Configuration [{$key}] is required."); + } + + $value = $config->get($key); + + if ($value !== null && (! is_int($value) || $value < 1)) { + throw new InvalidArgumentException(sprintf( + 'Configuration [%s] must be a positive integer or null.', + $key, + )); + } + + return $value; + } + + /** + * Get a configured name mapper. + * + * @return null|class-string + */ + private static function nameMapper(Repository $config, string $key): ?string + { + $mapper = self::nullableString($config, $key); + + if ($mapper !== null) { + self::ensureExtension($mapper, NameMapper::class, $key); + } + + return $mapper; + } + + /** + * Validate a configured extension map. + * + * @template TExtension of object + * + * @param array $extensions + * @param class-string $contract + * @return array> + */ + private static function extensionMap(array $extensions, string $contract, string $key): array + { + $validated = []; + + foreach ($extensions as $type => $extension) { + if (! is_string($type) || $type === '') { + throw new InvalidArgumentException(sprintf( + 'Configuration [%s] keys must be non-empty type strings.', + $key, + )); + } + + $validated[$type] = self::ensureExtension($extension, $contract, $key); + } + + return $validated; + } + + /** + * Validate a configured extension list. + * + * @template TExtension of object + * + * @param array $extensions + * @param class-string $contract + * @return list> + */ + private static function extensionList(array $extensions, string $contract, string $key): array + { + $validated = []; + + foreach ($extensions as $extension) { + $validated[] = self::ensureExtension($extension, $contract, $key); + } + + return $validated; + } + + /** + * Validate one configured extension class. + * + * @template TExtension of object + * + * @param class-string $contract + * @return class-string + */ + private static function ensureExtension(mixed $extension, string $contract, string $key): string + { + if (! is_string($extension) || ! is_a($extension, $contract, true)) { + throw new InvalidArgumentException(sprintf( + 'Configuration [%s] extension [%s] must implement [%s].', + $key, + is_string($extension) ? $extension : get_debug_type($extension), + $contract, + )); + } + + return $extension; + } +} diff --git a/src/data/src/Transformers/Transformer.php b/src/data/src/Transformers/Transformer.php new file mode 100644 index 000000000..70a29d8ff --- /dev/null +++ b/src/data/src/Transformers/Transformer.php @@ -0,0 +1,16 @@ +makeConfig(); + + $this->assertSame([DATE_ATOM], $config->dateFormats); + $this->assertNull($config->dateTimezone); + $this->assertSame(ValidationStrategy::OnlyRequests, $config->validationStrategy); + $this->assertNull($config->inputNameMapper); + $this->assertNull($config->outputNameMapper); + $this->assertSame([], $config->casts); + $this->assertSame([], $config->transformers); + $this->assertSame([], $config->normalizers); + $this->assertNull($config->wrap); + $this->assertNull($config->maxTransformationDepth); + } + + public function testCustomConfigurationIsNormalizedAndValidated(): void + { + $config = $this->makeConfig([ + 'date_format' => ['Y-m-d', DATE_ATOM], + 'date_timezone' => 'UTC', + 'validation_strategy' => ValidationStrategy::Always->value, + 'name_mapping_strategy' => [ + 'input' => ConfigNameMapper::class, + 'output' => ConfigNameMapper::class, + ], + 'casts' => [DateTimeInterface::class => ConfigCast::class], + 'transformers' => [BackedEnum::class => ConfigTransformer::class], + 'normalizers' => [ConfigNormalizer::class], + 'wrap' => 'payload', + 'max_transformation_depth' => 8, + ]); + + $this->assertSame(['Y-m-d', DATE_ATOM], $config->dateFormats); + $this->assertSame('UTC', $config->dateTimezone); + $this->assertSame(ValidationStrategy::Always, $config->validationStrategy); + $this->assertSame(ConfigNameMapper::class, $config->inputNameMapper); + $this->assertSame(ConfigNameMapper::class, $config->outputNameMapper); + $this->assertSame([DateTimeInterface::class => ConfigCast::class], $config->casts); + $this->assertSame([BackedEnum::class => ConfigTransformer::class], $config->transformers); + $this->assertSame([ConfigNormalizer::class], $config->normalizers); + $this->assertSame('payload', $config->wrap); + $this->assertSame(8, $config->maxTransformationDepth); + } + + #[DataProvider('invalidScalarConfigurationProvider')] + public function testInvalidScalarConfigurationFailsFast(array $overrides, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->makeConfig($overrides); + } + + public static function invalidScalarConfigurationProvider(): iterable + { + yield 'empty date formats' => [ + ['date_format' => []], + 'Configuration [data.date_format] must be a string or a non-empty array of strings.', + ]; + + yield 'invalid date format member' => [ + ['date_format' => ['Y-m-d', false]], + 'Configuration [data.date_format] must be a string or a non-empty array of strings.', + ]; + + yield 'invalid timezone' => [ + ['date_timezone' => false], + 'Configuration [data.date_timezone] must be a string or null.', + ]; + + yield 'invalid wrapper' => [ + ['wrap' => []], + 'Configuration [data.wrap] must be a string or null.', + ]; + + yield 'invalid maximum depth' => [ + ['max_transformation_depth' => 0], + 'Configuration [data.max_transformation_depth] must be a positive integer or null.', + ]; + + yield 'missing output mapper' => [ + ['name_mapping_strategy' => ['input' => null]], + 'Configuration [data.name_mapping_strategy.output] is required.', + ]; + } + + public function testInvalidValidationStrategyFailsFast(): void + { + $this->expectException(ValueError::class); + + $this->makeConfig(['validation_strategy' => 'sometimes']); + } + + #[DataProvider('invalidExtensionProvider')] + public function testInvalidExtensionsFailFast(array $overrides, string $key, string $contract): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "Configuration [{$key}] extension [" . stdClass::class . "] must implement [{$contract}].", + ); + + $this->makeConfig($overrides); + } + + public static function invalidExtensionProvider(): iterable + { + yield 'input mapper' => [ + ['name_mapping_strategy' => ['input' => stdClass::class, 'output' => null]], + 'data.name_mapping_strategy.input', + NameMapper::class, + ]; + + yield 'cast' => [ + ['casts' => ['string' => stdClass::class]], + 'data.casts', + Cast::class, + ]; + + yield 'transformer' => [ + ['transformers' => ['string' => stdClass::class]], + 'data.transformers', + Transformer::class, + ]; + + yield 'normalizer' => [ + ['normalizers' => [stdClass::class]], + 'data.normalizers', + Normalizer::class, + ]; + } + + public function testMorphMapSupportsForwardAndReverseLookups(): void + { + $config = $this->makeConfig(); + + $config->enforceMorphMap(['example' => ConfigMorphData::class]); + $config->enforceMorphMap(['example' => ConfigMorphData::class]); + + $this->assertSame(ConfigMorphData::class, $config->getMorphedDataClass('example')); + $this->assertSame('example', $config->getDataClassAlias(ConfigMorphData::class)); + $this->assertNull($config->getMorphedDataClass('missing')); + $this->assertNull($config->getDataClassAlias(ConfigOtherMorphData::class)); + } + + public function testMorphMapRejectsAliasCollisionsAtomically(): void + { + $config = $this->makeConfig(); + $config->enforceMorphMap(['example' => ConfigMorphData::class]); + + try { + $config->enforceMorphMap([ + 'other' => ConfigOtherMorphData::class, + 'example' => ConfigOtherMorphData::class, + ]); + $this->fail('Expected the duplicate morph alias to be rejected.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame( + 'Data morph alias [example] is already mapped to [' . ConfigMorphData::class . '].', + $exception->getMessage(), + ); + } + + $this->assertSame(ConfigMorphData::class, $config->getMorphedDataClass('example')); + $this->assertNull($config->getMorphedDataClass('other')); + } + + public function testMorphMapRejectsClassCollisions(): void + { + $config = $this->makeConfig(); + $config->enforceMorphMap(['example' => ConfigMorphData::class]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Data morph class [' . ConfigMorphData::class . '] is already mapped to alias [example].', + ); + + $config->enforceMorphMap(['duplicate' => ConfigMorphData::class]); + } + + #[DataProvider('invalidMorphMapProvider')] + public function testInvalidMorphMapsFailFast(array $map, string $message): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + $this->makeConfig()->enforceMorphMap($map); + } + + public static function invalidMorphMapProvider(): iterable + { + yield 'numeric alias' => [ + [0 => ConfigMorphData::class], + 'Data morph aliases must be non-empty strings.', + ]; + + yield 'invalid class' => [ + ['example' => stdClass::class], + 'Data morph class [' . stdClass::class . '] must implement [' . BaseData::class . '].', + ]; + } + + private function makeConfig(array $overrides = []): DataConfig + { + $defaults = require __DIR__ . '/../../src/data/config/data.php'; + + return new DataConfig(new Repository([ + 'data' => array_replace($defaults, $overrides), + ])); + } +} + +abstract class ConfigCast implements Cast +{ +} + +abstract class ConfigMorphData implements BaseData +{ +} + +abstract class ConfigNameMapper implements NameMapper +{ +} + +abstract class ConfigNormalizer implements Normalizer +{ +} + +abstract class ConfigOtherMorphData implements BaseData +{ +} + +abstract class ConfigTransformer implements Transformer +{ +} diff --git a/tests/Data/DataServiceProviderTest.php b/tests/Data/DataServiceProviderTest.php new file mode 100644 index 000000000..d0fc69431 --- /dev/null +++ b/tests/Data/DataServiceProviderTest.php @@ -0,0 +1,36 @@ +assertTrue($this->app->bound(DataConfig::class)); + $this->assertTrue($this->app->resolved(DataConfig::class)); + + $dataConfig = $this->app->make(DataConfig::class); + + $this->assertSame([DATE_ATOM], $dataConfig->dateFormats); + $this->assertNull($dataConfig->wrap); + + config()->set('data.date_format', 'Y-m-d'); + config()->set('data.wrap', 'payload'); + + $this->assertSame($dataConfig, $this->app->make(DataConfig::class)); + $this->assertSame([DATE_ATOM], $dataConfig->dateFormats); + $this->assertNull($dataConfig->wrap); + } +} diff --git a/tests/Data/PackageMetadataTest.php b/tests/Data/PackageMetadataTest.php new file mode 100644 index 000000000..edde14029 --- /dev/null +++ b/tests/Data/PackageMetadataTest.php @@ -0,0 +1,65 @@ +assertArrayHasKey($dependency, $rootComposer['require']); + $this->assertArrayHasKey($dependency, $composer['require']); + $this->assertSame($rootComposer['require'][$dependency], $composer['require'][$dependency]); + } + + foreach (array_keys($composer['require']) as $dependency) { + if (! str_starts_with($dependency, 'hypervel/')) { + continue; + } + + $this->assertSame('self.version', $rootComposer['replace'][$dependency] ?? null); + } + + $this->assertSame( + [DataServiceProvider::class], + $composer['extra']['hypervel']['providers'], + ); + $this->assertContains( + DataServiceProvider::class, + $rootComposer['extra']['hypervel']['providers'], + ); + $this->assertSame('src/data/src/', $rootComposer['autoload']['psr-4']['Hypervel\Data\\']); + $this->assertArrayHasKey('hypervel/inertia', $composer['suggest']); + } +} From 0991af61de623c363c3989f52aff8ac2e3ded055 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:17:52 +0000 Subject: [PATCH 05/35] Compile immutable Data declaration metadata Add the familiar mapping, casting, lazy, computed, relation, morph, and validation-control attributes together with immutable DataClass, DataProperty, DataMethod, DataParameter, and native/PHPDoc type models. Resolve constructor ownership, inheritance, promoted and contextual parameters, named factories, iterable annotations, unions, intersections, DNF types, mapper precedence, and duplicate input/output ownership once per declared Data class. Cache only bounded class and source import metadata for the worker lifetime. Keep annotation selection in DataClassFactory, resolve imported names in the class that declared the annotation, preserve late-bound static semantics, and retain reflection recipes rather than request-derived or mutable extension instances. Cover metadata immutability, declaration scope and import precedence, recursive graphs, constructor binding, mapping collisions, method matching, contextual declarations, PHPDoc generics, type guarantees, and actionable invalid-declaration diagnostics. --- src/data/src/Attributes/AutoLazy.php | 22 + src/data/src/Attributes/Computed.php | 12 + src/data/src/Attributes/DataCollectionOf.php | 26 + src/data/src/Attributes/GetsCast.php | 15 + src/data/src/Attributes/Hidden.php | 12 + src/data/src/Attributes/LoadRelation.php | 12 + src/data/src/Attributes/MapInputName.php | 18 + src/data/src/Attributes/MapName.php | 28 + src/data/src/Attributes/MapOutputName.php | 18 + .../src/Attributes/MergeValidationRules.php | 12 + src/data/src/Attributes/PropertyForMorph.php | 12 + src/data/src/Attributes/WithCast.php | 40 ++ .../src/Attributes/WithCastAndTransformer.php | 46 ++ src/data/src/Attributes/WithCastable.php | 41 ++ src/data/src/Attributes/WithTransformer.php | 40 ++ src/data/src/Attributes/WithoutValidation.php | 12 + src/data/src/Casts/Castable.php | 15 + src/data/src/Casts/Uncastable.php | 18 + .../src/Enums/CustomCreationMethodType.php | 12 + src/data/src/Enums/DataTypeKind.php | 100 +++ .../Exceptions/CannotBuildValidationRule.php | 18 + src/data/src/Exceptions/CannotCastData.php | 42 ++ src/data/src/Exceptions/CannotCastDate.php | 29 + src/data/src/Exceptions/CannotCastEnum.php | 34 + .../Exceptions/CannotCreateAbstractClass.php | 37 + .../Exceptions/CannotCreateCastAttribute.php | 28 + src/data/src/Exceptions/CannotCreateData.php | 112 +++ .../CannotCreateDataCollectable.php | 20 + .../CannotCreateTransformerAttribute.php | 19 + .../src/Exceptions/CannotFindDataClass.php | 42 ++ .../CannotPerformPartialOnDataField.php | 36 + .../src/Exceptions/CannotSetComputedValue.php | 21 + .../DataPropertyCanOnlyHaveOneType.php | 25 + .../InvalidDataCollectionOperation.php | 18 + .../src/Exceptions/InvalidDataDeclaration.php | 143 ++++ .../MaxTransformationDepthReached.php | 18 + src/data/src/Mappers/CamelCaseMapper.php | 18 + src/data/src/Mappers/KebabCaseMapper.php | 18 + src/data/src/Mappers/LowerCaseMapper.php | 18 + src/data/src/Mappers/ProvidedNameMapper.php | 23 + src/data/src/Mappers/SnakeCaseMapper.php | 18 + src/data/src/Mappers/StudlyCaseMapper.php | 18 + src/data/src/Mappers/UpperCaseMapper.php | 18 + .../Annotations/DataIterableAnnotation.php | 24 + .../DataIterableAnnotationReader.php | 199 ++++++ .../src/Support/Creation/CreationContext.php | 57 ++ .../src/Support/Creation/CreationMode.php | 12 + .../src/Support/DataAttributesCollection.php | 52 ++ src/data/src/Support/DataClass.php | 68 ++ src/data/src/Support/DataClassRepository.php | 130 ++++ src/data/src/Support/DataMethod.php | 172 +++++ src/data/src/Support/DataMethodMatch.php | 41 ++ src/data/src/Support/DataParameter.php | 32 + src/data/src/Support/DataProperty.php | 100 +++ src/data/src/Support/DataPropertyType.php | 86 +++ src/data/src/Support/DataType.php | 81 +++ .../DataAttributesCollectionFactory.php | 109 +++ .../Support/Factories/DataClassFactory.php | 468 +++++++++++++ .../Support/Factories/DataMethodFactory.php | 89 +++ .../Factories/DataParameterFactory.php | 53 ++ .../Support/Factories/DataPropertyFactory.php | 145 ++++ .../src/Support/Factories/DataTypeFactory.php | 607 +++++++++++++++++ src/data/src/Support/NameMapperResolver.php | 90 +++ .../src/Support/Types/CombinationType.php | 52 ++ .../src/Support/Types/IntersectionType.php | 58 ++ src/data/src/Support/Types/NamedType.php | 139 ++++ .../Support/Types/PhpDocTypeNameResolver.php | 292 ++++++++ src/data/src/Support/Types/Type.php | 42 ++ src/data/src/Support/Types/UnionType.php | 66 ++ tests/Data/Attributes/AttributeTest.php | 160 +++++ .../ChildScope/ChildAnnotations.php | 69 ++ tests/Data/Fixtures/ImportedType.php | 9 + .../Fixtures/MultiNamespacePhpDocTypes.php | 19 + tests/Data/Fixtures/PhpDocTypeContext.php | 12 + tests/Data/Fixtures/SiblingType.php | 9 + .../Data/Fixtures/TypeNameResolverParent.php | 9 + tests/Data/Fixtures/Types/GroupedType.php | 9 + tests/Data/Fixtures/Types/ImportedData.php | 11 + tests/Data/Fixtures/Types/ImportedType.php | 9 + tests/Data/Mappers/NameMapperTest.php | 49 ++ .../Support/DataAttributesCollectionTest.php | 190 ++++++ .../Data/Support/DataClassRepositoryTest.php | 252 +++++++ tests/Data/Support/DataClassTest.php | 439 ++++++++++++ .../DataIterableAnnotationReaderTest.php | 116 ++++ tests/Data/Support/DataMethodTest.php | 638 ++++++++++++++++++ tests/Data/Support/DataParameterTest.php | 112 +++ tests/Data/Support/DataPropertyTest.php | 309 +++++++++ tests/Data/Support/DataTypeFactoryTest.php | 388 +++++++++++ .../Support/PhpDocTypeNameResolverTest.php | 80 +++ 89 files changed, 7437 insertions(+) create mode 100644 src/data/src/Attributes/AutoLazy.php create mode 100644 src/data/src/Attributes/Computed.php create mode 100644 src/data/src/Attributes/DataCollectionOf.php create mode 100644 src/data/src/Attributes/GetsCast.php create mode 100644 src/data/src/Attributes/Hidden.php create mode 100644 src/data/src/Attributes/LoadRelation.php create mode 100644 src/data/src/Attributes/MapInputName.php create mode 100644 src/data/src/Attributes/MapName.php create mode 100644 src/data/src/Attributes/MapOutputName.php create mode 100644 src/data/src/Attributes/MergeValidationRules.php create mode 100644 src/data/src/Attributes/PropertyForMorph.php create mode 100644 src/data/src/Attributes/WithCast.php create mode 100644 src/data/src/Attributes/WithCastAndTransformer.php create mode 100644 src/data/src/Attributes/WithCastable.php create mode 100644 src/data/src/Attributes/WithTransformer.php create mode 100644 src/data/src/Attributes/WithoutValidation.php create mode 100644 src/data/src/Casts/Castable.php create mode 100644 src/data/src/Casts/Uncastable.php create mode 100644 src/data/src/Enums/CustomCreationMethodType.php create mode 100644 src/data/src/Enums/DataTypeKind.php create mode 100644 src/data/src/Exceptions/CannotBuildValidationRule.php create mode 100644 src/data/src/Exceptions/CannotCastData.php create mode 100644 src/data/src/Exceptions/CannotCastDate.php create mode 100644 src/data/src/Exceptions/CannotCastEnum.php create mode 100644 src/data/src/Exceptions/CannotCreateAbstractClass.php create mode 100644 src/data/src/Exceptions/CannotCreateCastAttribute.php create mode 100644 src/data/src/Exceptions/CannotCreateData.php create mode 100644 src/data/src/Exceptions/CannotCreateDataCollectable.php create mode 100644 src/data/src/Exceptions/CannotCreateTransformerAttribute.php create mode 100644 src/data/src/Exceptions/CannotFindDataClass.php create mode 100644 src/data/src/Exceptions/CannotPerformPartialOnDataField.php create mode 100644 src/data/src/Exceptions/CannotSetComputedValue.php create mode 100644 src/data/src/Exceptions/DataPropertyCanOnlyHaveOneType.php create mode 100644 src/data/src/Exceptions/InvalidDataCollectionOperation.php create mode 100644 src/data/src/Exceptions/InvalidDataDeclaration.php create mode 100644 src/data/src/Exceptions/MaxTransformationDepthReached.php create mode 100644 src/data/src/Mappers/CamelCaseMapper.php create mode 100644 src/data/src/Mappers/KebabCaseMapper.php create mode 100644 src/data/src/Mappers/LowerCaseMapper.php create mode 100644 src/data/src/Mappers/ProvidedNameMapper.php create mode 100644 src/data/src/Mappers/SnakeCaseMapper.php create mode 100644 src/data/src/Mappers/StudlyCaseMapper.php create mode 100644 src/data/src/Mappers/UpperCaseMapper.php create mode 100644 src/data/src/Support/Annotations/DataIterableAnnotation.php create mode 100644 src/data/src/Support/Annotations/DataIterableAnnotationReader.php create mode 100644 src/data/src/Support/Creation/CreationContext.php create mode 100644 src/data/src/Support/Creation/CreationMode.php create mode 100644 src/data/src/Support/DataAttributesCollection.php create mode 100644 src/data/src/Support/DataClass.php create mode 100644 src/data/src/Support/DataClassRepository.php create mode 100644 src/data/src/Support/DataMethod.php create mode 100644 src/data/src/Support/DataMethodMatch.php create mode 100644 src/data/src/Support/DataParameter.php create mode 100644 src/data/src/Support/DataProperty.php create mode 100644 src/data/src/Support/DataPropertyType.php create mode 100644 src/data/src/Support/DataType.php create mode 100644 src/data/src/Support/Factories/DataAttributesCollectionFactory.php create mode 100644 src/data/src/Support/Factories/DataClassFactory.php create mode 100644 src/data/src/Support/Factories/DataMethodFactory.php create mode 100644 src/data/src/Support/Factories/DataParameterFactory.php create mode 100644 src/data/src/Support/Factories/DataPropertyFactory.php create mode 100644 src/data/src/Support/Factories/DataTypeFactory.php create mode 100644 src/data/src/Support/NameMapperResolver.php create mode 100644 src/data/src/Support/Types/CombinationType.php create mode 100644 src/data/src/Support/Types/IntersectionType.php create mode 100644 src/data/src/Support/Types/NamedType.php create mode 100644 src/data/src/Support/Types/PhpDocTypeNameResolver.php create mode 100644 src/data/src/Support/Types/Type.php create mode 100644 src/data/src/Support/Types/UnionType.php create mode 100644 tests/Data/Attributes/AttributeTest.php create mode 100644 tests/Data/Fixtures/DataClassAnnotations/ChildScope/ChildAnnotations.php create mode 100644 tests/Data/Fixtures/ImportedType.php create mode 100644 tests/Data/Fixtures/MultiNamespacePhpDocTypes.php create mode 100644 tests/Data/Fixtures/PhpDocTypeContext.php create mode 100644 tests/Data/Fixtures/SiblingType.php create mode 100644 tests/Data/Fixtures/TypeNameResolverParent.php create mode 100644 tests/Data/Fixtures/Types/GroupedType.php create mode 100644 tests/Data/Fixtures/Types/ImportedData.php create mode 100644 tests/Data/Fixtures/Types/ImportedType.php create mode 100644 tests/Data/Mappers/NameMapperTest.php create mode 100644 tests/Data/Support/DataAttributesCollectionTest.php create mode 100644 tests/Data/Support/DataClassRepositoryTest.php create mode 100644 tests/Data/Support/DataClassTest.php create mode 100644 tests/Data/Support/DataIterableAnnotationReaderTest.php create mode 100644 tests/Data/Support/DataMethodTest.php create mode 100644 tests/Data/Support/DataParameterTest.php create mode 100644 tests/Data/Support/DataPropertyTest.php create mode 100644 tests/Data/Support/DataTypeFactoryTest.php create mode 100644 tests/Data/Support/PhpDocTypeNameResolverTest.php diff --git a/src/data/src/Attributes/AutoLazy.php b/src/data/src/Attributes/AutoLazy.php new file mode 100644 index 000000000..2ab7edb17 --- /dev/null +++ b/src/data/src/Attributes/AutoLazy.php @@ -0,0 +1,22 @@ + $castValue($value)); + } +} diff --git a/src/data/src/Attributes/Computed.php b/src/data/src/Attributes/Computed.php new file mode 100644 index 000000000..6cd167e41 --- /dev/null +++ b/src/data/src/Attributes/Computed.php @@ -0,0 +1,12 @@ + $class + */ + public function __construct( + public readonly string $class, + ) { + if (! is_a($this->class, BaseData::class, true)) { + throw CannotFindDataClass::forClass($this->class); + } + } +} diff --git a/src/data/src/Attributes/GetsCast.php b/src/data/src/Attributes/GetsCast.php new file mode 100644 index 000000000..8288a6ba9 --- /dev/null +++ b/src/data/src/Attributes/GetsCast.php @@ -0,0 +1,15 @@ +input = $input; + $this->output = $output ?? $input; + } +} diff --git a/src/data/src/Attributes/MapOutputName.php b/src/data/src/Attributes/MapOutputName.php new file mode 100644 index 000000000..8829f986e --- /dev/null +++ b/src/data/src/Attributes/MapOutputName.php @@ -0,0 +1,18 @@ + */ + public readonly array $arguments; + + /** + * Create a new cast attribute. + * + * @param class-string $castClass + */ + public function __construct( + public readonly string $castClass, + mixed ...$arguments, + ) { + if (! is_a($this->castClass, Cast::class, true)) { + throw CannotCreateCastAttribute::notACast($this->castClass); + } + + $this->arguments = $arguments; + } + + /** + * Get the configured cast. + */ + public function get(): Cast + { + return new ($this->castClass)(...$this->arguments); + } +} diff --git a/src/data/src/Attributes/WithCastAndTransformer.php b/src/data/src/Attributes/WithCastAndTransformer.php new file mode 100644 index 000000000..ff80b54fe --- /dev/null +++ b/src/data/src/Attributes/WithCastAndTransformer.php @@ -0,0 +1,46 @@ + */ + public readonly array $arguments; + + /** + * Create a new cast and transformer attribute. + * + * @param class-string $class + */ + public function __construct( + public readonly string $class, + mixed ...$arguments, + ) { + if (! is_a($this->class, Transformer::class, true)) { + throw CannotCreateTransformerAttribute::notATransformer($this->class); + } + + if (! is_a($this->class, Cast::class, true)) { + throw CannotCreateCastAttribute::notACast($this->class); + } + + $this->arguments = $arguments; + } + + /** + * Get the configured cast and transformer. + */ + public function get(): Cast&Transformer + { + return new ($this->class)(...$this->arguments); + } +} diff --git a/src/data/src/Attributes/WithCastable.php b/src/data/src/Attributes/WithCastable.php new file mode 100644 index 000000000..8bd607e2a --- /dev/null +++ b/src/data/src/Attributes/WithCastable.php @@ -0,0 +1,41 @@ + */ + public readonly array $arguments; + + /** + * Create a new castable attribute. + * + * @param class-string $castableClass + */ + public function __construct( + public readonly string $castableClass, + mixed ...$arguments, + ) { + if (! is_a($this->castableClass, Castable::class, true)) { + throw CannotCreateCastAttribute::notACastable($this->castableClass); + } + + $this->arguments = $arguments; + } + + /** + * Get the configured cast. + */ + public function get(): Cast + { + return $this->castableClass::dataCastUsing($this->arguments); + } +} diff --git a/src/data/src/Attributes/WithTransformer.php b/src/data/src/Attributes/WithTransformer.php new file mode 100644 index 000000000..e9d4715a7 --- /dev/null +++ b/src/data/src/Attributes/WithTransformer.php @@ -0,0 +1,40 @@ + */ + public readonly array $arguments; + + /** + * Create a new transformer attribute. + * + * @param class-string $transformerClass + */ + public function __construct( + public readonly string $transformerClass, + mixed ...$arguments, + ) { + if (! is_a($this->transformerClass, Transformer::class, true)) { + throw CannotCreateTransformerAttribute::notATransformer($this->transformerClass); + } + + $this->arguments = $arguments; + } + + /** + * Get the configured transformer. + */ + public function get(): Transformer + { + return new ($this->transformerClass)(...$this->arguments); + } +} diff --git a/src/data/src/Attributes/WithoutValidation.php b/src/data/src/Attributes/WithoutValidation.php new file mode 100644 index 000000000..f956826a1 --- /dev/null +++ b/src/data/src/Attributes/WithoutValidation.php @@ -0,0 +1,12 @@ + $arguments + */ + public static function dataCastUsing(array $arguments): Cast; +} diff --git a/src/data/src/Casts/Uncastable.php b/src/data/src/Casts/Uncastable.php new file mode 100644 index 000000000..1803e27f3 --- /dev/null +++ b/src/data/src/Casts/Uncastable.php @@ -0,0 +1,18 @@ +isDataObject() || $this->isDataCollectable(); + } + + /** + * Determine if this kind is not related to data objects. + */ + public function isNonDataRelated(): bool + { + return $this === self::Default + || $this === self::Array + || $this === self::Iterable + || $this === self::Enumerable + || $this === self::Paginator + || $this === self::CursorPaginator; + } + + /** + * Determine if this kind describes a non-data iterable. + */ + public function isNonDataIterable(): bool + { + return $this === self::Array + || $this === self::Iterable + || $this === self::Enumerable + || $this === self::Paginator + || $this === self::CursorPaginator; + } + + /** + * Get the equivalent kind containing data objects. + */ + public function getDataRelatedEquivalent(): self + { + return match ($this) { + self::Array => self::DataArray, + self::Iterable => self::DataIterable, + self::Enumerable => self::DataEnumerable, + self::Paginator => self::DataPaginator, + self::CursorPaginator => self::DataCursorPaginator, + self::DataCollection => self::DataCollection, + self::DataPaginatedCollection => self::DataPaginatedCollection, + self::DataCursorPaginatedCollection => self::DataCursorPaginatedCollection, + default => throw new LogicException("No data-related equivalent exists for [{$this->name}]."), + }; + } +} diff --git a/src/data/src/Exceptions/CannotBuildValidationRule.php b/src/data/src/Exceptions/CannotBuildValidationRule.php new file mode 100644 index 000000000..ff762ac85 --- /dev/null +++ b/src/data/src/Exceptions/CannotBuildValidationRule.php @@ -0,0 +1,18 @@ + $formats + * @param class-string $type + */ + public static function create(array $formats, string $type, mixed $value): self + { + $value = is_scalar($value) || $value === null + ? var_export($value, true) + : get_debug_type($value); + + return new self( + "Could not cast value [{$value}] to date [{$type}] using formats [" + . implode(', ', $formats) . '].' + ); + } +} diff --git a/src/data/src/Exceptions/CannotCastEnum.php b/src/data/src/Exceptions/CannotCastEnum.php new file mode 100644 index 000000000..4898979f8 --- /dev/null +++ b/src/data/src/Exceptions/CannotCastEnum.php @@ -0,0 +1,34 @@ +className}::\${$property->name}] to enum [{$type}]." + ); + } + + /** + * Describe a value without triggering object string conversion. + */ + protected static function describe(mixed $value): string + { + return is_scalar($value) || $value === null + ? var_export($value, true) + : get_debug_type($value); + } +} diff --git a/src/data/src/Exceptions/CannotCreateAbstractClass.php b/src/data/src/Exceptions/CannotCreateAbstractClass.php new file mode 100644 index 000000000..21f8b3d7b --- /dev/null +++ b/src/data/src/Exceptions/CannotCreateAbstractClass.php @@ -0,0 +1,37 @@ + $parameters + */ + public static function constructorMissingParameters( + DataClass $dataClass, + array $parameters, + ): self { + $given = array_keys($parameters); + $missing = []; + $required = 0; + + foreach ($dataClass->constructorParameters as $parameter) { + if ($parameter->contextualAttribute !== null || $parameter->hasDefaultValue) { + continue; + } + + ++$required; + + if (! array_key_exists($parameter->name, $parameters)) { + $missing[] = $parameter->name; + } + } + + $message = "Could not create data class [{$dataClass->name}]: its constructor requires " + . $required . ' payload parameters and ' . count($given) . ' were supplied.'; + + if ($given !== []) { + $message .= ' Parameters supplied: ' . implode(', ', $given) . '.'; + } + + return new self($message . ' Parameters missing: ' . implode(', ', $missing) . '.'); + } + + /** + * Create an exception for ordinary construction through a non-public constructor. + */ + public static function nonPublicConstructor(DataClass $dataClass): self + { + $visibility = $dataClass->constructor?->isPrivate() ? 'private' : 'protected'; + + return new self( + "Could not create data class [{$dataClass->name}] because its constructor is {$visibility} and no " + . 'matching named factory returned an instance. Return the target instance from a matching public ' + . 'static from* method or make the constructor public.' + ); + } + + /** + * Create an exception for a missing unbound property value. + */ + public static function propertyMissing(DataClass $dataClass, DataProperty $property): self + { + return new self( + "Could not create data class [{$dataClass->name}]: required property " + . "[{$property->className}::\${$property->name}] is missing." + ); + } + + /** + * Create an exception for an ambiguous data-object union. + * + * @param list $candidates + */ + public static function ambiguousDataObjectUnion( + DataProperty $property, + array $candidates, + ): self { + return new self( + "Could not create property [{$property->className}::\${$property->name}] from an ambiguous " + . 'data-object union [' . implode(', ', $candidates) . ']. Supply an existing instance or define ' + . 'an explicit cast, morph discriminator, or named factory.' + ); + } + + /** + * Create an exception for an invalid after-creation replacement. + */ + public static function invalidAfterCreationResult(DataClass $dataClass, mixed $value): self + { + return new self( + "Could not create data class [{$dataClass->name}]: an after-creation hook returned [" + . get_debug_type($value) . "] instead of an instance of [{$dataClass->name}]." + ); + } +} diff --git a/src/data/src/Exceptions/CannotCreateDataCollectable.php b/src/data/src/Exceptions/CannotCreateDataCollectable.php new file mode 100644 index 000000000..3b0eb4986 --- /dev/null +++ b/src/data/src/Exceptions/CannotCreateDataCollectable.php @@ -0,0 +1,20 @@ +getDeclaringClass()?->getName() ?? 'unknown'; + + $name = match (true) { + $typeable instanceof ReflectionMethod => "method [{$class}::{$typeable->getName()}]", + $typeable instanceof ReflectionProperty => "property [{$class}::\${$typeable->getName()}]", + $typeable instanceof ReflectionParameter => "parameter [{$class}::{$typeable->getDeclaringFunction()->getName()}(\${$typeable->getName()})]", + }; + + return new self("Cannot find a data class for {$name}."); + } +} diff --git a/src/data/src/Exceptions/CannotPerformPartialOnDataField.php b/src/data/src/Exceptions/CannotPerformPartialOnDataField.php new file mode 100644 index 000000000..e78d5b21d --- /dev/null +++ b/src/data/src/Exceptions/CannotPerformPartialOnDataField.php @@ -0,0 +1,36 @@ +className}::\${$property->name}] because it is computed." + ); + } +} diff --git a/src/data/src/Exceptions/DataPropertyCanOnlyHaveOneType.php b/src/data/src/Exceptions/DataPropertyCanOnlyHaveOneType.php new file mode 100644 index 000000000..e69ec40ff --- /dev/null +++ b/src/data/src/Exceptions/DataPropertyCanOnlyHaveOneType.php @@ -0,0 +1,25 @@ +className, + $property->name, + ), + ); + } +} diff --git a/src/data/src/Exceptions/InvalidDataCollectionOperation.php b/src/data/src/Exceptions/InvalidDataCollectionOperation.php new file mode 100644 index 000000000..1735899d6 --- /dev/null +++ b/src/data/src/Exceptions/InvalidDataCollectionOperation.php @@ -0,0 +1,18 @@ +reflection->getDeclaringClass()?->getName() ?? $class; + + return new self( + "Data class [{$class}] promotes non-public property [{$declaringClass}::\${$parameter->name}]. " + . 'Promoted data properties must be public.' + ); + } + + /** + * Create an exception for a constructor parameter without a data property. + * + * @param class-string $class + */ + public static function missingDataProperty(string $class, DataParameter $parameter): self + { + $declaringClass = $parameter->reflection->getDeclaringClass()?->getName() ?? $class; + + return new self( + "Data class [{$class}] constructor parameter [{$declaringClass}::\${$parameter->name}] has no " + . 'corresponding public data property or contextual attribute. Promote the parameter, declare a ' + . 'public property with the same name, or use a named factory.' + ); + } + + /** + * Create an exception for a non-promoted readonly input property. + * + * @param class-string $class + */ + public static function unassignableReadonlyProperty(string $class, DataProperty $property): self + { + return new self( + "Data class [{$class}] cannot assign unbound readonly property " + . "[{$property->className}::\${$property->name}]. Promote the property, declare a same-name " + . 'constructor parameter, or mark it as computed.' + ); + } + + /** + * Create an exception for an output-only constructor property. + * + * @param class-string $class + */ + public static function computedConstructorProperty(string $class, DataProperty $property): self + { + return new self( + "Data class [{$class}] declares output-only property [{$property->className}::\${$property->name}] " + . 'as a constructor parameter. Remove the computed declaration or initialize the property from other parameters.' + ); + } + + /** + * Create an exception for a contextual parameter conflicting with a data property. + * + * @param class-string $class + */ + public static function contextualParameterConflictsWithProperty( + string $class, + DataParameter $parameter, + DataProperty $property, + ): self { + $declaringClass = $parameter->reflection->getDeclaringClass()?->getName() ?? $class; + + return new self( + "Data class [{$class}] contextual constructor parameter [{$declaringClass}::\${$parameter->name}] " + . "conflicts with public data property [{$property->className}::\${$property->name}]. Promote the " + . 'attributed parameter when it is the data property, or rename it when it is a separate dependency.' + ); + } + + /** + * Create an exception for a variadic creation context. + * + * @param class-string $class + */ + public static function variadicCreationContext( + string $class, + string $method, + string $parameter, + ): self { + return new self( + "Data factory [{$class}::{$method}] cannot declare variadic CreationContext parameter [\${$parameter}]. " + . 'Declare a single CreationContext parameter instead.' + ); + } + + /** + * Create an exception for a duplicate input path. + * + * @param class-string $class + */ + public static function duplicateInputPath( + string $class, + string|int $path, + DataProperty $firstProperty, + DataProperty $secondProperty, + ): self { + return new self( + "Data class [{$class}] has properties [{$firstProperty->className}::\${$firstProperty->name}] and " + . "[{$secondProperty->className}::\${$secondProperty->name}] that both resolve to input path [{$path}]. " + . 'Give each property a unique input path. If one value is derived from another, use a computed property with a distinct name.' + ); + } + + /** + * Create an exception for a duplicate output key. + * + * @param class-string $class + */ + public static function duplicateOutputKey( + string $class, + string|int $key, + DataProperty $firstProperty, + DataProperty $secondProperty, + ): self { + return new self( + "Data class [{$class}] has properties [{$firstProperty->className}::\${$firstProperty->name}] and " + . "[{$secondProperty->className}::\${$secondProperty->name}] that both resolve to output key [{$key}]. " + . 'Give each property a unique output key.' + ); + } +} diff --git a/src/data/src/Exceptions/MaxTransformationDepthReached.php b/src/data/src/Exceptions/MaxTransformationDepthReached.php new file mode 100644 index 000000000..de003f56a --- /dev/null +++ b/src/data/src/Exceptions/MaxTransformationDepthReached.php @@ -0,0 +1,18 @@ +name; + } +} diff --git a/src/data/src/Mappers/SnakeCaseMapper.php b/src/data/src/Mappers/SnakeCaseMapper.php new file mode 100644 index 000000000..4895970e9 --- /dev/null +++ b/src/data/src/Mappers/SnakeCaseMapper.php @@ -0,0 +1,18 @@ +lexer = new Lexer($config); + $this->parser = new PhpDocParser( + $config, + new TypeParser($config, $constantExpressionParser), + $constantExpressionParser, + ); + } + + /** + * Get iterable annotations declared for class properties. + * + * @param ReflectionClass $class + * @return array> + */ + public function getForClass(ReflectionClass $class): array + { + $node = $this->parse($class->getDocComment()); + + if ($node === null) { + return []; + } + + $annotations = []; + + foreach ($node->getPropertyTagValues() as $tag) { + $property = ltrim($tag->propertyName, '$'); + $resolved = $this->extract($tag->type, $class->getName(), $property); + + if ($resolved !== []) { + $annotations[$property] = $resolved; + } + } + + return $annotations; + } + + /** + * Get iterable annotations declared for a property. + * + * @return list + */ + public function getForProperty(ReflectionProperty $property): array + { + $node = $this->parse($property->getDocComment()); + $tag = $node?->getVarTagValues()[0] ?? null; + + return $tag === null + ? [] + : $this->extract($tag->type, $property->getDeclaringClass()->getName()); + } + + /** + * Get iterable annotations declared for method parameters. + * + * @return array> + */ + public function getForMethod(ReflectionMethod $method): array + { + $node = $this->parse($method->getDocComment()); + + if ($node === null) { + return []; + } + + $annotations = []; + + foreach ($node->getParamTagValues() as $tag) { + $parameter = ltrim($tag->parameterName, '$'); + $resolved = $this->extract( + $tag->type, + $method->getDeclaringClass()->getName(), + $parameter, + ); + + if ($resolved !== []) { + $annotations[$parameter] = $resolved; + } + } + + return $annotations; + } + + /** + * Parse a PHPDoc comment. + */ + protected function parse(string|false $comment): ?PhpDocNode + { + if ($comment === false) { + return null; + } + + return $this->parser->parse(new TokenIterator($this->lexer->tokenize($comment))); + } + + /** + * Extract every iterable declaration from a type node. + * + * @param class-string $declaringClass + * @return list + */ + protected function extract( + TypeNode $type, + string $declaringClass, + ?string $property = null, + ): array + { + if ($type instanceof NullableTypeNode) { + return $this->extract($type->type, $declaringClass, $property); + } + + if ($type instanceof UnionTypeNode) { + $annotations = []; + + foreach ($type->types as $subType) { + array_push($annotations, ...$this->extract($subType, $declaringClass, $property)); + } + + return $annotations; + } + + if ($type instanceof ArrayTypeNode) { + return [new DataIterableAnnotation( + containerType: 'array', + itemType: $type->type, + declaringClass: $declaringClass, + keyType: new IdentifierTypeNode('array-key'), + property: $property, + )]; + } + + if (! $type instanceof GenericTypeNode || ! $type->type instanceof IdentifierTypeNode) { + return []; + } + + $container = $type->type->name; + $genericTypes = $type->genericTypes; + + if ($genericTypes === []) { + return []; + } + + if ($container === 'list' || $container === 'non-empty-list') { + return [new DataIterableAnnotation( + containerType: 'array', + itemType: $genericTypes[0], + declaringClass: $declaringClass, + keyType: new IdentifierTypeNode('int'), + property: $property, + )]; + } + + return [new DataIterableAnnotation( + containerType: $container, + itemType: $genericTypes[1] ?? $genericTypes[0], + declaringClass: $declaringClass, + keyType: isset($genericTypes[1]) + ? $genericTypes[0] + : new IdentifierTypeNode('array-key'), + property: $property, + )]; + } +} diff --git a/src/data/src/Support/Creation/CreationContext.php b/src/data/src/Support/Creation/CreationContext.php new file mode 100644 index 000000000..0561e72de --- /dev/null +++ b/src/data/src/Support/Creation/CreationContext.php @@ -0,0 +1,57 @@ + $dataClass + * @param list $ignoredMagicalMethods + * @param array> $casts + * @param list> $normalizers + * @param list $prepareDataHooks + * @param list $beforeValidationHooks + * @param list $beforeRulesHooks + * @param list $afterRulesHooks + * @param list $withValidatorHooks + * @param list $afterValidationHooks + * @param list $beforeCreationHooks + * @param list $afterCreationHooks + * @param non-empty-list $dateFormats + */ + public function __construct( + public string $dataClass, + public CreationMode $mode = CreationMode::Create, + public ValidationStrategy $validationStrategy = ValidationStrategy::OnlyRequests, + public bool $mapPropertyNames = true, + public bool $disableMagicalCreation = false, + public array $ignoredMagicalMethods = [], + public array $casts = [], + public array $normalizers = [], + public array $prepareDataHooks = [], + public array $beforeValidationHooks = [], + public array $beforeRulesHooks = [], + public array $afterRulesHooks = [], + public array $withValidatorHooks = [], + public array $afterValidationHooks = [], + public array $beforeCreationHooks = [], + public array $afterCreationHooks = [], + public array $dateFormats = [DATE_ATOM], + public ?string $dateTimezone = null, + ) { + } +} diff --git a/src/data/src/Support/Creation/CreationMode.php b/src/data/src/Support/Creation/CreationMode.php new file mode 100644 index 000000000..b67b1353b --- /dev/null +++ b/src/data/src/Support/Creation/CreationMode.php @@ -0,0 +1,12 @@ +>> $attributes + */ + public function __construct( + protected readonly array $attributes = [], + ) { + } + + /** + * Determine if an attribute recipe is present. + * + * @param class-string $type + */ + public function has(string $type): bool + { + return array_key_exists($type, $this->attributes); + } + + /** + * @template T of object + * + * @param class-string $type + * @return null|ReflectionAttribute + */ + public function first(string $type): ?ReflectionAttribute + { + return $this->attributes[$type][0] ?? null; + } + + /** + * @template T of object + * + * @param class-string $type + * @return list> + */ + public function all(string $type): array + { + return $this->attributes[$type] ?? []; + } +} diff --git a/src/data/src/Support/DataClass.php b/src/data/src/Support/DataClass.php new file mode 100644 index 000000000..69883896e --- /dev/null +++ b/src/data/src/Support/DataClass.php @@ -0,0 +1,68 @@ + $name + * @param array $properties + * @param array $methods + * @param list $constructorParameters + * @param array $lifecycleMethods + * @param array> $dataIterablePropertyAnnotations + * @param array $outputMappedProperties + * @param ReflectionClass $reflection + */ + public function __construct( + public readonly string $name, + public readonly array $properties, + public readonly array $methods, + public readonly ?ReflectionMethod $constructor, + public readonly array $constructorParameters, + public readonly bool $isReadonly, + public readonly bool $isAbstract, + public readonly bool $isFinal, + public readonly bool $propertyMorphable, + public readonly bool $appendable, + public readonly bool $includeable, + public readonly bool $responsable, + public readonly bool $transformable, + public readonly bool $validateable, + public readonly bool $wrappable, + public readonly bool $emptyData, + public readonly array $lifecycleMethods, + public readonly bool $mergeValidationRules, + public readonly bool $failOnUnknownFields, + public readonly bool $stopOnFirstFailure, + public readonly ?string $errorBag, + public readonly ?string $redirect, + public readonly ?string $redirectRoute, + public readonly bool $plainTransform, + public readonly DataAttributesCollection $attributes, + public readonly array $dataIterablePropertyAnnotations, + public readonly array $outputMappedProperties, + public readonly ReflectionClass $reflection, + ) { + } + + /** + * Determine if the class declares a creation lifecycle method. + */ + public function hasLifecycleMethod(string $method): bool + { + return isset($this->lifecycleMethods[$method]); + } +} diff --git a/src/data/src/Support/DataClassRepository.php b/src/data/src/Support/DataClassRepository.php new file mode 100644 index 000000000..54d609ab4 --- /dev/null +++ b/src/data/src/Support/DataClassRepository.php @@ -0,0 +1,130 @@ +, DataClass> */ + protected array $classes = []; + + /** @var array, bool> */ + protected array $dynamicRuleGraphs = []; + + /** + * Create a new data class repository. + */ + public function __construct( + protected readonly DataClassFactory $factory, + ) { + } + + /** + * Get immutable metadata for a data class. + * + * @template TData of BaseData + * + * @param class-string $class + */ + public function get(string $class): DataClass + { + if (! is_a($class, BaseData::class, true)) { + throw CannotFindDataClass::forClass($class); + } + + return $this->classes[$class] ??= $this->factory->build( + ClassMetadataCache::reflectClass($class), + ); + } + + /** + * Determine if a validated class graph can produce payload-dependent rules. + * + * @param class-string $class + */ + public function hasDynamicRuleGraph(string $class): bool + { + if (array_key_exists($class, $this->dynamicRuleGraphs)) { + return $this->dynamicRuleGraphs[$class]; + } + + $visited = []; + + if ($this->resolveDynamicRuleGraph($class, $visited)) { + return true; + } + + foreach (array_keys($visited) as $visitedClass) { + $this->dynamicRuleGraphs[$visitedClass] = false; + } + + return false; + } + + /** + * Traverse one dynamic-rule graph without caching incomplete cycle results. + * + * @param class-string $class + * @param array, true> $visited + */ + protected function resolveDynamicRuleGraph(string $class, array &$visited): bool + { + if (array_key_exists($class, $this->dynamicRuleGraphs)) { + return $this->dynamicRuleGraphs[$class]; + } + + if (isset($visited[$class])) { + return false; + } + + $visited[$class] = true; + $dataClass = $this->get($class); + + if ($dataClass->propertyMorphable || $dataClass->hasLifecycleMethod('rules')) { + return $this->dynamicRuleGraphs[$class] = true; + } + + $contextualProperties = []; + + foreach ($dataClass->constructorParameters as $parameter) { + if ($parameter->isPromoted && $parameter->contextualAttribute !== null) { + $contextualProperties[$parameter->name] = true; + } + } + + foreach ($dataClass->properties as $property) { + if ($property->computed + || ! $property->validate + || isset($contextualProperties[$property->name]) + ) { + continue; + } + + $nestedClasses = []; + $dataObjectTypes = $property->type->getDataObjectTypes(); + $dataCollectableTypes = $property->type->getDataCollectableTypes(); + + if (count($dataObjectTypes) === 1 && $dataObjectTypes[0]->dataClass !== null) { + $nestedClasses[$dataObjectTypes[0]->dataClass] = true; + } + + if (count($dataCollectableTypes) === 1 && $dataCollectableTypes[0]->dataClass !== null) { + $nestedClasses[$dataCollectableTypes[0]->dataClass] = true; + } + + foreach (array_keys($nestedClasses) as $nestedClass) { + if ($this->resolveDynamicRuleGraph($nestedClass, $visited)) { + return $this->dynamicRuleGraphs[$class] = true; + } + } + } + + return false; + } +} diff --git a/src/data/src/Support/DataMethod.php b/src/data/src/Support/DataMethod.php new file mode 100644 index 000000000..bf551c85a --- /dev/null +++ b/src/data/src/Support/DataMethod.php @@ -0,0 +1,172 @@ + $parameters + */ + public function __construct( + public readonly string $name, + public readonly array $parameters, + public readonly bool $isStatic, + public readonly bool $isPublic, + public readonly CustomCreationMethodType $customCreationMethodType, + public readonly ?DataType $returnType, + public readonly ReflectionMethod $reflection, + ) { + } + + /** + * Match creation payloads to this method's parameters. + */ + public function matchPayloads(CreationContext $context, mixed ...$payloads): ?DataMethodMatch + { + $positionalPayloads = []; + $namedPayloads = []; + + foreach ($payloads as $key => $payload) { + if (is_int($key)) { + $positionalPayloads[] = $payload; + } else { + $namedPayloads[$key] = $payload; + } + } + + $namedArguments = []; + $positionalArguments = []; + $consumedNamedPayloads = []; + $declaredParameterNames = []; + $positionalIndex = 0; + $requiresContainerCall = false; + $hasSkippedParameter = false; + + foreach ($this->parameters as $parameter) { + if ($parameter->isVariadic) { + $variadicPayloads = array_slice($positionalPayloads, $positionalIndex); + + foreach ($namedPayloads as $name => $payload) { + if (isset($consumedNamedPayloads[$name])) { + continue; + } + + if (isset($declaredParameterNames[$name])) { + return null; + } + + $variadicPayloads[] = $payload; + } + + foreach ($variadicPayloads as $payload) { + if (! $parameter->type->acceptsValue($payload)) { + return null; + } + } + + if ($variadicPayloads === []) { + return new DataMethodMatch($namedArguments, $requiresContainerCall); + } + + if (! $requiresContainerCall && ! $hasSkippedParameter) { + return new DataMethodMatch( + [...$positionalArguments, ...$variadicPayloads], + false, + ); + } + + if ($parameter->className !== null) { + $namedArguments[$parameter->className] = array_shift($variadicPayloads); + } + + foreach ($variadicPayloads as $payload) { + $namedArguments[] = $payload; + } + + return new DataMethodMatch($namedArguments, true); + } + + $declaredParameterNames[$parameter->name] = true; + + if ($parameter->className === CreationContext::class) { + $namedArguments[$parameter->name] = $context; + $positionalArguments[] = $context; + $requiresContainerCall = $requiresContainerCall || $parameter->hasAttributes; + + continue; + } + + if ($parameter->contextualAttribute !== null) { + $requiresContainerCall = true; + $hasSkippedParameter = true; + + continue; + } + + if (array_key_exists($parameter->name, $namedPayloads)) { + $payload = $namedPayloads[$parameter->name]; + + if (! $parameter->type->acceptsValue($payload)) { + return null; + } + + $consumedNamedPayloads[$parameter->name] = true; + $namedArguments[$parameter->name] = $payload; + $positionalArguments[] = $payload; + $requiresContainerCall = $requiresContainerCall || $parameter->hasAttributes; + + continue; + } + + if (array_key_exists($positionalIndex, $positionalPayloads) + && $parameter->type->acceptsValue($positionalPayloads[$positionalIndex])) { + $payload = $positionalPayloads[$positionalIndex++]; + $namedArguments[$parameter->name] = $payload; + $positionalArguments[] = $payload; + $requiresContainerCall = $requiresContainerCall || $parameter->hasAttributes; + + continue; + } + + if ($parameter->className !== null) { + $requiresContainerCall = true; + $hasSkippedParameter = true; + + continue; + } + + if ($parameter->hasDefaultValue) { + $requiresContainerCall = $requiresContainerCall || $parameter->hasAttributes; + $hasSkippedParameter = true; + + continue; + } + + return null; + } + + if ($positionalIndex !== count($positionalPayloads) + || count($consumedNamedPayloads) !== count($namedPayloads)) { + return null; + } + + return new DataMethodMatch($namedArguments, $requiresContainerCall); + } + + /** + * Determine if the method can return the requested type. + */ + public function returns(string $type): bool + { + return $this->returnType?->acceptsType($type) ?? false; + } + +} diff --git a/src/data/src/Support/DataMethodMatch.php b/src/data/src/Support/DataMethodMatch.php new file mode 100644 index 000000000..433839e22 --- /dev/null +++ b/src/data/src/Support/DataMethodMatch.php @@ -0,0 +1,41 @@ + $arguments + */ + public function __construct( + public array $arguments, + public bool $requiresContainerCall, + ) { + } + + /** + * Replace one matched payload without rebuilding the argument map. + */ + public function replacePayload(mixed $payload, mixed $replacement): self + { + $arguments = $this->arguments; + + foreach ($arguments as $key => $argument) { + if ($argument !== $payload) { + continue; + } + + $arguments[$key] = $replacement; + + return new self($arguments, $this->requiresContainerCall); + } + + throw new LogicException('The matched payload is missing from the invocation arguments.'); + } +} diff --git a/src/data/src/Support/DataParameter.php b/src/data/src/Support/DataParameter.php new file mode 100644 index 000000000..1647d9830 --- /dev/null +++ b/src/data/src/Support/DataParameter.php @@ -0,0 +1,32 @@ + $contextualAttribute + */ + public function __construct( + public readonly string $name, + public readonly int $position, + public readonly bool $isPromoted, + public readonly bool $isVariadic, + public readonly bool $hasDefaultValue, + public readonly bool $hasAttributes, + public readonly ?string $className, + public readonly DataType $type, + public readonly ReflectionParameter $reflection, + public readonly ?ReflectionAttribute $contextualAttribute, + ) { + } +} diff --git a/src/data/src/Support/DataProperty.php b/src/data/src/Support/DataProperty.php new file mode 100644 index 000000000..892a267bc --- /dev/null +++ b/src/data/src/Support/DataProperty.php @@ -0,0 +1,100 @@ + $autoLazy + * @param null|ReflectionAttribute $cast + * @param null|ReflectionAttribute $transformer + * @param list> $configuredCasts + * @param list> $configuredTransformers + */ + public function __construct( + public readonly string $name, + public readonly string $className, + public readonly DataPropertyType $type, + public readonly bool $validate, + public readonly bool $computed, + public readonly bool $hidden, + public readonly bool $isPromoted, + public readonly bool $isConstructorParameter, + public readonly bool $isReadonly, + public readonly bool $isVirtual, + public readonly bool $morphable, + public readonly bool $loadRelation, + public readonly ?ReflectionAttribute $autoLazy, + public readonly bool $hasDefaultValue, + public readonly ?ReflectionAttribute $cast, + public readonly ?ReflectionAttribute $transformer, + public readonly string|int|null $inputMappedName, + public readonly string|int|null $outputMappedName, + public readonly array $configuredCasts, + public readonly array $configuredTransformers, + public readonly DataAttributesCollection $attributes, + public readonly ReflectionProperty $reflection, + ) { + } + + /** + * Determine if a supplied value is a finished declared Data value. + */ + public function isFinishedValue(mixed $value): bool + { + if ($value instanceof BaseData) { + return $this->type->acceptsValue($value); + } + + $type = $this->type->getDataCollectableType(); + + if ($type === null + || $type->dataClass === null + || ! $type->acceptsValue($value) + ) { + return false; + } + + if ($value instanceof DataCollection) { + return is_a($value->getDataClass(), $type->dataClass, true); + } + + if ($value instanceof LazyCollection) { + return false; + } + + $items = match (true) { + $value instanceof Enumerable => $value->all(), + $value instanceof CursorPaginator, $value instanceof Paginator => $value->items(), + default => null, + }; + + if ($items === null) { + return false; + } + + foreach ($items as $item) { + if (! $item instanceof $type->dataClass) { + return false; + } + } + + return true; + } +} diff --git a/src/data/src/Support/DataPropertyType.php b/src/data/src/Support/DataPropertyType.php new file mode 100644 index 000000000..9c0b84796 --- /dev/null +++ b/src/data/src/Support/DataPropertyType.php @@ -0,0 +1,86 @@ + $lazyType + */ + public function __construct( + Type $type, + public readonly bool $isOptional, + bool $isNullable, + bool $isMixed, + public readonly ?string $lazyType, + ) { + parent::__construct($type, $isNullable, $isMixed); + } + + /** + * Get the declared data object types. + * + * @return list + */ + public function getDataObjectTypes(): array + { + return array_values(array_filter( + $this->getNamedTypes(), + fn (NamedType $type): bool => $type->kind->isDataObject(), + )); + } + + /** + * Get the one unambiguous declared data object type. + */ + public function getDataObjectType(): ?NamedType + { + $types = $this->getDataObjectTypes(); + + return count($types) === 1 ? $types[0] : null; + } + + /** + * Get the declared data collection types. + * + * @return list + */ + public function getDataCollectableTypes(): array + { + return array_values(array_filter( + $this->getNamedTypes(), + fn (NamedType $type): bool => $type->kind->isDataCollectable(), + )); + } + + /** + * Get the one unambiguous declared data collection type. + */ + public function getDataCollectableType(): ?NamedType + { + $types = $this->getDataCollectableTypes(); + + return count($types) === 1 ? $types[0] : null; + } + + /** + * Get the declared iterable types with item metadata. + * + * @return list + */ + public function getIterableTypes(): array + { + return array_values(array_filter( + $this->getNamedTypes(), + fn (NamedType $type): bool => $type->iterableItemType !== null, + )); + } +} diff --git a/src/data/src/Support/DataType.php b/src/data/src/Support/DataType.php new file mode 100644 index 000000000..36cdc6427 --- /dev/null +++ b/src/data/src/Support/DataType.php @@ -0,0 +1,81 @@ +type->findAcceptedTypeForBaseType($class); + } + + /** + * Determine if this declaration accepts the given type name. + */ + public function acceptsType(string $type): bool + { + if ($this->isMixed) { + return true; + } + + return $this->type->acceptsType($type); + } + + /** + * Get the declared types and their inherited types. + * + * @return array> + */ + public function getAcceptedTypes(): array + { + if ($this->isMixed) { + return []; + } + + return $this->type->getAcceptedTypes(); + } + + /** + * Get every named type in declaration order. + * + * @return list + */ + public function getNamedTypes(): array + { + return $this->type->getNamedTypes(); + } + + /** + * Determine if this declaration accepts the given value. + */ + public function acceptsValue(mixed $value): bool + { + if ($this->isMixed) { + return true; + } + + if ($this->isNullable && $value === null) { + return true; + } + + return $this->type->acceptsValue($value); + } +} diff --git a/src/data/src/Support/Factories/DataAttributesCollectionFactory.php b/src/data/src/Support/Factories/DataAttributesCollectionFactory.php new file mode 100644 index 000000000..2c4734e18 --- /dev/null +++ b/src/data/src/Support/Factories/DataAttributesCollectionFactory.php @@ -0,0 +1,109 @@ + $reflectionClass + */ + public static function buildFromReflectionClass(ReflectionClass $reflectionClass): DataAttributesCollection + { + $attributeGroups = [ + $reflectionClass->getAttributes(), + ]; + + while ($parent = static::findParentReflectionClass($reflectionClass)) { + $attributeGroups[] = $parent->getAttributes(); + + $reflectionClass = $parent; + } + + return new DataAttributesCollection( + static::mapAttributesIntoGroups(array_merge(...$attributeGroups)), + ); + } + + /** + * Build the attribute recipes for a property. + */ + public static function buildFromReflectionProperty(ReflectionProperty $reflectionProperty): DataAttributesCollection + { + return new DataAttributesCollection( + static::mapAttributesIntoGroups($reflectionProperty->getAttributes()), + ); + } + + /** + * Build the attribute recipes for a parameter. + */ + public static function buildFromReflectionParameter(ReflectionParameter $reflectionParameter): DataAttributesCollection + { + return new DataAttributesCollection( + static::mapAttributesIntoGroups($reflectionParameter->getAttributes()), + ); + } + + /** + * Find the next parent whose attributes belong to the data declaration. + * + * @param ReflectionClass $reflectionClass + * @return null|ReflectionClass + */ + protected static function findParentReflectionClass(ReflectionClass $reflectionClass): ?ReflectionClass + { + $parent = $reflectionClass->getParentClass(); + + if ($parent === false) { + return null; + } + + if (in_array($parent->name, [Data::class, Dto::class, Resource::class], true)) { + return null; + } + + return $parent; + } + + /** + * Group attribute recipes by their concrete type, parents, and interfaces. + * + * @param list> $reflectionAttributes + * @return array>> + */ + protected static function mapAttributesIntoGroups(array $reflectionAttributes): array + { + $attributes = []; + + foreach ($reflectionAttributes as $reflectionAttribute) { + if (! class_exists($reflectionAttribute->getName())) { + continue; + } + + $attributes[$reflectionAttribute->getName()][] = $reflectionAttribute; + + foreach (class_implements($reflectionAttribute->getName()) ?: [] as $interface) { + $attributes[$interface][] = $reflectionAttribute; + } + + foreach (class_parents($reflectionAttribute->getName()) ?: [] as $parent) { + $attributes[$parent][] = $reflectionAttribute; + } + } + + return $attributes; + } +} diff --git a/src/data/src/Support/Factories/DataClassFactory.php b/src/data/src/Support/Factories/DataClassFactory.php new file mode 100644 index 000000000..ef91764e7 --- /dev/null +++ b/src/data/src/Support/Factories/DataClassFactory.php @@ -0,0 +1,468 @@ + $reflectionClass + */ + public function build(ReflectionClass $reflectionClass): DataClass + { + /** @var class-string $name */ + $name = $reflectionClass->getName(); + $attributes = DataAttributesCollectionFactory::buildFromReflectionClass($reflectionClass); + $constructor = $reflectionClass->getConstructor(); + $constructorParameters = $this->resolveConstructorParameters($reflectionClass, $constructor); + $reflectionProperties = $this->resolveReflectionProperties($reflectionClass); + + $this->validateConstructorParameters($name, $constructorParameters, $reflectionProperties); + + $classInputNameMapper = $this->nameMapperResolver->resolveInput( + $attributes, + $this->nameMapperResolver->resolveConfigured($this->config->inputNameMapper), + ); + $classOutputNameMapper = $this->nameMapperResolver->resolveOutput( + $attributes, + $this->nameMapperResolver->resolveConfigured($this->config->outputNameMapper), + ); + + if ($classInputNameMapper instanceof ProvidedNameMapper) { + $classInputNameMapper = null; + } + + if ($classOutputNameMapper instanceof ProvidedNameMapper) { + $classOutputNameMapper = null; + } + + [$properties, $iterableAnnotations] = $this->resolveProperties( + $name, + $reflectionClass, + $reflectionProperties, + $constructor, + $constructorParameters, + $classInputNameMapper, + $classOutputNameMapper, + $attributes->first(AutoLazy::class), + ); + + $failOnUnknownFields = $attributes->first(FailOnUnknownFields::class)?->newInstance(); + $errorBag = $attributes->first(ErrorBag::class)?->newInstance(); + $redirect = $attributes->first(RedirectTo::class)?->newInstance(); + $redirectRoute = $attributes->first(RedirectToRoute::class)?->newInstance(); + + return new DataClass( + name: $name, + properties: $properties, + methods: $this->resolveMethods($reflectionClass), + constructor: $constructor, + constructorParameters: array_values($constructorParameters), + isReadonly: $reflectionClass->isReadOnly(), + isAbstract: $reflectionClass->isAbstract(), + isFinal: $reflectionClass->isFinal(), + propertyMorphable: $reflectionClass->implementsInterface(PropertyMorphableData::class), + appendable: $reflectionClass->implementsInterface(AppendableData::class), + includeable: $reflectionClass->implementsInterface(IncludeableData::class), + responsable: $reflectionClass->implementsInterface(ResponsableData::class), + transformable: $reflectionClass->implementsInterface(TransformableData::class), + validateable: $reflectionClass->implementsInterface(ValidateableData::class), + wrappable: $reflectionClass->implementsInterface(WrappableData::class), + emptyData: $reflectionClass->implementsInterface(EmptyData::class), + lifecycleMethods: $this->resolveLifecycleMethods($reflectionClass), + mergeValidationRules: $attributes->has(MergeValidationRules::class), + failOnUnknownFields: $failOnUnknownFields?->value ?? false, + stopOnFirstFailure: $attributes->has(StopOnFirstFailure::class), + errorBag: $errorBag?->name, + redirect: $redirect?->url, + redirectRoute: $redirectRoute?->route, + plainTransform: $this->isPlainTransform($properties), + attributes: $attributes, + dataIterablePropertyAnnotations: $iterableAnnotations, + outputMappedProperties: $this->validateMappings($name, $properties), + reflection: $reflectionClass, + ); + } + + /** + * Build constructor parameter metadata keyed by parameter name. + * + * @param ReflectionClass $reflectionClass + * @return array + */ + protected function resolveConstructorParameters( + ReflectionClass $reflectionClass, + ?ReflectionMethod $constructor, + ): array { + if ($constructor === null) { + return []; + } + + $parameters = []; + + foreach ($constructor->getParameters() as $parameter) { + $parameters[$parameter->name] = $this->parameterFactory->build($parameter, $reflectionClass); + } + + return $parameters; + } + + /** + * Get public, non-static data properties keyed by name. + * + * @param ReflectionClass $reflectionClass + * @return array + */ + protected function resolveReflectionProperties(ReflectionClass $reflectionClass): array + { + $properties = []; + + foreach ($reflectionClass->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if (! $property->isStatic()) { + $properties[$property->name] = $property; + } + } + + return $properties; + } + + /** + * Validate that constructor inputs have one supported ownership form. + * + * @param class-string $class + * @param array $parameters + * @param array $properties + */ + protected function validateConstructorParameters( + string $class, + array $parameters, + array $properties, + ): void { + foreach ($parameters as $parameter) { + if ($parameter->isPromoted && ! isset($properties[$parameter->name])) { + throw InvalidDataDeclaration::nonPublicPromotedProperty($class, $parameter); + } + + if (! $parameter->isPromoted + && $parameter->contextualAttribute === null + && ! isset($properties[$parameter->name])) { + throw InvalidDataDeclaration::missingDataProperty($class, $parameter); + } + } + } + + /** + * Build data properties and their selected iterable annotations. + * + * @param class-string $class + * @param ReflectionClass $reflectionClass + * @param array $reflectionProperties + * @param array $constructorParameters + * @param null|ReflectionAttribute $classAutoLazy + * @return array{array, array>} + */ + protected function resolveProperties( + string $class, + ReflectionClass $reflectionClass, + array $reflectionProperties, + ?ReflectionMethod $constructor, + array $constructorParameters, + ?NameMapper $classInputNameMapper, + ?NameMapper $classOutputNameMapper, + ?ReflectionAttribute $classAutoLazy, + ): array { + $constructorAnnotations = $constructor === null + ? [] + : $this->iterableAnnotationReader->getForMethod($constructor); + $classAnnotations = $this->resolveClassAnnotations($reflectionClass); + $properties = []; + $selectedAnnotations = []; + + foreach ($reflectionProperties as $name => $reflectionProperty) { + $parameter = $constructorParameters[$name] ?? null; + $constructorParameter = $parameter !== null + && ($parameter->isPromoted || $parameter->contextualAttribute === null) + ? $parameter + : null; + $propertyAnnotations = $this->iterableAnnotationReader->getForProperty($reflectionProperty); + $annotations = $constructorParameter === null + ? [] + : ($constructorAnnotations[$name] ?? []); + + if ($annotations === []) { + $annotations = $propertyAnnotations !== [] + ? $propertyAnnotations + : ($classAnnotations[$name] ?? []); + } + + $property = $this->propertyFactory->build( + reflectionProperty: $reflectionProperty, + reflectionClass: $reflectionClass, + constructorParameter: $constructorParameter, + classInputNameMapper: $classInputNameMapper, + classOutputNameMapper: $classOutputNameMapper, + classDefinedDataIterableAnnotations: $annotations, + classAutoLazy: $classAutoLazy, + ); + + if ($parameter?->contextualAttribute !== null && ! $parameter->isPromoted) { + throw InvalidDataDeclaration::contextualParameterConflictsWithProperty( + $class, + $parameter, + $property, + ); + } + + if ($property->computed && $property->isConstructorParameter) { + throw InvalidDataDeclaration::computedConstructorProperty($class, $property); + } + + if ($property->isReadonly && ! $property->isConstructorParameter && ! $property->computed) { + throw InvalidDataDeclaration::unassignableReadonlyProperty($class, $property); + } + + $properties[$name] = $property; + + if ($annotations !== []) { + $selectedAnnotations[$name] = $annotations; + } + } + + return [$properties, $selectedAnnotations]; + } + + /** + * Resolve nearest class-level iterable annotations across inheritance. + * + * @param ReflectionClass $reflectionClass + * @return array> + */ + protected function resolveClassAnnotations(ReflectionClass $reflectionClass): array + { + $annotations = []; + $current = $reflectionClass; + + while (! in_array($current->getName(), [Data::class, Dto::class, Resource::class], true)) { + foreach ($this->iterableAnnotationReader->getForClass($current) as $property => $propertyAnnotations) { + $annotations[$property] ??= $propertyAnnotations; + } + + $parent = $current->getParentClass(); + + if ($parent === false) { + break; + } + + $current = $parent; + } + + return $annotations; + } + + /** + * Build named creation method metadata in declaration order. + * + * @param ReflectionClass $reflectionClass + * @return array + */ + protected function resolveMethods(ReflectionClass $reflectionClass): array + { + $methods = []; + + foreach ($reflectionClass->getMethods() as $reflectionMethod) { + if (! $reflectionMethod->isPublic() + || ! $reflectionMethod->isStatic() + || in_array($reflectionMethod->name, ['from', 'collect', 'collection'], true) + || (! str_starts_with($reflectionMethod->name, 'from') + && ! str_starts_with($reflectionMethod->name, 'collect'))) { + continue; + } + + $method = $this->methodFactory->build($reflectionMethod, $reflectionClass); + + if ($method->customCreationMethodType !== CustomCreationMethodType::None) { + $methods[$method->name] = $method; + } + } + + return $methods; + } + + /** + * Compile user-owned creation lifecycle method presence. + * + * @param ReflectionClass $reflectionClass + * @return array + */ + protected function resolveLifecycleMethods(ReflectionClass $reflectionClass): array + { + $methods = []; + + foreach ([ + 'authorize', + 'rules', + 'messages', + 'attributes', + 'withValidator', + 'after', + 'normalizers', + 'stopOnFirstFailure', + 'redirect', + 'redirectRoute', + 'errorBag', + ] as $name) { + if (! $reflectionClass->hasMethod($name)) { + continue; + } + + $declaringClass = $reflectionClass->getMethod($name)->getDeclaringClass()->getName(); + + if (! in_array($declaringClass, [Data::class, Dto::class, Resource::class], true)) { + $methods[$name] = true; + } + } + + return $methods; + } + + /** + * Validate mapping ownership and build the output reverse map. + * + * @param class-string $class + * @param array $properties + * @return array + */ + protected function validateMappings(string $class, array $properties): array + { + $inputOwners = []; + $outputOwners = []; + $outputMappedProperties = []; + + foreach ($properties as $property) { + $inputPaths = [$property->name]; + + if ($property->inputMappedName !== null && $property->inputMappedName !== $property->name) { + $inputPaths[] = $property->inputMappedName; + } + + foreach ($inputPaths as $path) { + if (isset($inputOwners[$path]) && $inputOwners[$path] !== $property) { + throw InvalidDataDeclaration::duplicateInputPath( + $class, + $path, + $inputOwners[$path], + $property, + ); + } + + $inputOwners[$path] = $property; + } + + if ($property->hidden) { + continue; + } + + $outputKey = $property->outputMappedName ?? $property->name; + + if (isset($outputOwners[$outputKey]) && $outputOwners[$outputKey] !== $property) { + throw InvalidDataDeclaration::duplicateOutputKey( + $class, + $outputKey, + $outputOwners[$outputKey], + $property, + ); + } + + $outputOwners[$outputKey] = $property; + + if ($property->outputMappedName !== null) { + $outputMappedProperties[$property->outputMappedName] = $property->name; + } + } + + return $outputMappedProperties; + } + + /** + * Determine if declared values can be copied directly during transformation. + * + * @param array $properties + */ + protected function isPlainTransform(array $properties): bool + { + foreach ($properties as $property) { + if ($property->hidden + || $property->outputMappedName !== null + || $property->transformer !== null + || $property->configuredTransformers !== [] + || $property->type->lazyType !== null + || $property->type->isOptional + || $property->type->isMixed) { + return false; + } + + foreach ($property->type->getNamedTypes() as $type) { + if (! $type->builtIn + || $type->kind->isNonDataIterable() + || $type->kind->isDataRelated() + || $type->name === 'object') { + return false; + } + } + } + + return true; + } +} diff --git a/src/data/src/Support/Factories/DataMethodFactory.php b/src/data/src/Support/Factories/DataMethodFactory.php new file mode 100644 index 000000000..41ab1a695 --- /dev/null +++ b/src/data/src/Support/Factories/DataMethodFactory.php @@ -0,0 +1,89 @@ + $reflectionClass + */ + public function build( + ReflectionMethod $reflectionMethod, + ReflectionClass $reflectionClass, + ): DataMethod { + $parameters = array_map( + fn (ReflectionParameter $parameter): DataParameter => $this->parameterFactory->build($parameter, $reflectionClass), + $reflectionMethod->getParameters(), + ); + + $returnType = $reflectionMethod->hasReturnType() + ? $this->typeFactory->build($reflectionMethod->getReturnType(), $reflectionClass, $reflectionMethod) + : null; + $customCreationMethodType = $this->resolveCustomCreationMethodType($reflectionMethod, $returnType); + + if ($reflectionMethod->isPublic() + && $reflectionMethod->isStatic() + && $customCreationMethodType !== CustomCreationMethodType::None) { + foreach ($parameters as $parameter) { + if ($parameter->isVariadic && $parameter->className === CreationContext::class) { + throw InvalidDataDeclaration::variadicCreationContext( + $reflectionClass->name, + $reflectionMethod->name, + $parameter->name, + ); + } + } + } + + return new DataMethod( + name: $reflectionMethod->name, + parameters: $parameters, + isStatic: $reflectionMethod->isStatic(), + isPublic: $reflectionMethod->isPublic(), + customCreationMethodType: $customCreationMethodType, + returnType: $returnType, + reflection: $reflectionMethod, + ); + } + + /** + * Resolve the method's named creation role. + */ + protected function resolveCustomCreationMethodType( + ReflectionMethod $method, + ?DataType $returnType, + ): CustomCreationMethodType { + if (str_starts_with($method->name, 'from')) { + return CustomCreationMethodType::Object; + } + + if (str_starts_with($method->name, 'collect') && $returnType !== null) { + return CustomCreationMethodType::Collection; + } + + return CustomCreationMethodType::None; + } +} diff --git a/src/data/src/Support/Factories/DataParameterFactory.php b/src/data/src/Support/Factories/DataParameterFactory.php new file mode 100644 index 000000000..dc90f3c73 --- /dev/null +++ b/src/data/src/Support/Factories/DataParameterFactory.php @@ -0,0 +1,53 @@ + $reflectionClass + */ + public function build( + ReflectionParameter $reflectionParameter, + ReflectionClass $reflectionClass, + ): DataParameter { + return new DataParameter( + name: $reflectionParameter->name, + position: $reflectionParameter->getPosition(), + isPromoted: $reflectionParameter->isPromoted(), + isVariadic: $reflectionParameter->isVariadic(), + hasDefaultValue: $reflectionParameter->isDefaultValueAvailable(), + hasAttributes: $reflectionParameter->getAttributes() !== [], + className: Reflector::getParameterClassName($reflectionParameter), + type: $this->typeFactory->build( + $reflectionParameter->getType(), + $reflectionClass, + $reflectionParameter, + ), + reflection: $reflectionParameter, + contextualAttribute: $reflectionParameter->getAttributes( + ContextualAttribute::class, + ReflectionAttribute::IS_INSTANCEOF, + )[0] ?? null, + ); + } +} diff --git a/src/data/src/Support/Factories/DataPropertyFactory.php b/src/data/src/Support/Factories/DataPropertyFactory.php new file mode 100644 index 000000000..c2ab78be0 --- /dev/null +++ b/src/data/src/Support/Factories/DataPropertyFactory.php @@ -0,0 +1,145 @@ + $reflectionClass + * @param list $classDefinedDataIterableAnnotations + * @param null|ReflectionAttribute $classAutoLazy + */ + public function build( + ReflectionProperty $reflectionProperty, + ReflectionClass $reflectionClass, + ?DataParameter $constructorParameter = null, + ?NameMapper $classInputNameMapper = null, + ?NameMapper $classOutputNameMapper = null, + array $classDefinedDataIterableAnnotations = [], + ?ReflectionAttribute $classAutoLazy = null, + ): DataProperty { + $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty($reflectionProperty); + + $type = $this->typeFactory->buildProperty( + $reflectionProperty->getType(), + $reflectionClass, + $reflectionProperty, + $attributes, + $classDefinedDataIterableAnnotations, + ); + + $inputMappedName = $this->nameMapperResolver + ->resolveInput($attributes, $classInputNameMapper) + ?->map($reflectionProperty->name); + $outputMappedName = $this->nameMapperResolver + ->resolveOutput($attributes, $classOutputNameMapper) + ?->map($reflectionProperty->name); + + if ($constructorParameter !== null) { + $hasDefaultValue = $constructorParameter->hasDefaultValue; + $defaultValue = $hasDefaultValue + ? $constructorParameter->reflection->getDefaultValue() + : null; + } else { + $hasDefaultValue = $reflectionProperty->hasDefaultValue(); + $defaultValue = $hasDefaultValue ? $reflectionProperty->getDefaultValue() : null; + } + + if ($hasDefaultValue && $defaultValue instanceof Optional) { + $hasDefaultValue = false; + } + + $autoLazy = $attributes->first(AutoLazy::class); + + if ($classAutoLazy !== null && $type->lazyType !== null && $autoLazy === null) { + $autoLazy = $classAutoLazy; + } + + $isVirtual = $reflectionProperty->isVirtual(); + $computed = $attributes->has(Computed::class) || $isVirtual; + + return new DataProperty( + name: $reflectionProperty->name, + className: $reflectionProperty->class, + type: $type, + validate: ! $computed + && $constructorParameter?->contextualAttribute === null + && ! $attributes->has(WithoutValidation::class), + computed: $computed, + hidden: $attributes->has(Hidden::class), + isPromoted: $reflectionProperty->isPromoted(), + isConstructorParameter: $constructorParameter !== null, + isReadonly: $reflectionProperty->isReadOnly(), + isVirtual: $isVirtual, + morphable: $attributes->has(PropertyForMorph::class), + loadRelation: $attributes->has(LoadRelation::class), + autoLazy: $autoLazy, + hasDefaultValue: $hasDefaultValue, + cast: $attributes->first(GetsCast::class), + transformer: $attributes->first(WithTransformer::class) + ?? $attributes->first(WithCastAndTransformer::class), + inputMappedName: $inputMappedName, + outputMappedName: $outputMappedName, + configuredCasts: $this->applicableExtensions($type, $this->config->casts), + configuredTransformers: $this->applicableExtensions($type, $this->config->transformers), + attributes: $attributes, + reflection: $reflectionProperty, + ); + } + + /** + * Select configured extensions that apply to a property type. + * + * @template TExtension of object + * + * @param array> $extensions + * @return list> + */ + protected function applicableExtensions(DataPropertyType $type, array $extensions): array + { + $applicable = []; + + foreach ($extensions as $baseType => $extension) { + if ($type->findAcceptedTypeForBaseType($baseType) !== null) { + $applicable[] = $extension; + } + } + + return array_values(array_unique($applicable)); + } +} diff --git a/src/data/src/Support/Factories/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php new file mode 100644 index 000000000..2b569fdc3 --- /dev/null +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -0,0 +1,607 @@ +|class-string $class + * @param list $iterableAnnotations + */ + public function buildProperty( + ?ReflectionType $reflectionType, + ReflectionClass|string $class, + ReflectionProperty|ReflectionParameter|string $typeable, + ?DataAttributesCollection $attributes = null, + array $iterableAnnotations = [], + ): DataPropertyType { + $class = $this->reflectionClass($class); + $declaringClass = $this->declaringClass($typeable, $class); + + $collectionOf = $attributes?->first(DataCollectionOf::class)?->newInstance(); + $type = $this->buildNativeType( + $reflectionType, + $class, + $declaringClass, + $typeable, + $iterableAnnotations, + $collectionOf instanceof DataCollectionOf ? $collectionOf->class : null, + true, + ); + $namedTypes = $type->getNamedTypes(); + + return new DataPropertyType( + type: $type, + isOptional: $this->containsType($namedTypes, Optional::class), + isNullable: $reflectionType?->allowsNull() ?? true, + isMixed: $this->containsType($namedTypes, 'mixed'), + lazyType: $this->findLazyType($namedTypes), + ); + } + + /** + * Build a parameter or return data type. + * + * @param ReflectionClass|class-string $class + */ + public function build( + ?ReflectionType $reflectionType, + ReflectionClass|string $class, + ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, + ): DataType { + $class = $this->reflectionClass($class); + $type = $this->buildNativeType( + $reflectionType, + $class, + $this->declaringClass($typeable, $class), + $typeable, + ); + + return new DataType( + type: $type, + isNullable: $reflectionType?->allowsNull() ?? true, + isMixed: $this->containsType($type->getNamedTypes(), 'mixed'), + ); + } + + /** + * Build a data type from a declared type name. + * + * @param ReflectionClass|class-string $class + */ + public function buildFromString( + string $type, + ReflectionClass|string $class, + bool $isBuiltIn, + bool $isNullable = false, + ): DataType { + $class = $this->reflectionClass($class); + $namedType = $this->buildNamedType( + $this->resolveNativeName($type, $class, $class), + $isBuiltIn, + ); + + return new DataType( + type: $namedType, + isNullable: $isNullable, + isMixed: $namedType->name === 'mixed', + ); + } + + /** + * Build a reflected type graph. + * + * @param ReflectionClass $targetClass + * @param ReflectionClass $declaringClass + * @param list $iterableAnnotations + */ + protected function buildNativeType( + ?ReflectionType $reflectionType, + ReflectionClass $targetClass, + ReflectionClass $declaringClass, + ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, + array $iterableAnnotations = [], + ?string $collectionOf = null, + bool $forProperty = false, + ): Type { + if ($reflectionType === null) { + return $this->buildNamedType('mixed', true); + } + + if ($reflectionType instanceof ReflectionNamedType) { + $name = $this->resolveNativeName( + $reflectionType->getName(), + $targetClass, + $declaringClass, + ); + $itemType = null; + $keyType = null; + + $kind = $this->kindFor($name); + + if ($collectionOf !== null && ($kind->isNonDataIterable() || $kind->isDataCollectable())) { + $itemType = $this->buildNamedType($collectionOf, false); + $keyType = $this->buildArrayKeyType(); + } elseif ($annotation = $this->matchingAnnotation($name, $targetClass, $iterableAnnotations)) { + $annotationClass = ClassMetadataCache::reflectClass($annotation->declaringClass); + $itemType = $this->buildPhpDocType( + $annotation->itemType, + $targetClass, + $annotationClass, + ); + $keyType = $annotation->keyType === null + ? $this->buildArrayKeyType() + : $this->buildPhpDocType( + $annotation->keyType, + $targetClass, + $annotationClass, + ); + } + + $type = $this->buildNamedType( + $name, + $reflectionType->isBuiltin(), + $itemType, + $keyType, + ); + + if ($forProperty && $this->requiresDataItemType($type->kind) && $type->dataClass === null) { + throw CannotFindDataClass::forTypeable($typeable); + } + + return $type; + } + + if ($reflectionType instanceof ReflectionUnionType || $reflectionType instanceof ReflectionIntersectionType) { + $types = []; + + foreach ($reflectionType->getTypes() as $subType) { + $types[] = $this->buildNativeType( + $subType, + $targetClass, + $declaringClass, + $typeable, + $iterableAnnotations, + $collectionOf, + $forProperty, + ); + } + + return $reflectionType instanceof ReflectionUnionType + ? new UnionType($types) + : new IntersectionType($types); + } + + throw new InvalidArgumentException('Unsupported reflected data type.'); + } + + /** + * Build a PHPDoc type graph. + * + * @param ReflectionClass $targetClass + * @param ReflectionClass $declaringClass + */ + protected function buildPhpDocType( + TypeNode $type, + ReflectionClass $targetClass, + ReflectionClass $declaringClass, + ): Type { + if ($type instanceof IdentifierTypeNode) { + if ($type->name === 'array-key') { + return $this->buildArrayKeyType(); + } + + $name = $this->normalizePhpDocType($type->name); + + return $this->buildNamedType( + $this->resolvePhpDocName($name, $targetClass, $declaringClass), + $this->isBuiltIn($name), + ); + } + + if ($type instanceof ThisTypeNode) { + return $this->buildNamedType($targetClass->getName(), false); + } + + if ($type instanceof NullableTypeNode) { + return new UnionType([ + $this->buildPhpDocType($type->type, $targetClass, $declaringClass), + $this->buildNamedType('null', true), + ]); + } + + if ($type instanceof PhpDocUnionTypeNode || $type instanceof PhpDocIntersectionTypeNode) { + $types = array_map( + fn (TypeNode $subType): Type => $this->buildPhpDocType( + $subType, + $targetClass, + $declaringClass, + ), + $type->types, + ); + + return $type instanceof PhpDocUnionTypeNode + ? new UnionType($types) + : new IntersectionType($types); + } + + if ($type instanceof ArrayTypeNode) { + return $this->buildNamedType( + 'array', + true, + $this->buildPhpDocType($type->type, $targetClass, $declaringClass), + $this->buildArrayKeyType(), + ); + } + + if ($type instanceof GenericTypeNode && $type->type instanceof IdentifierTypeNode) { + $name = $type->type->name; + $genericTypes = $type->genericTypes; + + if ($name === 'list' || $name === 'non-empty-list') { + return $this->buildNamedType( + 'array', + true, + $this->buildPhpDocType($genericTypes[0], $targetClass, $declaringClass), + $this->buildNamedType('int', true), + ); + } + + $resolved = $this->resolvePhpDocName( + $this->normalizePhpDocType($name), + $targetClass, + $declaringClass, + ); + $itemType = null; + $keyType = $this->buildArrayKeyType(); + + if (isset($genericTypes[1])) { + $itemType = $this->buildPhpDocType($genericTypes[1], $targetClass, $declaringClass); + $keyType = $this->buildPhpDocType($genericTypes[0], $targetClass, $declaringClass); + } elseif (isset($genericTypes[0])) { + $itemType = $this->buildPhpDocType($genericTypes[0], $targetClass, $declaringClass); + } + + return $this->buildNamedType( + $resolved, + $this->isBuiltIn($resolved), + $itemType, + $keyType, + ); + } + + if ($type instanceof ConstTypeNode) { + $name = (string) $type; + + if (in_array($name, ['true', 'false', 'null'], true)) { + return $this->buildNamedType($name, true); + } + } + + return $this->buildNamedType('mixed', true); + } + + /** + * Build one named type and its iterable metadata. + */ + protected function buildNamedType( + string $name, + bool $builtIn, + ?Type $itemType = null, + ?Type $keyType = null, + ): NamedType { + $kind = $this->kindFor($name); + $dataClass = $kind->isDataObject() ? $name : $this->uniqueDataClass($itemType); + + if ($itemType !== null && $dataClass !== null && $kind->isNonDataIterable()) { + $kind = $kind->getDataRelatedEquivalent(); + } + + return new NamedType( + name: $name, + builtIn: $builtIn, + kind: $kind, + dataClass: $dataClass, + iterableClass: $kind->isNonDataIterable() || $kind->isDataCollectable() ? $name : null, + iterableItemType: $itemType, + iterableKeyType: $keyType, + ); + } + + /** + * Find the iterable annotation matching a native container type. + * + * @param ReflectionClass $targetClass + * @param list $annotations + */ + protected function matchingAnnotation( + string $nativeType, + ReflectionClass $targetClass, + array $annotations, + ): ?DataIterableAnnotation { + $fallback = null; + + foreach ($annotations as $annotation) { + $container = $this->resolvePhpDocName( + $this->normalizePhpDocType($annotation->containerType), + $targetClass, + ClassMetadataCache::reflectClass($annotation->declaringClass), + ); + + if ($container === $nativeType) { + return $annotation; + } + + if ($fallback === null + && ( + ($container === 'iterable' && $this->kindFor($nativeType)->isNonDataIterable()) + || (! $this->isBuiltIn($container) && is_a($nativeType, $container, true)) + ) + ) { + $fallback = $annotation; + } + } + + return $fallback; + } + + /** + * Resolve a native self, static, or parent type name. + * + * @param ReflectionClass $targetClass + * @param ReflectionClass $declaringClass + */ + protected function resolveNativeName( + string $name, + ReflectionClass $targetClass, + ReflectionClass $declaringClass, + ): string { + return match ($name) { + 'self' => $declaringClass->getName(), + 'static' => $targetClass->getName(), + 'parent' => $declaringClass->getParentClass()?->getName() ?? $name, + default => $name, + }; + } + + /** + * Resolve a PHPDoc type name in its declaration and target scopes. + * + * @param ReflectionClass $targetClass + * @param ReflectionClass $declaringClass + */ + protected function resolvePhpDocName( + string $name, + ReflectionClass $targetClass, + ReflectionClass $declaringClass, + ): string { + return match ($name) { + 'self' => $declaringClass->getName(), + 'static', '$this' => $targetClass->getName(), + 'parent' => $declaringClass->getParentClass()?->getName() ?? $name, + default => $this->typeNameResolver->resolve($name, $declaringClass), + }; + } + + /** + * Resolve the semantic kind for a named type. + */ + protected function kindFor(string $name): DataTypeKind + { + return match (true) { + $name === DataCollection::class || is_a($name, DataCollection::class, true) => DataTypeKind::DataCollection, + $name === PaginatedDataCollection::class || is_a($name, PaginatedDataCollection::class, true) => DataTypeKind::DataPaginatedCollection, + $name === CursorPaginatedDataCollection::class || is_a($name, CursorPaginatedDataCollection::class, true) => DataTypeKind::DataCursorPaginatedCollection, + is_a($name, BaseData::class, true) => DataTypeKind::DataObject, + $name === 'array' => DataTypeKind::Array, + $name === 'iterable' => DataTypeKind::Iterable, + is_a($name, CursorPaginatorContract::class, true) || is_a($name, AbstractCursorPaginator::class, true) => DataTypeKind::CursorPaginator, + is_a($name, PaginatorContract::class, true) || is_a($name, AbstractPaginator::class, true) => DataTypeKind::Paginator, + is_a($name, Enumerable::class, true) => DataTypeKind::Enumerable, + is_a($name, Traversable::class, true) => DataTypeKind::Iterable, + default => DataTypeKind::Default, + }; + } + + /** + * Find the one data class declared by an iterable item type. + * + * @return null|class-string + */ + protected function uniqueDataClass(?Type $type): ?string + { + if ($type === null) { + return null; + } + + $classes = []; + + foreach ($type->getNamedTypes() as $namedType) { + if ($namedType->kind->isDataObject() && $namedType->dataClass !== null) { + $classes[$namedType->dataClass] = true; + } + } + + return count($classes) === 1 ? array_key_first($classes) : null; + } + + /** + * Build the PHPDoc array-key union. + */ + protected function buildArrayKeyType(): UnionType + { + return new UnionType([ + $this->buildNamedType('int', true), + $this->buildNamedType('string', true), + ]); + } + + /** + * Determine if a data collection kind requires one concrete data item type. + */ + protected function requiresDataItemType(DataTypeKind $kind): bool + { + return $kind === DataTypeKind::DataCollection + || $kind === DataTypeKind::DataPaginatedCollection + || $kind === DataTypeKind::DataCursorPaginatedCollection; + } + + /** + * Determine if the named types contain an exact declaration. + * + * @param list $types + */ + protected function containsType(array $types, string $name): bool + { + foreach ($types as $type) { + if ($type->name === $name) { + return true; + } + } + + return false; + } + + /** + * Find the declared Lazy implementation. + * + * @param list $types + * @return null|class-string + */ + protected function findLazyType(array $types): ?string + { + foreach ($types as $type) { + if ($type->name === Lazy::class || is_a($type->name, Lazy::class, true)) { + return $type->name; + } + } + + return null; + } + + /** + * Normalize PHPDoc aliases to native type names. + */ + protected function normalizePhpDocType(string $type): string + { + return match (strtolower($type)) { + 'boolean' => 'bool', + 'double', 'real' => 'float', + 'integer', 'negative-int', 'non-negative-int', 'non-positive-int', 'positive-int' => 'int', + 'callable-string', 'class-string', 'literal-string', 'non-empty-string', 'numeric-string' => 'string', + default => $type, + }; + } + + /** + * Determine if a name is a built-in type. + */ + protected function isBuiltIn(string $type): bool + { + return in_array($type, [ + 'array', + 'bool', + 'callable', + 'false', + 'float', + 'int', + 'iterable', + 'mixed', + 'never', + 'null', + 'object', + 'resource', + 'string', + 'true', + 'void', + ], true); + } + + /** + * Get the class that declared a reflected type. + * + * @param ReflectionClass $targetClass + * @return ReflectionClass + */ + protected function declaringClass( + ReflectionMethod|ReflectionProperty|ReflectionParameter|string $typeable, + ReflectionClass $targetClass, + ): ReflectionClass { + if ($typeable instanceof ReflectionParameter) { + return $typeable->getDeclaringClass() ?? $targetClass; + } + + if ($typeable instanceof ReflectionMethod || $typeable instanceof ReflectionProperty) { + return $typeable->getDeclaringClass(); + } + + return $targetClass; + } + + /** + * Get the reflected class context. + * + * @param ReflectionClass|class-string $class + * @return ReflectionClass + */ + protected function reflectionClass(ReflectionClass|string $class): ReflectionClass + { + return is_string($class) ? ClassMetadataCache::reflectClass($class) : $class; + } +} diff --git a/src/data/src/Support/NameMapperResolver.php b/src/data/src/Support/NameMapperResolver.php new file mode 100644 index 000000000..3d8e776c3 --- /dev/null +++ b/src/data/src/Support/NameMapperResolver.php @@ -0,0 +1,90 @@ +first(MapInputName::class) + ?? $attributes->first(MapName::class); + + if ($attribute === null) { + return $default; + } + + $mapper = $attribute->newInstance(); + + return $this->resolve($mapper->input); + } + + /** + * Resolve the output mapper declared by the attributes. + */ + public function resolveOutput( + DataAttributesCollection $attributes, + ?NameMapper $default = null, + ): ?NameMapper { + $attribute = $attributes->first(MapOutputName::class) + ?? $attributes->first(MapName::class); + + if ($attribute === null) { + return $default; + } + + $mapper = $attribute->newInstance(); + + return $this->resolve($mapper->output); + } + + /** + * Resolve a configured mapper class. + * + * @param null|class-string $mapper + */ + public function resolveConfigured(?string $mapper): ?NameMapper + { + return $mapper === null ? null : $this->resolve($mapper); + } + + /** + * Resolve one mapper declaration. + */ + protected function resolve(string|int|NameMapper $mapper): NameMapper + { + if ($mapper instanceof NameMapper) { + return $mapper; + } + + if (is_string($mapper) && is_a($mapper, NameMapper::class, true)) { + /** @var NameMapper $resolved */ + $resolved = $this->container->make($mapper); + + return $resolved; + } + + return new ProvidedNameMapper($mapper); + } +} diff --git a/src/data/src/Support/Types/CombinationType.php b/src/data/src/Support/Types/CombinationType.php new file mode 100644 index 000000000..9af9b556b --- /dev/null +++ b/src/data/src/Support/Types/CombinationType.php @@ -0,0 +1,52 @@ + $types + */ + public function __construct( + public readonly array $types, + ) { + } + + /** + * Get the declared types and their inherited types. + * + * @return array> + */ + public function getAcceptedTypes(): array + { + $types = []; + + foreach ($this->types as $type) { + foreach ($type->getAcceptedTypes() as $name => $acceptedTypes) { + $types[$name] = $acceptedTypes; + } + } + + return $types; + } + + /** + * Get every named type in declaration order. + * + * @return list + */ + public function getNamedTypes(): array + { + $types = []; + + foreach ($this->types as $type) { + array_push($types, ...$type->getNamedTypes()); + } + + return $types; + } +} diff --git a/src/data/src/Support/Types/IntersectionType.php b/src/data/src/Support/Types/IntersectionType.php new file mode 100644 index 000000000..644646601 --- /dev/null +++ b/src/data/src/Support/Types/IntersectionType.php @@ -0,0 +1,58 @@ +types as $subType) { + if (! $subType->acceptsType($type)) { + return false; + } + } + + return true; + } + + /** + * Determine if this declaration accepts the given value. + */ + public function acceptsValue(mixed $value): bool + { + foreach ($this->types as $subType) { + if (! $subType->acceptsValue($value)) { + return false; + } + } + + return true; + } + + /** + * Determine if every value accepted by this declaration is an instance of the given type. + */ + public function guaranteesType(string $type): bool + { + foreach ($this->types as $subType) { + if ($subType->guaranteesType($type)) { + return true; + } + } + + return false; + } + + /** + * Find the declared type accepted by a base type. + */ + public function findAcceptedTypeForBaseType(string $class): ?string + { + return $this->acceptsType($class) ? $class : null; + } +} diff --git a/src/data/src/Support/Types/NamedType.php b/src/data/src/Support/Types/NamedType.php new file mode 100644 index 000000000..bdfc4708b --- /dev/null +++ b/src/data/src/Support/Types/NamedType.php @@ -0,0 +1,139 @@ + $dataClass + * @param null|class-string|literal-string $iterableClass + */ + public function __construct( + public readonly string $name, + public readonly bool $builtIn, + public readonly DataTypeKind $kind, + public readonly ?string $dataClass = null, + public readonly ?string $iterableClass = null, + public readonly ?Type $iterableItemType = null, + public readonly ?Type $iterableKeyType = null, + ) { + $this->isCastable = ! $this->builtIn && is_a($this->name, Castable::class, true); + } + + /** + * Determine if this declaration accepts the given type name. + */ + public function acceptsType(string $type): bool + { + if ($type === $this->name) { + return true; + } + + return match ($this->name) { + 'mixed' => true, + 'float' => $type === 'int', + 'bool' => $type === 'true' || $type === 'false', + 'object' => self::typeExists($type), + 'iterable' => $type === 'array' || is_a($type, Traversable::class, true), + 'callable' => self::typeExists($type) && method_exists($type, '__invoke'), + default => ! $this->builtIn && is_a($type, $this->name, true), + }; + } + + /** + * Determine if this declaration accepts the given value. + */ + public function acceptsValue(mixed $value): bool + { + return match ($this->name) { + 'mixed' => true, + 'null' => $value === null, + 'true' => $value === true, + 'false' => $value === false, + 'bool' => is_bool($value), + 'int' => is_int($value), + 'float' => is_float($value) || is_int($value), + 'string' => is_string($value), + 'array' => is_array($value), + 'object' => is_object($value), + 'iterable' => is_iterable($value), + 'callable' => is_callable($value), + 'void', 'never' => false, + default => is_object($value) && is_a($value, $this->name), + }; + } + + /** + * Determine if every value accepted by this declaration is an instance of the given type. + */ + public function guaranteesType(string $type): bool + { + return ! $this->builtIn && is_a($this->name, $type, true); + } + + /** + * Find the declared type accepted by a base type. + */ + public function findAcceptedTypeForBaseType(string $class): ?string + { + if ($class === $this->name) { + return $this->name; + } + + if (! $this->builtIn && is_a($this->name, $class, true)) { + return $this->name; + } + + return null; + } + + /** + * Get the declared type and its inherited types. + * + * @return array> + */ + public function getAcceptedTypes(): array + { + $acceptedTypes = []; + + if (! $this->builtIn && self::typeExists($this->name)) { + $acceptedTypes = array_values(array_unique([ + ...array_values(class_parents($this->name) ?: []), + ...array_values(class_implements($this->name) ?: []), + ])); + } + + return [ + $this->name => $acceptedTypes, + ]; + } + + /** + * Get every named type in declaration order. + * + * @return list + */ + public function getNamedTypes(): array + { + return [$this]; + } + + /** + * Determine if a class or interface type exists. + */ + protected static function typeExists(string $type): bool + { + return class_exists($type) || interface_exists($type); + } +} diff --git a/src/data/src/Support/Types/PhpDocTypeNameResolver.php b/src/data/src/Support/Types/PhpDocTypeNameResolver.php new file mode 100644 index 000000000..653fe5110 --- /dev/null +++ b/src/data/src/Support/Types/PhpDocTypeNameResolver.php @@ -0,0 +1,292 @@ +>> */ + protected array $imports = []; + + /** + * Resolve a PHPDoc type name in its declaring class context. + * + * @param ReflectionClass $class + */ + public function resolve(string $name, ReflectionClass $class): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + if (self::isBuiltIn($name)) { + return $name; + } + + $namespace = $class->getNamespaceName(); + $sameNamespace = $namespace === '' ? $name : "{$namespace}\\{$name}"; + + [$alias, $suffix] = array_pad(explode('\\', $name, 2), 2, null); + $import = $this->importsFor($class)[$alias] ?? null; + + if ($import !== null) { + return $suffix === null ? $import : "{$import}\\{$suffix}"; + } + + return $sameNamespace; + } + + /** + * Get the class imports declared by the source file. + * + * @param ReflectionClass $class + * @return array + */ + protected function importsFor(ReflectionClass $class): array + { + $file = $class->getFileName(); + + if ($file === false) { + return []; + } + + $imports = $this->imports[$file] ??= $this->parseImports($file); + + return $imports[$class->getNamespaceName()] ?? []; + } + + /** + * Parse class imports from a PHP source file. + * + * @return array + */ + protected function parseImports(string $file): array + { + $source = file_get_contents($file); + + if ($source === false) { + throw new RuntimeException("Unable to read PHPDoc source file [{$file}]."); + } + + $tokens = PhpToken::tokenize($source); + $imports = []; + $namespace = ''; + $namespaceDepth = 0; + $braceDepth = 0; + + for ($index = 0, $count = count($tokens); $index < $count; ++$index) { + $token = $tokens[$index]; + + if ($token->id === T_NAMESPACE && $braceDepth === 0) { + [$namespace, $delimiterIndex] = $this->parseNamespace($tokens, $index + 1); + $delimiter = $tokens[$delimiterIndex]->text; + $namespaceDepth = $delimiter === '{' ? 1 : 0; + $braceDepth = $namespaceDepth; + $index = $delimiterIndex; + + continue; + } + + if ($token->text === '{') { + ++$braceDepth; + + continue; + } + + if ($token->text === '}') { + --$braceDepth; + + continue; + } + + if ($token->id !== T_USE || $braceDepth !== $namespaceDepth) { + continue; + } + + $next = $this->nextSignificantToken($tokens, $index + 1); + + if ($next === null || $next->text === '(' || $next->id === T_FUNCTION || $next->id === T_CONST) { + continue; + } + + [$statement, $delimiterIndex] = $this->collectUseStatement($tokens, $index + 1); + $imports[$namespace] ??= []; + $imports[$namespace] += $this->parseUseStatement($statement); + $index = $delimiterIndex; + } + + return $imports; + } + + /** + * Parse a namespace declaration. + * + * @param list $tokens + * @return array{string, int} + */ + protected function parseNamespace(array $tokens, int $index): array + { + $namespace = ''; + + for ($count = count($tokens); $index < $count; ++$index) { + $token = $tokens[$index]; + + if ($token->text === ';' || $token->text === '{') { + return [$namespace, $index]; + } + + if (! $token->isIgnorable()) { + $namespace .= $token->text; + } + } + + return [$namespace, $index - 1]; + } + + /** + * Collect the tokens belonging to one use statement. + * + * @param list $tokens + * @return array{list, int} + */ + protected function collectUseStatement(array $tokens, int $index): array + { + $statement = []; + + for ($count = count($tokens); $index < $count; ++$index) { + if ($tokens[$index]->text === ';') { + return [$statement, $index]; + } + + if (! $tokens[$index]->isIgnorable()) { + $statement[] = $tokens[$index]; + } + } + + return [$statement, $index - 1]; + } + + /** + * Parse one normal or grouped use statement. + * + * @param list $tokens + * @return array + */ + protected function parseUseStatement(array $tokens): array + { + $groupStart = array_find_key($tokens, fn (PhpToken $token): bool => $token->text === '{'); + + if ($groupStart === null) { + return $this->parseImportEntries($tokens); + } + + $prefix = rtrim($this->joinTokenText(array_slice($tokens, 0, $groupStart)), '\\'); + $entries = array_slice($tokens, $groupStart + 1, -1); + + return $this->parseImportEntries($entries, $prefix); + } + + /** + * Parse comma-separated import entries. + * + * @param list $tokens + * @return array + */ + protected function parseImportEntries(array $tokens, string $prefix = ''): array + { + $imports = []; + $entry = []; + + foreach ([...$tokens, new PhpToken(ord(','), ',')] as $token) { + if ($token->text !== ',') { + $entry[] = $token; + + continue; + } + + if ($entry === []) { + continue; + } + + $as = array_find_key($entry, fn (PhpToken $entryToken): bool => $entryToken->id === T_AS); + $nameTokens = $as === null ? $entry : array_slice($entry, 0, $as); + $name = ltrim($this->joinTokenText($nameTokens), '\\'); + $class = $prefix === '' ? $name : "{$prefix}\\{$name}"; + $alias = $as === null + ? class_basename($name) + : $this->joinTokenText(array_slice($entry, $as + 1)); + + $imports[$alias] = $class; + $entry = []; + } + + return $imports; + } + + /** + * Find the next non-ignorable token. + * + * @param list $tokens + */ + protected function nextSignificantToken(array $tokens, int $index): ?PhpToken + { + for ($count = count($tokens); $index < $count; ++$index) { + if (! $tokens[$index]->isIgnorable()) { + return $tokens[$index]; + } + } + + return null; + } + + /** + * Join token text without whitespace or comments. + * + * @param list $tokens + */ + protected function joinTokenText(array $tokens): string + { + $value = ''; + + foreach ($tokens as $token) { + if (! $token->isIgnorable()) { + $value .= $token->text; + } + } + + return $value; + } + + /** + * Determine if the type is built into PHP or PHPDoc. + */ + protected static function isBuiltIn(string $type): bool + { + return in_array(strtolower($type), [ + 'array', + 'array-key', + 'bool', + 'boolean', + 'callable', + 'false', + 'float', + 'int', + 'integer', + 'iterable', + 'mixed', + 'never', + 'null', + 'object', + 'resource', + 'string', + 'true', + 'void', + ], true); + } + +} diff --git a/src/data/src/Support/Types/Type.php b/src/data/src/Support/Types/Type.php new file mode 100644 index 000000000..66b7c5c0e --- /dev/null +++ b/src/data/src/Support/Types/Type.php @@ -0,0 +1,42 @@ +> + */ + abstract public function getAcceptedTypes(): array; + + /** + * Get every named type in declaration order. + * + * @return list + */ + abstract public function getNamedTypes(): array; +} diff --git a/src/data/src/Support/Types/UnionType.php b/src/data/src/Support/Types/UnionType.php new file mode 100644 index 000000000..67d46f15a --- /dev/null +++ b/src/data/src/Support/Types/UnionType.php @@ -0,0 +1,66 @@ +types as $subType) { + if ($subType->acceptsType($type)) { + return true; + } + } + + return false; + } + + /** + * Determine if this declaration accepts the given value. + */ + public function acceptsValue(mixed $value): bool + { + foreach ($this->types as $subType) { + if ($subType->acceptsValue($value)) { + return true; + } + } + + return false; + } + + /** + * Determine if every value accepted by this declaration is an instance of the given type. + */ + public function guaranteesType(string $type): bool + { + foreach ($this->types as $subType) { + if (! $subType->guaranteesType($type)) { + return false; + } + } + + return true; + } + + /** + * Find the declared type accepted by a base type. + */ + public function findAcceptedTypeForBaseType(string $class): ?string + { + foreach ($this->types as $subType) { + $found = $subType->findAcceptedTypeForBaseType($class); + + if ($found !== null) { + return $found; + } + } + + return null; + } +} diff --git a/tests/Data/Attributes/AttributeTest.php b/tests/Data/Attributes/AttributeTest.php new file mode 100644 index 000000000..d365d02a2 --- /dev/null +++ b/tests/Data/Attributes/AttributeTest.php @@ -0,0 +1,160 @@ +assertSame('input', $mapName->input); + $this->assertSame('input', $mapName->output); + $this->assertSame('input', (new MapName('input', 'output'))->input); + $this->assertSame('output', (new MapName('input', 'output'))->output); + $this->assertSame(10, (new MapInputName(10))->input); + $this->assertSame(20, (new MapOutputName(20))->output); + } + + public function testDataCollectionOfRequiresADataClass(): void + { + $this->assertSame(AttributeTestData::class, (new DataCollectionOf(AttributeTestData::class))->class); + + $this->expectException(CannotFindDataClass::class); + + new DataCollectionOf(stdClass::class); + } + + public function testCastAttributesCreateFreshConfiguredExtensions(): void + { + $castAttribute = new WithCast(AttributeTestArgumentCast::class, 'cast'); + $castableAttribute = new WithCastable(AttributeTestCastable::class, 'castable'); + $transformerAttribute = new WithTransformer(AttributeTestArgumentTransformer::class, 'transformer'); + $combinedAttribute = new WithCastAndTransformer(AttributeTestCastAndTransformer::class, 'combined'); + + $this->assertEquals(new AttributeTestArgumentCast('cast'), $castAttribute->get()); + $this->assertNotSame($castAttribute->get(), $castAttribute->get()); + $this->assertEquals(new AttributeTestArgumentCast('castable'), $castableAttribute->get()); + $this->assertEquals(new AttributeTestArgumentTransformer('transformer'), $transformerAttribute->get()); + $this->assertEquals(new AttributeTestCastAndTransformer('combined'), $combinedAttribute->get()); + } + + public function testCastAttributesRejectInvalidExtensionClasses(): void + { + try { + new WithCast(stdClass::class); + + $this->fail('Expected an invalid cast class to be rejected.'); + } catch (CannotCreateCastAttribute $exception) { + $this->assertStringContainsString(stdClass::class, $exception->getMessage()); + } + + $this->expectException(CannotCreateTransformerAttribute::class); + + new WithTransformer(stdClass::class); + } +} + +abstract class AttributeTestData implements BaseData +{ +} + +class AttributeTestArgumentCast implements Cast +{ + /** @var list */ + public readonly array $arguments; + + /** + * Create a new argument cast. + */ + public function __construct(mixed ...$arguments) + { + $this->arguments = $arguments; + } + + /** + * Cast a property value. + */ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): mixed { + return $value; + } +} + +class AttributeTestArgumentTransformer implements Transformer +{ + /** @var list */ + public readonly array $arguments; + + /** + * Create a new argument transformer. + */ + public function __construct(mixed ...$arguments) + { + $this->arguments = $arguments; + } + + /** + * Transform a property value. + */ + public function transform( + DataProperty $property, + mixed $value, + TransformationContext $context, + ): mixed { + return $value; + } +} + +class AttributeTestCastAndTransformer extends AttributeTestArgumentCast implements Transformer +{ + /** + * Transform a property value. + */ + public function transform( + DataProperty $property, + mixed $value, + TransformationContext $context, + ): mixed { + return $value; + } +} + +class AttributeTestCastable implements Castable +{ + /** + * Create the cast for this type. + */ + public static function dataCastUsing(array $arguments): Cast + { + return new AttributeTestArgumentCast(...$arguments); + } +} diff --git a/tests/Data/Fixtures/DataClassAnnotations/ChildScope/ChildAnnotations.php b/tests/Data/Fixtures/DataClassAnnotations/ChildScope/ChildAnnotations.php new file mode 100644 index 000000000..41f757e73 --- /dev/null +++ b/tests/Data/Fixtures/DataClassAnnotations/ChildScope/ChildAnnotations.php @@ -0,0 +1,69 @@ + $parentOnly + * @property array $classItems + * @property array $inlineItems + * @property array $constructorItems + */ + class ParentAnnotations + { + public array $parentOnly; + + public array $classItems; + + /** @var array */ + public array $inlineItems; + + /** @var array */ + public array $constructorItems; + } +} + +namespace Hypervel\Tests\Data\Fixtures\DataClassAnnotations\ChildScope { + use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\ChildClassItem as ScopedClassItem; + use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\ConstructorItem as ScopedConstructorItem; + use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\ParentScope\ParentAnnotations; + + /** + * @property array $classItems + * @property array $inlineItems + * @property array $constructorItems + */ + class ChildAnnotations extends ParentAnnotations + { + /** + * Create a new child annotation fixture. + * + * @param array $constructorItems + */ + public function __construct(array $constructorItems) + { + $this->constructorItems = $constructorItems; + } + } +} diff --git a/tests/Data/Fixtures/ImportedType.php b/tests/Data/Fixtures/ImportedType.php new file mode 100644 index 000000000..7fed20f3a --- /dev/null +++ b/tests/Data/Fixtures/ImportedType.php @@ -0,0 +1,9 @@ + + */ + public static function caseMapperProvider(): iterable + { + yield 'camel' => [new CamelCaseMapper, 'firstName']; + yield 'kebab' => [new KebabCaseMapper, 'first-name']; + yield 'lower' => [new LowerCaseMapper, 'first name']; + yield 'snake' => [new SnakeCaseMapper, 'first_name']; + yield 'studly' => [new StudlyCaseMapper, 'FirstName']; + yield 'upper' => [new UpperCaseMapper, 'FIRST NAME']; + } + + #[DataProvider('caseMapperProvider')] + public function testCaseMappersTransformStringsAndPreserveIntegerKeys( + NameMapper $mapper, + string $expected, + ): void { + $this->assertSame($expected, $mapper->map('first name')); + $this->assertSame(10, $mapper->map(10)); + } + + public function testProvidedNameMapperReturnsItsConfiguredName(): void + { + $this->assertSame('wire_name', (new ProvidedNameMapper('wire_name'))->map('property')); + $this->assertSame(10, (new ProvidedNameMapper(10))->map('property')); + } +} diff --git a/tests/Data/Support/DataAttributesCollectionTest.php b/tests/Data/Support/DataAttributesCollectionTest.php new file mode 100644 index 000000000..fc251ba03 --- /dev/null +++ b/tests/Data/Support/DataAttributesCollectionTest.php @@ -0,0 +1,190 @@ +assertTrue($attributes->has(DataAttributesThrowingAttribute::class)); + + $this->expectException(RuntimeException::class); + + $attributes->first(DataAttributesThrowingAttribute::class)?->newInstance(); + } + + /** + * Test that recipes are grouped by concrete, parent, and interface types. + */ + public function testAttributesAreGroupedByConcreteParentAndInterfaceTypes(): void + { + $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty( + new ReflectionProperty(DataAttributesPropertyFixture::class, 'value'), + ); + + $concrete = $attributes->first(DataAttributesConcreteAttribute::class); + + $this->assertNotNull($concrete); + $this->assertSame($concrete, $attributes->first(DataAttributesBaseAttribute::class)); + $this->assertSame($concrete, $attributes->first(DataAttributesAttributeContract::class)); + $this->assertSame(['first', 'second'], array_map( + fn ($attribute): string => $attribute->newInstance()->name, + $attributes->all(DataAttributesConcreteAttribute::class), + )); + } + + /** + * Test that child class recipes take precedence over inherited recipes. + */ + public function testClassAttributesIncludeParentsInChildFirstOrder(): void + { + $attributes = DataAttributesCollectionFactory::buildFromReflectionClass( + new ReflectionClass(DataAttributesChildFixture::class), + ); + + $this->assertSame(['child', 'parent'], array_map( + fn ($attribute): string => $attribute->newInstance()->name, + $attributes->all(DataAttributesConcreteAttribute::class), + )); + } + + /** + * Test that unknown class attributes are ignored safely. + */ + public function testUnknownAttributesAreIgnored(): void + { + $attributes = DataAttributesCollectionFactory::buildFromReflectionClass( + new ReflectionClass(DataAttributesUnknownAttributeFixture::class), + ); + + $this->assertFalse($attributes->has('Hypervel\\Tests\\Data\\Support\\MissingAttribute')); + } + + /** + * Test that parameter attributes retain fresh object arguments. + */ + public function testParameterAttributeArgumentsAreRecreatedForEachInstantiation(): void + { + $parameter = (new ReflectionMethod(DataAttributesParameterFixture::class, '__construct')) + ->getParameters()[0]; + $attributes = DataAttributesCollectionFactory::buildFromReflectionParameter($parameter); + $recipe = $attributes->first(DataAttributesObjectAttribute::class); + + $this->assertNotNull($recipe); + + $first = $recipe->newInstance(); + $second = $recipe->newInstance(); + + $this->assertEquals($first, $second); + $this->assertNotSame($first, $second); + $this->assertNotSame($first->value, $second->value); + } +} + +interface DataAttributesAttributeContract +{ +} + +class DataAttributesBaseAttribute +{ +} + +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +class DataAttributesConcreteAttribute extends DataAttributesBaseAttribute implements DataAttributesAttributeContract +{ + /** + * Create a new concrete test attribute. + */ + public function __construct(public readonly string $name) + { + } +} + +#[Attribute(Attribute::TARGET_CLASS)] +class DataAttributesThrowingAttribute +{ + /** + * Create a new throwing test attribute. + */ + public function __construct() + { + throw new RuntimeException('Attribute construction must remain lazy.'); + } +} + +class DataAttributesObjectValue +{ + /** + * Create a new object attribute value. + */ + public function __construct(public readonly string $value) + { + } +} + +#[Attribute(Attribute::TARGET_PARAMETER)] +class DataAttributesObjectAttribute +{ + /** + * Create a new object-bearing test attribute. + */ + public function __construct(public readonly DataAttributesObjectValue $value) + { + } +} + +#[DataAttributesThrowingAttribute] +class DataAttributesClassWithThrowingAttribute +{ +} + +class DataAttributesPropertyFixture +{ + #[DataAttributesConcreteAttribute('first')] + #[DataAttributesConcreteAttribute('second')] + public string $value; +} + +#[DataAttributesConcreteAttribute('parent')] +class DataAttributesParentFixture +{ +} + +#[DataAttributesConcreteAttribute('child')] +class DataAttributesChildFixture extends DataAttributesParentFixture +{ +} + +#[MissingAttribute] +class DataAttributesUnknownAttributeFixture +{ +} + +class DataAttributesParameterFixture +{ + /** + * Create a new parameter fixture. + */ + public function __construct( + #[DataAttributesObjectAttribute(new DataAttributesObjectValue('value'))] + public readonly string $value, + ) { + } +} diff --git a/tests/Data/Support/DataClassRepositoryTest.php b/tests/Data/Support/DataClassRepositoryTest.php new file mode 100644 index 000000000..f8ae60777 --- /dev/null +++ b/tests/Data/Support/DataClassRepositoryTest.php @@ -0,0 +1,252 @@ +repository(); + $first = $repository->get(RepositoryDataFixture::class); + $second = $repository->get(RepositoryDataFixture::class); + $otherRepository = $this->repository(); + + $this->assertSame($first, $second); + $this->assertNotSame($first, $otherRepository->get(RepositoryDataFixture::class)); + } + + /** + * Test recursive types retain class strings instead of recursive metadata. + */ + public function testRecursiveDataTypesRemainFinite(): void + { + $repository = $this->repository(); + $class = $repository->get(RecursiveRepositoryDataFixture::class); + $types = $class->properties['child']->type->getDataObjectTypes(); + + $this->assertCount(1, $types); + $this->assertSame(RecursiveRepositoryDataFixture::class, $types[0]->dataClass); + $this->assertFalse($repository->hasDynamicRuleGraph(RecursiveRepositoryDataFixture::class)); + $this->assertFalse($repository->hasDynamicRuleGraph(RecursiveRepositoryDataFixture::class)); + $this->assertTrue($repository->hasDynamicRuleGraph(RecursiveDynamicRepositoryDataFixture::class)); + $this->assertTrue($repository->hasDynamicRuleGraph(RecursiveDynamicChildRepositoryDataFixture::class)); + } + + /** + * Test dynamic rule graphs include unambiguous validated descendants. + */ + public function testDynamicRuleGraphsIncludeValidatedDescendants(): void + { + $repository = $this->repository(); + + $this->assertTrue($repository->hasDynamicRuleGraph(DynamicRepositoryDataFixture::class)); + $this->assertTrue($repository->hasDynamicRuleGraph(NestedDynamicRepositoryDataFixture::class)); + $this->assertTrue($repository->hasDynamicRuleGraph(CollectionDynamicRepositoryDataFixture::class)); + } + + /** + * Test properties excluded from validation do not make a graph dynamic. + */ + public function testDynamicRuleGraphsSkipNonValidatingProperties(): void + { + $repository = $this->repository(); + + $this->assertFalse($repository->hasDynamicRuleGraph(SkippedDynamicRepositoryDataFixture::class)); + $this->assertFalse($repository->hasDynamicRuleGraph(ContextualDynamicRepositoryDataFixture::class)); + } + + /** + * Test property morph selection makes a rule graph dynamic. + */ + public function testPropertyMorphableRuleGraphsAreDynamic(): void + { + $this->assertTrue( + $this->repository()->hasDynamicRuleGraph(MorphableRepositoryDataFixture::class), + ); + } + + /** + * Test only declared data classes enter the worker repository. + */ + public function testInvalidClassesAreRejected(): void + { + $this->expectException(CannotFindDataClass::class); + $this->expectExceptionMessage('must implement'); + + $this->repository()->get(RepositoryInvalidFixture::class); + } + + /** + * Create a fresh repository and its worker-safe factory graph. + */ + protected function repository(): DataClassRepository + { + $defaults = require __DIR__ . '/../../../src/data/config/data.php'; + $config = new DataConfig(new Repository(['data' => $defaults])); + $nameMapperResolver = new NameMapperResolver(new Container); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $parameterFactory = new DataParameterFactory($typeFactory); + + return new DataClassRepository(new DataClassFactory( + new DataPropertyFactory($typeFactory, $config, $nameMapperResolver), + new DataMethodFactory($parameterFactory, $typeFactory), + $parameterFactory, + new DataIterableAnnotationReader, + $nameMapperResolver, + $config, + )); + } +} + +abstract class RepositoryDataFixture implements BaseData +{ + /** + * Create a new repository fixture. + */ + public function __construct( + public string $name, + ) { + } +} + +abstract class RecursiveRepositoryDataFixture implements BaseData +{ + /** + * Create a new recursive repository fixture. + */ + public function __construct( + public ?self $child = null, + ) { + } +} + +abstract class DynamicRepositoryDataFixture implements BaseData +{ + /** + * Get payload-dependent validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['name' => ['in:' . ($context->payload['name'] ?? '')]]; + } +} + +abstract class RecursiveDynamicRepositoryDataFixture implements BaseData +{ + /** + * Create a recursive dynamic repository fixture. + */ + public function __construct( + public ?RecursiveDynamicChildRepositoryDataFixture $child = null, + ) { + } +} + +abstract class RecursiveDynamicChildRepositoryDataFixture implements BaseData +{ + /** + * Create a recursive dynamic child repository fixture. + */ + public function __construct( + public ?RecursiveDynamicRepositoryDataFixture $parent = null, + ) { + } + + /** + * Get payload-dependent validation rules. + */ + public static function rules(ValidationContext $context): array + { + return []; + } +} + +abstract class NestedDynamicRepositoryDataFixture implements BaseData +{ + /** + * Create a nested dynamic repository fixture. + */ + public function __construct( + public DynamicRepositoryDataFixture $child, + ) { + } +} + +abstract class CollectionDynamicRepositoryDataFixture implements BaseData +{ + /** + * Create a collection dynamic repository fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(DynamicRepositoryDataFixture::class)] + public array $children, + ) { + } +} + +abstract class SkippedDynamicRepositoryDataFixture implements BaseData +{ + /** + * Create a skipped dynamic repository fixture. + */ + public function __construct( + #[WithoutValidation] + public DynamicRepositoryDataFixture $child, + ) { + } +} + +abstract class ContextualDynamicRepositoryDataFixture implements BaseData +{ + /** + * Create a contextual dynamic repository fixture. + */ + public function __construct( + #[Config('data.dynamic')] + public DynamicRepositoryDataFixture $child, + ) { + } +} + +abstract class MorphableRepositoryDataFixture implements BaseData, PropertyMorphableData +{ + /** + * Resolve the selected morph class. + */ + public static function morph(array $properties): ?string + { + return null; + } +} + +class RepositoryInvalidFixture +{ +} diff --git a/tests/Data/Support/DataClassTest.php b/tests/Data/Support/DataClassTest.php new file mode 100644 index 000000000..edaac6e85 --- /dev/null +++ b/tests/Data/Support/DataClassTest.php @@ -0,0 +1,439 @@ +factory()->build(new ReflectionClass(DataClassMetadataFixture::class)); + + $this->assertSame(DataClassMetadataFixture::class, $class->name); + $this->assertSame(['firstName', 'lastName'], array_keys($class->properties)); + $this->assertSame(['firstName', 'lastName'], array_column($class->constructorParameters, 'name')); + $this->assertSame(['fromString', 'collectStrings'], array_keys($class->methods)); + $this->assertTrue($class->hasLifecycleMethod('rules')); + $this->assertFalse($class->hasLifecycleMethod('authorize')); + $this->assertTrue($class->mergeValidationRules); + $this->assertTrue($class->failOnUnknownFields); + $this->assertTrue($class->stopOnFirstFailure); + $this->assertSame('metadata', $class->errorBag); + $this->assertSame('/metadata', $class->redirect); + $this->assertSame('metadata.store', $class->redirectRoute); + $this->assertSame('first_name', $class->properties['firstName']->inputMappedName); + $this->assertSame('last_name', $class->properties['lastName']->outputMappedName); + $this->assertSame([ + 'first_name' => 'firstName', + 'last_name' => 'lastName', + ], $class->outputMappedProperties); + $this->assertFalse($class->plainTransform); + } + + /** + * Test constructor-bound ownership and default precedence. + */ + public function testConstructorBoundPropertiesUseConstructorMetadata(): void + { + $class = $this->factory()->build(new ReflectionClass(ConstructorBindingDataFixture::class)); + + $this->assertTrue($class->properties['readonlyName']->isConstructorParameter); + $this->assertTrue($class->properties['readonlyName']->isReadonly); + $this->assertFalse($class->properties['readonlyName']->hasDefaultValue); + $this->assertTrue($class->properties['requiredWithPropertyDefault']->isConstructorParameter); + $this->assertFalse($class->properties['requiredWithPropertyDefault']->hasDefaultValue); + $this->assertTrue($class->properties['normalizedName']->isConstructorParameter); + $this->assertTrue($class->properties['normalizedName']->hasDefaultValue); + $this->assertFalse($class->properties['unbound']->isConstructorParameter); + $this->assertTrue($class->properties['unbound']->hasDefaultValue); + $this->assertTrue($class->plainTransform); + } + + /** + * Test iterable annotation precedence and declaration scopes. + */ + public function testIterableAnnotationsUseTheNearestOwningDeclaration(): void + { + $class = $this->factory()->build(new ReflectionClass(ChildAnnotations::class)); + + $this->assertSame(ParentClassItem::class, $this->iterableItemName($class->properties['parentOnly'])); + $this->assertSame(ChildClassItem::class, $this->iterableItemName($class->properties['classItems'])); + $this->assertSame(InlineItem::class, $this->iterableItemName($class->properties['inlineItems'])); + $this->assertSame(ConstructorItem::class, $this->iterableItemName($class->properties['constructorItems'])); + $this->assertSame(ChildAnnotations::class, $class->dataIterablePropertyAnnotations['constructorItems'][0]->declaringClass); + } + + /** + * Test contextual ownership for promoted and constructor-only parameters. + */ + public function testContextualParametersUseOneUnambiguousOwnershipForm(): void + { + $promoted = $this->factory()->build(new ReflectionClass(PromotedContextualDataFixture::class)); + $constructorOnly = $this->factory()->build(new ReflectionClass(ConstructorOnlyContextualDataFixture::class)); + + $this->assertTrue($promoted->properties['userId']->isConstructorParameter); + $this->assertFalse($promoted->properties['userId']->validate); + $this->assertSame(ContextualValue::class, $promoted->constructorParameters[0]->contextualAttribute?->getName()); + $this->assertFalse($constructorOnly->properties['name']->isConstructorParameter); + $this->assertTrue($constructorOnly->properties['name']->validate); + $this->assertSame('userId', $constructorOnly->constructorParameters[0]->name); + } + + /** + * Test invalid constructor/property ownership declarations. + * + * @param class-string $class + */ + #[DataProvider('invalidDeclarationProvider')] + public function testInvalidConstructorDeclarationsFailDuringMetadataBuild( + string $class, + string $message, + ): void { + $this->expectException(InvalidDataDeclaration::class); + $this->expectExceptionMessage($message); + + $this->factory()->build(new ReflectionClass($class)); + } + + /** + * Provide invalid constructor declarations. + */ + public static function invalidDeclarationProvider(): array + { + return [ + 'unbound readonly property' => [UnboundReadonlyDataFixture::class, 'cannot assign unbound readonly property'], + 'computed constructor property' => [ComputedConstructorDataFixture::class, 'declares output-only property'], + 'contextual property collision' => [ContextualCollisionDataFixture::class, 'conflicts with public data property'], + 'non-public promoted property' => [NonPublicPromotedDataFixture::class, 'promotes non-public property'], + 'constructor parameter without property' => [MissingPropertyDataFixture::class, 'has no corresponding public data property'], + ]; + } + + /** + * Test duplicate effective mapping ownership. + * + * @param class-string $class + */ + #[DataProvider('mappingCollisionProvider')] + public function testMappingCollisionsFailDuringMetadataBuild(string $class, string $message): void + { + $this->expectException(InvalidDataDeclaration::class); + $this->expectExceptionMessage($message); + + $this->factory()->build(new ReflectionClass($class)); + } + + /** + * Provide duplicate mapping declarations. + */ + public static function mappingCollisionProvider(): array + { + return [ + 'input' => [DuplicateInputDataFixture::class, 'both resolve to input path [first]'], + 'output' => [DuplicateOutputDataFixture::class, 'both resolve to output key [first]'], + ]; + } + + /** + * Test allowed mapping overlap and hidden output ownership. + */ + public function testPrefixOverlapAndHiddenOutputMappingsRemainValid(): void + { + $class = $this->factory()->build(new ReflectionClass(AllowedMappingDataFixture::class)); + + $this->assertSame('artist', $class->properties['artist']->inputMappedName); + $this->assertSame('artist.name', $class->properties['artistName']->inputMappedName); + $this->assertTrue($class->properties['hiddenArtist']->hidden); + $this->assertSame([], $class->outputMappedProperties); + } + + /** + * Test non-public constructors remain valid metadata. + */ + public function testPrivateConstructorIsAValidNamedFactoryOnlyDeclaration(): void + { + $class = $this->factory()->build(new ReflectionClass(PrivateConstructorDataFixture::class)); + + $this->assertNotNull($class->constructor); + $this->assertTrue($class->constructor?->isPrivate()); + $this->assertTrue($class->properties['name']->isConstructorParameter); + $this->assertArrayHasKey('fromString', $class->methods); + } + + /** + * Create the metadata factory with boot-stable collaborators. + */ + protected function factory(array $overrides = []): DataClassFactory + { + $defaults = require __DIR__ . '/../../../src/data/config/data.php'; + $config = new DataConfig(new Repository([ + 'data' => array_replace($defaults, $overrides), + ])); + $nameMapperResolver = new NameMapperResolver(new Container); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $parameterFactory = new DataParameterFactory($typeFactory); + + return new DataClassFactory( + new DataPropertyFactory($typeFactory, $config, $nameMapperResolver), + new DataMethodFactory($parameterFactory, $typeFactory), + $parameterFactory, + new DataIterableAnnotationReader, + $nameMapperResolver, + $config, + ); + } + + /** + * Get the one item type compiled for an iterable property. + */ + protected function iterableItemName(DataProperty $property): string + { + $iterableType = $property->type->getIterableTypes()[0]; + + $this->assertNotNull($iterableType->iterableItemType); + + return $iterableType->iterableItemType->getNamedTypes()[0]->name; + } +} + +#[MapName(SnakeCaseMapper::class)] +#[MergeValidationRules] +#[FailOnUnknownFields] +#[StopOnFirstFailure] +#[ErrorBag('metadata')] +#[RedirectTo('/metadata')] +#[RedirectToRoute('metadata.store')] +class DataClassMetadataFixture +{ + /** + * Create a new metadata fixture. + */ + public function __construct( + public string $firstName, + public string $lastName = 'Doe', + ) { + } + + /** + * Create the fixture from a string. + */ + public static function fromString(string $name): static + { + throw new RuntimeException($name); + } + + /** + * Collect fixture values. + */ + public static function collectStrings(array $items): array + { + return $items; + } + + /** + * Get fixture validation rules. + */ + public static function rules(): array + { + return []; + } +} + +class ConstructorBindingDataFixture +{ + public readonly string $readonlyName; + + public string $requiredWithPropertyDefault = 'property-default'; + + public string $normalizedName; + + public string $unbound = 'unbound'; + + /** + * Create a new constructor-binding fixture. + */ + public function __construct( + string $readonlyName, + string $requiredWithPropertyDefault, + string $normalizedName = 'constructor-default', + ) { + $this->readonlyName = $readonlyName; + $this->requiredWithPropertyDefault = $requiredWithPropertyDefault; + $this->normalizedName = strtoupper($normalizedName); + } +} + +class PromotedContextualDataFixture +{ + /** + * Create a new promoted contextual fixture. + */ + public function __construct( + #[ContextualValue] + public int $userId, + ) { + } +} + +class ConstructorOnlyContextualDataFixture +{ + public string $name = 'Taylor'; + + /** + * Create a new constructor-only contextual fixture. + */ + public function __construct( + #[ContextualValue] + int $userId, + ) { + } +} + +class UnboundReadonlyDataFixture +{ + public readonly string $name; +} + +class ComputedConstructorDataFixture +{ + /** + * Create a new computed constructor fixture. + */ + public function __construct( + #[Computed] + public string $slug, + ) { + } +} + +class ContextualCollisionDataFixture +{ + public int $authorId; + + /** + * Create a new contextual collision fixture. + */ + public function __construct( + #[ContextualValue] + int $authorId, + ) { + $this->authorId = $authorId; + } +} + +class NonPublicPromotedDataFixture +{ + /** + * Create a new non-public promoted fixture. + */ + public function __construct( + protected string $secret, + ) { + } +} + +class MissingPropertyDataFixture +{ + /** + * Create a new missing-property fixture. + */ + public function __construct(string $source) + { + } +} + +class DuplicateInputDataFixture +{ + public string $first; + + #[MapInputName('first')] + public string $second; +} + +class DuplicateOutputDataFixture +{ + public string $first; + + #[MapOutputName('first')] + public string $second; +} + +class AllowedMappingDataFixture +{ + #[MapInputName('artist')] + public string $artist; + + #[MapInputName('artist.name')] + public string $artistName; + + #[Hidden] + #[MapOutputName('artist')] + public string $hiddenArtist; +} + +class PrivateConstructorDataFixture +{ + public readonly string $name; + + /** + * Create a new private-constructor fixture. + */ + private function __construct(string $name) + { + $this->name = $name; + } + + /** + * Create the fixture from a string. + */ + public static function fromString(string $name): static + { + return new static($name); + } +} + +#[Attribute(Attribute::TARGET_PARAMETER)] +class ContextualValue implements ContextualAttribute +{ +} diff --git a/tests/Data/Support/DataIterableAnnotationReaderTest.php b/tests/Data/Support/DataIterableAnnotationReaderTest.php new file mode 100644 index 000000000..3046b173d --- /dev/null +++ b/tests/Data/Support/DataIterableAnnotationReaderTest.php @@ -0,0 +1,116 @@ +getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'array')); + $generic = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'generic')); + $list = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'list')); + $nullable = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'nullable')); + $union = $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'union')); + + $this->assertAnnotation($array[0], 'array', 'FooData', 'array-key'); + $this->assertAnnotation($generic[0], 'array', 'FooData', 'int'); + $this->assertAnnotation($list[0], 'array', 'FooData', 'int'); + $this->assertAnnotation($nullable[0], 'Collection', 'FooData', 'array-key'); + $this->assertAnnotation($union[0], 'array', 'FooData', 'array-key'); + $this->assertAnnotation($union[1], 'Collection', 'BarData', 'array-key'); + $this->assertSame( + [DataIterablePropertyFixture::class], + array_values(array_unique(array_map( + fn (DataIterableAnnotation $annotation): string => $annotation->declaringClass, + [...$array, ...$generic, ...$list, ...$nullable, ...$union], + ))), + ); + $this->assertSame([], $reader->getForProperty( + new ReflectionProperty(DataIterablePropertyFixture::class, 'scalar'), + )); + } + + /** + * Test class property and method parameter annotations. + */ + public function testClassAndMethodAnnotationsAreKeyedByTheirDeclarationNames(): void + { + $reader = new DataIterableAnnotationReader; + $class = $reader->getForClass(new ReflectionClass(DataIterableClassFixture::class)); + $method = $reader->getForMethod(new ReflectionMethod(DataIterableClassFixture::class, 'handle')); + + $this->assertSame(['items'], array_keys($class)); + $this->assertSame('items', $class['items'][0]->property); + $this->assertSame(DataIterableClassFixture::class, $class['items'][0]->declaringClass); + $this->assertAnnotation($class['items'][0], 'array', 'FooData', 'string'); + + $this->assertSame(['values'], array_keys($method)); + $this->assertSame('values', $method['values'][0]->property); + $this->assertSame(DataIterableClassFixture::class, $method['values'][0]->declaringClass); + $this->assertAnnotation($method['values'][0], 'Collection', 'FooData', 'int'); + } + + /** + * Assert one parsed iterable annotation. + */ + protected function assertAnnotation( + DataIterableAnnotation $annotation, + string $container, + string $item, + string $key, + ): void { + $this->assertSame($container, $annotation->containerType); + $this->assertSame($item, (string) $annotation->itemType); + $this->assertSame($key, (string) $annotation->keyType); + } +} + +class DataIterablePropertyFixture +{ + /** @var FooData[] */ + public array $array; + + /** @var array */ + public array $generic; + + /** @var list */ + public array $list; + + /** @var Collection|null */ + public ?object $nullable; + + /** @var array|Collection */ + public array|object $union; + + /** @var FooData */ + public object $scalar; +} + +/** @property array $items */ +class DataIterableClassFixture +{ + public array $items; + + /** + * Handle the given values. + * + * @param Collection $values + */ + public function handle(object $values): void + { + } +} diff --git a/tests/Data/Support/DataMethodTest.php b/tests/Data/Support/DataMethodTest.php new file mode 100644 index 000000000..3bb4e731c --- /dev/null +++ b/tests/Data/Support/DataMethodTest.php @@ -0,0 +1,638 @@ +method('__construct'); + + $this->assertSame('__construct', $constructor->name); + $this->assertCount(2, $constructor->parameters); + $this->assertTrue($constructor->isPublic); + $this->assertFalse($constructor->isStatic); + $this->assertSame(CustomCreationMethodType::None, $constructor->customCreationMethodType); + $this->assertNull($constructor->returnType); + $this->assertTrue($constructor->parameters[0]->isPromoted); + $this->assertFalse($constructor->parameters[1]->isPromoted); + + $from = $this->method('fromValues'); + + $this->assertSame(CustomCreationMethodType::Object, $from->customCreationMethodType); + $this->assertTrue($from->isStatic); + $this->assertTrue($from->returns(DataMethodFixture::class)); + $this->assertSame('fromValues', $from->reflection->name); + + $collect = $this->method('collectValues'); + + $this->assertSame(CustomCreationMethodType::Collection, $collect->customCreationMethodType); + $this->assertTrue($collect->returnType?->isNullable); + $this->assertTrue($collect->returns('array')); + + $this->assertSame( + CustomCreationMethodType::None, + $this->method('collectUntyped')->customCreationMethodType, + ); + } + + /** + * Test positional and named payload matching. + */ + public function testPayloadMatchingUsesTypesDefaultsAndContainerDependencies(): void + { + $method = $this->method('fromValues'); + $context = $this->context(); + + $match = $method->matchPayloads($context, 'value', 42); + + $this->assertSame(['value' => 'value', 'number' => 42], $match?->arguments); + $this->assertTrue($match?->requiresContainerCall); + + $dependency = new DataMethodDependency; + $match = $method->matchPayloads($context, 'value', 42, $dependency); + + $this->assertSame( + ['value' => 'value', 'number' => 42, 'dependency' => $dependency], + $match?->arguments, + ); + $this->assertFalse($match?->requiresContainerCall); + + $match = $method->matchPayloads($context, ...['number' => 42, 'value' => 'value']); + + $this->assertSame(['value' => 'value', 'number' => 42], $match?->arguments); + $this->assertNull($method->matchPayloads($context, 42, 'value')); + $this->assertNull($method->matchPayloads($context, 'value')); + $this->assertNull($method->matchPayloads($context, 'value', 42, $dependency, 'extra')); + $this->assertNull($method->matchPayloads($context, ...['value' => 'value', 'unknown' => 42])); + + $dependencyFirst = $this->method('fromDependencyFirst')->matchPayloads($context, 'value'); + $interleaved = $this->method('fromInterleaved')->matchPayloads($context, 'value', 42); + + $this->assertSame(['value' => 'value'], $dependencyFirst?->arguments); + $this->assertTrue($dependencyFirst?->requiresContainerCall); + $this->assertSame(['value' => 'value', 'number' => 42], $interleaved?->arguments); + $this->assertTrue($interleaved?->requiresContainerCall); + } + + /** + * Test creation context placement and container identity. + */ + public function testCreationContextIsPlacedDuringTheParameterWalk(): void + { + $context = $this->context(); + + $first = $this->method('fromCreationContextFirst')->matchPayloads($context, 'value'); + $middle = $this->method('fromCreationContextMiddle')->matchPayloads($context, 'value', 42); + $trailing = $this->method('fromCreationContextTrailing')->matchPayloads($context, 'value'); + $two = $this->method('fromTwoCreationContexts')->matchPayloads($context, 'value'); + + $this->assertSame(['context' => $context, 'value' => 'value'], $first?->arguments); + $this->assertSame( + ['value' => 'value', 'context' => $context, 'number' => 42], + $middle?->arguments, + ); + $this->assertSame(['value' => 'value', 'context' => $context], $trailing?->arguments); + $this->assertSame( + ['first' => $context, 'value' => 'value', 'second' => $context], + $two?->arguments, + ); + + $variadic = $this->method('fromCreationContextVariadic')->matchPayloads($context, 'value', 1, 2); + + $this->assertSame(['value', $context, 1, 2], $variadic?->arguments); + $this->assertFalse($variadic?->requiresContainerCall); + + $containerMatch = $this->method( + 'fromCreationContextDependency', + DataMethodInvocationFixture::class, + )->matchPayloads($context, 'value'); + + $this->assertTrue($containerMatch?->requiresContainerCall); + + $result = $this->invoke( + DataMethodInvocationFixture::class, + 'fromCreationContextDependency', + $containerMatch, + new Container, + ); + + $this->assertSame($context, $result[0]); + $this->assertInstanceOf(DataMethodDependency::class, $result[1]); + $this->assertSame('value', $result[2]); + } + + /** + * Test contextual, defaulted, union, and intersection parameters. + */ + public function testOnlySingleNamedClassesAreImplicitlyInjectable(): void + { + $context = $this->context(); + $contextual = $this->method('fromContext')->matchPayloads($context, 'value'); + + $this->assertSame(['value' => 'value'], $contextual?->arguments); + $this->assertTrue($contextual?->requiresContainerCall); + $this->assertNull($this->method('fromContext')->matchPayloads($context, 42)); + + $this->assertSame([], $this->method('fromDefault')->matchPayloads($context)?->arguments); + $this->assertSame( + ['value' => 'value'], + $this->method('fromDefault')->matchPayloads($context, 'value')?->arguments, + ); + + $union = $this->method('fromUnion'); + $intersection = $this->method('fromIntersection'); + + $this->assertNull($union->matchPayloads($context)); + $this->assertSame(['value' => 'value'], $union->matchPayloads($context, 'value')?->arguments); + $this->assertNull($intersection->matchPayloads($context)); + + $intersectionValue = new DataMethodIntersectionDependency; + + $this->assertSame( + ['value' => $intersectionValue], + $intersection->matchPayloads($context, $intersectionValue)?->arguments, + ); + + $contextUnion = $this->method('fromCreationContextUnion'); + + $this->assertNull($contextUnion->matchPayloads($context)); + $this->assertSame( + ['value' => 'value'], + $contextUnion->matchPayloads($context, 'value')?->arguments, + ); + } + + /** + * Test direct and container variadic argument shapes. + */ + public function testVariadicMatchesUseRepresentableInvocationShapes(): void + { + $context = $this->context(); + + $this->assertSame([], $this->method('fromVariadic')->matchPayloads($context)?->arguments); + $this->assertSame( + ['first', 'second'], + $this->method('fromVariadic')->matchPayloads($context, 'first', 'second')?->arguments, + ); + $this->assertNull($this->method('fromVariadic')->matchPayloads($context, 'first', 2)); + + $prefixed = $this->method( + 'fromPrefixedVariadic', + DataMethodInvocationFixture::class, + )->matchPayloads($context, 'prefix', 'first', 'second'); + + $this->assertSame(['prefix', 'first', 'second'], $prefixed?->arguments); + $this->assertFalse($prefixed?->requiresContainerCall); + + $defaulted = $this->method( + 'fromDefaultVariadic', + DataMethodInvocationFixture::class, + )->matchPayloads($context, 'first', 'second'); + + $this->assertSame(['first', 'second'], $defaulted?->arguments); + $this->assertTrue($defaulted?->requiresContainerCall); + $this->assertSame( + [5, 'first', 'second'], + $this->invoke( + DataMethodInvocationFixture::class, + 'fromDefaultVariadic', + $defaulted, + new Container, + ), + ); + + $dependency = $this->method( + 'fromDependencyVariadic', + DataMethodInvocationFixture::class, + )->matchPayloads($context, 'first', 'second'); + + $this->assertSame(['first', 'second'], $dependency?->arguments); + $this->assertTrue($dependency?->requiresContainerCall); + + $dependencyResult = $this->invoke( + DataMethodInvocationFixture::class, + 'fromDependencyVariadic', + $dependency, + new Container, + ); + + $this->assertInstanceOf(DataMethodDependency::class, $dependencyResult[0]); + $this->assertSame(['first', 'second'], array_slice($dependencyResult, 1)); + + $named = $this->method( + 'fromPrefixedVariadic', + DataMethodInvocationFixture::class, + )->matchPayloads($context, ...[ + 'prefix' => 'prefix', + 'first' => 'first', + 'second' => 'second', + ]); + + $this->assertSame(['prefix', 'first', 'second'], $named?->arguments); + } + + /** + * Test class variadics preserve caller payloads across Container dispatch. + */ + public function testClassVariadicsUseTheClassKeyOnContainerDispatch(): void + { + $context = $this->context(); + $first = new DataMethodModel; + $second = new DataMethodModel; + $container = new Container; + $attributeCallbacks = 0; + + $container->afterResolvingAttribute( + DataMethodMarker::class, + function () use (&$attributeCallbacks): void { + ++$attributeCallbacks; + }, + ); + + $method = $this->method('fromAttributedModels', DataMethodInvocationFixture::class); + $match = $method->matchPayloads($context, 'prefix', $first, $second); + + $this->assertSame( + ['prefix' => 'prefix', DataMethodModel::class => $first, 0 => $second], + $match?->arguments, + ); + $this->assertTrue($match?->requiresContainerCall); + + $result = $this->invoke( + DataMethodInvocationFixture::class, + 'fromAttributedModels', + $match, + $container, + ); + + $this->assertSame(['prefix', $first, $second], $result); + $this->assertSame(1, $attributeCallbacks); + + $sameClass = $this->method('fromModels', DataMethodInvocationFixture::class) + ->matchPayloads($context, $first, $second); + + $this->assertSame([$first, $second], $sameClass?->arguments); + $this->assertFalse($sameClass?->requiresContainerCall); + $this->assertSame( + [$first, $second], + $this->invoke(DataMethodInvocationFixture::class, 'fromModels', $sameClass, $container), + ); + + $zeroPayload = $method->matchPayloads($context, 'prefix'); + $zeroResult = $this->invoke( + DataMethodInvocationFixture::class, + 'fromAttributedModels', + $zeroPayload, + $container, + ); + + $this->assertSame('prefix', $zeroResult[0]); + $this->assertInstanceOf(DataMethodModel::class, $zeroResult[1]); + $this->assertCount(2, $zeroResult); + $this->assertSame(2, $attributeCallbacks); + } + + /** + * Test variadic creation contexts are rejected as invalid factories. + */ + public function testVariadicCreationContextIsRejectedDuringMetadataBuild(): void + { + $this->expectException(InvalidDataDeclaration::class); + $this->expectExceptionMessage( + 'Data factory [Hypervel\\Tests\\Data\\Support\\DataMethodInvalidFixture::fromContexts] ' + . 'cannot declare variadic CreationContext parameter [$contexts]. ' + . 'Declare a single CreationContext parameter instead.', + ); + + $this->method('fromContexts', DataMethodInvalidFixture::class); + } + + /** + * Build metadata for one fixture method. + */ + protected function method(string $name, string $className = DataMethodFixture::class): DataMethod + { + $class = new ReflectionClass($className); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $factory = new DataMethodFactory( + new DataParameterFactory($typeFactory), + $typeFactory, + ); + + return $factory->build(new ReflectionMethod($className, $name), $class); + } + + /** + * Create an operation context for matching. + */ + protected function context(): CreationContext + { + return new CreationContext(DataMethodFixture::class); + } + + /** + * Invoke a matched fixture method through its selected path. + */ + protected function invoke( + string $className, + string $methodName, + ?DataMethodMatch $match, + Container $container, + ): mixed { + $this->assertNotNull($match); + + return $match->requiresContainerCall + ? $container->call($className::$methodName(...), $match->arguments) + : $className::$methodName(...$match->arguments); + } +} + +class DataMethodDependency +{ +} + +class DataMethodFixture +{ + /** + * Create a new fixture. + */ + public function __construct( + public string $promoted = 'hello', + string $plain = 'world', + ) { + } + + /** + * Create a fixture from scalar values. + */ + public static function fromValues( + string $value, + int $number, + DataMethodDependency $dependency, + ): self { + return new self($value); + } + + /** + * Create a fixture from a contextual value. + */ + public static function fromContext( + #[Config('app.name')] + string $context, + string $value, + ): self { + return new self($value); + } + + /** + * Create a fixture with a leading dependency. + */ + public static function fromDependencyFirst( + DataMethodDependency $dependency, + string $value, + ): self { + return new self($value); + } + + /** + * Create a fixture with an interleaved dependency. + */ + public static function fromInterleaved( + string $value, + DataMethodDependency $dependency, + int $number, + ): self { + return new self($value); + } + + /** + * Create a fixture with a leading creation context. + */ + public static function fromCreationContextFirst( + CreationContext $context, + string $value, + ): self { + return new self($value); + } + + /** + * Create a fixture with an interleaved creation context. + */ + public static function fromCreationContextMiddle( + string $value, + CreationContext $context, + int $number, + ): self { + return new self($value); + } + + /** + * Create a fixture with a trailing creation context. + */ + public static function fromCreationContextTrailing( + string $value, + CreationContext $context, + ): self { + return new self($value); + } + + /** + * Create a fixture with two creation contexts. + */ + public static function fromTwoCreationContexts( + CreationContext $first, + string $value, + CreationContext $second, + ): self { + return new self($value); + } + + /** + * Create a fixture with a creation context before variadic values. + */ + public static function fromCreationContextVariadic( + string $value, + CreationContext $context, + int ...$numbers, + ): self { + return new self($value); + } + + /** + * Create a fixture from a class or scalar value. + */ + public static function fromUnion(DataMethodDependency|string $value): self + { + return new self(is_string($value) ? $value : 'dependency'); + } + + /** + * Create a fixture from an intersection value. + */ + public static function fromIntersection( + DataMethodFirstContract&DataMethodSecondContract $value, + ): self { + return new self('intersection'); + } + + /** + * Create a fixture from a creation context union. + */ + public static function fromCreationContextUnion(CreationContext|string $value): self + { + return new self(is_string($value) ? $value : 'context'); + } + + /** + * Create a fixture from an optional value. + */ + public static function fromDefault(string $value = 'default'): self + { + return new self($value); + } + + /** + * Create a fixture from variadic values. + */ + public static function fromVariadic(string ...$values): self + { + return new self($values[0] ?? 'default'); + } + + /** + * Collect fixture values. + * + * @return null|array + */ + public static function collectValues(array $values): ?array + { + return $values; + } + + /** + * Collect fixture values without a declared return type. + */ + public static function collectUntyped(array $values) + { + return $values; + } +} + +interface DataMethodFirstContract +{ +} + +interface DataMethodSecondContract +{ +} + +class DataMethodIntersectionDependency implements DataMethodFirstContract, DataMethodSecondContract +{ +} + +class DataMethodModel +{ +} + +#[Attribute(Attribute::TARGET_PARAMETER)] +class DataMethodMarker +{ +} + +class DataMethodInvocationFixture +{ + /** + * Return a context with an injected dependency. + */ + public static function fromCreationContextDependency( + CreationContext $context, + DataMethodDependency $dependency, + string $value, + ): array { + return [$context, $dependency, $value]; + } + + /** + * Return prefixed variadic values. + */ + public static function fromPrefixedVariadic(string $prefix, string ...$values): array + { + return [$prefix, ...$values]; + } + + /** + * Return variadic values after a default. + */ + public static function fromDefaultVariadic(int $count = 5, string ...$values): array + { + return [$count, ...$values]; + } + + /** + * Return an injected dependency and variadic values. + */ + public static function fromDependencyVariadic( + DataMethodDependency $dependency, + string ...$values, + ): array { + return [$dependency, ...$values]; + } + + /** + * Return attributed prefix and model payloads. + * + * @return array{string, DataMethodModel, DataMethodModel...} + */ + public static function fromAttributedModels( + #[DataMethodMarker] + string $prefix, + DataMethodModel ...$models, + ): array { + return [$prefix, ...$models]; + } + + /** + * Return the first model and remaining model payloads. + * + * @return non-empty-list + */ + public static function fromModels( + DataMethodModel $first, + DataMethodModel ...$models, + ): array { + return [$first, ...$models]; + } +} + +class DataMethodInvalidFixture +{ + /** + * Declare an invalid variadic creation context. + */ + public static function fromContexts(CreationContext ...$contexts): self + { + return new self; + } +} diff --git a/tests/Data/Support/DataParameterTest.php b/tests/Data/Support/DataParameterTest.php new file mode 100644 index 000000000..8f1d2ea2c --- /dev/null +++ b/tests/Data/Support/DataParameterTest.php @@ -0,0 +1,112 @@ +build($plainReflection, $class); + + $this->assertSame('plain', $plain->name); + $this->assertSame(0, $plain->position); + $this->assertFalse($plain->isPromoted); + $this->assertFalse($plain->isVariadic); + $this->assertFalse($plain->hasDefaultValue); + $this->assertFalse($plain->hasAttributes); + $this->assertNull($plain->className); + $this->assertSame('string', $plain->type->getNamedTypes()[0]->name); + $this->assertSame($plainReflection, $plain->reflection); + $this->assertNull($plain->contextualAttribute); + + $contextual = $factory->build( + new ReflectionParameter([DataParameterFixture::class, '__construct'], 'contextual'), + $class, + ); + + $this->assertTrue($contextual->isPromoted); + $this->assertTrue($contextual->hasAttributes); + $this->assertNull($contextual->className); + $this->assertSame(Config::class, $contextual->contextualAttribute?->getName()); + + $defaulted = $factory->build( + new ReflectionParameter([DataParameterFixture::class, '__construct'], 'defaulted'), + $class, + ); + + $this->assertTrue($defaulted->hasDefaultValue); + $this->assertTrue($defaulted->type->isMixed); + $this->assertTrue($defaulted->type->isNullable); + + $variadic = $factory->build( + new ReflectionParameter([DataParameterFixture::class, '__construct'], 'values'), + $class, + ); + + $this->assertSame(3, $variadic->position); + $this->assertTrue($variadic->isVariadic); + $this->assertFalse($variadic->hasDefaultValue); + $this->assertNull($variadic->className); + $this->assertSame('int', $variadic->type->getNamedTypes()[0]->name); + + $dependency = $factory->build( + new ReflectionParameter([DataParameterFixture::class, 'fromDependencies'], 'dependency'), + $class, + ); + $dependencies = $factory->build( + new ReflectionParameter([DataParameterFixture::class, 'fromDependencies'], 'dependencies'), + $class, + ); + + $this->assertSame(DataParameterDependency::class, $dependency->className); + $this->assertSame(DataParameterDependency::class, $dependencies->className); + $this->assertFalse($dependency->isVariadic); + $this->assertTrue($dependencies->isVariadic); + } +} + +class DataParameterFixture +{ + /** + * Create a new fixture. + */ + public function __construct( + string $plain, + #[Config('app.name')] + public string $contextual, + public mixed $defaulted = null, + int ...$values, + ) { + } + + /** + * Create a fixture from dependencies. + */ + public static function fromDependencies( + DataParameterDependency $dependency, + DataParameterDependency ...$dependencies, + ): self { + return new self('value'); + } +} + +class DataParameterDependency +{ +} diff --git a/tests/Data/Support/DataPropertyTest.php b/tests/Data/Support/DataPropertyTest.php new file mode 100644 index 000000000..1df605f7b --- /dev/null +++ b/tests/Data/Support/DataPropertyTest.php @@ -0,0 +1,309 @@ +factory([ + 'casts' => ['string' => PropertyFallbackCast::class], + 'transformers' => ['string' => PropertyFallbackTransformer::class], + ]); + $class = new ReflectionClass(DataPropertyFixture::class); + $property = $this->buildProperty($factory, $class, 'displayName', $config, $mapperResolver); + + $this->assertSame('displayName', $property->name); + $this->assertSame(DataPropertyFixture::class, $property->className); + $this->assertTrue($property->isPromoted); + $this->assertTrue($property->isConstructorParameter); + $this->assertTrue($property->isReadonly); + $this->assertTrue($property->hasDefaultValue); + $this->assertFalse($property->validate); + $this->assertTrue($property->hidden); + $this->assertSame('wire.name', $property->inputMappedName); + $this->assertSame('display', $property->outputMappedName); + $this->assertSame([PropertyFallbackCast::class], $property->configuredCasts); + $this->assertSame([PropertyFallbackTransformer::class], $property->configuredTransformers); + $this->assertInstanceOf(ReflectionAttribute::class, $property->autoLazy); + $this->assertInstanceOf(ReflectionAttribute::class, $property->cast); + $this->assertInstanceOf(ReflectionAttribute::class, $property->transformer); + $this->assertSame(WithCast::class, $property->cast?->getName()); + $this->assertSame(WithTransformer::class, $property->transformer?->getName()); + $this->assertSame('displayName', $property->reflection->name); + $this->assertSame(DataPropertyFixture::class, $property->reflection->getDeclaringClass()->name); + + $relation = $this->buildProperty($factory, $class, 'relation', $config, $mapperResolver); + $morph = $this->buildProperty($factory, $class, 'type', $config, $mapperResolver); + + $this->assertTrue($relation->loadRelation); + $this->assertTrue($morph->morphable); + } + + /** + * Test defaults and output-only properties without retaining default objects. + */ + public function testDefaultsComputedAndVirtualPropertiesAreCompiled(): void + { + [$factory, $config, $mapperResolver] = $this->factory(); + $class = new ReflectionClass(DataPropertyFixture::class); + $optional = $this->buildProperty($factory, $class, 'optional', $config, $mapperResolver); + $nonPromoted = $this->buildProperty( + $factory, + $class, + 'nonPromoted', + $config, + $mapperResolver, + ); + $computed = $this->buildProperty($factory, $class, 'computed', $config, $mapperResolver); + $virtual = $this->buildProperty($factory, $class, 'virtual', $config, $mapperResolver); + + $this->assertFalse($optional->hasDefaultValue); + $this->assertTrue($optional->type->isOptional); + $this->assertFalse($nonPromoted->isPromoted); + $this->assertFalse($nonPromoted->isConstructorParameter); + $this->assertTrue($nonPromoted->hasDefaultValue); + $this->assertTrue($computed->computed); + $this->assertFalse($computed->validate); + $this->assertTrue($virtual->isVirtual); + $this->assertTrue($virtual->computed); + $this->assertFalse($virtual->validate); + } + + /** + * Test constructor-bound property ownership and default precedence. + */ + public function testConstructorParametersOwnBoundPropertyDefaults(): void + { + [$factory, $config, $mapperResolver] = $this->factory(); + $class = new ReflectionClass(DataPropertyFixture::class); + $readonly = $this->buildProperty($factory, $class, 'readonlyBound', $config, $mapperResolver); + $defaulted = $this->buildProperty($factory, $class, 'constructorDefault', $config, $mapperResolver); + $required = $this->buildProperty( + $factory, + $class, + 'requiredWithPropertyDefault', + $config, + $mapperResolver, + ); + + $this->assertTrue($readonly->isConstructorParameter); + $this->assertTrue($readonly->isReadonly); + $this->assertFalse($readonly->hasDefaultValue); + $this->assertTrue($defaulted->isConstructorParameter); + $this->assertTrue($defaulted->hasDefaultValue); + $this->assertTrue($required->isConstructorParameter); + $this->assertFalse($required->hasDefaultValue); + } + + /** + * Test class and configured mapper precedence. + */ + public function testNameMappersAreResolvedOnceWithPropertyPrecedence(): void + { + [$factory, $config, $mapperResolver] = $this->factory([ + 'name_mapping_strategy' => [ + 'input' => KebabCaseMapper::class, + 'output' => KebabCaseMapper::class, + ], + ]); + $class = new ReflectionClass(DataPropertyFixture::class); + $mapped = $this->buildProperty($factory, $class, 'createdAt', $config, $mapperResolver); + $numeric = $this->buildProperty($factory, $class, 'numeric', $config, $mapperResolver); + + $this->assertSame('created_at', $mapped->inputMappedName); + $this->assertSame('created_at', $mapped->outputMappedName); + $this->assertSame(0, $numeric->inputMappedName); + $this->assertSame('numeric', $numeric->outputMappedName); + } + + /** + * Build one fixture property with its constructor default metadata. + * + * @param ReflectionClass $class + */ + protected function buildProperty( + DataPropertyFactory $factory, + ReflectionClass $class, + string $name, + DataConfig $config, + NameMapperResolver $mapperResolver, + ): DataProperty { + $reflectionProperty = $class->getProperty($name); + $constructorParameter = null; + + foreach ($class->getConstructor()?->getParameters() ?? [] as $parameter) { + if ($parameter->name === $name) { + $constructorParameter = (new DataParameterFactory( + new DataTypeFactory(new PhpDocTypeNameResolver), + ))->build($parameter, $class); + + break; + } + } + + $classAttributes = DataAttributesCollectionFactory::buildFromReflectionClass($class); + $defaultInputMapper = $mapperResolver->resolveConfigured($config->inputNameMapper); + $defaultOutputMapper = $mapperResolver->resolveConfigured($config->outputNameMapper); + + return $factory->build( + reflectionProperty: $reflectionProperty, + reflectionClass: $class, + constructorParameter: $constructorParameter, + classInputNameMapper: $mapperResolver->resolveInput($classAttributes, $defaultInputMapper), + classOutputNameMapper: $mapperResolver->resolveOutput($classAttributes, $defaultOutputMapper), + ); + } + + /** + * Create a property factory and its boot-stable collaborators. + * + * @return array{DataPropertyFactory, DataConfig, NameMapperResolver} + */ + protected function factory(array $overrides = []): array + { + $defaults = require __DIR__ . '/../../../src/data/config/data.php'; + $config = new DataConfig(new Repository([ + 'data' => array_replace($defaults, $overrides), + ])); + $mapperResolver = new NameMapperResolver(new Container); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + + return [ + new DataPropertyFactory($typeFactory, $config, $mapperResolver), + $config, + $mapperResolver, + ]; + } +} + +#[MapName(SnakeCaseMapper::class)] +class DataPropertyFixture +{ + public readonly string $readonlyBound; + + public string $requiredWithPropertyDefault = 'property-default'; + + public string $constructorDefault; + + /** + * Create a new property fixture. + */ + public function __construct( + string $readonlyBound, + string $requiredWithPropertyDefault, + #[AutoLazy] + #[Hidden] + #[MapInputName('wire.name')] + #[MapOutputName('display')] + #[WithCast(PropertyCast::class)] + #[WithTransformer(PropertyTransformer::class)] + #[WithoutValidation] + public readonly string $displayName = 'Taylor', + public string|Optional $optional = new Optional, + string $constructorDefault = 'constructor-default', + ) { + $this->readonlyBound = $readonlyBound; + $this->requiredWithPropertyDefault = $requiredWithPropertyDefault; + $this->constructorDefault = $constructorDefault; + } + + #[LoadRelation] + public PropertyRelation $relation; + + #[PropertyForMorph] + public string $type; + + public string $nonPromoted = 'default'; + + #[Computed] + public string $computed = 'computed'; + + public string $virtual { + get => 'virtual'; + } + + public string $createdAt; + + #[MapInputName(0)] + public string $numeric; +} + +class PropertyRelation +{ +} + +class PropertyCast implements Cast +{ + /** + * Cast a property value. + */ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): mixed { + return $value; + } +} + +class PropertyFallbackCast extends PropertyCast +{ +} + +class PropertyTransformer implements Transformer +{ + /** + * Transform a property value. + */ + public function transform( + DataProperty $property, + mixed $value, + TransformationContext $context, + ): mixed { + return $value; + } +} + +class PropertyFallbackTransformer extends PropertyTransformer +{ +} diff --git a/tests/Data/Support/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php new file mode 100644 index 000000000..27c063189 --- /dev/null +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -0,0 +1,388 @@ +property('integer'); + $nullable = $this->property('nullable'); + $untyped = $this->property('untyped'); + $optional = $this->property('optional'); + + $this->assertTrue($integer->acceptsValue(10)); + $this->assertFalse($integer->acceptsValue('10')); + $this->assertTrue($nullable->isNullable); + $this->assertTrue($nullable->acceptsValue(null)); + $this->assertTrue($untyped->isMixed); + $this->assertTrue($untyped->isNullable); + $this->assertTrue($untyped->acceptsValue(new stdClass)); + $this->assertTrue($optional->isOptional); + $this->assertTrue($optional->acceptsValue(Optional::create())); + $this->assertTrue($optional->acceptsValue('value')); + } + + /** + * Test union, intersection, and DNF declarations without collapsing them. + */ + public function testCombinationTypesRetainTheirDeclarationGraph(): void + { + $union = $this->property('union'); + $guaranteedUnion = $this->property('guaranteedUnion'); + $intersection = $this->property('intersection'); + $dnf = $this->property('dnf'); + $both = new DataTypeFactoryBothTypes; + + $this->assertInstanceOf(UnionType::class, $union->type); + $this->assertTrue($union->acceptsValue('value')); + $this->assertTrue($union->acceptsValue(10)); + $this->assertFalse($union->acceptsValue(10.5)); + $this->assertTrue($guaranteedUnion->type->guaranteesType(DataTypeFactoryMarker::class)); + $this->assertFalse($union->type->guaranteesType(DataTypeFactoryMarker::class)); + + $this->assertInstanceOf(IntersectionType::class, $intersection->type); + $this->assertTrue($intersection->acceptsValue($both)); + $this->assertFalse($intersection->acceptsValue(new stdClass)); + $this->assertTrue($intersection->type->guaranteesType(DataTypeFactoryFirstType::class)); + + $this->assertInstanceOf(UnionType::class, $dnf->type); + $this->assertTrue($dnf->acceptsValue($both)); + $this->assertTrue($dnf->acceptsValue('value')); + $this->assertFalse($dnf->acceptsValue(new stdClass)); + $this->assertFalse($dnf->type->guaranteesType(DataTypeFactoryFirstType::class)); + } + + /** + * Test PHPDoc and attribute iterable item metadata. + */ + public function testIterableItemTypesAreCompiledFromPhpDocAndAttributes(): void + { + $imported = $this->property('imported'); + $attributed = $this->property('attributed'); + $unionItems = $this->property('unionItems'); + + $this->assertSame(DataTypeKind::DataArray, $imported->getNamedTypes()[0]->kind); + $this->assertSame(GroupedImportedData::class, $imported->getNamedTypes()[0]->dataClass); + $this->assertSame(DataTypeFactoryItemData::class, $attributed->getNamedTypes()[0]->dataClass); + + $itemType = $unionItems->getNamedTypes()[0]->iterableItemType; + + $this->assertInstanceOf(UnionType::class, $itemType); + $this->assertTrue($itemType->acceptsValue('value')); + $this->assertTrue($itemType->acceptsValue(m::mock(DataTypeFactoryItemData::class))); + $this->assertSame(DataTypeFactoryItemData::class, $unionItems->getNamedTypes()[0]->dataClass); + } + + /** + * Test exact iterable annotations win before widened container matches. + */ + public function testExactIterableAnnotationsWinRegardlessOfUnionOrder(): void + { + $expected = [ + EloquentCollection::class => DataTypeFactoryFirstItemData::class, + Collection::class => DataTypeFactorySecondItemData::class, + ]; + + foreach (['annotationBaseFirst', 'annotationExactFirst'] as $property) { + $types = []; + + foreach ($this->property($property)->getDataCollectableTypes() as $type) { + $types[$type->name] = $type->dataClass; + } + + $this->assertSame($expected, $types); + } + } + + /** + * Test data object declarations and float widening. + */ + public function testNamedTypesUseNativePhpAcceptanceRules(): void + { + $data = $this->property('data'); + $dataCollection = $this->property('dataCollection'); + $float = $this->property('float'); + + $this->assertSame(DataTypeKind::DataObject, $data->getNamedTypes()[0]->kind); + $this->assertSame($data->getNamedTypes()[0], $data->getDataObjectType()); + $this->assertNull($data->getDataCollectableType()); + $this->assertTrue($data->acceptsValue(m::mock(DataTypeFactoryItemData::class))); + $this->assertSame( + $dataCollection->getNamedTypes()[0], + $dataCollection->getDataCollectableType(), + ); + $this->assertNull($dataCollection->getDataObjectType()); + $this->assertTrue($float->acceptsValue(10)); + $this->assertTrue($float->acceptsValue(10.5)); + } + + /** + * Test inherited native types use declaration and target scopes. + */ + public function testInheritedNativeTypesUseTheirPhpScopes(): void + { + $factory = new DataTypeFactory(new PhpDocTypeNameResolver); + $target = new ReflectionClass(DataTypeFactoryNativeChild::class); + $selfProperty = new ReflectionProperty(DataTypeFactoryNativeChild::class, 'selfValue'); + $parentProperty = new ReflectionProperty(DataTypeFactoryNativeChild::class, 'parentValue'); + $constructorParameter = $target->getConstructor()?->getParameters()[0]; + $method = new ReflectionMethod(DataTypeFactoryNativeChild::class, 'fromValue'); + + $this->assertNotNull($constructorParameter); + $this->assertSame( + DataTypeFactoryNativeParent::class, + $factory->buildProperty( + $selfProperty->getType(), + $target, + $selfProperty, + )->getNamedTypes()[0]->name, + ); + $this->assertSame( + DataTypeFactoryNativeGrandparent::class, + $factory->buildProperty( + $parentProperty->getType(), + $target, + $parentProperty, + )->getNamedTypes()[0]->name, + ); + $this->assertSame( + DataTypeFactoryNativeParent::class, + $factory->build($constructorParameter->getType(), $target, $constructorParameter) + ->getNamedTypes()[0]->name, + ); + $this->assertSame( + DataTypeFactoryNativeParent::class, + $factory->build($method->getParameters()[0]->getType(), $target, $method->getParameters()[0]) + ->getNamedTypes()[0]->name, + ); + $this->assertSame( + DataTypeFactoryNativeChild::class, + $factory->build($method->getReturnType(), $target, $method)->getNamedTypes()[0]->name, + ); + } + + /** + * Test inherited PHPDoc keywords use declaration and target scopes. + */ + public function testInheritedPhpDocKeywordsUseTheirPhpScopes(): void + { + $reader = new DataIterableAnnotationReader; + $annotations = $reader->getForClass(new ReflectionClass(DataTypeFactoryPhpDocParent::class)); + $resolved = []; + + foreach ($annotations as $property => $propertyAnnotations) { + $resolved[$property] = $this + ->propertyFor( + DataTypeFactoryPhpDocChild::class, + $property, + $propertyAnnotations, + ) + ->getNamedTypes()[0] + ->iterableItemType + ?->getNamedTypes()[0] + ->name; + } + + $this->assertSame( + [ + 'selfValues' => DataTypeFactoryPhpDocParent::class, + 'staticValues' => DataTypeFactoryPhpDocChild::class, + 'thisValues' => DataTypeFactoryPhpDocChild::class, + 'parentValues' => DataTypeFactoryPhpDocGrandparent::class, + ], + $resolved, + ); + } + + /** + * Build metadata for one fixture property. + */ + protected function property(string $name): DataPropertyType + { + $reader = new DataIterableAnnotationReader; + + return $this->propertyFor( + DataTypeFactoryFixture::class, + $name, + $reader->getForProperty(new ReflectionProperty(DataTypeFactoryFixture::class, $name)), + ); + } + + /** + * Build metadata for one fixture property and selected annotation list. + */ + protected function propertyFor(string $className, string $name, array $annotations): DataPropertyType + { + $class = new ReflectionClass($className); + $property = new ReflectionProperty($className, $name); + $attributes = DataAttributesCollectionFactory::buildFromReflectionProperty($property); + + return (new DataTypeFactory(new PhpDocTypeNameResolver))->buildProperty( + $property->getType(), + $class, + $property, + $attributes, + $annotations, + ); + } +} + +interface DataTypeFactoryFirstType +{ +} + +interface DataTypeFactorySecondType +{ +} + +interface DataTypeFactoryMarker +{ +} + +class DataTypeFactoryFirstMarkedType implements DataTypeFactoryMarker +{ +} + +class DataTypeFactorySecondMarkedType implements DataTypeFactoryMarker +{ +} + +class DataTypeFactoryBothTypes implements DataTypeFactoryFirstType, DataTypeFactorySecondType +{ +} + +abstract class DataTypeFactoryItemData implements BaseData +{ +} + +class DataTypeFactoryFixture +{ + public int $integer; + + public ?int $nullable; + + public $untyped; + + public string|Optional $optional; + + public string|int $union; + + public DataTypeFactoryFirstMarkedType|DataTypeFactorySecondMarkedType $guaranteedUnion; + + public DataTypeFactoryFirstType&DataTypeFactorySecondType $intersection; + + public (DataTypeFactoryFirstType&DataTypeFactorySecondType)|string $dnf; + + /** @var array */ + public array $imported; + + #[DataCollectionOf(DataTypeFactoryItemData::class)] + public array $attributed; + + /** @var array */ + public array $unionItems; + + public DataTypeFactoryItemData $data; + + #[DataCollectionOf(DataTypeFactoryItemData::class)] + public DataCollection $dataCollection; + + /** @var Collection|EloquentCollection */ + public EloquentCollection|Collection $annotationBaseFirst; + + /** @var EloquentCollection|Collection */ + public EloquentCollection|Collection $annotationExactFirst; + + public float $float; +} + +abstract class DataTypeFactoryFirstItemData implements BaseData +{ +} + +abstract class DataTypeFactorySecondItemData implements BaseData +{ +} + +class DataTypeFactoryNativeGrandparent +{ +} + +class DataTypeFactoryNativeParent extends DataTypeFactoryNativeGrandparent +{ + public self $selfValue; + + public parent $parentValue; + + /** + * Create a new native-scope fixture. + */ + public function __construct(public self $constructorValue) + { + } + + /** + * Create a native-scope fixture from a value. + */ + public static function fromValue(self $value): static + { + return new static($value); + } +} + +class DataTypeFactoryNativeChild extends DataTypeFactoryNativeParent +{ +} + +class DataTypeFactoryPhpDocGrandparent +{ +} + +/** + * @property array $selfValues + * @property array $staticValues + * @property array<$this> $thisValues + * @property array $parentValues + */ +class DataTypeFactoryPhpDocParent extends DataTypeFactoryPhpDocGrandparent +{ + public array $selfValues; + + public array $staticValues; + + public array $thisValues; + + public array $parentValues; +} + +class DataTypeFactoryPhpDocChild extends DataTypeFactoryPhpDocParent +{ +} diff --git a/tests/Data/Support/PhpDocTypeNameResolverTest.php b/tests/Data/Support/PhpDocTypeNameResolverTest.php new file mode 100644 index 000000000..90c668fc4 --- /dev/null +++ b/tests/Data/Support/PhpDocTypeNameResolverTest.php @@ -0,0 +1,80 @@ +assertSame('string', $resolver->resolve('string', $class)); + $this->assertSame(ImportedType::class, $resolver->resolve('\\' . ImportedType::class, $class)); + $this->assertSame([], $this->imports($resolver)); + $this->assertSame(SiblingType::class, $resolver->resolve('SiblingType', $class)); + $this->assertCount(1, $this->imports($resolver)); + } + + /** + * Test direct, grouped, and aliased imports. + */ + public function testImportedNamesAreResolvedFromOneCachedSourceMap(): void + { + $resolver = new PhpDocTypeNameResolver; + $class = new ReflectionClass(PhpDocTypeContext::class); + + $this->assertTrue(class_exists(SameNamespaceImportedType::class)); + $this->assertSame(ImportedType::class, $resolver->resolve('ImportedType', $class)); + $this->assertSame(GroupedType::class, $resolver->resolve('GroupAlias', $class)); + $this->assertSame(GroupedType::class . '\\Nested', $resolver->resolve('GroupAlias\\Nested', $class)); + $this->assertCount(1, $this->imports($resolver)); + } + + /** + * Test one source parse caches imports for every namespace in the file. + */ + public function testOneSourceMapContainsEveryNamespace(): void + { + require_once __DIR__ . '/../Fixtures/MultiNamespacePhpDocTypes.php'; + + $resolver = new PhpDocTypeNameResolver; + + $this->assertSame( + ImportedType::class, + $resolver->resolve('SharedAlias', new ReflectionClass(MultiNamespaceFirst::class)), + ); + $this->assertSame( + GroupedType::class, + $resolver->resolve('SharedAlias', new ReflectionClass(MultiNamespaceSecond::class)), + ); + $this->assertCount(1, $this->imports($resolver)); + } + + /** + * Get the resolver's bounded source import cache. + * + * @return array>> + */ + protected function imports(PhpDocTypeNameResolver $resolver): array + { + return (new ReflectionProperty($resolver, 'imports'))->getValue($resolver); + } +} From d6563155d3e2533c20da5b34e93dd914cd27f040 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:18:34 +0000 Subject: [PATCH 06/35] Add the Data validation system Provide Spatie-familiar validation attributes for Hypervel's supported Laravel rules, plus thin first-party wrappers for Hypervel-native rules. Preserve backed enums, external references, field references, fluent Exists and Unique constraints, strict numeric strings, and canonical nested null encoding without a second validation implementation. Compile inferred, declared, nested, mapped, contextual, and unknown-field rules into one root Validator. Use structural wildcard graphs for uniform collections, sparse concrete fallbacks for divergent items, conservative accumulator equality for dynamic rules, and marker provenance that preserves Laravel Distinct and dependent-field identity. Construct from filtered validated payloads, restore only declared WithoutValidation and finished values, preserve source key order, support authorization, messages, attributes, redirects, Precognition, and validator hooks, and keep all request state in the root operation. Cover rule denormalization, every attribute family, database constraints, mapped paths, finished subtrees, wildcard and concrete compilation, strict unknown fields, lifecycle hooks, payload ordering, Exact and Distinct identity, dynamic graphs, and validation accumulator equivalence. --- .../Concerns/AppliesDatabaseConstraints.php | 32 + .../src/Attributes/Validation/Accepted.php | 27 + .../src/Attributes/Validation/AcceptedIf.php | 56 + .../src/Attributes/Validation/ActiveUrl.php | 27 + src/data/src/Attributes/Validation/After.php | 47 + .../Attributes/Validation/AfterOrEqual.php | 47 + src/data/src/Attributes/Validation/Alpha.php | 27 + .../src/Attributes/Validation/AlphaDash.php | 27 + .../Attributes/Validation/AlphaNumeric.php | 27 + src/data/src/Attributes/Validation/AnyOf.php | 45 + .../src/Attributes/Validation/ArrayType.php | 40 + src/data/src/Attributes/Validation/Ascii.php | 27 + src/data/src/Attributes/Validation/Bail.php | 27 + src/data/src/Attributes/Validation/Base64.php | 27 + src/data/src/Attributes/Validation/Before.php | 47 + .../Attributes/Validation/BeforeOrEqual.php | 47 + .../src/Attributes/Validation/Between.php | 37 + .../src/Attributes/Validation/BooleanType.php | 27 + src/data/src/Attributes/Validation/Can.php | 48 + .../src/Attributes/Validation/Confirmed.php | 27 + .../src/Attributes/Validation/Contains.php | 40 + .../Attributes/Validation/CurrentPassword.php | 36 + .../Validation/CustomValidationAttribute.php | 18 + src/data/src/Attributes/Validation/Date.php | 27 + .../src/Attributes/Validation/DateEquals.php | 46 + .../src/Attributes/Validation/DateFormat.php | 39 + .../src/Attributes/Validation/Decimal.php | 37 + .../src/Attributes/Validation/Declined.php | 27 + .../src/Attributes/Validation/DeclinedIf.php | 56 + .../src/Attributes/Validation/Different.php | 38 + src/data/src/Attributes/Validation/Digits.php | 35 + .../Attributes/Validation/DigitsBetween.php | 37 + .../src/Attributes/Validation/Dimensions.php | 113 + .../src/Attributes/Validation/Distinct.php | 50 + .../Attributes/Validation/DoesntContain.php | 40 + .../Attributes/Validation/DoesntEndWith.php | 39 + .../Attributes/Validation/DoesntStartWith.php | 39 + src/data/src/Attributes/Validation/Email.php | 75 + .../src/Attributes/Validation/Encoding.php | 35 + .../src/Attributes/Validation/EndsWith.php | 39 + src/data/src/Attributes/Validation/Enum.php | 73 + .../src/Attributes/Validation/Exclude.php | 44 + .../src/Attributes/Validation/ExcludeIf.php | 56 + .../Attributes/Validation/ExcludeUnless.php | 45 + .../src/Attributes/Validation/ExcludeWith.php | 39 + .../Attributes/Validation/ExcludeWithout.php | 41 + src/data/src/Attributes/Validation/Exists.php | 106 + .../src/Attributes/Validation/Extensions.php | 39 + src/data/src/Attributes/Validation/File.php | 27 + src/data/src/Attributes/Validation/Filled.php | 27 + .../src/Attributes/Validation/GreaterThan.php | 39 + .../Validation/GreaterThanOrEqualTo.php | 39 + .../src/Attributes/Validation/HexColor.php | 27 + src/data/src/Attributes/Validation/IP.php | 27 + src/data/src/Attributes/Validation/IPv4.php | 27 + src/data/src/Attributes/Validation/IPv6.php | 27 + src/data/src/Attributes/Validation/Image.php | 27 + src/data/src/Attributes/Validation/In.php | 79 + .../src/Attributes/Validation/InArray.php | 39 + .../src/Attributes/Validation/InArrayKeys.php | 40 + .../src/Attributes/Validation/IntegerType.php | 27 + src/data/src/Attributes/Validation/Json.php | 27 + .../src/Attributes/Validation/LessThan.php | 39 + .../Validation/LessThanOrEqualTo.php | 39 + .../src/Attributes/Validation/ListType.php | 27 + .../src/Attributes/Validation/Lowercase.php | 27 + .../src/Attributes/Validation/MacAddress.php | 27 + src/data/src/Attributes/Validation/Max.php | 35 + .../src/Attributes/Validation/MaxDigits.php | 35 + .../src/Attributes/Validation/MimeTypes.php | 39 + src/data/src/Attributes/Validation/Mimes.php | 39 + src/data/src/Attributes/Validation/Min.php | 35 + .../src/Attributes/Validation/MinDigits.php | 35 + .../src/Attributes/Validation/Missing.php | 27 + .../src/Attributes/Validation/MissingIf.php | 46 + .../Attributes/Validation/MissingUnless.php | 46 + .../src/Attributes/Validation/MissingWith.php | 41 + .../Attributes/Validation/MissingWithAll.php | 41 + .../src/Attributes/Validation/MultipleOf.php | 35 + src/data/src/Attributes/Validation/NotIn.php | 78 + .../src/Attributes/Validation/NotRegex.php | 35 + .../src/Attributes/Validation/Nullable.php | 27 + .../src/Attributes/Validation/Numeric.php | 27 + .../Validation/ObjectValidationAttribute.php | 15 + .../src/Attributes/Validation/Password.php | 119 + .../src/Attributes/Validation/Present.php | 27 + .../src/Attributes/Validation/PresentIf.php | 46 + .../Attributes/Validation/PresentUnless.php | 46 + .../src/Attributes/Validation/PresentWith.php | 41 + .../Attributes/Validation/PresentWithAll.php | 41 + .../src/Attributes/Validation/Prohibited.php | 41 + .../Attributes/Validation/ProhibitedIf.php | 49 + .../Validation/ProhibitedIfAccepted.php | 38 + .../Validation/ProhibitedIfDeclined.php | 38 + .../Validation/ProhibitedUnless.php | 49 + .../src/Attributes/Validation/Prohibits.php | 43 + src/data/src/Attributes/Validation/Regex.php | 37 + .../src/Attributes/Validation/Required.php | 45 + .../Validation/RequiredArrayKeys.php | 39 + .../src/Attributes/Validation/RequiredIf.php | 50 + .../Validation/RequiredIfAccepted.php | 39 + .../Validation/RequiredIfDeclined.php | 39 + .../Attributes/Validation/RequiredUnless.php | 50 + .../Attributes/Validation/RequiredWith.php | 44 + .../Attributes/Validation/RequiredWithAll.php | 44 + .../Attributes/Validation/RequiredWithout.php | 44 + .../Validation/RequiredWithoutAll.php | 44 + src/data/src/Attributes/Validation/Rule.php | 34 + src/data/src/Attributes/Validation/Same.php | 38 + src/data/src/Attributes/Validation/Size.php | 35 + .../src/Attributes/Validation/Sometimes.php | 27 + .../src/Attributes/Validation/StartsWith.php | 39 + .../src/Attributes/Validation/StringType.php | 27 + .../Validation/StringValidationAttribute.php | 21 + .../src/Attributes/Validation/Timezone.php | 27 + src/data/src/Attributes/Validation/Ulid.php | 27 + src/data/src/Attributes/Validation/Unique.php | 118 + .../src/Attributes/Validation/Uppercase.php | 27 + src/data/src/Attributes/Validation/Url.php | 40 + src/data/src/Attributes/Validation/Uuid.php | 27 + .../Validation/ValidationAttribute.php | 83 + .../Support/Validation/CompiledValidation.php | 115 + .../Constraints/DatabaseConstraint.php | 25 + .../Constraints/WhereConstraint.php | 33 + .../Constraints/WhereInConstraint.php | 34 + .../Constraints/WhereNotConstraint.php | 32 + .../Constraints/WhereNotInConstraint.php | 34 + .../Constraints/WhereNotNullConstraint.php | 30 + .../Constraints/WhereNullConstraint.php | 30 + .../Validation/DataValidationCompiler.php | 1359 +++++++ .../src/Support/Validation/DataValidator.php | 368 ++ .../References/ExternalReference.php | 13 + .../Validation/References/FieldReference.php | 29 + .../src/Support/Validation/RequiringRule.php | 9 + .../Support/Validation/RuleDenormalizer.php | 145 + .../Validation/TranslatedValidationPath.php | 20 + .../Validation/ValidationAccumulator.php | 151 + .../Support/Validation/ValidationContext.php | 18 + .../src/Support/Validation/ValidationPath.php | 227 ++ .../src/Support/Validation/ValidationRule.php | 9 + .../Data/Attributes/Validation/ExistsTest.php | 143 + tests/Data/Attributes/Validation/InTest.php | 114 + .../Data/Attributes/Validation/NotInTest.php | 114 + .../Attributes/Validation/PasswordTest.php | 189 + .../Data/Attributes/Validation/UniqueTest.php | 162 + .../Validation/ValidationAttributeTest.php | 672 ++++ .../Validation/CompiledValidationTest.php | 105 + .../Constraints/DatabaseConstraintTest.php | 102 + .../Support/Validation/DataValidatorTest.php | 3130 +++++++++++++++++ .../Validation/ValidationAccumulatorTest.php | 240 ++ .../Support/Validation/ValidationPathTest.php | 173 + 151 files changed, 12747 insertions(+) create mode 100644 src/data/src/Attributes/Concerns/AppliesDatabaseConstraints.php create mode 100644 src/data/src/Attributes/Validation/Accepted.php create mode 100644 src/data/src/Attributes/Validation/AcceptedIf.php create mode 100644 src/data/src/Attributes/Validation/ActiveUrl.php create mode 100644 src/data/src/Attributes/Validation/After.php create mode 100644 src/data/src/Attributes/Validation/AfterOrEqual.php create mode 100644 src/data/src/Attributes/Validation/Alpha.php create mode 100644 src/data/src/Attributes/Validation/AlphaDash.php create mode 100644 src/data/src/Attributes/Validation/AlphaNumeric.php create mode 100644 src/data/src/Attributes/Validation/AnyOf.php create mode 100644 src/data/src/Attributes/Validation/ArrayType.php create mode 100644 src/data/src/Attributes/Validation/Ascii.php create mode 100644 src/data/src/Attributes/Validation/Bail.php create mode 100644 src/data/src/Attributes/Validation/Base64.php create mode 100644 src/data/src/Attributes/Validation/Before.php create mode 100644 src/data/src/Attributes/Validation/BeforeOrEqual.php create mode 100644 src/data/src/Attributes/Validation/Between.php create mode 100644 src/data/src/Attributes/Validation/BooleanType.php create mode 100644 src/data/src/Attributes/Validation/Can.php create mode 100644 src/data/src/Attributes/Validation/Confirmed.php create mode 100644 src/data/src/Attributes/Validation/Contains.php create mode 100644 src/data/src/Attributes/Validation/CurrentPassword.php create mode 100644 src/data/src/Attributes/Validation/CustomValidationAttribute.php create mode 100644 src/data/src/Attributes/Validation/Date.php create mode 100644 src/data/src/Attributes/Validation/DateEquals.php create mode 100644 src/data/src/Attributes/Validation/DateFormat.php create mode 100644 src/data/src/Attributes/Validation/Decimal.php create mode 100644 src/data/src/Attributes/Validation/Declined.php create mode 100644 src/data/src/Attributes/Validation/DeclinedIf.php create mode 100644 src/data/src/Attributes/Validation/Different.php create mode 100644 src/data/src/Attributes/Validation/Digits.php create mode 100644 src/data/src/Attributes/Validation/DigitsBetween.php create mode 100644 src/data/src/Attributes/Validation/Dimensions.php create mode 100644 src/data/src/Attributes/Validation/Distinct.php create mode 100644 src/data/src/Attributes/Validation/DoesntContain.php create mode 100644 src/data/src/Attributes/Validation/DoesntEndWith.php create mode 100644 src/data/src/Attributes/Validation/DoesntStartWith.php create mode 100644 src/data/src/Attributes/Validation/Email.php create mode 100644 src/data/src/Attributes/Validation/Encoding.php create mode 100644 src/data/src/Attributes/Validation/EndsWith.php create mode 100644 src/data/src/Attributes/Validation/Enum.php create mode 100644 src/data/src/Attributes/Validation/Exclude.php create mode 100644 src/data/src/Attributes/Validation/ExcludeIf.php create mode 100644 src/data/src/Attributes/Validation/ExcludeUnless.php create mode 100644 src/data/src/Attributes/Validation/ExcludeWith.php create mode 100644 src/data/src/Attributes/Validation/ExcludeWithout.php create mode 100644 src/data/src/Attributes/Validation/Exists.php create mode 100644 src/data/src/Attributes/Validation/Extensions.php create mode 100644 src/data/src/Attributes/Validation/File.php create mode 100644 src/data/src/Attributes/Validation/Filled.php create mode 100644 src/data/src/Attributes/Validation/GreaterThan.php create mode 100644 src/data/src/Attributes/Validation/GreaterThanOrEqualTo.php create mode 100644 src/data/src/Attributes/Validation/HexColor.php create mode 100644 src/data/src/Attributes/Validation/IP.php create mode 100644 src/data/src/Attributes/Validation/IPv4.php create mode 100644 src/data/src/Attributes/Validation/IPv6.php create mode 100644 src/data/src/Attributes/Validation/Image.php create mode 100644 src/data/src/Attributes/Validation/In.php create mode 100644 src/data/src/Attributes/Validation/InArray.php create mode 100644 src/data/src/Attributes/Validation/InArrayKeys.php create mode 100644 src/data/src/Attributes/Validation/IntegerType.php create mode 100644 src/data/src/Attributes/Validation/Json.php create mode 100644 src/data/src/Attributes/Validation/LessThan.php create mode 100644 src/data/src/Attributes/Validation/LessThanOrEqualTo.php create mode 100644 src/data/src/Attributes/Validation/ListType.php create mode 100644 src/data/src/Attributes/Validation/Lowercase.php create mode 100644 src/data/src/Attributes/Validation/MacAddress.php create mode 100644 src/data/src/Attributes/Validation/Max.php create mode 100644 src/data/src/Attributes/Validation/MaxDigits.php create mode 100644 src/data/src/Attributes/Validation/MimeTypes.php create mode 100644 src/data/src/Attributes/Validation/Mimes.php create mode 100644 src/data/src/Attributes/Validation/Min.php create mode 100644 src/data/src/Attributes/Validation/MinDigits.php create mode 100644 src/data/src/Attributes/Validation/Missing.php create mode 100644 src/data/src/Attributes/Validation/MissingIf.php create mode 100644 src/data/src/Attributes/Validation/MissingUnless.php create mode 100644 src/data/src/Attributes/Validation/MissingWith.php create mode 100644 src/data/src/Attributes/Validation/MissingWithAll.php create mode 100644 src/data/src/Attributes/Validation/MultipleOf.php create mode 100644 src/data/src/Attributes/Validation/NotIn.php create mode 100644 src/data/src/Attributes/Validation/NotRegex.php create mode 100644 src/data/src/Attributes/Validation/Nullable.php create mode 100644 src/data/src/Attributes/Validation/Numeric.php create mode 100644 src/data/src/Attributes/Validation/ObjectValidationAttribute.php create mode 100644 src/data/src/Attributes/Validation/Password.php create mode 100644 src/data/src/Attributes/Validation/Present.php create mode 100644 src/data/src/Attributes/Validation/PresentIf.php create mode 100644 src/data/src/Attributes/Validation/PresentUnless.php create mode 100644 src/data/src/Attributes/Validation/PresentWith.php create mode 100644 src/data/src/Attributes/Validation/PresentWithAll.php create mode 100644 src/data/src/Attributes/Validation/Prohibited.php create mode 100644 src/data/src/Attributes/Validation/ProhibitedIf.php create mode 100644 src/data/src/Attributes/Validation/ProhibitedIfAccepted.php create mode 100644 src/data/src/Attributes/Validation/ProhibitedIfDeclined.php create mode 100644 src/data/src/Attributes/Validation/ProhibitedUnless.php create mode 100644 src/data/src/Attributes/Validation/Prohibits.php create mode 100644 src/data/src/Attributes/Validation/Regex.php create mode 100644 src/data/src/Attributes/Validation/Required.php create mode 100644 src/data/src/Attributes/Validation/RequiredArrayKeys.php create mode 100644 src/data/src/Attributes/Validation/RequiredIf.php create mode 100644 src/data/src/Attributes/Validation/RequiredIfAccepted.php create mode 100644 src/data/src/Attributes/Validation/RequiredIfDeclined.php create mode 100644 src/data/src/Attributes/Validation/RequiredUnless.php create mode 100644 src/data/src/Attributes/Validation/RequiredWith.php create mode 100644 src/data/src/Attributes/Validation/RequiredWithAll.php create mode 100644 src/data/src/Attributes/Validation/RequiredWithout.php create mode 100644 src/data/src/Attributes/Validation/RequiredWithoutAll.php create mode 100644 src/data/src/Attributes/Validation/Rule.php create mode 100644 src/data/src/Attributes/Validation/Same.php create mode 100644 src/data/src/Attributes/Validation/Size.php create mode 100644 src/data/src/Attributes/Validation/Sometimes.php create mode 100644 src/data/src/Attributes/Validation/StartsWith.php create mode 100644 src/data/src/Attributes/Validation/StringType.php create mode 100644 src/data/src/Attributes/Validation/StringValidationAttribute.php create mode 100644 src/data/src/Attributes/Validation/Timezone.php create mode 100644 src/data/src/Attributes/Validation/Ulid.php create mode 100644 src/data/src/Attributes/Validation/Unique.php create mode 100644 src/data/src/Attributes/Validation/Uppercase.php create mode 100644 src/data/src/Attributes/Validation/Url.php create mode 100644 src/data/src/Attributes/Validation/Uuid.php create mode 100644 src/data/src/Attributes/Validation/ValidationAttribute.php create mode 100644 src/data/src/Support/Validation/CompiledValidation.php create mode 100644 src/data/src/Support/Validation/Constraints/DatabaseConstraint.php create mode 100644 src/data/src/Support/Validation/Constraints/WhereConstraint.php create mode 100644 src/data/src/Support/Validation/Constraints/WhereInConstraint.php create mode 100644 src/data/src/Support/Validation/Constraints/WhereNotConstraint.php create mode 100644 src/data/src/Support/Validation/Constraints/WhereNotInConstraint.php create mode 100644 src/data/src/Support/Validation/Constraints/WhereNotNullConstraint.php create mode 100644 src/data/src/Support/Validation/Constraints/WhereNullConstraint.php create mode 100644 src/data/src/Support/Validation/DataValidationCompiler.php create mode 100644 src/data/src/Support/Validation/DataValidator.php create mode 100644 src/data/src/Support/Validation/References/ExternalReference.php create mode 100644 src/data/src/Support/Validation/References/FieldReference.php create mode 100644 src/data/src/Support/Validation/RequiringRule.php create mode 100644 src/data/src/Support/Validation/RuleDenormalizer.php create mode 100644 src/data/src/Support/Validation/TranslatedValidationPath.php create mode 100644 src/data/src/Support/Validation/ValidationAccumulator.php create mode 100644 src/data/src/Support/Validation/ValidationContext.php create mode 100644 src/data/src/Support/Validation/ValidationPath.php create mode 100644 src/data/src/Support/Validation/ValidationRule.php create mode 100644 tests/Data/Attributes/Validation/ExistsTest.php create mode 100644 tests/Data/Attributes/Validation/InTest.php create mode 100644 tests/Data/Attributes/Validation/NotInTest.php create mode 100644 tests/Data/Attributes/Validation/PasswordTest.php create mode 100644 tests/Data/Attributes/Validation/UniqueTest.php create mode 100644 tests/Data/Attributes/Validation/ValidationAttributeTest.php create mode 100644 tests/Data/Support/Validation/CompiledValidationTest.php create mode 100644 tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php create mode 100644 tests/Data/Support/Validation/DataValidatorTest.php create mode 100644 tests/Data/Support/Validation/ValidationAccumulatorTest.php create mode 100644 tests/Data/Support/Validation/ValidationPathTest.php diff --git a/src/data/src/Attributes/Concerns/AppliesDatabaseConstraints.php b/src/data/src/Attributes/Concerns/AppliesDatabaseConstraints.php new file mode 100644 index 000000000..c3152445e --- /dev/null +++ b/src/data/src/Attributes/Concerns/AppliesDatabaseConstraints.php @@ -0,0 +1,32 @@ + $constraints + */ + protected function applyDatabaseConstraints(Exists|Unique $rule, Closure|DatabaseConstraint|array $constraints): void + { + $constraintsList = is_array($constraints) ? $constraints : [$constraints]; + + foreach ($constraintsList as $constraint) { + match (true) { + $constraint instanceof Closure => $rule->where($constraint), + $constraint instanceof DatabaseConstraint => $constraint->apply($rule), + default => throw new InvalidArgumentException('Each where item must be a DatabaseConstraint or Closure'), + }; + } + } +} diff --git a/src/data/src/Attributes/Validation/Accepted.php b/src/data/src/Attributes/Validation/Accepted.php new file mode 100644 index 000000000..8091471f0 --- /dev/null +++ b/src/data/src/Attributes/Validation/Accepted.php @@ -0,0 +1,27 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'accepted_if'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + $this->value, + ]; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return parent::create( + $parameters[0], + self::parseBooleanValue($parameters[1]), + ); + } +} diff --git a/src/data/src/Attributes/Validation/ActiveUrl.php b/src/data/src/Attributes/Validation/ActiveUrl.php new file mode 100644 index 000000000..6c92ef17e --- /dev/null +++ b/src/data/src/Attributes/Validation/ActiveUrl.php @@ -0,0 +1,27 @@ +date]; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return parent::create( + self::parseDateValue($parameters[0]), + ); + } +} diff --git a/src/data/src/Attributes/Validation/AfterOrEqual.php b/src/data/src/Attributes/Validation/AfterOrEqual.php new file mode 100644 index 000000000..9200c55c6 --- /dev/null +++ b/src/data/src/Attributes/Validation/AfterOrEqual.php @@ -0,0 +1,47 @@ +date]; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return parent::create( + self::parseDateValue($parameters[0]), + ); + } +} diff --git a/src/data/src/Attributes/Validation/Alpha.php b/src/data/src/Attributes/Validation/Alpha.php new file mode 100644 index 000000000..b75280981 --- /dev/null +++ b/src/data/src/Attributes/Validation/Alpha.php @@ -0,0 +1,27 @@ +rules); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'any_of'; + } + + /** + * Reject string-based any-of rule construction. + */ + public static function create(string ...$parameters): static + { + throw CannotBuildValidationRule::create('Cannot create an any-of rule from string parameters.'); + } +} diff --git a/src/data/src/Attributes/Validation/ArrayType.php b/src/data/src/Attributes/Validation/ArrayType.php new file mode 100644 index 000000000..754dcfa03 --- /dev/null +++ b/src/data/src/Attributes/Validation/ArrayType.php @@ -0,0 +1,40 @@ + */ + protected array $keys; + + /** + * Create an array rule attribute. + */ + public function __construct(array|string|ExternalReference ...$keys) + { + $this->keys = Arr::flatten($keys); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'array'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return $this->keys; + } +} diff --git a/src/data/src/Attributes/Validation/Ascii.php b/src/data/src/Attributes/Validation/Ascii.php new file mode 100644 index 000000000..faf83b9b8 --- /dev/null +++ b/src/data/src/Attributes/Validation/Ascii.php @@ -0,0 +1,27 @@ +date]; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return parent::create( + self::parseDateValue($parameters[0]), + ); + } +} diff --git a/src/data/src/Attributes/Validation/BeforeOrEqual.php b/src/data/src/Attributes/Validation/BeforeOrEqual.php new file mode 100644 index 000000000..fe2858c6c --- /dev/null +++ b/src/data/src/Attributes/Validation/BeforeOrEqual.php @@ -0,0 +1,47 @@ +date]; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return parent::create( + self::parseDateValue($parameters[0]), + ); + } +} diff --git a/src/data/src/Attributes/Validation/Between.php b/src/data/src/Attributes/Validation/Between.php new file mode 100644 index 000000000..30720158f --- /dev/null +++ b/src/data/src/Attributes/Validation/Between.php @@ -0,0 +1,37 @@ +min, $this->max]; + } +} diff --git a/src/data/src/Attributes/Validation/BooleanType.php b/src/data/src/Attributes/Validation/BooleanType.php new file mode 100644 index 000000000..9151a00c5 --- /dev/null +++ b/src/data/src/Attributes/Validation/BooleanType.php @@ -0,0 +1,27 @@ +arguments = $arguments; + } + + /** + * Get the Validator rule object. + */ + public function getRule(ValidationPath $path): object|string + { + return new BaseCan($this->ability, $this->arguments); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'can'; + } + + /** + * Reject string-based can rule construction. + */ + public static function create(string ...$parameters): static + { + throw CannotBuildValidationRule::create('Cannot create a can rule from string parameters.'); + } +} diff --git a/src/data/src/Attributes/Validation/Confirmed.php b/src/data/src/Attributes/Validation/Confirmed.php new file mode 100644 index 000000000..81c5cad39 --- /dev/null +++ b/src/data/src/Attributes/Validation/Confirmed.php @@ -0,0 +1,27 @@ +values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'contains'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/CurrentPassword.php b/src/data/src/Attributes/Validation/CurrentPassword.php new file mode 100644 index 000000000..540684e5a --- /dev/null +++ b/src/data/src/Attributes/Validation/CurrentPassword.php @@ -0,0 +1,36 @@ +guard]; + } +} diff --git a/src/data/src/Attributes/Validation/CustomValidationAttribute.php b/src/data/src/Attributes/Validation/CustomValidationAttribute.php new file mode 100644 index 000000000..77463c271 --- /dev/null +++ b/src/data/src/Attributes/Validation/CustomValidationAttribute.php @@ -0,0 +1,18 @@ +|object|string + */ + abstract public function getRules(ValidationPath $path): array|object|string; +} diff --git a/src/data/src/Attributes/Validation/Date.php b/src/data/src/Attributes/Validation/Date.php new file mode 100644 index 000000000..31c9b762d --- /dev/null +++ b/src/data/src/Attributes/Validation/Date.php @@ -0,0 +1,27 @@ +date]; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return parent::create( + self::parseDateValue($parameters[0]), + ); + } +} diff --git a/src/data/src/Attributes/Validation/DateFormat.php b/src/data/src/Attributes/Validation/DateFormat.php new file mode 100644 index 000000000..5fd2888ae --- /dev/null +++ b/src/data/src/Attributes/Validation/DateFormat.php @@ -0,0 +1,39 @@ +format = Arr::flatten($format); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'date_format'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->format]; + } +} diff --git a/src/data/src/Attributes/Validation/Decimal.php b/src/data/src/Attributes/Validation/Decimal.php new file mode 100644 index 000000000..54ae0c269 --- /dev/null +++ b/src/data/src/Attributes/Validation/Decimal.php @@ -0,0 +1,37 @@ +min, $this->max]; + } +} diff --git a/src/data/src/Attributes/Validation/Declined.php b/src/data/src/Attributes/Validation/Declined.php new file mode 100644 index 000000000..fbabe347d --- /dev/null +++ b/src/data/src/Attributes/Validation/Declined.php @@ -0,0 +1,27 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'declined_if'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + $this->value, + ]; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return parent::create( + $parameters[0], + self::parseBooleanValue($parameters[1]), + ); + } +} diff --git a/src/data/src/Attributes/Validation/Different.php b/src/data/src/Attributes/Validation/Different.php new file mode 100644 index 000000000..1d2189086 --- /dev/null +++ b/src/data/src/Attributes/Validation/Different.php @@ -0,0 +1,38 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'different'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/Digits.php b/src/data/src/Attributes/Validation/Digits.php new file mode 100644 index 000000000..6b5a43991 --- /dev/null +++ b/src/data/src/Attributes/Validation/Digits.php @@ -0,0 +1,35 @@ +value]; + } +} diff --git a/src/data/src/Attributes/Validation/DigitsBetween.php b/src/data/src/Attributes/Validation/DigitsBetween.php new file mode 100644 index 000000000..7d3ca1d77 --- /dev/null +++ b/src/data/src/Attributes/Validation/DigitsBetween.php @@ -0,0 +1,37 @@ +min, $this->max]; + } +} diff --git a/src/data/src/Attributes/Validation/Dimensions.php b/src/data/src/Attributes/Validation/Dimensions.php new file mode 100644 index 000000000..4eced8ea2 --- /dev/null +++ b/src/data/src/Attributes/Validation/Dimensions.php @@ -0,0 +1,113 @@ +rule !== null) { + return $this->rule; + } + + $minWidth = $this->normalizePossibleExternalReferenceParameter($this->minWidth); + $minHeight = $this->normalizePossibleExternalReferenceParameter($this->minHeight); + $maxWidth = $this->normalizePossibleExternalReferenceParameter($this->maxWidth); + $maxHeight = $this->normalizePossibleExternalReferenceParameter($this->maxHeight); + $ratio = $this->normalizePossibleExternalReferenceParameter($this->ratio); + $width = $this->normalizePossibleExternalReferenceParameter($this->width); + $height = $this->normalizePossibleExternalReferenceParameter($this->height); + + $rule = new BaseDimensions(); + + if ($minWidth !== null) { + $rule->minWidth($minWidth); + } + + if ($minHeight !== null) { + $rule->minHeight($minHeight); + } + + if ($maxWidth !== null) { + $rule->maxWidth($maxWidth); + } + + if ($maxHeight !== null) { + $rule->maxHeight($maxHeight); + } + + if ($width !== null) { + $rule->width($width); + } + + if ($height !== null) { + $rule->height($height); + } + + if ($ratio !== null) { + $rule->ratio($ratio); + } + + return $rule; + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'dimensions'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + $parameters = collect($parameters)->mapWithKeys(function (string $parameter) { + return [Str::camel(Str::before($parameter, '=')) => Str::after($parameter, '=')]; + })->all(); + + return new static(...$parameters); + } +} diff --git a/src/data/src/Attributes/Validation/Distinct.php b/src/data/src/Attributes/Validation/Distinct.php new file mode 100644 index 000000000..275ebcca9 --- /dev/null +++ b/src/data/src/Attributes/Validation/Distinct.php @@ -0,0 +1,50 @@ +normalizePossibleExternalReferenceParameter($this->mode); + + if ($mode === null) { + return []; + } + + if (! is_string($mode) || ! in_array($mode, [self::IgnoreCase, self::Strict], true)) { + throw CannotBuildValidationRule::create('Distinct mode should be ignore_case or strict.'); + } + + return [$mode]; + } +} diff --git a/src/data/src/Attributes/Validation/DoesntContain.php b/src/data/src/Attributes/Validation/DoesntContain.php new file mode 100644 index 000000000..09303e665 --- /dev/null +++ b/src/data/src/Attributes/Validation/DoesntContain.php @@ -0,0 +1,40 @@ +values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'doesnt_contain'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/DoesntEndWith.php b/src/data/src/Attributes/Validation/DoesntEndWith.php new file mode 100644 index 000000000..cda245d03 --- /dev/null +++ b/src/data/src/Attributes/Validation/DoesntEndWith.php @@ -0,0 +1,39 @@ +values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'doesnt_end_with'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/DoesntStartWith.php b/src/data/src/Attributes/Validation/DoesntStartWith.php new file mode 100644 index 000000000..812d79527 --- /dev/null +++ b/src/data/src/Attributes/Validation/DoesntStartWith.php @@ -0,0 +1,39 @@ +values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'doesnt_start_with'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/Email.php b/src/data/src/Attributes/Validation/Email.php new file mode 100644 index 000000000..667b9736f --- /dev/null +++ b/src/data/src/Attributes/Validation/Email.php @@ -0,0 +1,75 @@ +modes = Arr::flatten($modes); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'email'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + $modes = $this->modes === [] ? [self::RfcValidation] : $this->modes; + $parameters = []; + + foreach ($modes as $mode) { + $mode = $this->normalizePossibleExternalReferenceParameter($mode); + + if (! is_string($mode) || (! in_array($mode, [ + self::RfcValidation, + self::NoRfcWarningsValidation, + self::DnsCheckValidation, + self::SpoofCheckValidation, + self::FilterEmailValidation, + self::FilterUnicodeEmailValidation, + ], true) && ! class_exists($mode))) { + throw CannotBuildValidationRule::create(sprintf( + 'Email validation mode [%s] is not supported.', + is_string($mode) ? $mode : get_debug_type($mode), + )); + } + + $parameters[] = $mode; + } + + return $parameters; + } +} diff --git a/src/data/src/Attributes/Validation/Encoding.php b/src/data/src/Attributes/Validation/Encoding.php new file mode 100644 index 000000000..649c3f4f1 --- /dev/null +++ b/src/data/src/Attributes/Validation/Encoding.php @@ -0,0 +1,35 @@ +encoding]; + } +} diff --git a/src/data/src/Attributes/Validation/EndsWith.php b/src/data/src/Attributes/Validation/EndsWith.php new file mode 100644 index 000000000..e0d30e1f2 --- /dev/null +++ b/src/data/src/Attributes/Validation/EndsWith.php @@ -0,0 +1,39 @@ +values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'ends_with'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/Enum.php b/src/data/src/Attributes/Validation/Enum.php new file mode 100644 index 000000000..df59668cd --- /dev/null +++ b/src/data/src/Attributes/Validation/Enum.php @@ -0,0 +1,73 @@ +rule !== null) { + return $this->rule; + } + + $enum = $this->normalizePossibleExternalReferenceParameter($this->enum); + + $rule = match (true) { + $enum instanceof EnumRule => $enum, + is_string($enum) => new EnumRule($enum), + default => throw CannotBuildValidationRule::create(sprintf( + 'Enum validation rule requires an enum class or Enum rule; [%s] was resolved.', + get_debug_type($enum), + )), + }; + + if ($this->only !== null) { + $rule->only($this->only); + } + + if ($this->except !== null) { + $rule->except($this->except); + } + + return $rule; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static(new EnumRule($parameters[0])); + } +} diff --git a/src/data/src/Attributes/Validation/Exclude.php b/src/data/src/Attributes/Validation/Exclude.php new file mode 100644 index 000000000..38381c7f8 --- /dev/null +++ b/src/data/src/Attributes/Validation/Exclude.php @@ -0,0 +1,44 @@ +rule ?? self::keyword(); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'exclude'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static(); + } +} diff --git a/src/data/src/Attributes/Validation/ExcludeIf.php b/src/data/src/Attributes/Validation/ExcludeIf.php new file mode 100644 index 000000000..0a539acf2 --- /dev/null +++ b/src/data/src/Attributes/Validation/ExcludeIf.php @@ -0,0 +1,56 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'exclude_if'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return parent::create( + $parameters[0], + self::parseBooleanValue($parameters[1]), + ); + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + $this->value, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/ExcludeUnless.php b/src/data/src/Attributes/Validation/ExcludeUnless.php new file mode 100644 index 000000000..c8316a4f7 --- /dev/null +++ b/src/data/src/Attributes/Validation/ExcludeUnless.php @@ -0,0 +1,45 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'exclude_unless'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + $this->value, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/ExcludeWith.php b/src/data/src/Attributes/Validation/ExcludeWith.php new file mode 100644 index 000000000..19d292c24 --- /dev/null +++ b/src/data/src/Attributes/Validation/ExcludeWith.php @@ -0,0 +1,39 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'exclude_with'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/ExcludeWithout.php b/src/data/src/Attributes/Validation/ExcludeWithout.php new file mode 100644 index 000000000..5e7c38f71 --- /dev/null +++ b/src/data/src/Attributes/Validation/ExcludeWithout.php @@ -0,0 +1,41 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'exclude_without'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/Exists.php b/src/data/src/Attributes/Validation/Exists.php new file mode 100644 index 000000000..70fc32c0f --- /dev/null +++ b/src/data/src/Attributes/Validation/Exists.php @@ -0,0 +1,106 @@ +|null $where + */ + public function __construct( + protected string|ExternalReference|null $table = null, + protected string|ExternalReference|null $column = 'NULL', + protected string|ExternalReference|null $connection = null, + protected bool|ExternalReference $withoutTrashed = false, + protected string|ExternalReference $deletedAtColumn = 'deleted_at', + protected Closure|DatabaseConstraint|array|null $where = null, + protected ?BaseExists $rule = null, + ) { + if ($rule === null && $table === null) { + throw CannotBuildValidationRule::create('Could not make exists rule since a table or rule is required.'); + } + } + + /** + * Get the Validator rule object. + */ + public function getRule(ValidationPath $path): object|string + { + if ($this->rule !== null) { + return $this->rule; + } + + $table = $this->normalizePossibleExternalReferenceParameter($this->table); + $column = $this->normalizePossibleExternalReferenceParameter($this->column); + $connection = $this->normalizePossibleExternalReferenceParameter($this->connection); + $withoutTrashed = $this->normalizePossibleExternalReferenceParameter($this->withoutTrashed); + $deletedAtColumn = $this->normalizePossibleExternalReferenceParameter($this->deletedAtColumn); + + if (! is_string($table)) { + throw CannotBuildValidationRule::create('Exists table must resolve to a string.'); + } + + if ($column !== null && ! is_string($column)) { + throw CannotBuildValidationRule::create('Exists column must resolve to a string or null.'); + } + + if ($connection !== null && ! is_string($connection)) { + throw CannotBuildValidationRule::create('Exists connection must resolve to a string or null.'); + } + + if (! is_bool($withoutTrashed)) { + throw CannotBuildValidationRule::create('Exists withoutTrashed must resolve to a boolean.'); + } + + if (! is_string($deletedAtColumn)) { + throw CannotBuildValidationRule::create('Exists deletedAtColumn must resolve to a string.'); + } + + $rule = new BaseExists( + $connection !== null && $connection !== '' ? "{$connection}.{$table}" : $table, + $column ?? 'NULL', + ); + + if ($withoutTrashed) { + $rule->withoutTrashed($deletedAtColumn); + } + + if ($this->where !== null) { + $this->applyDatabaseConstraints($rule, $this->where); + } + + return $rule; + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'exists'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static(rule: new BaseExists($parameters[0], $parameters[1] ?? 'NULL')); + } +} diff --git a/src/data/src/Attributes/Validation/Extensions.php b/src/data/src/Attributes/Validation/Extensions.php new file mode 100644 index 000000000..da4fa9a6e --- /dev/null +++ b/src/data/src/Attributes/Validation/Extensions.php @@ -0,0 +1,39 @@ +extensions = Arr::flatten($extensions); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'extensions'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->extensions]; + } +} diff --git a/src/data/src/Attributes/Validation/File.php b/src/data/src/Attributes/Validation/File.php new file mode 100644 index 000000000..995255222 --- /dev/null +++ b/src/data/src/Attributes/Validation/File.php @@ -0,0 +1,27 @@ +field = is_numeric($field) ? $field : $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'gt'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/GreaterThanOrEqualTo.php b/src/data/src/Attributes/Validation/GreaterThanOrEqualTo.php new file mode 100644 index 000000000..cf5f4d15a --- /dev/null +++ b/src/data/src/Attributes/Validation/GreaterThanOrEqualTo.php @@ -0,0 +1,39 @@ +field = is_numeric($field) ? $field : $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'gte'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/HexColor.php b/src/data/src/Attributes/Validation/HexColor.php new file mode 100644 index 000000000..a347515ba --- /dev/null +++ b/src/data/src/Attributes/Validation/HexColor.php @@ -0,0 +1,27 @@ +values = $values; + } + + /** + * Get the Validator rule object. + */ + public function getRule(ValidationPath $path): object|string + { + if ($this->rule !== null) { + return $this->rule; + } + + $values = array_map( + fn (mixed $value) => $this->normalizePossibleExternalReferenceParameter($value), + $this->values, + ); + + if (count($values) === 1 && $values[0] instanceof BaseIn) { + return $this->rule = $values[0]; + } + + $values = array_map( + fn (mixed $value) => $value instanceof Arrayable ? $value->toArray() : $value, + $values, + ); + $values = Arr::flatten($values); + + $values = array_map(function (mixed $value) { + $value = $this->normalizePossibleExternalReferenceParameter($value); + + return $value instanceof Arrayable ? $value->toArray() : $value; + }, $values); + + return new BaseIn(Arr::flatten($values)); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'in'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static(new BaseIn($parameters)); + } +} diff --git a/src/data/src/Attributes/Validation/InArray.php b/src/data/src/Attributes/Validation/InArray.php new file mode 100644 index 000000000..0683956e8 --- /dev/null +++ b/src/data/src/Attributes/Validation/InArray.php @@ -0,0 +1,39 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'in_array'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/InArrayKeys.php b/src/data/src/Attributes/Validation/InArrayKeys.php new file mode 100644 index 000000000..04a67e6bc --- /dev/null +++ b/src/data/src/Attributes/Validation/InArrayKeys.php @@ -0,0 +1,40 @@ +keys = Arr::flatten($keys); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'in_array_keys'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->keys]; + } +} diff --git a/src/data/src/Attributes/Validation/IntegerType.php b/src/data/src/Attributes/Validation/IntegerType.php new file mode 100644 index 000000000..62d848e4e --- /dev/null +++ b/src/data/src/Attributes/Validation/IntegerType.php @@ -0,0 +1,27 @@ +field = is_numeric($field) ? $field : $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'lt'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/LessThanOrEqualTo.php b/src/data/src/Attributes/Validation/LessThanOrEqualTo.php new file mode 100644 index 000000000..b2ed93621 --- /dev/null +++ b/src/data/src/Attributes/Validation/LessThanOrEqualTo.php @@ -0,0 +1,39 @@ +field = is_numeric($field) ? $field : $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'lte'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/ListType.php b/src/data/src/Attributes/Validation/ListType.php new file mode 100644 index 000000000..48077674a --- /dev/null +++ b/src/data/src/Attributes/Validation/ListType.php @@ -0,0 +1,27 @@ +value]; + } +} diff --git a/src/data/src/Attributes/Validation/MaxDigits.php b/src/data/src/Attributes/Validation/MaxDigits.php new file mode 100644 index 000000000..f8067a965 --- /dev/null +++ b/src/data/src/Attributes/Validation/MaxDigits.php @@ -0,0 +1,35 @@ +value]; + } +} diff --git a/src/data/src/Attributes/Validation/MimeTypes.php b/src/data/src/Attributes/Validation/MimeTypes.php new file mode 100644 index 000000000..ef32ad323 --- /dev/null +++ b/src/data/src/Attributes/Validation/MimeTypes.php @@ -0,0 +1,39 @@ +mimeTypes = Arr::flatten($mimeTypes); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'mimetypes'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->mimeTypes]; + } +} diff --git a/src/data/src/Attributes/Validation/Mimes.php b/src/data/src/Attributes/Validation/Mimes.php new file mode 100644 index 000000000..45db7b237 --- /dev/null +++ b/src/data/src/Attributes/Validation/Mimes.php @@ -0,0 +1,39 @@ +mimes = Arr::flatten($mimes); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'mimes'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->mimes]; + } +} diff --git a/src/data/src/Attributes/Validation/Min.php b/src/data/src/Attributes/Validation/Min.php new file mode 100644 index 000000000..c1e2dceb6 --- /dev/null +++ b/src/data/src/Attributes/Validation/Min.php @@ -0,0 +1,35 @@ +value]; + } +} diff --git a/src/data/src/Attributes/Validation/MinDigits.php b/src/data/src/Attributes/Validation/MinDigits.php new file mode 100644 index 000000000..c51e0c468 --- /dev/null +++ b/src/data/src/Attributes/Validation/MinDigits.php @@ -0,0 +1,35 @@ +value]; + } +} diff --git a/src/data/src/Attributes/Validation/Missing.php b/src/data/src/Attributes/Validation/Missing.php new file mode 100644 index 000000000..4f610ce31 --- /dev/null +++ b/src/data/src/Attributes/Validation/Missing.php @@ -0,0 +1,27 @@ +field = $this->parseFieldReference($field); + $this->values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'missing_if'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field, $this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/MissingUnless.php b/src/data/src/Attributes/Validation/MissingUnless.php new file mode 100644 index 000000000..826f969d1 --- /dev/null +++ b/src/data/src/Attributes/Validation/MissingUnless.php @@ -0,0 +1,46 @@ +field = $this->parseFieldReference($field); + $this->values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'missing_unless'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field, $this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/MissingWith.php b/src/data/src/Attributes/Validation/MissingWith.php new file mode 100644 index 000000000..d4764e9fc --- /dev/null +++ b/src/data/src/Attributes/Validation/MissingWith.php @@ -0,0 +1,41 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'missing_with'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->fields]; + } +} diff --git a/src/data/src/Attributes/Validation/MissingWithAll.php b/src/data/src/Attributes/Validation/MissingWithAll.php new file mode 100644 index 000000000..5635cb5f4 --- /dev/null +++ b/src/data/src/Attributes/Validation/MissingWithAll.php @@ -0,0 +1,41 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'missing_with_all'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->fields]; + } +} diff --git a/src/data/src/Attributes/Validation/MultipleOf.php b/src/data/src/Attributes/Validation/MultipleOf.php new file mode 100644 index 000000000..04f6b2d47 --- /dev/null +++ b/src/data/src/Attributes/Validation/MultipleOf.php @@ -0,0 +1,35 @@ +value]; + } +} diff --git a/src/data/src/Attributes/Validation/NotIn.php b/src/data/src/Attributes/Validation/NotIn.php new file mode 100644 index 000000000..ccc4236be --- /dev/null +++ b/src/data/src/Attributes/Validation/NotIn.php @@ -0,0 +1,78 @@ +values = $values; + } + + /** + * Get the Validator rule object. + */ + public function getRule(ValidationPath $path): object|string + { + if ($this->rule !== null) { + return $this->rule; + } + + $values = array_map( + fn (mixed $value) => $this->normalizePossibleExternalReferenceParameter($value), + $this->values, + ); + + if (count($values) === 1 && $values[0] instanceof BaseNotIn) { + return $this->rule = $values[0]; + } + + $values = array_map( + fn (mixed $value) => $value instanceof Arrayable ? $value->toArray() : $value, + $values, + ); + $values = Arr::flatten($values); + + $values = array_map(function (mixed $value) { + $value = $this->normalizePossibleExternalReferenceParameter($value); + + return $value instanceof Arrayable ? $value->toArray() : $value; + }, $values); + + return new BaseNotIn(Arr::flatten($values)); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'not_in'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static(new BaseNotIn($parameters)); + } +} diff --git a/src/data/src/Attributes/Validation/NotRegex.php b/src/data/src/Attributes/Validation/NotRegex.php new file mode 100644 index 000000000..a59701e6c --- /dev/null +++ b/src/data/src/Attributes/Validation/NotRegex.php @@ -0,0 +1,35 @@ +pattern]; + } +} diff --git a/src/data/src/Attributes/Validation/Nullable.php b/src/data/src/Attributes/Validation/Nullable.php new file mode 100644 index 000000000..134ef3f93 --- /dev/null +++ b/src/data/src/Attributes/Validation/Nullable.php @@ -0,0 +1,27 @@ +rule !== null) { + return $this->rule; + } + + $min = $this->resolveInteger($this->min, 'min'); + $letters = $this->resolveBoolean($this->letters, 'letters'); + $mixedCase = $this->resolveBoolean($this->mixedCase, 'mixedCase'); + $numbers = $this->resolveBoolean($this->numbers, 'numbers'); + $symbols = $this->resolveBoolean($this->symbols, 'symbols'); + $uncompromised = $this->resolveBoolean($this->uncompromised, 'uncompromised'); + $uncompromisedThreshold = $this->resolveInteger($this->uncompromisedThreshold, 'uncompromisedThreshold'); + $default = $this->resolveBoolean($this->default, 'default'); + + if ($default) { + return BasePassword::default(); + } + + $rule = BasePassword::min($min); + + if ($letters) { + $rule->letters(); + } + + if ($mixedCase) { + $rule->mixedCase(); + } + + if ($numbers) { + $rule->numbers(); + } + + if ($symbols) { + $rule->symbols(); + } + + if ($uncompromised) { + $rule->uncompromised($uncompromisedThreshold); + } + + return $rule; + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'password'; + } + + /** + * Reject string-based password rule construction. + */ + public static function create(string ...$parameters): static + { + throw CannotBuildValidationRule::create('Cannot create a password rule from string parameters.'); + } + + /** + * Resolve an integer parameter. + */ + protected function resolveInteger(int|ExternalReference $value, string $parameter): int + { + $value = $this->normalizePossibleExternalReferenceParameter($value); + + if (! is_int($value)) { + throw CannotBuildValidationRule::create("Password {$parameter} must resolve to an integer."); + } + + return $value; + } + + /** + * Resolve a boolean parameter. + */ + protected function resolveBoolean(bool|ExternalReference $value, string $parameter): bool + { + $value = $this->normalizePossibleExternalReferenceParameter($value); + + if (! is_bool($value)) { + throw CannotBuildValidationRule::create("Password {$parameter} must resolve to a boolean."); + } + + return $value; + } +} diff --git a/src/data/src/Attributes/Validation/Present.php b/src/data/src/Attributes/Validation/Present.php new file mode 100644 index 000000000..cd90fa6f6 --- /dev/null +++ b/src/data/src/Attributes/Validation/Present.php @@ -0,0 +1,27 @@ +field = $this->parseFieldReference($field); + $this->values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'present_if'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field, $this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/PresentUnless.php b/src/data/src/Attributes/Validation/PresentUnless.php new file mode 100644 index 000000000..540c441ed --- /dev/null +++ b/src/data/src/Attributes/Validation/PresentUnless.php @@ -0,0 +1,46 @@ +field = $this->parseFieldReference($field); + $this->values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'present_unless'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field, $this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/PresentWith.php b/src/data/src/Attributes/Validation/PresentWith.php new file mode 100644 index 000000000..d854d5845 --- /dev/null +++ b/src/data/src/Attributes/Validation/PresentWith.php @@ -0,0 +1,41 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'present_with'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->fields]; + } +} diff --git a/src/data/src/Attributes/Validation/PresentWithAll.php b/src/data/src/Attributes/Validation/PresentWithAll.php new file mode 100644 index 000000000..5152056d9 --- /dev/null +++ b/src/data/src/Attributes/Validation/PresentWithAll.php @@ -0,0 +1,41 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'present_with_all'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->fields]; + } +} diff --git a/src/data/src/Attributes/Validation/Prohibited.php b/src/data/src/Attributes/Validation/Prohibited.php new file mode 100644 index 000000000..d2b279e9c --- /dev/null +++ b/src/data/src/Attributes/Validation/Prohibited.php @@ -0,0 +1,41 @@ +rule ?? self::keyword(); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'prohibited'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static(); + } +} diff --git a/src/data/src/Attributes/Validation/ProhibitedIf.php b/src/data/src/Attributes/Validation/ProhibitedIf.php new file mode 100644 index 000000000..8b41a24e9 --- /dev/null +++ b/src/data/src/Attributes/Validation/ProhibitedIf.php @@ -0,0 +1,49 @@ +field = $this->parseFieldReference($field); + $this->values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'prohibited_if'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + $this->values, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/ProhibitedIfAccepted.php b/src/data/src/Attributes/Validation/ProhibitedIfAccepted.php new file mode 100644 index 000000000..ca043f4f1 --- /dev/null +++ b/src/data/src/Attributes/Validation/ProhibitedIfAccepted.php @@ -0,0 +1,38 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'prohibited_if_accepted'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/ProhibitedIfDeclined.php b/src/data/src/Attributes/Validation/ProhibitedIfDeclined.php new file mode 100644 index 000000000..08858be13 --- /dev/null +++ b/src/data/src/Attributes/Validation/ProhibitedIfDeclined.php @@ -0,0 +1,38 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'prohibited_if_declined'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/ProhibitedUnless.php b/src/data/src/Attributes/Validation/ProhibitedUnless.php new file mode 100644 index 000000000..3df494c01 --- /dev/null +++ b/src/data/src/Attributes/Validation/ProhibitedUnless.php @@ -0,0 +1,49 @@ +field = $this->parseFieldReference($field); + $this->values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'prohibited_unless'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + $this->values, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/Prohibits.php b/src/data/src/Attributes/Validation/Prohibits.php new file mode 100644 index 000000000..d8dfe9458 --- /dev/null +++ b/src/data/src/Attributes/Validation/Prohibits.php @@ -0,0 +1,43 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'prohibits'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->fields, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/Regex.php b/src/data/src/Attributes/Validation/Regex.php new file mode 100644 index 000000000..bb79821d5 --- /dev/null +++ b/src/data/src/Attributes/Validation/Regex.php @@ -0,0 +1,37 @@ +pattern, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/Required.php b/src/data/src/Attributes/Validation/Required.php new file mode 100644 index 000000000..bcb0f2d3e --- /dev/null +++ b/src/data/src/Attributes/Validation/Required.php @@ -0,0 +1,45 @@ +rule ?? self::keyword(); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static(); + } +} diff --git a/src/data/src/Attributes/Validation/RequiredArrayKeys.php b/src/data/src/Attributes/Validation/RequiredArrayKeys.php new file mode 100644 index 000000000..1d996c60a --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredArrayKeys.php @@ -0,0 +1,39 @@ +values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_array_keys'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/RequiredIf.php b/src/data/src/Attributes/Validation/RequiredIf.php new file mode 100644 index 000000000..a87be49ad --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredIf.php @@ -0,0 +1,50 @@ +field = $this->parseFieldReference($field); + $this->values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_if'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + $this->values, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/RequiredIfAccepted.php b/src/data/src/Attributes/Validation/RequiredIfAccepted.php new file mode 100644 index 000000000..025e3bd83 --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredIfAccepted.php @@ -0,0 +1,39 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_if_accepted'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/RequiredIfDeclined.php b/src/data/src/Attributes/Validation/RequiredIfDeclined.php new file mode 100644 index 000000000..caf40aa9f --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredIfDeclined.php @@ -0,0 +1,39 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_if_declined'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/RequiredUnless.php b/src/data/src/Attributes/Validation/RequiredUnless.php new file mode 100644 index 000000000..67cc94a06 --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredUnless.php @@ -0,0 +1,50 @@ +field = $this->parseFieldReference($field); + $this->values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_unless'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->field, + $this->values, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/RequiredWith.php b/src/data/src/Attributes/Validation/RequiredWith.php new file mode 100644 index 000000000..4a4951b3c --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredWith.php @@ -0,0 +1,44 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_with'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->fields, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/RequiredWithAll.php b/src/data/src/Attributes/Validation/RequiredWithAll.php new file mode 100644 index 000000000..eb1e88bc4 --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredWithAll.php @@ -0,0 +1,44 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_with_all'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->fields, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/RequiredWithout.php b/src/data/src/Attributes/Validation/RequiredWithout.php new file mode 100644 index 000000000..06221a6d4 --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredWithout.php @@ -0,0 +1,44 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_without'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->fields, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/RequiredWithoutAll.php b/src/data/src/Attributes/Validation/RequiredWithoutAll.php new file mode 100644 index 000000000..27988ab64 --- /dev/null +++ b/src/data/src/Attributes/Validation/RequiredWithoutAll.php @@ -0,0 +1,44 @@ +fields[] = $this->parseFieldReference($field); + } + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'required_without_all'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [ + $this->fields, + ]; + } +} diff --git a/src/data/src/Attributes/Validation/Rule.php b/src/data/src/Attributes/Validation/Rule.php new file mode 100644 index 000000000..2db4f01e1 --- /dev/null +++ b/src/data/src/Attributes/Validation/Rule.php @@ -0,0 +1,34 @@ + */ + protected array $rules = []; + + /** + * Create a custom rule attribute. + */ + public function __construct(string|array|ValidationRule|RuleContract|InvokableRuleContract|ValidationRuleContract ...$rules) + { + $this->rules = $rules; + } + + /** + * Get the wrapped Validator rules. + */ + public function get(): array + { + return $this->rules; + } +} diff --git a/src/data/src/Attributes/Validation/Same.php b/src/data/src/Attributes/Validation/Same.php new file mode 100644 index 000000000..f98759c32 --- /dev/null +++ b/src/data/src/Attributes/Validation/Same.php @@ -0,0 +1,38 @@ +field = $this->parseFieldReference($field); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'same'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->field]; + } +} diff --git a/src/data/src/Attributes/Validation/Size.php b/src/data/src/Attributes/Validation/Size.php new file mode 100644 index 000000000..07f001909 --- /dev/null +++ b/src/data/src/Attributes/Validation/Size.php @@ -0,0 +1,35 @@ +size]; + } +} diff --git a/src/data/src/Attributes/Validation/Sometimes.php b/src/data/src/Attributes/Validation/Sometimes.php new file mode 100644 index 000000000..2f013d64c --- /dev/null +++ b/src/data/src/Attributes/Validation/Sometimes.php @@ -0,0 +1,27 @@ +values = Arr::flatten($values); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'starts_with'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return [$this->values]; + } +} diff --git a/src/data/src/Attributes/Validation/StringType.php b/src/data/src/Attributes/Validation/StringType.php new file mode 100644 index 000000000..9a26d8b13 --- /dev/null +++ b/src/data/src/Attributes/Validation/StringType.php @@ -0,0 +1,27 @@ +|null $where + */ + public function __construct( + protected string|ExternalReference|null $table = null, + protected string|ExternalReference|null $column = 'NULL', + protected string|ExternalReference|null $connection = null, + protected int|string|ExternalReference|null $ignore = null, + protected string|ExternalReference|null $ignoreColumn = null, + protected bool|ExternalReference $withoutTrashed = false, + protected string|ExternalReference $deletedAtColumn = 'deleted_at', + protected Closure|DatabaseConstraint|array|null $where = null, + protected ?BaseUnique $rule = null, + ) { + if ($rule === null && $table === null) { + throw CannotBuildValidationRule::create('Could not make unique rule since a table or rule is required.'); + } + } + + /** + * Get the Validator rule object. + */ + public function getRule(ValidationPath $path): object|string + { + if ($this->rule !== null) { + return $this->rule; + } + + $table = $this->normalizePossibleExternalReferenceParameter($this->table); + $column = $this->normalizePossibleExternalReferenceParameter($this->column); + $connection = $this->normalizePossibleExternalReferenceParameter($this->connection); + $ignore = $this->normalizePossibleExternalReferenceParameter($this->ignore); + $ignoreColumn = $this->normalizePossibleExternalReferenceParameter($this->ignoreColumn); + $withoutTrashed = $this->normalizePossibleExternalReferenceParameter($this->withoutTrashed); + $deletedAtColumn = $this->normalizePossibleExternalReferenceParameter($this->deletedAtColumn); + + if (! is_string($table)) { + throw CannotBuildValidationRule::create('Unique table must resolve to a string.'); + } + + if ($column !== null && ! is_string($column)) { + throw CannotBuildValidationRule::create('Unique column must resolve to a string or null.'); + } + + if ($connection !== null && ! is_string($connection)) { + throw CannotBuildValidationRule::create('Unique connection must resolve to a string or null.'); + } + + if ($ignoreColumn !== null && ! is_string($ignoreColumn)) { + throw CannotBuildValidationRule::create('Unique ignoreColumn must resolve to a string or null.'); + } + + if (! is_bool($withoutTrashed)) { + throw CannotBuildValidationRule::create('Unique withoutTrashed must resolve to a boolean.'); + } + + if (! is_string($deletedAtColumn)) { + throw CannotBuildValidationRule::create('Unique deletedAtColumn must resolve to a string.'); + } + + $rule = new BaseUnique( + $connection !== null && $connection !== '' ? "{$connection}.{$table}" : $table, + $column ?? 'NULL', + ); + + if ($withoutTrashed) { + $rule->withoutTrashed($deletedAtColumn); + } + + if ($ignore !== null) { + $rule->ignore($ignore, $ignoreColumn); + } + + if ($this->where !== null) { + $this->applyDatabaseConstraints($rule, $this->where); + } + + return $rule; + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'unique'; + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static(rule: new BaseUnique($parameters[0], $parameters[1] ?? 'NULL')); + } +} diff --git a/src/data/src/Attributes/Validation/Uppercase.php b/src/data/src/Attributes/Validation/Uppercase.php new file mode 100644 index 000000000..e9b3d47a5 --- /dev/null +++ b/src/data/src/Attributes/Validation/Uppercase.php @@ -0,0 +1,27 @@ +protocols = Arr::flatten($protocols); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'url'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return $this->protocols; + } +} diff --git a/src/data/src/Attributes/Validation/Uuid.php b/src/data/src/Attributes/Validation/Uuid.php new file mode 100644 index 000000000..eb6504c5f --- /dev/null +++ b/src/data/src/Attributes/Validation/Uuid.php @@ -0,0 +1,27 @@ +execute($this, ValidationPath::create())); + } + + protected static function parseDateValue(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + if ($value === 'tomorrow') { + return $value; + } + + $time = strtotime($value); + + if ($time === false) { + return $value; + } + + return CarbonImmutable::parse($time); + } + + protected static function parseBooleanValue(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + if ($value === 'true' || $value === '1') { + return 'true'; + } + + if ($value === 'false' || $value === '0') { + return 'false'; + } + + return $value; + } + + protected function parseFieldReference( + string|FieldReference $reference + ): FieldReference { + return $reference instanceof FieldReference + ? $reference + : new FieldReference($reference); + } + + protected function normalizePossibleExternalReferenceParameter(mixed $parameter): mixed + { + return $parameter instanceof ExternalReference ? $parameter->getValue() : $parameter; + } +} diff --git a/src/data/src/Support/Validation/CompiledValidation.php b/src/data/src/Support/Validation/CompiledValidation.php new file mode 100644 index 000000000..c9ad1715d --- /dev/null +++ b/src/data/src/Support/Validation/CompiledValidation.php @@ -0,0 +1,115 @@ +> $rules + * @param array|string> $messages + * @param array $attributes + * @param list $preservedPaths + * @param list $additionalFields + * @param list $allowedSubtrees + */ + public function __construct( + public array $rules, + public array $messages = [], + public array $attributes = [], + public array $preservedPaths = [], + public array $additionalFields = [], + public array $allowedSubtrees = [], + ) { + } + + /** + * Restore only values deliberately excluded from validation. + * + * @param array $payload + * @param array $sourcePayload + * @return array + */ + public function restorePreservedValues(array $payload, array $sourcePayload): array + { + foreach ($this->preservedPaths as $path) { + $this->restoreValueAtPath( + $payload, + $sourcePayload, + $path->rawSegments(), + ); + } + + return $payload; + } + + /** + * Restore one exact or wildcard path from the source payload. + * + * @param list $segments + */ + private function restoreValueAtPath( + mixed &$target, + mixed $source, + array $segments, + int $offset = 0, + ): void { + if ($offset === count($segments)) { + $target = $source; + + return; + } + + if (! is_array($source)) { + return; + } + + $segment = $segments[$offset]; + + if ($segment === null) { + foreach ($source as $key => $value) { + if (! is_array($target)) { + $target = []; + } + + if (! array_key_exists($key, $target)) { + $target[$key] = []; + } + + $this->restoreValueAtPath( + $target[$key], + $value, + $segments, + $offset + 1, + ); + } + + return; + } + + if (! array_key_exists($segment, $source)) { + return; + } + + if (! is_array($target)) { + $target = []; + } + + if (! array_key_exists($segment, $target)) { + $target[$segment] = []; + } + + $this->restoreValueAtPath( + $target[$segment], + $source[$segment], + $segments, + $offset + 1, + ); + } +} diff --git a/src/data/src/Support/Validation/Constraints/DatabaseConstraint.php b/src/data/src/Support/Validation/Constraints/DatabaseConstraint.php new file mode 100644 index 000000000..b34fe463b --- /dev/null +++ b/src/data/src/Support/Validation/Constraints/DatabaseConstraint.php @@ -0,0 +1,25 @@ +getValue() : $parameter; + } +} diff --git a/src/data/src/Support/Validation/Constraints/WhereConstraint.php b/src/data/src/Support/Validation/Constraints/WhereConstraint.php new file mode 100644 index 000000000..283024ae5 --- /dev/null +++ b/src/data/src/Support/Validation/Constraints/WhereConstraint.php @@ -0,0 +1,33 @@ +where( + $this->parseExternalReference($this->column), + $this->parseExternalReference($this->value), + ); + } +} diff --git a/src/data/src/Support/Validation/Constraints/WhereInConstraint.php b/src/data/src/Support/Validation/Constraints/WhereInConstraint.php new file mode 100644 index 000000000..587c3bf5f --- /dev/null +++ b/src/data/src/Support/Validation/Constraints/WhereInConstraint.php @@ -0,0 +1,34 @@ +whereIn( + $this->parseExternalReference($this->column), + $this->parseExternalReference($this->values), + ); + } +} diff --git a/src/data/src/Support/Validation/Constraints/WhereNotConstraint.php b/src/data/src/Support/Validation/Constraints/WhereNotConstraint.php new file mode 100644 index 000000000..0952867a4 --- /dev/null +++ b/src/data/src/Support/Validation/Constraints/WhereNotConstraint.php @@ -0,0 +1,32 @@ +whereNot( + $this->parseExternalReference($this->column), + $this->parseExternalReference($this->value), + ); + } +} diff --git a/src/data/src/Support/Validation/Constraints/WhereNotInConstraint.php b/src/data/src/Support/Validation/Constraints/WhereNotInConstraint.php new file mode 100644 index 000000000..0df6b6112 --- /dev/null +++ b/src/data/src/Support/Validation/Constraints/WhereNotInConstraint.php @@ -0,0 +1,34 @@ +whereNotIn( + $this->parseExternalReference($this->column), + $this->parseExternalReference($this->values), + ); + } +} diff --git a/src/data/src/Support/Validation/Constraints/WhereNotNullConstraint.php b/src/data/src/Support/Validation/Constraints/WhereNotNullConstraint.php new file mode 100644 index 000000000..d0a6ba7b3 --- /dev/null +++ b/src/data/src/Support/Validation/Constraints/WhereNotNullConstraint.php @@ -0,0 +1,30 @@ +whereNotNull( + $this->parseExternalReference($this->column), + ); + } +} diff --git a/src/data/src/Support/Validation/Constraints/WhereNullConstraint.php b/src/data/src/Support/Validation/Constraints/WhereNullConstraint.php new file mode 100644 index 000000000..18b7030ef --- /dev/null +++ b/src/data/src/Support/Validation/Constraints/WhereNullConstraint.php @@ -0,0 +1,30 @@ +whereNull( + $this->parseExternalReference($this->column), + ); + } +} diff --git a/src/data/src/Support/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php new file mode 100644 index 000000000..3181072f6 --- /dev/null +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -0,0 +1,1359 @@ + */ + protected const array PRESENCE_RULES = [ + 'present', + 'present_if', + 'present_unless', + 'present_with', + 'present_with_all', + 'required', + 'required_if', + 'required_if_accepted', + 'required_if_declined', + 'required_unless', + 'required_with', + 'required_with_all', + 'required_without', + 'required_without_all', + ]; + + /** + * Create a data validation compiler. + */ + public function __construct( + protected readonly DataClassRepository $dataClasses, + protected readonly Container $container, + protected readonly RuleDenormalizer $ruleDenormalizer, + ) { + } + + /** + * Compile validation for one filled data graph. + */ + public function compile(ConstructionState $state): CompiledValidation + { + $accumulator = new ValidationAccumulator; + $compileUnknownFields = $state->unknownInput() !== null; + $lifecycleDeclarations = [ + 'messages' => [], + 'attributes' => [], + ]; + + $this->compileNode( + $state->nodeClass() ?? $state->context->dataClass, + $state, + ValidationPath::create(), + ValidationPath::create(), + $accumulator, + $lifecycleDeclarations, + $compileUnknownFields, + ); + $this->appendStructuralMarkers($accumulator, $state->payload()); + + return new CompiledValidation( + rules: $accumulator->rules, + messages: $accumulator->messages, + attributes: $accumulator->attributes, + preservedPaths: $accumulator->preservedPaths, + additionalFields: $accumulator->additionalFields, + allowedSubtrees: $accumulator->allowedSubtrees, + ); + } + + /** + * Compile validation for one filled root data collection. + * + * @param class-string $dataClass + */ + public function compileCollection( + ConstructionState $state, + string $dataClass, + ): CompiledValidation { + $accumulator = new ValidationAccumulator; + $compileUnknownFields = $state->unknownInput() !== null; + $lifecycleDeclarations = [ + 'messages' => [], + 'attributes' => [], + ]; + + $this->compileDataIterableValues( + $dataClass, + $state->payload(), + $state, + ValidationPath::create(), + ValidationPath::create(), + $accumulator, + $lifecycleDeclarations, + $compileUnknownFields, + ); + $this->appendStructuralMarkers($accumulator, $state->payload()); + + return new CompiledValidation( + rules: $accumulator->rules, + messages: $accumulator->messages, + attributes: $accumulator->attributes, + preservedPaths: $accumulator->preservedPaths, + additionalFields: $accumulator->additionalFields, + allowedSubtrees: $accumulator->allowedSubtrees, + ); + } + + /** + * Compile one data node into the root rule graph. + * + * @param class-string $class + * @param array{messages: array, array>, attributes: array, array>} $lifecycleDeclarations + */ + protected function compileNode( + string $class, + ConstructionState $state, + ValidationPath $path, + ValidationPath $structuralPath, + ValidationAccumulator $accumulator, + array &$lifecycleDeclarations, + bool $compileUnknownFields, + bool $observed = true, + ): void { + $dataClass = $this->dataClasses->get($class); + $contextualProperties = $this->contextualPropertyNames($dataClass); + + foreach ($dataClass->properties as $property) { + if ($property->computed) { + continue; + } + + $wireKey = $this->wireKey($property, $state, $observed); + $propertyPath = $path->property($wireKey); + $structuralPropertyPath = $structuralPath->property($wireKey); + + if (isset($contextualProperties[$property->name])) { + if ($compileUnknownFields) { + $this->recordAuxiliaryPath( + $property, + $propertyPath, + $accumulator, + ); + } + + continue; + } + + $hasValue = $observed && $state->hasValue($wireKey); + $value = $hasValue ? $state->getValue($wireKey) : null; + + if (! $property->validate) { + $accumulator->preservedPaths[] = $propertyPath; + + if ($compileUnknownFields) { + $this->recordAuxiliaryPath( + $property, + $propertyPath, + $accumulator, + ); + } + + continue; + } + + if ($this->isFinishedDataValue($property, $value)) { + $accumulator->preservedPaths[] = $propertyPath; + $accumulator->finishedStructuralPaths[$structuralPropertyPath->get()] = true; + + if ($compileUnknownFields) { + $accumulator->allowedSubtrees[] = $propertyPath->get(); + } + + continue; + } + + $nestedDataClass = $this->nestedDataClass($property); + $dataIterable = $this->dataIterableType($property); + $dataIterableClass = $dataIterable?->dataClass; + $inferredRequired = false; + $propertyRulePath = $propertyPath->get(); + $accumulator->rules[$propertyRulePath] = $this->propertyRules( + $property, + $path, + $propertyPath, + $value, + $state, + $nestedDataClass !== null || $dataIterable !== null, + $inferredRequired, + ); + + if (! $propertyPath->equals($structuralPropertyPath)) { + $accumulator->addMarkerCandidate( + $structuralPropertyPath, + $propertyPath, + ); + } + + if ($inferredRequired) { + $accumulator->inferredRequiredPaths[$propertyRulePath] = true; + } + + if ($compileUnknownFields && $this->hasUnstructuredDescendants($property)) { + $accumulator->allowedSubtrees[] = $propertyPath->get(); + } + + if (! $hasValue || $value === null || $value instanceof Optional) { + continue; + } + + if ($nestedDataClass !== null && is_array($value)) { + $state->enterProperty($property->name, $wireKey); + + try { + $this->compileNode( + $state->nodeClass() ?? $nestedDataClass, + $state, + $propertyPath, + $structuralPropertyPath, + $accumulator, + $lifecycleDeclarations, + $compileUnknownFields, + ); + } finally { + $state->leave(); + } + + continue; + } + + if ($dataIterableClass !== null && is_array($value)) { + $this->compileDataIterable( + $property, + $dataIterableClass, + $value, + $state, + $propertyPath, + $structuralPropertyPath, + $accumulator, + $lifecycleDeclarations, + $compileUnknownFields, + ); + } + } + + $this->applyClassRules( + $dataClass, + $state, + $path, + $structuralPath, + $accumulator, + $observed, + ); + + if ($state->context->mode !== CreationMode::Rules) { + $this->applyClassMessagesAndAttributes( + $dataClass, + $state, + $path, + $structuralPath, + $accumulator, + $lifecycleDeclarations, + $observed, + ); + } + } + + /** + * Compile nested rules for one data iterable. + * + * @param class-string $dataClass + * @param array $values + * @param array{messages: array, array>, attributes: array, array>} $lifecycleDeclarations + */ + protected function compileDataIterable( + DataProperty $property, + string $dataClass, + array $values, + ConstructionState $state, + ValidationPath $path, + ValidationPath $structuralPath, + ValidationAccumulator $accumulator, + array &$lifecycleDeclarations, + bool $compileUnknownFields, + ): void { + $state->enterProperty($property->name, $state->originalKey($property->name)); + + try { + $this->compileDataIterableValues( + $dataClass, + $values, + $state, + $path, + $structuralPath, + $accumulator, + $lifecycleDeclarations, + $compileUnknownFields, + ); + } finally { + $state->leave(); + } + } + + /** + * Compile one data iterable from its current structure path. + * + * @param class-string $dataClass + * @param array $values + * @param array{messages: array, array>, attributes: array, array>} $lifecycleDeclarations + */ + protected function compileDataIterableValues( + string $dataClass, + array $values, + ConstructionState $state, + ValidationPath $path, + ValidationPath $structuralPath, + ValidationAccumulator $accumulator, + array &$lifecycleDeclarations, + bool $compileUnknownFields, + ): void { + $hasFinishedValues = false; + + foreach ($values as $value) { + if ($value instanceof $dataClass) { + $hasFinishedValues = true; + + break; + } + } + + if ($values === []) { + $this->compileNode( + $dataClass, + $state, + $path->wildcard(), + $structuralPath->wildcard(), + $accumulator, + $lifecycleDeclarations, + $compileUnknownFields, + observed: false, + ); + + return; + } + + if ($state->isCurrentCollectionUniform() + && ! $hasFinishedValues + ) { + $firstKey = array_key_first($values); + $state->enterItem($firstKey); + + try { + $selectedClass = $state->nodeClass() ?? $dataClass; + + if (! $this->usesDynamicRules($selectedClass, $state)) { + $this->compileNode( + $selectedClass, + $state, + $path->wildcard(), + $structuralPath->wildcard(), + $accumulator, + $lifecycleDeclarations, + $compileUnknownFields, + ); + + return; + } + } finally { + $state->leave(); + } + + $speculative = $this->compileUniformDynamicIterable( + $dataClass, + $values, + $state, + $path, + $structuralPath, + $lifecycleDeclarations, + $compileUnknownFields, + ); + + if ($speculative !== null) { + $accumulator->merge($speculative); + + return; + } + } + + foreach ($values as $key => $value) { + $itemPath = $path->item($key); + + if ($value instanceof $dataClass) { + $accumulator->preservedPaths[] = $itemPath; + $accumulator->finishedStructuralPaths[$structuralPath->wildcard()->get()] = true; + + if ($compileUnknownFields) { + $accumulator->allowedSubtrees[] = $itemPath->get(); + } + + continue; + } + + $state->enterItem($key); + $itemAccumulator = new ValidationAccumulator; + + try { + $this->compileNode( + $state->nodeClass() ?? $dataClass, + $state, + $itemPath, + $structuralPath->wildcard(), + $itemAccumulator, + $lifecycleDeclarations, + $compileUnknownFields, + ); + } finally { + $state->leave(); + } + + $accumulator->merge($itemAccumulator); + } + } + + /** + * Compile dynamic items at one wildcard path when their complete output matches. + * + * @param class-string $dataClass + * @param array $values + * @param array{messages: array, array>, attributes: array, array>} $lifecycleDeclarations + */ + protected function compileUniformDynamicIterable( + string $dataClass, + array $values, + ConstructionState $state, + ValidationPath $path, + ValidationPath $structuralPath, + array &$lifecycleDeclarations, + bool $compileUnknownFields, + ): ?ValidationAccumulator { + $compiled = null; + + foreach (array_keys($values) as $key) { + $state->enterItem($key); + $itemAccumulator = new ValidationAccumulator; + + try { + $this->compileNode( + $state->nodeClass() ?? $dataClass, + $state, + $path->wildcard(), + $structuralPath->wildcard(), + $itemAccumulator, + $lifecycleDeclarations, + $compileUnknownFields, + ); + } finally { + $state->leave(); + } + + if ($compiled === null) { + $compiled = $itemAccumulator; + + continue; + } + + if (! $compiled->equals($itemAccumulator)) { + return null; + } + } + + return $compiled; + } + + /** + * Infer fixed presence and type rules for one property. + * + * @return list + */ + protected function propertyRules( + DataProperty $property, + ValidationPath $nodePath, + ValidationPath $propertyPath, + mixed $value, + ConstructionState $state, + bool $expectsArray, + bool &$inferredRequired, + ): array { + $attributeRules = []; + $hasPresenceRule = false; + + foreach ($property->attributes->all(ValidationRule::class) as $recipe) { + $attribute = $recipe->newInstance(); + $denormalizedRules = $this->ruleDenormalizer->execute($attribute, $nodePath); + $hasPresenceRule = $hasPresenceRule + || $attribute instanceof RequiringRule + || $this->hasPresenceRule($denormalizedRules); + array_push($attributeRules, ...$denormalizedRules); + } + + $generatedRules = null; + + foreach ($state->context->beforeRulesHooks as $hook) { + $generatedRules = $hook($property, $propertyPath, $value); + + if ($generatedRules !== null) { + break; + } + } + + if ($generatedRules === null) { + $generatedRules = $this->inferRules($property, $expectsArray, $hasPresenceRule); + $inferredRequired = in_array('required', $generatedRules, true); + } else { + $generatedRules = $this->ruleDenormalizer->execute($generatedRules, $nodePath); + } + $rules = $this->mergeRules($attributeRules, $generatedRules); + + foreach ($state->context->afterRulesHooks as $hook) { + $rules = $this->ruleDenormalizer->execute( + $hook($rules, $property, $propertyPath, $value), + $nodePath, + ); + } + + return $rules; + } + + /** + * Infer fixed presence and type rules for one property. + * + * @return list + */ + protected function inferRules( + DataProperty $property, + bool $expectsArray, + bool $hasPresenceRule = false, + ): array + { + $rules = match (true) { + $property->type->isOptional => ['sometimes'], + $property->type->isNullable => ['nullable'], + ! $property->hasDefaultValue && ! $hasPresenceRule => ['required'], + default => [], + }; + + $typeRule = $expectsArray ? 'array' : $this->primitiveRule($property); + + if ($typeRule !== null) { + $rules[] = $typeRule; + } + + if ($rules === []) { + $rules[] = 'sometimes'; + } + + return $rules; + } + + /** + * Apply class-owned rule replacements in PHP property-name space. + */ + protected function applyClassRules( + DataClass $dataClass, + ConstructionState $state, + ValidationPath $path, + ValidationPath $structuralPath, + ValidationAccumulator $accumulator, + bool $observed, + ): void { + if (! $dataClass->hasLifecycleMethod('rules')) { + return; + } + + $context = new ValidationContext( + payload: $observed ? $state->currentPayload() : [], + fullPayload: $state->payload(), + path: $path, + ); + $classRules = $this->callArrayLifecycleMethod( + $dataClass, + 'rules', + ['context' => $context], + ); + $classOwnedRules = []; + + foreach ($classRules as $key => $declaration) { + $rulePaths = $this->collapseTranslatedRulePaths( + $this->translateRulePaths( + (string) $key, + $dataClass, + $state, + $path, + $structuralPath, + $observed, + ), + $accumulator, + ); + $classRule = $this->ruleDenormalizer->execute($declaration, $path); + + foreach ($rulePaths as $translatedPath) { + $rulePath = $translatedPath->path->get(); + $fannedOut = ! $translatedPath->path->equals( + $translatedPath->structuralPath, + ); + + if ($fannedOut && isset($classOwnedRules[$rulePath])) { + $classOwnedRules[$rulePath] = $this->mergeRules( + $classOwnedRules[$rulePath], + $classRule, + ); + } else { + unset($classOwnedRules[$rulePath]); + $classOwnedRules[$rulePath] = $classRule; + } + + if ($fannedOut) { + $accumulator->addMarkerCandidate( + $translatedPath->structuralPath, + $translatedPath->path, + ); + } + } + } + + foreach ($classOwnedRules as $rulePath => $rules) { + if ($dataClass->mergeValidationRules) { + $existingRules = $accumulator->rules[$rulePath] ?? []; + + if (isset($accumulator->inferredRequiredPaths[$rulePath]) + && $this->hasPresenceRule($rules) + ) { + $existingRules = array_values(array_filter( + $existingRules, + static fn (array|object|string $rule): bool => $rule !== 'required', + )); + unset($accumulator->inferredRequiredPaths[$rulePath]); + } + + $rules = $this->mergeRules($existingRules, $rules); + } else { + unset($accumulator->inferredRequiredPaths[$rulePath]); + } + + unset($accumulator->rules[$rulePath]); + $accumulator->rules[$rulePath] = $rules; + } + } + + /** + * Collapse concrete translations onto an existing authoritative wildcard rule. + * + * @param list $paths + * @return list + */ + protected function collapseTranslatedRulePaths( + array $paths, + ValidationAccumulator $accumulator, + ): array { + if ($paths === []) { + return []; + } + + $structuralPath = $paths[0]->structuralPath; + + foreach ($paths as $path) { + if (! $path->structuralPath->equals($structuralPath)) { + return $paths; + } + } + + if (! array_key_exists($structuralPath->get(), $accumulator->rules)) { + return $paths; + } + + return [new TranslatedValidationPath($structuralPath, $structuralPath)]; + } + + /** + * Apply class-owned messages and attribute labels in PHP property-name space. + * + * @param array{messages: array, array>, attributes: array, array>} $lifecycleDeclarations + */ + protected function applyClassMessagesAndAttributes( + DataClass $dataClass, + ConstructionState $state, + ValidationPath $path, + ValidationPath $structuralPath, + ValidationAccumulator $accumulator, + array &$lifecycleDeclarations, + bool $observed, + ): void { + $class = $dataClass->name; + + if ($dataClass->hasLifecycleMethod('messages')) { + if (! array_key_exists($class, $lifecycleDeclarations['messages'])) { + $lifecycleDeclarations['messages'][$class] = $this->callArrayLifecycleMethod( + $dataClass, + 'messages', + ); + } + + /** @var array|string> $declarations */ + $declarations = $lifecycleDeclarations['messages'][$class]; + + foreach ($declarations as $key => $message) { + if (is_string($message) && ! str_contains($key, '.')) { + $messagePath = $path->wildcard()->property($key); + $paths = [new TranslatedValidationPath($messagePath, $messagePath)]; + } else { + $paths = $this->translateRulePaths( + $key, + $dataClass, + $state, + $path, + $structuralPath, + $observed, + ); + } + + foreach ($paths as $messagePath) { + $key = $messagePath->path->get(); + + if (! array_key_exists($key, $accumulator->messages)) { + $accumulator->messages[$key] = $message; + } + } + } + } + + if (! $dataClass->hasLifecycleMethod('attributes')) { + return; + } + + if (! array_key_exists($class, $lifecycleDeclarations['attributes'])) { + $lifecycleDeclarations['attributes'][$class] = $this->callArrayLifecycleMethod( + $dataClass, + 'attributes', + ); + } + + /** @var array $declarations */ + $declarations = $lifecycleDeclarations['attributes'][$class]; + + foreach ($declarations as $key => $attribute) { + foreach ($this->translateRulePaths( + $key, + $dataClass, + $state, + $path, + $structuralPath, + $observed, + ) as $attributePath) { + $key = $attributePath->path->get(); + + if (! array_key_exists($key, $accumulator->attributes)) { + $accumulator->attributes[$key] = $attribute; + } + } + } + } + + /** + * Invoke one array-returning validation lifecycle method. + */ + protected function callArrayLifecycleMethod( + DataClass $dataClass, + string $method, + array $parameters = [], + ): array { + $result = $this->container->call( + "{$dataClass->name}::{$method}", + $parameters, + ); + + if (! is_array($result)) { + throw new TypeError(sprintf( + '%s::%s() must return an array, %s returned.', + $dataClass->name, + $method, + get_debug_type($result), + )); + } + + return $result; + } + + /** + * Translate a class rule key to its observed wire paths. + * + * @return list + */ + protected function translateRulePaths( + string $key, + DataClass $dataClass, + ConstructionState $state, + ValidationPath $path, + ValidationPath $structuralPath, + bool $observed, + ): array { + return $this->translateRuleSegments( + ValidationPath::create($key)->rawSegments(), + 0, + $dataClass, + $state, + $path, + $structuralPath, + $observed, + ); + } + + /** + * Recursively translate class-rule segments through Data metadata. + * + * @param list $segments + * @return list + */ + protected function translateRuleSegments( + array $segments, + int $offset, + DataClass $dataClass, + ConstructionState $state, + ValidationPath $path, + ValidationPath $structuralPath, + bool $observed, + ): array { + if (! array_key_exists($offset, $segments)) { + return [new TranslatedValidationPath( + $path, + $structuralPath, + )]; + } + + $segment = $segments[$offset]; + $property = is_string($segment) + ? ($dataClass->properties[$segment] ?? null) + : null; + + if ($property === null) { + return [new TranslatedValidationPath( + $this->appendUnmappedSegments($path, $segments, $offset), + $this->appendUnmappedSegments($structuralPath, $segments, $offset), + )]; + } + + if ($property->computed + || ! $property->validate + || isset($this->contextualPropertyNames($dataClass)[$property->name]) + ) { + return []; + } + + $wireKey = $this->wireKey($property, $state, $observed); + $path = $path->property($wireKey); + $structuralPath = $structuralPath->property($wireKey); + $hasValue = $observed && $state->hasValue($wireKey); + $value = $hasValue ? $state->getValue($wireKey) : null; + + if ($this->isFinishedDataValue($property, $value)) { + return []; + } + + if (! array_key_exists($offset + 1, $segments)) { + return [new TranslatedValidationPath( + $path, + $structuralPath, + )]; + } + + $nestedDataClass = $this->nestedDataClass($property); + + if ($nestedDataClass !== null) { + $state->enterProperty($property->name, $wireKey); + + try { + return $this->translateRuleSegments( + $segments, + $offset + 1, + $this->dataClasses->get($state->nodeClass() ?? $nestedDataClass), + $state, + $path, + $structuralPath, + $hasValue && is_array($value), + ); + } finally { + $state->leave(); + } + } + + $dataIterable = $this->dataIterableType($property); + + if ($dataIterable === null) { + return [new TranslatedValidationPath( + $this->appendUnmappedSegments($path, $segments, $offset + 1), + $this->appendUnmappedSegments($structuralPath, $segments, $offset + 1), + )]; + } + + /** @var class-string $itemDataClass */ + $itemDataClass = $dataIterable->dataClass; + $itemSegment = $segments[$offset + 1]; + $values = $hasValue && is_array($value) ? $value : []; + $state->enterProperty($property->name, $wireKey); + + try { + if ($itemSegment !== null) { + $itemValue = $values[$itemSegment] ?? null; + + if ($itemValue instanceof $itemDataClass) { + return []; + } + + $state->enterItem($itemSegment); + + try { + return $this->translateRuleSegments( + $segments, + $offset + 2, + $this->dataClasses->get($state->nodeClass() ?? $itemDataClass), + $state, + $path->item($itemSegment), + $structuralPath->item($itemSegment), + array_key_exists($itemSegment, $values) && is_array($itemValue), + ); + } finally { + $state->leave(); + } + } + + if ($values === []) { + return $this->translateRuleSegments( + $segments, + $offset + 2, + $this->dataClasses->get($itemDataClass), + $state, + $path->wildcard(), + $structuralPath->wildcard(), + false, + ); + } + + $hasFinishedValues = false; + + foreach ($values as $itemValue) { + if ($itemValue instanceof $itemDataClass) { + $hasFinishedValues = true; + + break; + } + } + + if ($state->isCurrentCollectionUniform() && ! $hasFinishedValues) { + $firstKey = array_key_first($values); + $state->enterItem($firstKey); + + try { + $selectedClass = $state->nodeClass() ?? $itemDataClass; + + if (! $this->usesDynamicRules($selectedClass, $state)) { + return $this->translateRuleSegments( + $segments, + $offset + 2, + $this->dataClasses->get($selectedClass), + $state, + $path->wildcard(), + $structuralPath->wildcard(), + is_array($values[$firstKey]), + ); + } + } finally { + $state->leave(); + } + } + + $paths = []; + + foreach ($values as $itemKey => $itemValue) { + if ($itemValue instanceof $itemDataClass) { + continue; + } + + $state->enterItem($itemKey); + + try { + array_push($paths, ...$this->translateRuleSegments( + $segments, + $offset + 2, + $this->dataClasses->get($state->nodeClass() ?? $itemDataClass), + $state, + $path->item($itemKey), + $structuralPath->wildcard(), + is_array($itemValue), + )); + } finally { + $state->leave(); + } + } + + return $paths; + } finally { + $state->leave(); + } + } + + /** + * Append rule segments that no longer describe Data properties. + * + * @param list $segments + */ + protected function appendUnmappedSegments( + ValidationPath $path, + array $segments, + int $offset, + ): ValidationPath { + for ($index = $offset; $index < count($segments); ++$index) { + $path = $segments[$index] === null + ? $path->wildcard() + : $path->item($segments[$index]); + } + + return $path; + } + + /** + * Append wildcard identity markers after every rule replacement is complete. + */ + protected function appendStructuralMarkers( + ValidationAccumulator $accumulator, + array $payload, + ): void { + $markers = []; + + foreach ($accumulator->markerCandidates as $candidate => $rulePaths) { + $crossesFinishedValue = false; + + foreach (array_keys($accumulator->finishedStructuralPaths) as $finishedPath) { + if ($candidate === $finishedPath + || str_starts_with($candidate, $finishedPath . '.') + ) { + $crossesFinishedValue = true; + + break; + } + } + + if ($crossesFinishedValue) { + foreach (array_keys($rulePaths) as $rulePath) { + $rules = $accumulator->rules[$rulePath] ?? []; + + if ($rules !== [] && $this->hasDistinctRule($rules)) { + throw CannotBuildValidationRule::create(sprintf( + 'Cannot build the distinct rule for [%s] because its collection mixes raw and finished Data values.', + $candidate, + )); + } + } + + continue; + } + + $path = ValidationPath::create($candidate); + $concretePaths = $path->matchingWildcardPayloadValidationPaths($payload); + + if ($concretePaths === []) { + continue; + } + + $coveredPaths = []; + + foreach (array_keys($rulePaths) as $rulePath) { + $rules = $accumulator->rules[$rulePath] ?? []; + + if ($rules === []) { + continue; + } + + $contributor = ValidationPath::create($rulePath); + + if (! $contributor->containsWildcards()) { + $coveredPaths[$rulePath] = true; + + continue; + } + + foreach ($contributor->matchingWildcardPayloadValidationPaths($payload) as $coveredPath) { + $coveredPaths[$coveredPath->get()] = true; + } + } + + foreach ($concretePaths as $concretePath) { + if (! isset($coveredPaths[$concretePath->get()])) { + continue 2; + } + } + + $markers[$candidate] = []; + } + + $accumulator->rules = [...$markers, ...$accumulator->rules]; + } + + /** + * Determine if a rule list contains a distinct rule. + */ + protected function hasDistinctRule(array $rules): bool + { + foreach ($rules as $rule) { + if (is_array($rule) && $this->hasDistinctRule($rule)) { + return true; + } + + if (is_string($rule) + && strtolower(trim(explode(':', $rule, 2)[0])) === 'distinct' + ) { + return true; + } + } + + return false; + } + + /** + * Merge rule lists without duplicating identical string rules. + * + * @param list $rules + * @param list $additionalRules + * @return list + */ + protected function mergeRules(array $rules, array $additionalRules): array + { + foreach ($additionalRules as $rule) { + if (is_string($rule) && in_array($rule, $rules, true)) { + continue; + } + + $rules[] = $rule; + } + + return $rules; + } + + /** + * Determine if a rule list explicitly controls field presence. + * + * @param list $rules + */ + protected function hasPresenceRule(array $rules): bool + { + foreach ($rules as $rule) { + if ($rule instanceof NativeRequiredIf || $rule instanceof NativeRequiredUnless) { + return true; + } + + if (! is_string($rule)) { + continue; + } + + $name = strtolower(trim(explode(':', $rule, 2)[0])); + + if (in_array($name, self::PRESENCE_RULES, true)) { + return true; + } + } + + return false; + } + + /** + * Determine if item rules can differ across one collection. + * + * @param class-string $dataClass + */ + protected function usesDynamicRules( + string $dataClass, + ConstructionState $state, + ): bool { + return $this->dataClasses->hasDynamicRuleGraph($dataClass) + || $state->context->beforeRulesHooks !== [] + || $state->context->afterRulesHooks !== []; + } + + /** + * Get one unambiguous primitive validation rule. + */ + protected function primitiveRule(DataProperty $property): ?string + { + $rules = []; + + foreach ($property->type->getNamedTypes() as $type) { + $rule = match ($type->name) { + 'array', 'iterable' => 'array', + 'bool', 'false', 'true' => 'boolean', + 'float' => 'numeric', + 'int' => 'integer', + 'string' => 'string', + default => null, + }; + + if ($rule !== null) { + $rules[$rule] = true; + } + } + + return count($rules) === 1 ? array_key_first($rules) : null; + } + + /** + * Get the wire key selected during Fill or its canonical fallback. + */ + protected function wireKey( + DataProperty $property, + ConstructionState $state, + bool $observed, + ): string|int { + if ($observed && $state->hasOriginalKey($property->name)) { + return $state->originalKey($property->name); + } + + return $state->context->mapPropertyNames + ? ($property->inputMappedName ?? $property->name) + : $property->name; + } + + /** + * Determine if a supplied object is a finished declared Data value. + */ + protected function isFinishedDataValue(DataProperty $property, mixed $value): bool + { + return $property->isFinishedValue($value); + } + + /** + * Get constructor-backed properties resolved by contextual attributes. + * + * @return array + */ + protected function contextualPropertyNames(DataClass $dataClass): array + { + $properties = []; + + foreach ($dataClass->constructorParameters as $parameter) { + if ($parameter->isPromoted && $parameter->contextualAttribute !== null) { + $properties[$parameter->name] = true; + } + } + + return $properties; + } + + /** + * Record an exact field or opaque subtree excluded from rule compilation. + */ + protected function recordAuxiliaryPath( + DataProperty $property, + ValidationPath $path, + ValidationAccumulator $accumulator, + ): void { + if ($this->canContainDescendants($property)) { + $accumulator->allowedSubtrees[] = $path->get(); + } else { + $accumulator->additionalFields[] = $path->get(); + } + } + + /** + * Determine if a skipped property can contain nested input. + */ + protected function canContainDescendants(DataProperty $property): bool + { + if ($property->type->isMixed) { + return true; + } + + foreach ($property->type->getNamedTypes() as $type) { + if ($type->kind->isDataRelated() + || $type->kind->isNonDataIterable() + || $this->isUnstructuredObject($type) + ) { + return true; + } + } + + return false; + } + + /** + * Determine if a validated property has no recursive schema. + */ + protected function hasUnstructuredDescendants(DataProperty $property): bool + { + if ($property->type->isMixed) { + return true; + } + + foreach ($property->type->getNamedTypes() as $type) { + if ($this->isUnstructuredObject($type)) { + return true; + } + } + + return false; + } + + /** + * Determine if a named object type has no Data-owned child schema. + */ + protected function isUnstructuredObject(NamedType $type): bool + { + if ($type->name === 'object') { + return true; + } + + if ($type->builtIn || $type->kind->isDataRelated()) { + return false; + } + + return ! is_a($type->name, DateTimeInterface::class, true) + && ! is_a($type->name, UnitEnum::class, true) + && ! is_a($type->name, Optional::class, true) + && ! is_a($type->name, Lazy::class, true); + } + + /** + * Get the one unambiguous nested data class declared by a property. + * + * @return null|class-string + */ + protected function nestedDataClass(DataProperty $property): ?string + { + $types = $property->type->getDataObjectTypes(); + + return count($types) === 1 ? $types[0]->dataClass : null; + } + + /** + * Get the one unambiguous data iterable declared by a property. + */ + protected function dataIterableType(DataProperty $property): ?NamedType + { + $types = $property->type->getDataCollectableTypes(); + + return count($types) === 1 ? $types[0] : null; + } +} diff --git a/src/data/src/Support/Validation/DataValidator.php b/src/data/src/Support/Validation/DataValidator.php new file mode 100644 index 000000000..faed1df42 --- /dev/null +++ b/src/data/src/Support/Validation/DataValidator.php @@ -0,0 +1,368 @@ + $payloads + */ + public function shouldValidate(CreationContext $context, array $payloads): bool + { + return match ($context->validationStrategy) { + ValidationStrategy::Always => true, + ValidationStrategy::OnlyRequests => $this->request($payloads) !== null, + ValidationStrategy::Disabled => false, + }; + } + + /** + * Authorize the root Request before named creation can finish the object. + * + * @param class-string $class + * @param array $payloads + */ + public function authorize( + string $class, + array $payloads, + ): ?Request { + $request = $this->request($payloads); + $dataClass = $this->dataClasses->get($class); + + if ($request === null || ! $dataClass->hasLifecycleMethod('authorize')) { + return $request; + } + + $result = $this->container->call("{$class}::authorize"); + + if (! is_bool($result) && ! $result instanceof Response) { + throw new TypeError(sprintf( + '%s::authorize() must return bool or %s, %s returned.', + $class, + Response::class, + get_debug_type($result), + )); + } + + if ($result instanceof Response) { + $result->authorize(); + } elseif ($result === false) { + throw new AuthorizationException; + } + + return $request; + } + + /** + * Compile validation for a filled construction state. + */ + public function compile(ConstructionState $state): CompiledValidation + { + return $this->compiler->compile($state); + } + + /** + * Compile validation for a filled root collection. + * + * @param class-string $dataClass + */ + public function compileCollection( + ConstructionState $state, + string $dataClass, + ): CompiledValidation { + return $this->compiler->compileCollection($state, $dataClass); + } + + /** + * Validate and replace the state with its filtered payload. + * + * @param null|class-string $class + */ + public function validate( + ConstructionState $state, + CompiledValidation $compiled, + ?Request $request = null, + ?string $class = null, + ): void { + $sourcePayload = $state->payload(); + $dataClass = $this->dataClasses->get( + $class ?? $state->nodeClass() ?? $state->context->dataClass, + ); + $validator = $this->validationFactory->make( + $sourcePayload, + $compiled->rules, + $compiled->messages, + $compiled->attributes, + ); + $unfilteredRules = null; + + if ($request?->isPrecognitive()) { + $unfilteredRules = $validator->getRulesWithoutPlaceholders(); + $validator->setRules($request->filterPrecognitiveRules($unfilteredRules)); + } + + $this->configureValidator($validator, $state, $dataClass); + + if (($unknownInput = $state->unknownInput()) !== null) { + $validator->after(static function (Validator $validator) use ( + $unknownInput, + $compiled, + $unfilteredRules, + ): void { + UnknownFields::validate( + $validator, + $unknownInput, + $unfilteredRules, + $compiled->additionalFields, + $compiled->allowedSubtrees, + ); + }); + } + + if ($request?->isPrecognitive()) { + $validator->after(Precognition::afterValidationHook($request)); + } + + try { + $payload = $this->restoreSourceKeyOrder( + $compiled->restorePreservedValues( + $validator->validate(), + $sourcePayload, + ), + $sourcePayload, + ); + } catch (ValidationException $exception) { + $this->configureValidationException($exception, $dataClass); + + throw $exception; + } + + $state->replacePayload($payload); + } + + /** + * Restore surviving payload keys to their source insertion order. + * + * @param array $payload + * @param array $sourcePayload + * @return array + */ + protected function restoreSourceKeyOrder(array $payload, array $sourcePayload): array + { + $ordered = []; + + foreach ($sourcePayload as $key => $sourceValue) { + if (! array_key_exists($key, $payload)) { + continue; + } + + $value = $payload[$key]; + $ordered[$key] = is_array($sourceValue) && is_array($value) + ? $this->restoreSourceKeyOrder($value, $sourceValue) + : $value; + } + + foreach ($payload as $key => $value) { + if (! array_key_exists($key, $sourcePayload)) { + $ordered[$key] = $value; + } + } + + return $ordered; + } + + /** + * Apply operation-scoped validator hooks. + */ + protected function configureValidator( + Validator $validator, + ConstructionState $state, + DataClass $dataClass, + ): void { + $class = $dataClass->name; + $validator->stopOnFirstFailure( + $this->resolveStopOnFirstFailure($dataClass), + ); + + if ($dataClass->hasLifecycleMethod('withValidator')) { + $this->container->call( + "{$class}::withValidator", + ['validator' => $validator], + ); + } + + foreach ($state->context->withValidatorHooks as $hook) { + $hook($validator); + } + + if (! $dataClass->hasLifecycleMethod('after')) { + return; + } + + $callbacks = $this->container->call( + "{$class}::after", + ['validator' => $validator], + ); + + if (! is_array($callbacks)) { + throw new TypeError(sprintf( + '%s::after() must return an array, %s returned.', + $class, + get_debug_type($callbacks), + )); + } + + /** @var array $callbacks */ + foreach ($callbacks as $callback) { + $validator->after( + is_object($callback) && method_exists($callback, 'after') + ? $callback->after(...) + : $callback, + ); + } + } + + /** + * Apply class-owned validation failure configuration. + */ + protected function configureValidationException( + ValidationException $exception, + DataClass $dataClass, + ): void { + $errorBag = $this->resolveStringLifecycleSetting( + $dataClass, + 'errorBag', + $dataClass->errorBag, + ); + + if ($errorBag !== null) { + $exception->errorBag($errorBag); + } + + $urlGenerator = $this->container->make(UrlGenerator::class); + $redirect = $this->resolveStringLifecycleSetting( + $dataClass, + 'redirect', + $dataClass->redirect, + ); + + if ($redirect !== null && $redirect !== '') { + $exception->redirectTo($urlGenerator->to($redirect)); + + return; + } + + $redirectRoute = $this->resolveStringLifecycleSetting( + $dataClass, + 'redirectRoute', + $dataClass->redirectRoute, + ); + + $exception->redirectTo( + $redirectRoute !== null && $redirectRoute !== '' + ? $urlGenerator->route($redirectRoute) + : $urlGenerator->previous(), + ); + } + + /** + * Resolve the effective stop-on-first-failure setting. + */ + protected function resolveStopOnFirstFailure(DataClass $dataClass): bool + { + if (! $dataClass->hasLifecycleMethod('stopOnFirstFailure')) { + return $dataClass->stopOnFirstFailure; + } + + $result = $this->container->call( + "{$dataClass->name}::stopOnFirstFailure", + ); + + if (! is_bool($result)) { + throw new TypeError(sprintf( + '%s::stopOnFirstFailure() must return bool, %s returned.', + $dataClass->name, + get_debug_type($result), + )); + } + + return $result; + } + + /** + * Resolve a method-over-attribute string setting. + */ + protected function resolveStringLifecycleSetting( + DataClass $dataClass, + string $method, + ?string $attributeValue, + ): ?string { + if (! $dataClass->hasLifecycleMethod($method)) { + return $attributeValue; + } + + $result = $this->container->call( + "{$dataClass->name}::{$method}", + ); + + if (! is_string($result)) { + throw new TypeError(sprintf( + '%s::%s() must return string, %s returned.', + $dataClass->name, + $method, + get_debug_type($result), + )); + } + + return $result; + } + + /** + * Find the first Request in the root source list. + * + * @param array $payloads + */ + protected function request(array $payloads): ?Request + { + foreach ($payloads as $payload) { + if ($payload instanceof Request) { + return $payload; + } + } + + return null; + } +} diff --git a/src/data/src/Support/Validation/References/ExternalReference.php b/src/data/src/Support/Validation/References/ExternalReference.php new file mode 100644 index 000000000..c5dee25da --- /dev/null +++ b/src/data/src/Support/Validation/References/ExternalReference.php @@ -0,0 +1,13 @@ +fromRoot + ? $this->name + : $path->property($this->name)->get(); + } +} diff --git a/src/data/src/Support/Validation/RequiringRule.php b/src/data/src/Support/Validation/RequiringRule.php new file mode 100644 index 000000000..b06ac64d3 --- /dev/null +++ b/src/data/src/Support/Validation/RequiringRule.php @@ -0,0 +1,9 @@ + + */ + public function execute(mixed $rule, ValidationPath $path): array + { + if (is_string($rule)) { + return str_contains($rule, 'regex:') ? [$rule] : explode('|', $rule); + } + + if (is_array($rule)) { + $rules = []; + + foreach ($rule as $nestedRule) { + array_push($rules, ...$this->execute($nestedRule, $path)); + } + + return $rules; + } + + if ($rule instanceof StringValidationAttribute) { + return $this->normalizeStringValidationAttribute($rule, $path); + } + + if ($rule instanceof ObjectValidationAttribute) { + return [$rule->getRule($path)]; + } + + if ($rule instanceof CustomValidationAttribute) { + $rules = $rule->getRules($path); + + return is_array($rules) ? $rules : [$rules]; + } + + if ($rule instanceof Rule) { + return $this->execute($rule->get(), $path); + } + + if ($rule instanceof RuleContract || $rule instanceof InvokableRuleContract) { + return [$rule]; + } + + return [$rule]; + } + + /** + * Convert a string attribute into one Validator rule. + * + * @return list + */ + protected function normalizeStringValidationAttribute( + StringValidationAttribute $rule, + ValidationPath $path, + ): array { + $parameters = []; + + foreach ($rule->parameters() as $key => $value) { + $parameter = $this->normalizeRuleParameter($value, $path); + + if ($parameter === null) { + continue; + } + + $parameters[] = is_string($key) ? "{$key}={$parameter}" : $parameter; + } + + if ($parameters === []) { + return [$rule->keyword()]; + } + + return ["{$rule->keyword()}:" . implode(',', $parameters)]; + } + + /** + * Convert one rule parameter into Validator string form. + */ + protected function normalizeRuleParameter( + mixed $parameter, + ValidationPath $path, + ): ?string { + if ($parameter === null) { + return null; + } + + if (is_string($parameter) || is_numeric($parameter)) { + return (string) $parameter; + } + + if (is_bool($parameter)) { + return $parameter ? 'true' : 'false'; + } + + if (is_array($parameter) && count($parameter) === 0) { + return null; + } + + if (is_array($parameter)) { + // ValidatesAttributes::convertValuesToNull() decodes list values from this literal token. + $subParameters = array_map( + fn (mixed $subParameter): string => $this->normalizeRuleParameter($subParameter, $path) ?? 'null', + $parameter + ); + + return implode(',', $subParameters); + } + + if ($parameter instanceof DateTimeInterface) { + return $parameter->format(DATE_ATOM); + } + + if ($parameter instanceof BackedEnum) { + return (string) $parameter->value; + } + + if ($parameter instanceof FieldReference) { + return $parameter->getValue($path); + } + + if ($parameter instanceof ExternalReference) { + return $this->normalizeRuleParameter($parameter->getValue(), $path); + } + + return (string) $parameter; + } +} diff --git a/src/data/src/Support/Validation/TranslatedValidationPath.php b/src/data/src/Support/Validation/TranslatedValidationPath.php new file mode 100644 index 000000000..f51327f98 --- /dev/null +++ b/src/data/src/Support/Validation/TranslatedValidationPath.php @@ -0,0 +1,20 @@ +> */ + public array $rules = []; + + /** @var array */ + public array $inferredRequiredPaths = []; + + /** @var array|string> */ + public array $messages = []; + + /** @var array */ + public array $attributes = []; + + /** @var list */ + public array $preservedPaths = []; + + /** @var list */ + public array $additionalFields = []; + + /** @var list */ + public array $allowedSubtrees = []; + + /** @var array */ + public array $finishedStructuralPaths = []; + + /** @var array> */ + public array $markerCandidates = []; + + /** + * Determine if another accumulator has the same compiled output. + */ + public function equals(self $other): bool + { + if (! $this->rulesEqual($this->rules, $other->rules) + || $this->inferredRequiredPaths !== $other->inferredRequiredPaths + || $this->messages !== $other->messages + || $this->attributes !== $other->attributes + ) { + return false; + } + + foreach ($this->preservedPaths as $index => $path) { + if (! isset($other->preservedPaths[$index]) + || $path->get() !== $other->preservedPaths[$index]->get() + ) { + return false; + } + } + + return count($this->preservedPaths) === count($other->preservedPaths) + && $this->additionalFields === $other->additionalFields + && $this->allowedSubtrees === $other->allowedSubtrees + && $this->finishedStructuralPaths === $other->finishedStructuralPaths + && $this->markerCandidates === $other->markerCandidates; + } + + /** + * Merge another compilation result into this result. + */ + public function merge(self $other): void + { + $this->rules = array_replace($this->rules, $other->rules); + $this->inferredRequiredPaths = array_replace( + $this->inferredRequiredPaths, + $other->inferredRequiredPaths, + ); + $this->messages = array_replace($this->messages, $other->messages); + $this->attributes = array_replace($this->attributes, $other->attributes); + array_push($this->preservedPaths, ...$other->preservedPaths); + array_push($this->additionalFields, ...$other->additionalFields); + array_push($this->allowedSubtrees, ...$other->allowedSubtrees); + $this->finishedStructuralPaths += $other->finishedStructuralPaths; + + foreach ($other->markerCandidates as $path => $rulePaths) { + $this->markerCandidates[$path] ??= []; + $this->markerCandidates[$path] += $rulePaths; + } + } + + /** + * Record a structural marker candidate. + */ + public function addMarkerCandidate( + ValidationPath $path, + ValidationPath $rulePath, + ): void { + $key = $path->get(); + $this->markerCandidates[$key] ??= []; + $this->markerCandidates[$key][$rulePath->get()] = true; + } + + /** + * Determine if two ordered rule maps compile to the same Validator input. + */ + protected function rulesEqual(array $rules, array $otherRules): bool + { + if (array_keys($rules) !== array_keys($otherRules)) { + return false; + } + + foreach ($rules as $key => $rule) { + if (! $this->ruleValueEquals($rule, $otherRules[$key])) { + return false; + } + } + + return true; + } + + /** + * Determine if two rule values have the same Validator semantics. + */ + protected function ruleValueEquals(mixed $rule, mixed $otherRule): bool + { + if (is_array($rule) || is_array($otherRule)) { + return is_array($rule) + && is_array($otherRule) + && $this->rulesEqual($rule, $otherRule); + } + + if (($rule instanceof Exists || $rule instanceof Unique) + && $rule->queryCallbacks() !== [] + ) { + return is_object($otherRule) + && $otherRule::class === $rule::class + && (string) $rule === (string) $otherRule + && $rule->queryCallbacks() === $otherRule->queryCallbacks(); + } + + if (ValidationRuleParser::ruleReducesToString($rule)) { + $rule = (string) $rule; + } + + if (ValidationRuleParser::ruleReducesToString($otherRule)) { + $otherRule = (string) $otherRule; + } + + return $rule === $otherRule; + } +} diff --git a/src/data/src/Support/Validation/ValidationContext.php b/src/data/src/Support/Validation/ValidationContext.php new file mode 100644 index 000000000..a278fb722 --- /dev/null +++ b/src/data/src/Support/Validation/ValidationContext.php @@ -0,0 +1,18 @@ + $path + */ + public function __construct( + protected readonly array $path = [], + ) { + } + + /** + * Create a validation path from dot notation. + */ + public static function create(?string $path = null): self + { + if ($path === null) { + return new self; + } + + return new self(self::parseDotPath($path)); + } + + /** + * Append a property or collection segment. + */ + public function property(string|int $property): self + { + $newPath = $this->path; + + array_push( + $newPath, + ...(is_int($property) ? [$property] : self::parseDotPath($property)), + ); + + return new self($newPath); + } + + /** + * Append one raw collection key. + */ + public function item(string|int $key): self + { + $newPath = $this->path; + + $newPath[] = $key; + + return new self($newPath); + } + + /** + * Append a collection wildcard. + */ + public function wildcard(): self + { + $newPath = $this->path; + + $newPath[] = null; + + return new self($newPath); + } + + /** + * Determine if this is the root path. + */ + public function isRoot(): bool + { + return $this->path === []; + } + + /** + * Determine if this path equals another path. + */ + public function equals(string|ValidationPath $other): bool + { + $otherPath = $other instanceof ValidationPath + ? $other->path + : self::parseDotPath($other); + + return $this->path === $otherPath; + } + + /** + * Get the path segments. + * + * @return list + */ + public function segments(): array + { + return array_map( + fn (string|int|null $segment): string|int => $segment ?? '*', + $this->path, + ); + } + + /** + * Get structural segments with wildcards represented by null. + * + * @return list + */ + public function rawSegments(): array + { + return $this->path; + } + + /** + * Get the path in dot notation. + */ + public function get(): string + { + return implode('.', array_map( + fn (string|int|null $segment): string => match (true) { + $segment === null => '*', + is_int($segment) => (string) $segment, + default => str_replace(['.', '*'], ['\\.', '\\*'], $segment), + }, + $this->path, + )); + } + + /** + * Get the string form of the path. + */ + public function __toString(): string + { + return $this->get(); + } + + /** + * Determine if this path contains a wildcard. + */ + public function containsWildcards(): bool + { + return in_array(null, $this->path, true); + } + + /** + * Get the concrete paths matching this wildcard path. + * + * @return array + */ + public function matchingWildcardPayloadValidationPaths(array $fullPayload): array + { + return $this->expandWildcardPath($this->path, $fullPayload); + } + + /** + * Recursively expand wildcard segments against a payload. + * + * @param list $remainingSegments + * @param list $resolvedSegments + * @return list + */ + protected function expandWildcardPath( + array $remainingSegments, + mixed $payload, + array $resolvedSegments = [], + ): array + { + if ($remainingSegments === []) { + return [new self($resolvedSegments)]; + } + + $segment = array_shift($remainingSegments); + + if ($segment === null) { + if (! is_array($payload)) { + return []; + } + + $results = []; + + foreach (array_keys($payload) as $key) { + array_push($results, ...$this->expandWildcardPath( + $remainingSegments, + $payload[$key], + [...$resolvedSegments, $key], + )); + } + + return $results; + } + + return $this->expandWildcardPath( + $remainingSegments, + is_array($payload) && array_key_exists($segment, $payload) + ? $payload[$segment] + : null, + [...$resolvedSegments, $segment], + ); + } + + /** + * Parse Validator dot notation into structural segments. + * + * @return list + */ + protected static function parseDotPath(string $path): array + { + $segments = preg_split('/(?expectException(CannotBuildValidationRule::class); + + new Exists; + } + + /** + * Test an explicitly supplied native rule is preserved. + */ + public function testReturnsProvidedRule(): void + { + $rule = new ExistsRule('users', 'id'); + + $this->assertSame( + [$rule], + (new RuleDenormalizer)->execute(new Exists(rule: $rule), ValidationPath::create()), + ); + } + + /** + * Test externally resolved parameters and constraints configure the native rule. + */ + public function testBuildsConfiguredRule(): void + { + $attribute = new Exists( + table: new ExistsExternalReference('users'), + column: new ExistsExternalReference('id'), + connection: new ExistsExternalReference('tenant'), + withoutTrashed: new ExistsExternalReference(true), + deletedAtColumn: new ExistsExternalReference('removed_at'), + where: new WhereConstraint('status', 'active'), + ); + + $rule = (new RuleDenormalizer)->execute($attribute, ValidationPath::create())[0]; + + $this->assertSame('exists:tenant.users,id,removed_at,"NULL",status,"active"', (string) $rule); + } + + /** + * Test parsed parameters build a native exists rule. + */ + public function testCreatesFromParsedParameters(): void + { + $rule = (new RuleDenormalizer)->execute( + Exists::create('users', 'email'), + ValidationPath::create(), + )[0]; + + $this->assertSame('exists:users,email', (string) $rule); + } + + /** + * Test an explicit null column uses the native default sentinel. + */ + public function testUsesDefaultColumnForExplicitNull(): void + { + $rule = (new Exists('users', null))->getRule(ValidationPath::create()); + + $this->assertSame('exists:users,NULL', (string) $rule); + } + + /** + * Test invalid externally resolved parameters fail clearly. + */ + #[DataProvider('invalidResolvedParameters')] + public function testRejectsInvalidResolvedParameter(Exists $attribute, string $message): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage($message); + + $attribute->getRule(ValidationPath::create()); + } + + /** + * Provide invalid externally resolved parameter values. + */ + public static function invalidResolvedParameters(): iterable + { + yield [new Exists(new ExistsExternalReference(null)), 'Exists table must resolve to a string.']; + yield [new Exists('users', new ExistsExternalReference(42)), 'Exists column must resolve to a string or null.']; + yield [ + new Exists('users', connection: new ExistsExternalReference(false)), + 'Exists connection must resolve to a string or null.', + ]; + yield [ + new Exists('users', withoutTrashed: new ExistsExternalReference('true')), + 'Exists withoutTrashed must resolve to a boolean.', + ]; + yield [ + new Exists('users', deletedAtColumn: new ExistsExternalReference(null)), + 'Exists deletedAtColumn must resolve to a string.', + ]; + } + + /** + * Test invalid database constraints fail clearly. + */ + public function testRejectsInvalidDatabaseConstraint(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each where item must be a DatabaseConstraint or Closure'); + + (new Exists('users', where: ['invalid']))->getRule(ValidationPath::create()); + } +} + +class ExistsExternalReference implements ExternalReference +{ + public function __construct(protected mixed $value) + { + } + + /** + * Resolve the referenced value. + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/tests/Data/Attributes/Validation/InTest.php b/tests/Data/Attributes/Validation/InTest.php new file mode 100644 index 000000000..efcabb5ce --- /dev/null +++ b/tests/Data/Attributes/Validation/InTest.php @@ -0,0 +1,114 @@ +assertSame( + [$rule], + (new RuleDenormalizer)->execute(new InAttribute($rule), ValidationPath::create()), + ); + } + + /** + * Test an externally supplied native rule is preserved. + */ + public function testReturnsExternallyReferencedRule(): void + { + $rule = new InRule(['admin', 'editor']); + + $this->assertSame( + [$rule], + (new RuleDenormalizer)->execute( + new InAttribute(new InExternalReference($rule)), + ValidationPath::create(), + ), + ); + } + + /** + * Test values resolve, convert, and flatten before native rule construction. + */ + public function testBuildsRuleFromNestedValues(): void + { + $attribute = new InAttribute( + new InArrayable([ + InRole::Admin, + ['editor'], + new InExternalReference(new InArrayable(['viewer', ['owner']])), + ]), + ); + + $rule = (new RuleDenormalizer)->execute($attribute, ValidationPath::create())[0]; + + $this->assertSame('in:"admin","editor","viewer","owner"', (string) $rule); + } + + /** + * Test parsed parameters build a native in rule. + */ + public function testCreatesFromParsedParameters(): void + { + $rule = (new RuleDenormalizer)->execute( + InAttribute::create('admin', 'editor'), + ValidationPath::create(), + )[0]; + + $this->assertSame('in:"admin","editor"', (string) $rule); + } +} + +class InExternalReference implements ExternalReference +{ + public function __construct(protected mixed $value) + { + } + + /** + * Resolve the referenced value. + */ + public function getValue(): mixed + { + return $this->value; + } +} + +/** + * @implements Arrayable + */ +class InArrayable implements Arrayable +{ + public function __construct(protected array $values) + { + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + return $this->values; + } +} + +enum InRole: string +{ + case Admin = 'admin'; +} diff --git a/tests/Data/Attributes/Validation/NotInTest.php b/tests/Data/Attributes/Validation/NotInTest.php new file mode 100644 index 000000000..ce6e672a8 --- /dev/null +++ b/tests/Data/Attributes/Validation/NotInTest.php @@ -0,0 +1,114 @@ +assertSame( + [$rule], + (new RuleDenormalizer)->execute(new NotInAttribute($rule), ValidationPath::create()), + ); + } + + /** + * Test an externally supplied native rule is preserved. + */ + public function testReturnsExternallyReferencedRule(): void + { + $rule = new NotInRule(['admin', 'editor']); + + $this->assertSame( + [$rule], + (new RuleDenormalizer)->execute( + new NotInAttribute(new NotInExternalReference($rule)), + ValidationPath::create(), + ), + ); + } + + /** + * Test values resolve, convert, and flatten before native rule construction. + */ + public function testBuildsRuleFromNestedValues(): void + { + $attribute = new NotInAttribute( + new NotInArrayable([ + NotInRole::Admin, + ['editor'], + new NotInExternalReference(new NotInArrayable(['viewer', ['owner']])), + ]), + ); + + $rule = (new RuleDenormalizer)->execute($attribute, ValidationPath::create())[0]; + + $this->assertSame('not_in:"admin","editor","viewer","owner"', (string) $rule); + } + + /** + * Test parsed parameters build a native not-in rule. + */ + public function testCreatesFromParsedParameters(): void + { + $rule = (new RuleDenormalizer)->execute( + NotInAttribute::create('admin', 'editor'), + ValidationPath::create(), + )[0]; + + $this->assertSame('not_in:"admin","editor"', (string) $rule); + } +} + +class NotInExternalReference implements ExternalReference +{ + public function __construct(protected mixed $value) + { + } + + /** + * Resolve the referenced value. + */ + public function getValue(): mixed + { + return $this->value; + } +} + +/** + * @implements Arrayable + */ +class NotInArrayable implements Arrayable +{ + public function __construct(protected array $values) + { + } + + /** + * Get the instance as an array. + */ + public function toArray(): array + { + return $this->values; + } +} + +enum NotInRole: string +{ + case Admin = 'admin'; +} diff --git a/tests/Data/Attributes/Validation/PasswordTest.php b/tests/Data/Attributes/Validation/PasswordTest.php new file mode 100644 index 000000000..9ddb677cb --- /dev/null +++ b/tests/Data/Attributes/Validation/PasswordTest.php @@ -0,0 +1,189 @@ +assertSame( + $rule, + (new PasswordAttribute(rule: $rule))->getRule(ValidationPath::create()), + ); + } + + /** + * Test direct parameters configure the native rule. + */ + public function testBuildsConfiguredRule(): void + { + $rule = (new PasswordAttribute( + min: 12, + letters: true, + mixedCase: true, + numbers: true, + symbols: true, + uncompromised: true, + uncompromisedThreshold: 7, + ))->getRule(ValidationPath::create()); + + $this->assertSame([ + 'min' => 12, + 'max' => null, + 'mixedCase' => true, + 'letters' => true, + 'numbers' => true, + 'symbols' => true, + 'uncompromised' => true, + 'compromisedThreshold' => 7, + 'customRules' => [], + ], $rule->appliedRules()); + } + + /** + * Test externally resolved parameters configure the native rule. + */ + public function testBuildsConfiguredRuleFromExternalReferences(): void + { + $rule = (new PasswordAttribute( + min: new PasswordExternalReference(10), + letters: new PasswordExternalReference(true), + mixedCase: new PasswordExternalReference(true), + numbers: new PasswordExternalReference(true), + symbols: new PasswordExternalReference(true), + uncompromised: new PasswordExternalReference(true), + uncompromisedThreshold: new PasswordExternalReference(3), + default: new PasswordExternalReference(false), + ))->getRule(ValidationPath::create()); + + $this->assertSame([ + 'min' => 10, + 'max' => null, + 'mixedCase' => true, + 'letters' => true, + 'numbers' => true, + 'symbols' => true, + 'uncompromised' => true, + 'compromisedThreshold' => 3, + 'customRules' => [], + ], $rule->appliedRules()); + } + + /** + * Test the attribute uses the framework's default password rule. + */ + public function testUsesDefaultPasswordRule(): void + { + PasswordRule::defaults(fn () => PasswordRule::min(42)->uncompromised(7)); + + $rule = (new PasswordAttribute(default: true))->getRule(ValidationPath::create()); + + $this->assertSame(42, $rule->appliedRules()['min']); + $this->assertTrue($rule->appliedRules()['uncompromised']); + $this->assertSame(7, $rule->appliedRules()['compromisedThreshold']); + } + + /** + * Test the attribute's ordinary minimum remains independent of the framework default. + */ + public function testUsesConfiguredMinimumWhenDefaultIsDisabled(): void + { + PasswordRule::defaults(fn () => PasswordRule::min(42)); + + $rule = (new PasswordAttribute)->getRule(ValidationPath::create()); + + $this->assertSame(12, $rule->appliedRules()['min']); + } + + /** + * Test invalid externally resolved parameters fail clearly. + */ + #[DataProvider('invalidResolvedParameters')] + public function testRejectsInvalidResolvedParameter(PasswordAttribute $attribute, string $message): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage($message); + + $attribute->getRule(ValidationPath::create()); + } + + /** + * Provide invalid externally resolved parameter values. + */ + public static function invalidResolvedParameters(): iterable + { + yield [ + new PasswordAttribute(min: new PasswordExternalReference('12')), + 'Password min must resolve to an integer.', + ]; + yield [ + new PasswordAttribute(letters: new PasswordExternalReference(1)), + 'Password letters must resolve to a boolean.', + ]; + yield [ + new PasswordAttribute(mixedCase: new PasswordExternalReference(1)), + 'Password mixedCase must resolve to a boolean.', + ]; + yield [ + new PasswordAttribute(numbers: new PasswordExternalReference(1)), + 'Password numbers must resolve to a boolean.', + ]; + yield [ + new PasswordAttribute(symbols: new PasswordExternalReference(1)), + 'Password symbols must resolve to a boolean.', + ]; + yield [ + new PasswordAttribute(uncompromised: new PasswordExternalReference(1)), + 'Password uncompromised must resolve to a boolean.', + ]; + yield [ + new PasswordAttribute(uncompromisedThreshold: new PasswordExternalReference('7')), + 'Password uncompromisedThreshold must resolve to an integer.', + ]; + yield [ + new PasswordAttribute(default: new PasswordExternalReference(1)), + 'Password default must resolve to a boolean.', + ]; + } + + /** + * Test password rules cannot be built from string parameters. + */ + public function testCannotCreateFromStringParameters(): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage('Cannot create a password rule from string parameters.'); + + PasswordAttribute::create(); + } +} + +class PasswordExternalReference implements ExternalReference +{ + public function __construct(protected mixed $value) + { + } + + /** + * Resolve the referenced value. + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/tests/Data/Attributes/Validation/UniqueTest.php b/tests/Data/Attributes/Validation/UniqueTest.php new file mode 100644 index 000000000..9305a1303 --- /dev/null +++ b/tests/Data/Attributes/Validation/UniqueTest.php @@ -0,0 +1,162 @@ +expectException(CannotBuildValidationRule::class); + + new Unique; + } + + /** + * Test an explicitly supplied native rule is preserved. + */ + public function testReturnsProvidedRule(): void + { + $rule = new UniqueRule('users', 'email'); + + $this->assertSame( + [$rule], + (new RuleDenormalizer)->execute(new Unique(rule: $rule), ValidationPath::create()), + ); + } + + /** + * Test externally resolved parameters and constraints configure the native rule. + */ + public function testBuildsConfiguredRule(): void + { + $attribute = new Unique( + table: new UniqueExternalReference('users'), + column: new UniqueExternalReference('email'), + connection: new UniqueExternalReference('tenant'), + ignore: new UniqueExternalReference(69), + ignoreColumn: new UniqueExternalReference('uuid'), + withoutTrashed: new UniqueExternalReference(true), + deletedAtColumn: new UniqueExternalReference('removed_at'), + where: new WhereConstraint('status', 'active'), + ); + + $rule = (new RuleDenormalizer)->execute($attribute, ValidationPath::create())[0]; + + $this->assertSame( + 'unique:tenant.users,email,"69",uuid,removed_at,"NULL",status,"active"', + (string) $rule, + ); + } + + /** + * Test parsed parameters build a native unique rule. + */ + public function testCreatesFromParsedParameters(): void + { + $rule = (new RuleDenormalizer)->execute( + Unique::create('users', 'email'), + ValidationPath::create(), + )[0]; + + $this->assertSame('unique:users,email,NULL,id', (string) $rule); + } + + /** + * Test an explicit null column uses the native default sentinel. + */ + public function testUsesDefaultColumnForExplicitNull(): void + { + $rule = (new Unique('users', null))->getRule(ValidationPath::create()); + + $this->assertSame('unique:users,NULL,NULL,id', (string) $rule); + } + + /** + * Test integer zero remains a valid ignored identifier. + */ + public function testIgnoresZeroIdentifier(): void + { + $rule = (new Unique('users', 'email', ignore: 0))->getRule(ValidationPath::create()); + + $this->assertSame('unique:users,email,"0",id', (string) $rule); + } + + /** + * Test invalid externally resolved parameters fail clearly. + */ + #[DataProvider('invalidResolvedParameters')] + public function testRejectsInvalidResolvedParameter(Unique $attribute, string $message): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage($message); + + $attribute->getRule(ValidationPath::create()); + } + + /** + * Provide invalid externally resolved parameter values. + */ + public static function invalidResolvedParameters(): iterable + { + yield [new Unique(new UniqueExternalReference(null)), 'Unique table must resolve to a string.']; + yield [new Unique('users', new UniqueExternalReference(42)), 'Unique column must resolve to a string or null.']; + yield [ + new Unique('users', connection: new UniqueExternalReference(false)), + 'Unique connection must resolve to a string or null.', + ]; + yield [ + new Unique('users', ignoreColumn: new UniqueExternalReference(false)), + 'Unique ignoreColumn must resolve to a string or null.', + ]; + yield [ + new Unique('users', withoutTrashed: new UniqueExternalReference('true')), + 'Unique withoutTrashed must resolve to a boolean.', + ]; + yield [ + new Unique('users', deletedAtColumn: new UniqueExternalReference(null)), + 'Unique deletedAtColumn must resolve to a string.', + ]; + } + + /** + * Test invalid database constraints fail clearly. + */ + public function testRejectsInvalidDatabaseConstraint(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each where item must be a DatabaseConstraint or Closure'); + + (new Unique('users', where: ['invalid']))->getRule(ValidationPath::create()); + } +} + +class UniqueExternalReference implements ExternalReference +{ + public function __construct(protected mixed $value) + { + } + + /** + * Resolve the referenced value. + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/tests/Data/Attributes/Validation/ValidationAttributeTest.php b/tests/Data/Attributes/Validation/ValidationAttributeTest.php new file mode 100644 index 000000000..b5910b435 --- /dev/null +++ b/tests/Data/Attributes/Validation/ValidationAttributeTest.php @@ -0,0 +1,672 @@ +assertSame('string', (string) new StringType); + } + + /** + * Test rule parameters normalize to Validator string values. + */ + #[DataProvider('normalizedValues')] + public function testNormalizesValues(mixed $input, string $output): void + { + $attribute = new class ([$input]) extends StringValidationAttribute { + /** + * Create a test validation attribute. + * + * @param list $parameters + */ + public function __construct(protected array $parameters) + { + } + + /** + * Create the attribute from parsed string parameters. + */ + public static function create(string ...$parameters): static + { + return new static($parameters); + } + + /** + * Get the Validator rule keyword. + */ + public static function keyword(): string + { + return 'test'; + } + + /** + * Get the rule parameters. + */ + public function parameters(): array + { + return $this->parameters; + } + }; + + $this->assertSame("test:{$output}", (string) $attribute); + } + + /** + * Provide normalized rule parameter values. + */ + public static function normalizedValues(): iterable + { + yield ['Hello world', 'Hello world']; + yield [42, '42']; + yield [3.14, '3.14']; + yield [true, 'true']; + yield [false, 'false']; + yield [['a', 'b', 'c'], 'a,b,c']; + yield [[null], 'null']; + yield [ + CarbonImmutable::create( + 2020, + 5, + 16, + 0, + 0, + 0, + new DateTimeZone('Europe/Brussels'), + ), + '2020-05-16T00:00:00+02:00', + ]; + yield [ValidationAttributeBackedEnum::Foo, 'foo']; + yield [ + [ValidationAttributeBackedEnum::Foo, ValidationAttributeBackedEnum::Boo], + 'foo,boo', + ]; + } + + /** + * Test simple attributes compile from objects and parsed string parameters. + */ + #[DataProvider('stringRules')] + public function testCompilesStringValidationAttributes( + StringValidationAttribute $attribute, + string $expected, + ): void { + $rules = (new RuleDenormalizer)->execute( + $attribute, + ValidationPath::create(), + ); + + $this->assertSame([$expected], $rules); + + [, $parameters] = ValidationRuleParser::parse($expected); + $createdAttribute = $attribute::create(...$parameters); + + if ((new ReflectionMethod($attribute, 'create'))->getDeclaringClass()->getName() !== StringValidationAttribute::class) { + return; + } + + $this->assertSame( + [$expected], + (new RuleDenormalizer)->execute($createdAttribute, ValidationPath::create()), + ); + } + + /** + * Test any-of attributes compile to configured native rules. + */ + public function testCompilesAnyOfAttributes(): void + { + $rules = [['string'], ['integer']]; + $rule = (new AnyOf($rules))->getRule(ValidationPath::create()); + + $this->assertInstanceOf(AnyOfRule::class, $rule); + $this->assertSame($rules, (new ReflectionProperty($rule, 'rules'))->getValue($rule)); + } + + /** + * Test any-of rules cannot be built from string parameters. + */ + public function testCannotCreateAnyOfFromStringParameters(): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage('Cannot create an any-of rule from string parameters.'); + + AnyOf::create(); + } + + /** + * Test can attributes compile to configured native rules. + */ + public function testCompilesCanAttributes(): void + { + $rule = (new Can('update', 'post', 42))->getRule(ValidationPath::create()); + + $this->assertInstanceOf(CanRule::class, $rule); + $this->assertSame('update', (new ReflectionProperty($rule, 'ability'))->getValue($rule)); + $this->assertSame(['post', 42], (new ReflectionProperty($rule, 'arguments'))->getValue($rule)); + } + + /** + * Test can rules cannot be built from string parameters. + */ + public function testCannotCreateCanFromStringParameters(): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage('Cannot create a can rule from string parameters.'); + + Can::create(); + } + + /** + * Test dimensions attributes compile to native rule objects. + */ + public function testCompilesDimensionsAttributes(): void + { + $rules = (new RuleDenormalizer)->execute( + new Dimensions(minWidth: 15, maxHeight: 100, ratio: 1.5), + ValidationPath::create(), + ); + + $this->assertCount(1, $rules); + $this->assertInstanceOf(DimensionsRule::class, $rules[0]); + $this->assertSame('dimensions:min_width=15,max_height=100,ratio=1.5', (string) $rules[0]); + } + + /** + * Test dimensions attributes retain an explicitly supplied rule. + */ + public function testUsesProvidedDimensionsRule(): void + { + $rule = (new DimensionsRule)->width(320); + $rules = (new RuleDenormalizer)->execute( + new Dimensions(rule: $rule), + ValidationPath::create(), + ); + + $this->assertSame([$rule], $rules); + } + + /** + * Test parsed dimensions parameters use named constraints. + */ + public function testCreatesDimensionsAttributeFromParameters(): void + { + $rules = (new RuleDenormalizer)->execute( + Dimensions::create('min_width=15', 'max_height=100', 'ratio=1.5'), + ValidationPath::create(), + ); + + $this->assertSame('dimensions:min_width=15,max_height=100,ratio=1.5', (string) $rules[0]); + } + + /** + * Test enum attributes compile to configured native rule objects. + */ + public function testCompilesEnumAttributes(): void + { + $rules = (new RuleDenormalizer)->execute( + new Enum(ValidationAttributeBackedEnum::class, only: [ValidationAttributeBackedEnum::Foo]), + ValidationPath::create(), + ); + + $this->assertCount(1, $rules); + $this->assertInstanceOf(EnumRule::class, $rules[0]); + $this->assertSame('in:"foo"', (string) $rules[0]); + + $rule = new EnumRule(ValidationAttributeBackedEnum::class); + + $this->assertSame( + [$rule], + (new RuleDenormalizer)->execute( + new Enum(new ValidationAttributeExternalReference($rule)), + ValidationPath::create(), + ), + ); + + $createdRule = (new RuleDenormalizer)->execute( + Enum::create(ValidationAttributeBackedEnum::class), + ValidationPath::create(), + )[0]; + + $this->assertSame('in:"foo","boo"', (string) $createdRule); + } + + /** + * Test exclude attributes compile to strings or supplied native rules. + */ + public function testCompilesExcludeAttributes(): void + { + $denormalizer = new RuleDenormalizer; + $path = ValidationPath::create(); + + $this->assertSame(['exclude'], $denormalizer->execute(new Exclude, $path)); + + $rule = new ExcludeIfRule(true); + + $this->assertSame([$rule], $denormalizer->execute(new Exclude($rule), $path)); + $this->assertSame(['exclude'], $denormalizer->execute(Exclude::create(), $path)); + } + + /** + * Test prohibited attributes compile to strings or supplied native rules. + */ + public function testCompilesProhibitedAttributes(): void + { + $denormalizer = new RuleDenormalizer; + $path = ValidationPath::create(); + + $this->assertSame(['prohibited'], $denormalizer->execute(new Prohibited, $path)); + + $rule = new ProhibitedIfRule(true); + + $this->assertSame([$rule], $denormalizer->execute(new Prohibited($rule), $path)); + $this->assertSame(['prohibited'], $denormalizer->execute(Prohibited::create(), $path)); + } + + /** + * Test required attributes compile to strings or supplied native rules. + */ + public function testCompilesRequiredAttributes(): void + { + $denormalizer = new RuleDenormalizer; + $path = ValidationPath::create(); + + $this->assertSame(['required'], $denormalizer->execute(new Required, $path)); + + $rule = new RequiredIfRule(true); + + $this->assertSame([$rule], $denormalizer->execute(new Required($rule), $path)); + $this->assertSame(['required'], $denormalizer->execute(Required::create(), $path)); + } + + /** + * Test rule attributes flatten wrapped rule declarations. + */ + public function testCompilesWrappedRuleAttributes(): void + { + $rules = (new RuleDenormalizer)->execute( + new RuleAttribute('string|required', new Min(3), ['nullable']), + ValidationPath::create(), + ); + + $this->assertSame(['string', 'required', 'min:3', 'nullable'], $rules); + } + + /** + * Test empty dimensions declarations fail clearly. + */ + public function testRejectsEmptyDimensionsAttribute(): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage('You must specify one of width, height, minWidth, minHeight, maxWidth, maxHeight, ratio or a dimensions rule.'); + + new Dimensions; + } + + /** + * Test distinct rejects unsupported resolved modes. + */ + public function testRejectsInvalidDistinctMode(): void + { + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage('Distinct mode should be ignore_case or strict.'); + + (new Distinct(new ValidationAttributeExternalReference('invalid')))->parameters(); + } + + /** + * Test email rejects unsupported resolved modes. + */ + #[DataProvider('invalidEmailModes')] + public function testRejectsInvalidEmailMode(string|ExternalReference $mode): void + { + $this->expectException(CannotBuildValidationRule::class); + + (new Email($mode))->parameters(); + } + + /** + * Test enum rejects unsupported resolved declarations. + */ + public function testRejectsInvalidEnumDeclaration(): void + { + $this->expectException(CannotBuildValidationRule::class); + + (new Enum(new ValidationAttributeExternalReference(42)))->getRule(ValidationPath::create()); + } + + /** + * Provide unsupported email modes. + */ + public static function invalidEmailModes(): iterable + { + yield ['unsupported']; + yield [new ValidationAttributeExternalReference(['rfc', 'dns'])]; + } + + /** + * Provide simple string validation attributes. + */ + public static function stringRules(): iterable + { + yield [new Accepted, 'accepted']; + yield [new AcceptedIf('status', true), 'accepted_if:status,true']; + yield [new ActiveUrl, 'active_url']; + yield [new After('tomorrow'), 'after:tomorrow']; + yield [new AfterOrEqual('tomorrow'), 'after_or_equal:tomorrow']; + yield [new Alpha, 'alpha']; + yield [new AlphaDash, 'alpha_dash']; + yield [new AlphaNumeric, 'alpha_num']; + yield [new ArrayType(['name', 'email']), 'array:name,email']; + yield [new Ascii, 'ascii']; + yield [new Bail, 'bail']; + yield [new Base64, 'base64']; + yield [new Before('tomorrow'), 'before:tomorrow']; + yield [new BeforeOrEqual('tomorrow'), 'before_or_equal:tomorrow']; + yield [new Between(1, 10), 'between:1,10']; + yield [new BooleanType, 'boolean']; + yield [new Confirmed, 'confirmed']; + yield [new Contains(['admin', [42]], new ValidationAttributeExternalReference('member')), 'contains:admin,42,member']; + yield [new CurrentPassword, 'current_password']; + yield [new CurrentPassword('api'), 'current_password:api']; + yield [new CurrentPassword(ValidationAttributeBackedEnum::Foo), 'current_password:foo']; + yield [new CurrentPassword(new ValidationAttributeExternalReference), 'current_password:admin']; + yield [CurrentPassword::create('api'), 'current_password:api']; + yield [new Date, 'date']; + yield [new DateEquals('tomorrow'), 'date_equals:tomorrow']; + yield [new DateFormat('Y-m-d'), 'date_format:Y-m-d']; + yield [new DateFormat(['Y-m-d', 'Y-m-d H:i:s']), 'date_format:Y-m-d,Y-m-d H:i:s']; + yield [new DateFormat('Y-m-d', 'Y-m-d H:i:s'), 'date_format:Y-m-d,Y-m-d H:i:s']; + yield [new Decimal('2', '4'), 'decimal:2,4']; + yield [new Declined, 'declined']; + yield [new DeclinedIf('status', false), 'declined_if:status,false']; + yield [new Different('password'), 'different:password']; + yield [new Digits(4), 'digits:4']; + yield [new DigitsBetween(2, 6), 'digits_between:2,6']; + yield [new Distinct, 'distinct']; + yield [new Distinct(Distinct::Strict), 'distinct:strict']; + yield [new Distinct(Distinct::IgnoreCase), 'distinct:ignore_case']; + yield [new Distinct(new ValidationAttributeExternalReference(Distinct::Strict)), 'distinct:strict']; + yield [new Distinct(new ValidationAttributeExternalReference(null)), 'distinct']; + yield [ + new DoesntContain(['admin', [42]], new ValidationAttributeExternalReference('member')), + 'doesnt_contain:admin,42,member', + ]; + yield [ + new DoesntEndWith(['.php', ['.exe']], new ValidationAttributeExternalReference('.bat')), + 'doesnt_end_with:.php,.exe,.bat', + ]; + yield [ + new DoesntStartWith(['admin', ['root']], new ValidationAttributeExternalReference('system')), + 'doesnt_start_with:admin,root,system', + ]; + yield [new Email, 'email:rfc']; + yield [ + new Email(Email::DnsCheckValidation, Email::FilterUnicodeEmailValidation), + 'email:dns,filter_unicode', + ]; + yield [new Email(RFCValidation::class), 'email:' . RFCValidation::class]; + yield [new Email(new ValidationAttributeExternalReference(Email::SpoofCheckValidation)), 'email:spoof']; + yield [new Encoding('UTF-8'), 'encoding:UTF-8']; + yield [ + new EndsWith(['.json', ['.yaml']], new ValidationAttributeExternalReference('.yml')), + 'ends_with:.json,.yaml,.yml', + ]; + yield [new ExcludeIf('status', false), 'exclude_if:status,false']; + yield [new ExcludeUnless('status', 'published'), 'exclude_unless:status,published']; + yield [new ExcludeWith('archived_at'), 'exclude_with:archived_at']; + yield [new ExcludeWithout('published_at'), 'exclude_without:published_at']; + yield [new Extensions(['jpg', ['png']], new ValidationAttributeExternalReference('webp')), 'extensions:jpg,png,webp']; + yield [new File, 'file']; + yield [new Filled, 'filled']; + yield [new GreaterThan('other'), 'gt:other']; + yield [new GreaterThan(10), 'gt:10']; + yield [new GreaterThan('99999999999999999999'), 'gt:99999999999999999999']; + yield [new GreaterThanOrEqualTo('other'), 'gte:other']; + yield [new GreaterThanOrEqualTo('10'), 'gte:10']; + yield [new HexColor, 'hex_color']; + yield [new IP, 'ip']; + yield [new IPv4, 'ipv4']; + yield [new IPv6, 'ipv6']; + yield [new Image, 'image']; + yield [new InArray('roles.*'), 'in_array:roles.*']; + yield [new InArrayKeys(['name', [42]], new ValidationAttributeExternalReference('email')), 'in_array_keys:name,42,email']; + yield [new IntegerType, 'integer']; + yield [new Json, 'json']; + yield [new LessThan('other'), 'lt:other']; + yield [new LessThan('10.50'), 'lt:10.50']; + yield [new LessThanOrEqualTo('other'), 'lte:other']; + yield [new LessThanOrEqualTo(10), 'lte:10']; + yield [new ListType, 'list']; + yield [new Lowercase, 'lowercase']; + yield [new MacAddress, 'mac_address']; + yield [new Max('99999999999999999999'), 'max:99999999999999999999']; + yield [new MaxDigits(10), 'max_digits:10']; + yield [ + new MimeTypes(['image/jpeg', ['image/png']], new ValidationAttributeExternalReference('image/webp')), + 'mimetypes:image/jpeg,image/png,image/webp', + ]; + yield [new Mimes(['jpg', ['png']], new ValidationAttributeExternalReference('webp')), 'mimes:jpg,png,webp']; + yield [new Min(1.5), 'min:1.5']; + yield [new MinDigits(2), 'min_digits:2']; + yield [new Missing, 'missing']; + yield [new MissingIf('status', true, null), 'missing_if:status,true,null']; + yield [new MissingUnless('status', 1, 2.5), 'missing_unless:status,1,2.5']; + yield [new MissingWith(['email', ['phone']]), 'missing_with:email,phone']; + yield [new MissingWithAll(['email', ['phone']]), 'missing_with_all:email,phone']; + yield [new MultipleOf('0.000000000000000001'), 'multiple_of:0.000000000000000001']; + yield [new NotRegex('/foo/'), 'not_regex:/foo/']; + yield [new Nullable, 'nullable']; + yield [new Numeric, 'numeric']; + yield [new Present, 'present']; + yield [new PresentIf('status', true, null), 'present_if:status,true,null']; + yield [new PresentUnless('status', 1, 2.5), 'present_unless:status,1,2.5']; + yield [new PresentWith(['email', ['phone']]), 'present_with:email,phone']; + yield [new PresentWithAll(['email', ['phone']]), 'present_with_all:email,phone']; + yield [ + new ProhibitedIf('status', ['draft', ['pending']], new ValidationAttributeExternalReference('published')), + 'prohibited_if:status,draft,pending,published', + ]; + yield [new ProhibitedIf('enabled', true), 'prohibited_if:enabled,true']; + yield [new ProhibitedIfAccepted('terms'), 'prohibited_if_accepted:terms']; + yield [new ProhibitedIfDeclined('terms'), 'prohibited_if_declined:terms']; + yield [ + new ProhibitedUnless('status', ['draft', ['pending']], new ValidationAttributeExternalReference('published')), + 'prohibited_unless:status,draft,pending,published', + ]; + yield [new ProhibitedUnless('count', 1, 2.5), 'prohibited_unless:count,1,2.5']; + yield [new Prohibits(['email', ['phone']]), 'prohibits:email,phone']; + yield [new Regex('/foo/'), 'regex:/foo/']; + yield [ + new RequiredArrayKeys(['name', ['email']], new ValidationAttributeExternalReference('role')), + 'required_array_keys:name,email,role', + ]; + yield [ + new RequiredIf('status', ['draft', ['pending']], new ValidationAttributeExternalReference('published')), + 'required_if:status,draft,pending,published', + ]; + yield [new RequiredIf('enabled', true), 'required_if:enabled,true']; + yield [new RequiredIfAccepted('terms'), 'required_if_accepted:terms']; + yield [new RequiredIfDeclined('terms'), 'required_if_declined:terms']; + yield [ + new RequiredIf('status', 'draft', new ValidationAttributeExternalReference(null)), + 'required_if:status,draft,null', + ]; + yield [new RequiredUnless('status', null), 'required_unless:status,null']; + yield [new RequiredWith(['email', ['phone']]), 'required_with:email,phone']; + yield [new RequiredWithAll(['email', ['phone']]), 'required_with_all:email,phone']; + yield [new RequiredWithout(['email', ['phone']]), 'required_without:email,phone']; + yield [new RequiredWithoutAll(['email', ['phone']]), 'required_without_all:email,phone']; + yield [new Same('password'), 'same:password']; + yield [new Size('99999999999999999999'), 'size:99999999999999999999']; + yield [new Sometimes, 'sometimes']; + yield [ + new StartsWith(['admin', ['root']], new ValidationAttributeExternalReference('system')), + 'starts_with:admin,root,system', + ]; + yield [new Timezone, 'timezone']; + yield [new Ulid, 'ulid']; + yield [new Uppercase, 'uppercase']; + yield [new Url(['http', ['https']], new ValidationAttributeExternalReference('ftp')), 'url:http,https,ftp']; + yield [new Uuid, 'uuid']; + } +} + +class ValidationAttributeExternalReference implements ExternalReference +{ + public function __construct(protected mixed $value = 'admin') + { + } + + /** + * Resolve the referenced value. + */ + public function getValue(): mixed + { + return $this->value; + } +} + +enum ValidationAttributeBackedEnum: string +{ + case Foo = 'foo'; + case Boo = 'boo'; +} diff --git a/tests/Data/Support/Validation/CompiledValidationTest.php b/tests/Data/Support/Validation/CompiledValidationTest.php new file mode 100644 index 000000000..e5e9a8ed8 --- /dev/null +++ b/tests/Data/Support/Validation/CompiledValidationTest.php @@ -0,0 +1,105 @@ +item('first.item'), + ], + ); + + $payload = $compiled->restorePreservedValues( + ['items' => ['other' => 'value']], + ['items' => ['first.item' => $preserved]], + ); + + $this->assertSame($preserved, $payload['items']['first.item']); + $this->assertSame('value', $payload['items']['other']); + $this->assertArrayNotHasKey('first', $payload['items']); + } + + /** + * Test wildcard paths restore each existing source leaf without key collisions. + */ + public function testRestoresPreservedValuesByWildcardPath(): void + { + $compiled = new CompiledValidation( + rules: [], + preservedPaths: [ + ValidationPath::create('items')->wildcard()->property('secret'), + ], + ); + + $payload = $compiled->restorePreservedValues( + [ + 'items' => [ + 0 => ['id' => 1], + 1 => ['id' => 2], + 'literal.item' => ['id' => 3], + '*' => ['id' => 4], + ], + ], + [ + 'items' => [ + 0 => ['id' => 1], + 1 => ['id' => 2, 'secret' => 'two'], + 'literal.item' => ['id' => 3, 'secret' => null], + '*' => ['id' => 4, 'secret' => 'star'], + ], + ], + ); + + $this->assertArrayNotHasKey('secret', $payload['items'][0]); + $this->assertSame('two', $payload['items'][1]['secret']); + $this->assertArrayHasKey('secret', $payload['items']['literal.item']); + $this->assertNull($payload['items']['literal.item']['secret']); + $this->assertSame('star', $payload['items']['*']['secret']); + } + + /** + * Test a literal asterisk item key does not become a structural wildcard. + */ + public function testRestoresPreservedValuesByLiteralAsteriskPath(): void + { + $compiled = new CompiledValidation( + rules: [], + preservedPaths: [ + ValidationPath::create('items')->item('*')->property('secret'), + ], + ); + + $payload = $compiled->restorePreservedValues( + [ + 'items' => [ + '*' => ['id' => 1], + 'other' => ['id' => 2], + ], + ], + [ + 'items' => [ + '*' => ['id' => 1, 'secret' => 'star'], + 'other' => ['id' => 2, 'secret' => 'other'], + ], + ], + ); + + $this->assertSame('star', $payload['items']['*']['secret']); + $this->assertArrayNotHasKey('secret', $payload['items']['other']); + } +} diff --git a/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php b/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php new file mode 100644 index 000000000..ed732ded3 --- /dev/null +++ b/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php @@ -0,0 +1,102 @@ +apply($rule); + (new WhereNotConstraint('role', 'admin'))->apply($rule); + (new WhereNullConstraint('deleted_at'))->apply($rule); + (new WhereNotNullConstraint('verified_at'))->apply($rule); + + $this->assertSame($expected, (string) $rule); + } + + /** + * Test callback and set constraints register native query callbacks. + */ + #[DataProvider('databaseRuleObjects')] + public function testAppliesCallbackConstraints(Exists|Unique $rule): void + { + (new WhereConstraint(static fn (): null => null))->apply($rule); + (new WhereInConstraint('status', ['active', 'pending']))->apply($rule); + (new WhereNotInConstraint('role', ['admin', 'owner']))->apply($rule); + + $this->assertCount(3, $rule->queryCallbacks()); + } + + /** + * Test constraints resolve external references at application time. + */ + public function testResolvesExternalReferences(): void + { + $rule = new Exists('users', 'id'); + + (new WhereConstraint( + new DatabaseConstraintExternalReference('status'), + new DatabaseConstraintExternalReference('active'), + ))->apply($rule); + + $this->assertSame('exists:users,id,status,"active"', (string) $rule); + } + + /** + * Provide native database rules and their serialized scalar constraints. + */ + public static function databaseRules(): iterable + { + yield [ + new Exists('users', 'id'), + 'exists:users,id,status,"active",role,"!admin",deleted_at,"NULL",verified_at,"NOT_NULL"', + ]; + + yield [ + new Unique('users', 'email'), + 'unique:users,email,NULL,id,status,"active",role,"!admin",deleted_at,"NULL",verified_at,"NOT_NULL"', + ]; + } + + /** + * Provide native database rule objects. + */ + public static function databaseRuleObjects(): iterable + { + yield [new Exists('users', 'id')]; + yield [new Unique('users', 'email')]; + } +} + +class DatabaseConstraintExternalReference implements ExternalReference +{ + public function __construct(protected mixed $value) + { + } + + /** + * Resolve the referenced value. + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/tests/Data/Support/Validation/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php new file mode 100644 index 000000000..d8c265bd9 --- /dev/null +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -0,0 +1,3130 @@ + 'invalid']); + + $this->assertSame(0, $arrayData->id); + + $this->expectException(ValidationException::class); + + ValidatedDataFixture::from(Request::create('/', 'POST', ['id' => 'invalid'])); + } + + /** + * Test all three base classes share the request-only validation default. + */ + public function testBaseClassesShareRequestOnlyValidationByDefault(): void + { + $this->assertSame(0, ValidatedDtoFixture::from(['id' => 'invalid'])->id); + $this->assertSame(0, ValidatedResourceFixture::from(['id' => 'invalid'])->id); + + foreach ([ValidatedDtoFixture::class, ValidatedResourceFixture::class] as $class) { + try { + $class::from(Request::create('/', 'POST', ['id' => 'invalid'])); + $this->fail("Expected {$class} Request validation to fail."); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('id', $exception->errors()); + } + } + } + + /** + * Test a factory can validate non-Request payloads. + */ + public function testFactoryCanAlwaysValidateArrayPayloads(): void + { + $this->expectException(ValidationException::class); + + ValidatedDataFixture::factory() + ->alwaysValidate() + ->from(['id' => 'invalid']); + } + + /** + * Test validation-only mode returns uncast validated input. + */ + public function testValidateReturnsValidatedPayloadWithoutCasting(): void + { + $validated = ValidatedDataFixture::validate(['id' => '12']); + + $this->assertSame(['id' => '12'], $validated); + } + + /** + * Test validate-and-create casts only after validation succeeds. + */ + public function testValidateAndCreateUsesTheSameRulesBeforeCasting(): void + { + $data = ValidatedDataFixture::validateAndCreate(['id' => '12']); + + $this->assertSame(12, $data->id); + } + + /** + * Test inferred rules follow the declared presence and primitive types. + */ + public function testExposesInferredValidationRules(): void + { + $rules = ValidatedDataFixture::getValidationRules(['id' => 1]); + + $this->assertSame(['required', 'integer'], $rules['id']); + $this->assertSame(['nullable', 'string'], $rules['nickname']); + $this->assertSame(['sometimes', 'string'], $rules['note']); + $this->assertSame(['string'], $rules['label']); + } + + /** + * Test mixed collection wire choices produce exact mapped error paths. + */ + public function testValidatesNestedDataCollectionsWithMappedWireKeys(): void + { + try { + ValidatedParentDataFixture::factory() + ->alwaysValidate() + ->from([ + 'children' => [ + ['profile' => ['name' => 123]], + ['name' => 456], + ], + ]); + $this->fail('Expected nested validation to fail.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('children.0.profile.name', $exception->errors()); + $this->assertArrayHasKey('children.1.name', $exception->errors()); + } + } + + /** + * Test validation materializes lazy data items into an array declaration. + */ + public function testValidationMaterializesLazyCollectionForArrayProperty(): void + { + $data = ValidatedParentDataFixture::validateAndCreate([ + 'children' => LazyCollection::make([ + ['name' => 'Taylor'], + ]), + ]); + + $this->assertIsArray($data->children); + $this->assertInstanceOf(ValidatedChildDataFixture::class, $data->children[0]); + } + + /** + * Test validation rebuilds a declared LazyCollection after materializing it. + */ + public function testValidationRebuildsDeclaredLazyCollection(): void + { + $data = ValidatedLazyParentDataFixture::validateAndCreate([ + 'children' => LazyCollection::make([ + ['name' => 'Taylor'], + ]), + ]); + + $this->assertInstanceOf(LazyCollection::class, $data->children); + $this->assertInstanceOf( + ValidatedChildDataFixture::class, + $data->children->first(), + ); + } + + /** + * Test rule introspection materializes lazy collections for nested rules. + */ + public function testRuleIntrospectionMaterializesLazyCollections(): void + { + $children = [ + ['name' => 'Taylor'], + ['name' => 'Abigail'], + ]; + $arrayRules = ValidatedLazyParentDataFixture::getValidationRules([ + 'children' => $children, + ]); + $lazyRules = ValidatedLazyParentDataFixture::getValidationRules([ + 'children' => LazyCollection::make($children), + ]); + + $this->assertSame($arrayRules, $lazyRules); + $this->assertArrayHasKey('children.*.name', $lazyRules); + } + + /** + * Test class wildcard rules follow each observed collection wire path. + */ + public function testTranslatesClassWildcardRulesAcrossMixedWireKeys(): void + { + try { + ValidatedParentDataFixture::validateAndCreate([ + 'children' => [ + ['profile' => ['name' => 'one']], + ['name' => 'two'], + ], + ]); + $this->fail('Expected nested class rules to fail.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('children.0.profile.name', $exception->errors()); + $this->assertArrayHasKey('children.1.name', $exception->errors()); + } + } + + /** + * Test uniform static collections compile one wildcard rule template. + */ + public function testUniformStaticCollectionUsesWildcardRules(): void + { + $rules = ValidatedParentDataFixture::getValidationRules([ + 'children' => [ + ['name' => 'Taylor'], + ['name' => 'Swift'], + ], + ]); + + $this->assertArrayHasKey('children.*.name', $rules); + $this->assertArrayNotHasKey('children.0.name', $rules); + $this->assertArrayNotHasKey('children.1.name', $rules); + } + + /** + * Test identical dynamic child rules retain wildcard collection paths. + */ + public function testIdenticalDynamicChildRulesUseWildcardCollectionPaths(): void + { + $rules = DynamicRulesParentDataFixture::getValidationRules([ + 'children' => [ + ['name' => 'Taylor'], + ['name' => 'Taylor'], + ], + ]); + + $this->assertSame(['in:Taylor'], $rules['children.*.name']); + $this->assertArrayNotHasKey('children.0.name', $rules); + $this->assertArrayNotHasKey('children.1.name', $rules); + } + + /** + * Test divergent dynamic child rules retain an empty wildcard identity marker. + */ + public function testDivergentDynamicChildRulesUseConcreteCollectionPaths(): void + { + $rules = DynamicRulesParentDataFixture::getValidationRules([ + 'children' => [ + ['name' => 'Taylor'], + ['name' => 'Swift'], + ], + ]); + + $this->assertSame(['in:Taylor'], $rules['children.0.name']); + $this->assertSame(['in:Swift'], $rules['children.1.name']); + $this->assertSame([], $rules['children.*.name']); + } + + /** + * Test nested dynamic rule graphs compare every outer collection item. + */ + public function testNestedDynamicRuleGraphsRecompileOuterCollectionPaths(): void + { + $rules = NestedDynamicRulesParentDataFixture::getValidationRules([ + 'items' => [ + ['child' => ['name' => 'Taylor']], + ['child' => ['name' => 'Swift']], + ], + ]); + + $this->assertSame(['in:Taylor'], $rules['items.0.child.name']); + $this->assertSame(['in:Swift'], $rules['items.1.child.name']); + $this->assertSame([], $rules['items.*.child.name']); + } + + /** + * Test nested structural markers retain one complete wildcard identity. + */ + public function testNestedDynamicCollectionsDoNotRetainPartialWildcardRules(): void + { + $rules = NestedDynamicCollectionParentDataFixture::getValidationRules([ + 'groups' => [ + ['children' => [ + ['name' => 'Taylor'], + ['name' => 'Swift'], + ]], + ['children' => [ + ['name' => 'Abigail'], + ['name' => 'Joseph'], + ]], + ], + ]); + + $this->assertSame(['in:Taylor'], $rules['groups.0.children.0.name']); + $this->assertSame(['in:Swift'], $rules['groups.0.children.1.name']); + $this->assertSame(['in:Abigail'], $rules['groups.1.children.0.name']); + $this->assertSame(['in:Joseph'], $rules['groups.1.children.1.name']); + $this->assertSame([], $rules['groups.*.children.*.name']); + $this->assertArrayNotHasKey('groups.*.children.0.name', $rules); + $this->assertArrayNotHasKey('groups.*.children.1.name', $rules); + + $nameRules = array_filter( + $rules, + static fn (string $key): bool => str_ends_with($key, '.name'), + ARRAY_FILTER_USE_KEY, + ); + + $this->assertSame('groups.*.children.*.name', array_key_first($nameRules)); + } + + /** + * Test nested dynamic collection sizes retain exactly their supplied values. + * + * @param array> $groupChildNames + */ + #[DataProvider('nestedDynamicCollectionSizeCases')] + public function testNestedDynamicCollectionSizesCompileAuthoritatively( + array $groupChildNames, + ): void { + $this->app->make(ValidationFactory::class)->excludeUnvalidatedArrayKeys(); + $groups = []; + + foreach ($groupChildNames as $key => $childNames) { + $groups[$key] = [ + 'children' => array_map( + static fn (string $name): array => ['name' => $name], + $childNames, + ), + ]; + } + + $validated = NestedDynamicCollectionParentDataFixture::validate([ + 'groups' => $groups, + ]); + $data = NestedDynamicCollectionParentDataFixture::validateAndCreate([ + 'groups' => $groups, + ]); + $createdNames = []; + + foreach ($data->groups as $key => $group) { + $createdNames[$key] = array_map( + static fn (DynamicRulesChildDataFixture $child): string => $child->name, + $group->children, + ); + } + + $this->assertSame(array_keys($groups), array_keys($validated['groups'])); + $this->assertSame(array_keys($groups), array_keys($data->groups)); + $this->assertSame($groupChildNames, $createdNames); + + if (array_is_list($groups)) { + $this->assertTrue(array_is_list($validated['groups'])); + } + } + + /** + * Get nested dynamic collection size cases. + * + * @return array>}> + */ + public static function nestedDynamicCollectionSizeCases(): array + { + return [ + 'later group has more children' => [[ + ['Taylor'], + ['Abigail', 'Joseph'], + ]], + 'later group has fewer children' => [[ + ['Taylor', 'Swift'], + ['Abigail'], + ]], + 'string keys retain order' => [[ + 'primary' => ['Taylor'], + 'secondary' => ['Abigail', 'Joseph'], + ]], + 'numeric gaps remain gaps' => [[ + 1 => ['Taylor'], + 3 => ['Abigail', 'Joseph'], + ]], + ]; + } + + /** + * Test nested distinct rules compare across every wildcard level. + */ + public function testNestedDistinctRulesUseGlobalWildcardIdentity(): void + { + try { + NestedDistinctParentDataFixture::validateAndCreate([ + 'groups' => [ + ['children' => [ + ['name' => 'shared'], + ['name' => 'primary'], + ]], + ['children' => [ + ['name' => 'shared'], + ['name' => 'secondary'], + ]], + ], + ]); + $this->fail('Expected duplicate values across groups to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('groups.0.children.0.name', $exception->errors()); + $this->assertArrayHasKey('groups.1.children.0.name', $exception->errors()); + } + } + + /** + * Test unique nested values pass regardless of wildcard compilation depth. + */ + public function testNestedDistinctRulesAcceptValuesUniqueAcrossTheGraph(): void + { + $data = NestedDistinctParentDataFixture::validateAndCreate([ + 'groups' => [ + ['children' => [ + ['name' => 'first'], + ['name' => 'second'], + ]], + ['children' => [ + ['name' => 'third'], + ['name' => 'fourth'], + ]], + ], + ]); + + $this->assertSame('first', $data->groups[0]->children[0]->name); + $this->assertSame('third', $data->groups[1]->children[0]->name); + } + + /** + * Test partial wildcard and exact contributors retain one global identity. + */ + public function testNestedDistinctRulesCombinePartialAndExactContributors(): void + { + $payload = [ + 'groups' => [ + ['children' => [ + ['name' => 'shared', 'category' => 'same'], + ['name' => 'second', 'category' => 'same'], + ]], + ['children' => [ + ['name' => 'shared', 'category' => 'first'], + ['name' => 'fourth', 'category' => 'second'], + ]], + ], + ]; + $rules = MixedNestedDistinctParentDataFixture::getValidationRules($payload); + $nameRules = array_filter( + $rules, + static fn (string $key): bool => str_ends_with($key, '.name'), + ARRAY_FILTER_USE_KEY, + ); + + $this->assertSame('groups.*.children.*.name', array_key_first($nameRules)); + $this->assertSame([], $rules['groups.*.children.*.name']); + $this->assertArrayHasKey('groups.0.children.*.name', $rules); + $this->assertArrayHasKey('groups.1.children.0.name', $rules); + $this->assertArrayHasKey('groups.1.children.1.name', $rules); + + try { + MixedNestedDistinctParentDataFixture::validateAndCreate($payload); + $this->fail('Expected duplicate values across compilation modes to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('groups.0.children.0.name', $exception->errors()); + $this->assertArrayHasKey('groups.1.children.0.name', $exception->errors()); + } + } + + /** + * Test nested distinct rules still reject duplicate siblings. + */ + public function testNestedDistinctRulesRejectDuplicateSiblings(): void + { + try { + NestedDistinctParentDataFixture::validateAndCreate([ + 'groups' => [ + ['children' => [ + ['name' => 'duplicate'], + ['name' => 'duplicate'], + ]], + ['children' => [ + ['name' => 'primary'], + ['name' => 'secondary'], + ]], + ], + ]); + $this->fail('Expected duplicate siblings to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('groups.0.children.0.name', $exception->errors()); + $this->assertArrayHasKey('groups.0.children.1.name', $exception->errors()); + $this->assertArrayNotHasKey('groups.1.children.0.name', $exception->errors()); + $this->assertArrayNotHasKey('groups.1.children.1.name', $exception->errors()); + } + } + + /** + * Test finished items cannot narrow a nested distinct identity. + */ + #[DataProvider('finishedValueOrderCases')] + public function testFinishedNestedDistinctItemsRejectNarrowerIdentity( + bool $finishedFirst, + ): void { + $finished = new NestedDistinctChildDataFixture('finished'); + $children = $finishedFirst + ? [$finished, ['name' => 'raw']] + : [['name' => 'raw'], $finished]; + + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage( + 'Cannot build the distinct rule for [groups.*.children.*.name]', + ); + + NestedDistinctParentDataFixture::getValidationRules([ + 'groups' => [['children' => $children]], + ]); + } + + /** + * Test finished properties cannot narrow a nested distinct identity. + */ + #[DataProvider('finishedValueOrderCases')] + public function testFinishedNestedDistinctPropertiesRejectNarrowerIdentity( + bool $finishedFirst, + ): void { + $finished = new NestedDistinctChildDataFixture('duplicate'); + $items = $finishedFirst + ? [['child' => $finished], ['child' => ['name' => 'duplicate']]] + : [['child' => ['name' => 'duplicate']], ['child' => $finished]]; + + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage( + 'Cannot build the distinct rule for [items.*.child.name]', + ); + + FinishedDistinctPropertyParentDataFixture::getValidationRules(['items' => $items]); + } + + /** + * Test finished containers cannot narrow a nested distinct identity. + */ + #[DataProvider('finishedValueOrderCases')] + public function testFinishedNestedDistinctContainersRejectNarrowerIdentity( + bool $finishedFirst, + ): void { + $finished = new Collection([ + new NestedDistinctChildDataFixture('duplicate'), + ]); + $items = $finishedFirst + ? [['children' => $finished], ['children' => [['name' => 'duplicate']]]] + : [['children' => [['name' => 'duplicate']]], ['children' => $finished]]; + + $this->expectException(CannotBuildValidationRule::class); + $this->expectExceptionMessage( + 'Cannot build the distinct rule for [items.*.children.*.name]', + ); + + FinishedDistinctContainerParentDataFixture::getValidationRules(['items' => $items]); + } + + /** + * Test Data honors exclusion of unvalidated array keys. + */ + public function testHonorsExcludedUnvalidatedArrayKeys(): void + { + $this->app->make(ValidationFactory::class)->excludeUnvalidatedArrayKeys(); + + $validated = UnvalidatedArrayKeysDataFixture::validate([ + 'meta' => ['known' => 'value', 'extra' => 'filtered'], + ]); + $validatedNull = UnvalidatedArrayKeysDataFixture::validate([ + 'meta' => ['known' => null, 'extra' => 'filtered'], + ]); + + $this->assertSame(['meta' => ['known' => 'value']], $validated); + $this->assertSame(['meta' => ['known' => null]], $validatedNull); + } + + /** + * Test Data honors inclusion of unvalidated array keys. + */ + public function testHonorsIncludedUnvalidatedArrayKeys(): void + { + $this->app->make(ValidationFactory::class)->includeUnvalidatedArrayKeys(); + $payload = [ + 'meta' => ['known' => 'value', 'extra' => 'retained'], + ]; + + $this->assertSame($payload, UnvalidatedArrayKeysDataFixture::validate($payload)); + $this->assertSame( + $payload['meta'], + UnvalidatedArrayKeysDataFixture::validateAndCreate($payload)->meta, + ); + } + + /** + * Test uniform morph collections retain wildcard paths for equal dynamic rules. + */ + public function testUniformMorphUsesSelectedClassForWildcardEligibility(): void + { + $rules = DynamicMorphParentDataFixture::getValidationRules([ + 'children' => [ + ['type' => 'named', 'name' => 'Taylor'], + ['type' => 'named', 'name' => 'Taylor'], + ], + ]); + + $this->assertSame(['in:Taylor'], $rules['children.*.name']); + $this->assertArrayNotHasKey('children.0.name', $rules); + $this->assertArrayNotHasKey('children.1.name', $rules); + } + + /** + * Test an operation rule hook retains wildcard paths when output is equal. + */ + public function testIdenticalRuleHookOutputUsesWildcardCollectionPaths(): void + { + $rules = HookRulesParentDataFixture::factory() + ->beforeRules(static fn (): null => null) + ->getValidationRules([ + 'children' => [ + ['name' => 'Taylor'], + ['name' => 'Swift'], + ], + ]); + + $this->assertArrayHasKey('children.*.name', $rules); + $this->assertArrayNotHasKey('children.0.name', $rules); + $this->assertArrayNotHasKey('children.1.name', $rules); + } + + /** + * Test an operation rule hook recompiles concrete paths when output differs. + */ + public function testDivergentRuleHookOutputUsesConcreteCollectionPaths(): void + { + $rules = HookRulesParentDataFixture::factory() + ->beforeRules(static fn (DataProperty $property, ValidationPath $path, mixed $value): ?array => $property->name === 'name' + ? ['in:' . $value] + : null) + ->getValidationRules([ + 'children' => [ + ['name' => 'Taylor'], + ['name' => 'Swift'], + ], + ]); + + $this->assertSame(['in:Taylor'], $rules['children.0.name']); + $this->assertSame(['in:Swift'], $rules['children.1.name']); + $this->assertSame([], $rules['children.*.name']); + } + + /** + * Test operation rule hooks do not pollute worker rule-graph metadata. + */ + public function testRuleHooksDoNotPolluteDynamicRuleGraphMetadata(): void + { + $payload = [ + 'children' => [ + ['name' => 'Taylor'], + ['name' => 'Swift'], + ], + ]; + $hookRules = HookRulesParentDataFixture::factory() + ->beforeRules(static fn (DataProperty $property, ValidationPath $path, mixed $value): ?array => $property->name === 'name' + ? ['in:' . $value] + : null) + ->getValidationRules($payload); + $plainRules = HookRulesParentDataFixture::getValidationRules($payload); + + $this->assertArrayHasKey('children.0.name', $hookRules); + $this->assertArrayHasKey('children.1.name', $hookRules); + $this->assertArrayHasKey('children.*.name', $plainRules); + $this->assertArrayNotHasKey('children.0.name', $plainRules); + $this->assertArrayNotHasKey('children.1.name', $plainRules); + } + + /** + * Test class rules compose with uniform and concrete child rules in declaration order. + * + * @param class-string $class + * @param list $names + * @param array> $expectedRules + */ + #[DataProvider('classRuleOverlapCases')] + public function testClassRuleOverlapPreservesGeneratedBaselinesAndOrder( + string $class, + array $names, + array $expectedRules, + ): void { + $rules = $class::factory() + ->afterRules(static fn ( + array $rules, + DataProperty $property, + ValidationPath $path, + mixed $value, + ): array => $property->name === 'name' + ? [...$rules, 'in:' . $value] + : $rules) + ->getValidationRules([ + 'children' => array_map( + static fn (string $name): array => ['name' => $name], + $names, + ), + ]); + $childRules = array_filter( + $rules, + static fn (string $key): bool => str_starts_with($key, 'children.'), + ARRAY_FILTER_USE_KEY, + ); + + $this->assertSame($expectedRules, $childRules); + } + + /** + * Get class-rule overlap cases. + * + * @return array, list, array>}> + */ + public static function classRuleOverlapCases(): array + { + return [ + 'replace exact then wildcard, uniform' => [ + ReplaceExactThenWildcardRulesParentDataFixture::class, + ['Taylor', 'Taylor'], + [ + 'children.0.name' => ['min:2'], + 'children.*.name' => ['max:9'], + ], + ], + 'replace wildcard then exact, uniform' => [ + ReplaceWildcardThenExactRulesParentDataFixture::class, + ['Taylor', 'Taylor'], + [ + 'children.*.name' => ['max:9'], + 'children.0.name' => ['min:2'], + ], + ], + 'merge exact then wildcard, uniform' => [ + MergeExactThenWildcardRulesParentDataFixture::class, + ['Taylor', 'Taylor'], + [ + 'children.0.name' => ['min:2'], + 'children.*.name' => ['required', 'string', 'in:Taylor', 'max:9'], + ], + ], + 'merge wildcard then exact, uniform' => [ + MergeWildcardThenExactRulesParentDataFixture::class, + ['Taylor', 'Taylor'], + [ + 'children.*.name' => ['required', 'string', 'in:Taylor', 'max:9'], + 'children.0.name' => ['min:2'], + ], + ], + 'replace exact then wildcard, divergent' => [ + ReplaceExactThenWildcardRulesParentDataFixture::class, + ['Taylor', 'Swift'], + [ + 'children.*.name' => [], + 'children.0.name' => ['min:2', 'max:9'], + 'children.1.name' => ['max:9'], + ], + ], + 'replace wildcard then exact, divergent' => [ + ReplaceWildcardThenExactRulesParentDataFixture::class, + ['Taylor', 'Swift'], + [ + 'children.*.name' => [], + 'children.1.name' => ['max:9'], + 'children.0.name' => ['min:2'], + ], + ], + 'merge exact then wildcard, divergent' => [ + MergeExactThenWildcardRulesParentDataFixture::class, + ['Taylor', 'Swift'], + [ + 'children.*.name' => [], + 'children.0.name' => ['required', 'string', 'in:Taylor', 'min:2', 'max:9'], + 'children.1.name' => ['required', 'string', 'in:Swift', 'max:9'], + ], + ], + 'merge wildcard then exact, divergent' => [ + MergeWildcardThenExactRulesParentDataFixture::class, + ['Taylor', 'Swift'], + [ + 'children.*.name' => [], + 'children.1.name' => ['required', 'string', 'in:Swift', 'max:9'], + 'children.0.name' => ['required', 'string', 'in:Taylor', 'min:2'], + ], + ], + ]; + } + + /** + * Test final fanned class rules own inferred presence suppression. + */ + public function testFannedClassPresenceRulesSuppressInferredRequired(): void + { + $rules = MergePresenceWildcardRulesParentDataFixture::factory() + ->afterRules(static fn ( + array $rules, + DataProperty $property, + ValidationPath $path, + mixed $value, + ): array => $property->name === 'name' + ? [...$rules, 'in:' . $value] + : $rules) + ->getValidationRules([ + 'children' => [ + ['name' => 'Taylor'], + ['name' => 'Swift'], + ], + 'enabled' => false, + ]); + + $this->assertSame( + ['string', 'in:Taylor', 'required_if:enabled,true', 'max:9'], + $rules['children.0.name'], + ); + $this->assertSame( + ['string', 'in:Swift', 'required_if:enabled,true', 'max:9'], + $rules['children.1.name'], + ); + $this->assertSame([], $rules['children.*.name']); + } + + /** + * Test validation attributes supplement inference without duplicate presence rules. + */ + public function testCompilesValidationAttributes(): void + { + $rules = AttributeValidatedDataFixture::getValidationRules([]); + + $this->assertSame(['required', 'string'], $rules['name']); + } + + /** + * Test class-owned rules replace generated rules by default. + */ + public function testClassRulesReplaceGeneratedRules(): void + { + $rules = ClassRulesValidatedDataFixture::getValidationRules(['name' => 'value']); + + $this->assertSame(['min:3'], $rules['name']); + } + + /** + * Test merged requiring rules replace only the inferred requirement. + */ + public function testMergedRequiringRulesSuppressOnlyInferredRequired(): void + { + $rules = MergedRequiringRulesDataFixture::getValidationRules([ + 'enabled' => false, + ]); + + $this->assertSame( + ['string', 'required_if:enabled,true', 'max:10'], + $rules['value'], + ); + } + + /** + * Test a merged present rule replaces only the inferred requirement. + */ + public function testMergedPresentRuleSuppressesOnlyInferredRequired(): void + { + $rules = MergedPresentRuleDataFixture::getValidationRules([]); + + $this->assertSame(['string', 'present'], $rules['value']); + } + + /** + * Test merged class rules never remove an explicit requiring attribute. + */ + public function testMergedRulesPreserveExplicitRequiringAttributes(): void + { + try { + ExplicitAndMergedRequiringRulesDataFixture::validate([ + 'enabled' => false, + ]); + $this->fail('Expected the explicit required attribute to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('value', $exception->errors()); + } + } + + /** + * Test validation-only mode authorizes Request sources. + */ + public function testValidateAuthorizesRequestSources(): void + { + $this->expectException(AuthorizationException::class); + + UnauthorizedValidatedDataFixture::validate( + Request::create('/', 'POST', ['id' => 1]), + ); + } + + /** + * Test authorization responses retain their details before a direct factory exit. + */ + public function testAuthorizationResponseRunsBeforeDirectFactoryExit(): void + { + DeniedDirectFactoryDataFixture::$factoryCalls = 0; + + try { + DeniedDirectFactoryDataFixture::from( + Request::create('/', 'POST', ['id' => 1]), + ); + $this->fail('Expected authorization to fail.'); + } catch (AuthorizationException $exception) { + $this->assertSame('Denied by policy.', $exception->getMessage()); + $this->assertSame('policy-code', $exception->getCode()); + $this->assertSame(403, $exception->status()); + $this->assertSame(0, DeniedDirectFactoryDataFixture::$factoryCalls); + } + } + + /** + * Test validation-only APIs bypass direct-returning named factories. + */ + public function testValidationOnlyModeBypassesNamedFactories(): void + { + try { + DirectFactoryValidatedDataFixture::validate(['id' => 'invalid']); + $this->fail('Expected raw payload validation to fail.'); + } catch (ValidationException) { + } + + $data = DirectFactoryValidatedDataFixture::validateAndCreate(['id' => 'invalid']); + + $this->assertSame(99, $data->id); + } + + /** + * Test rule introspection retains its upstream array-only payload contract. + */ + public function testRuleIntrospectionAcceptsOnlyArrays(): void + { + $parameter = (new ReflectionMethod( + ValidatedDataFixture::class, + 'getValidationRules', + ))->getParameters()[0]; + + $this->assertSame('array', (string) $parameter->getType()); + } + + /** + * Test rule introspection exits before unrelated lifecycle declarations. + */ + public function testRuleIntrospectionDoesNotResolveMessagesOrAttributes(): void + { + RuleIntrospectionLifecycleDataFixture::$rulesCalls = 0; + RuleIntrospectionLifecycleDataFixture::$messagesCalls = 0; + RuleIntrospectionLifecycleDataFixture::$attributesCalls = 0; + + $rules = RuleIntrospectionLifecycleDataFixture::getValidationRules([]); + + $this->assertSame(['required'], $rules['value']); + $this->assertSame(1, RuleIntrospectionLifecycleDataFixture::$rulesCalls); + $this->assertSame(0, RuleIntrospectionLifecycleDataFixture::$messagesCalls); + $this->assertSame(0, RuleIntrospectionLifecycleDataFixture::$attributesCalls); + } + + /** + * Test finished nested Data values own and preserve their validation path. + */ + public function testFinishedNestedDataSkipsDeclaredRulesAndRetainsIdentity(): void + { + $child = new FinishedValidatedChildDataFixture('x'); + $parent = FinishedValidatedParentDataFixture::validateAndCreate([ + 'child' => $child, + ]); + + $this->assertSame($child, $parent->child); + } + + /** + * Test finished nested properties latch every enclosing collection. + */ + #[DataProvider('finishedValueOrderCases')] + public function testFinishedNestedPropertiesLatchEnclosingCollections( + bool $finishedFirst, + ): void { + $finished = new FinishedValidatedChildDataFixture('finished'); + $items = $finishedFirst + ? [['child' => $finished], ['child' => ['name' => 'raw']]] + : [['child' => ['name' => 'raw']], ['child' => $finished]]; + $rawIndex = $finishedFirst ? 1 : 0; + $finishedIndex = $finishedFirst ? 0 : 1; + $rules = FinishedNestedParentDataFixture::getValidationRules([ + 'items' => $items, + ]); + $data = FinishedNestedParentDataFixture::validateAndCreate([ + 'items' => $items, + ]); + + $this->assertSame(['min:3'], $rules["items.{$rawIndex}.child.name"]); + $this->assertArrayNotHasKey("items.{$finishedIndex}.child.name", $rules); + $this->assertArrayNotHasKey('items.*.child.name', $rules); + $this->assertSame($finished, $data->items[$finishedIndex]->child); + $this->assertSame('raw', $data->items[$rawIndex]->child->name); + } + + /** + * Test finished data collections latch every enclosing collection. + */ + #[DataProvider('finishedValueOrderCases')] + public function testFinishedDataCollectionsLatchEnclosingCollections( + bool $finishedFirst, + ): void { + $finished = new DataCollection(FinishedValidatedChildDataFixture::class, [ + 'finished' => new FinishedValidatedChildDataFixture('finished'), + ]); + $items = $finishedFirst + ? [['children' => $finished], ['children' => ['raw' => ['name' => 'raw']]]] + : [['children' => ['raw' => ['name' => 'raw']]], ['children' => $finished]]; + $rawIndex = $finishedFirst ? 1 : 0; + $finishedIndex = $finishedFirst ? 0 : 1; + $payload = ['items' => $items]; + $rules = FinishedDataCollectionParentDataFixture::getValidationRules($payload); + $data = FinishedDataCollectionParentDataFixture::validateAndCreate($payload); + + $this->assertSame(['min:3'], $rules["items.{$rawIndex}.children.*.name"]); + $this->assertArrayNotHasKey("items.{$finishedIndex}.children.*.name", $rules); + $this->assertArrayNotHasKey('items.*.children.*.name', $rules); + $this->assertSame($finished, $data->items[$finishedIndex]->children); + $this->assertSame('raw', $data->items[$rawIndex]->children->items()['raw']->name); + } + + /** + * Test finished native collections latch every enclosing collection. + */ + #[DataProvider('finishedValueOrderCases')] + public function testFinishedNativeCollectionsLatchEnclosingCollections( + bool $finishedFirst, + ): void { + $finished = new Collection([ + 'finished' => new FinishedValidatedChildDataFixture('finished'), + ]); + $items = $finishedFirst + ? [['children' => $finished], ['children' => ['raw' => ['name' => 'raw']]]] + : [['children' => ['raw' => ['name' => 'raw']]], ['children' => $finished]]; + $rawIndex = $finishedFirst ? 1 : 0; + $finishedIndex = $finishedFirst ? 0 : 1; + $payload = ['items' => $items]; + $rules = FinishedNativeCollectionParentDataFixture::getValidationRules($payload); + $data = FinishedNativeCollectionParentDataFixture::validateAndCreate($payload); + + $this->assertSame(['min:3'], $rules["items.{$rawIndex}.children.*.name"]); + $this->assertArrayNotHasKey("items.{$finishedIndex}.children.*.name", $rules); + $this->assertArrayNotHasKey('items.*.children.*.name', $rules); + $this->assertSame($finished, $data->items[$finishedIndex]->children); + $this->assertSame('raw', $data->items[$rawIndex]->children->get('raw')->name); + } + + /** + * Test finished nested collection items latch every enclosing collection. + */ + #[DataProvider('finishedValueOrderCases')] + public function testFinishedNestedCollectionItemsLatchEnclosingCollections( + bool $finishedFirst, + ): void { + $finished = new FinishedValidatedChildDataFixture('finished'); + $children = $finishedFirst + ? [$finished, ['name' => 'raw']] + : [['name' => 'raw'], $finished]; + $rawIndex = $finishedFirst ? 1 : 0; + $finishedIndex = $finishedFirst ? 0 : 1; + $payload = [ + 'groups' => [['children' => $children]], + ]; + $rules = FinishedNestedCollectionParentDataFixture::getValidationRules($payload); + $data = FinishedNestedCollectionParentDataFixture::validateAndCreate($payload); + + $this->assertSame(['min:3'], $rules["groups.0.children.{$rawIndex}.name"]); + $this->assertArrayNotHasKey("groups.0.children.{$finishedIndex}.name", $rules); + $this->assertArrayNotHasKey('groups.*.children.*.name', $rules); + $this->assertArrayNotHasKey("groups.*.children.{$rawIndex}.name", $rules); + $this->assertSame($finished, $data->groups[0]->children[$finishedIndex]); + $this->assertSame('raw', $data->groups[0]->children[$rawIndex]->name); + } + + /** + * Get finished-value order cases. + * + * @return array + */ + public static function finishedValueOrderCases(): array + { + return [ + 'finished first' => [true], + 'finished last' => [false], + ]; + } + + /** + * Test a direct factory exit cannot hide a raw collection sibling. + */ + public function testDirectFactoryFinishedValueDoesNotHideRawSibling(): void + { + try { + DirectFinishedParentDataFixture::validateAndCreate([ + 'children' => [ + ['finished' => true, 'name' => 'finished'], + ['name' => 'invalid'], + ], + ]); + $this->fail('Expected the raw sibling to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('children.1.name', $exception->errors()); + $this->assertArrayNotHasKey('children.0.name', $exception->errors()); + } + } + + /** + * Test a strict root rejects input outside its compiled schema. + */ + public function testFailOnUnknownFieldsRejectsUnknownRootInput(): void + { + try { + StrictValidatedDataFixture::validateAndCreate([ + 'name' => 'Taylor', + 'role' => 'admin', + ]); + $this->fail('Expected unknown-field validation to fail.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('role', $exception->errors()); + } + } + + /** + * Test strictness applies at the selected nested data class. + */ + public function testFailOnUnknownFieldsRejectsUnknownNestedInput(): void + { + try { + NestedStrictParentDataFixture::validateAndCreate([ + 'child' => [ + 'name' => 'Taylor', + 'role' => 'admin', + ], + ]); + $this->fail('Expected nested unknown-field validation to fail.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('child.role', $exception->errors()); + } + } + + /** + * Test a strict parent keeps ordinary nested Data structured. + */ + public function testStrictParentDoesNotTreatNestedDataAsAnOpaqueSubtree(): void + { + try { + StrictNestedParentDataFixture::validateAndCreate([ + 'child' => [ + 'name' => 'Taylor', + 'role' => 'admin', + ], + ]); + $this->fail('Expected nested unknown-field validation to fail.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('child.role', $exception->errors()); + } + } + + /** + * Test direct Request query input stays outside the unknown-field boundary. + */ + public function testFailOnUnknownFieldsIgnoresDirectRequestQueryInput(): void + { + $request = Request::create('/?tracking=campaign', 'POST', [ + 'name' => 'Taylor', + ]); + + $data = StrictValidatedDataFixture::from($request); + + $this->assertSame('Taylor', $data->name); + } + + /** + * Test a nested strict array retains query values selected by its parent Request. + */ + public function testFailOnUnknownFieldsChecksNestedArraysFromRequestQueryInput(): void + { + $request = Request::create( + '/?child[name]=Taylor&child[role]=admin', + 'GET', + ); + + try { + NestedStrictParentDataFixture::from($request); + $this->fail('Expected nested query input to fail unknown-field validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('child.role', $exception->errors()); + } + } + + /** + * Test a strict parent checks caller input removed by its prepare hook. + */ + public function testFailOnUnknownFieldsUsesInputBeforeTheCurrentNodePrepareHook(): void + { + try { + StrictNestedParentDataFixture::factory() + ->alwaysValidate() + ->prepareData(static function (array $payload): array { + unset($payload['role']); + + return $payload; + }) + ->from([ + 'child' => ['name' => 'Taylor'], + 'role' => 'admin', + ]); + $this->fail('Expected removed caller input to fail unknown-field validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('role', $exception->errors()); + } + } + + /** + * Test uniform collections accept wildcard-shaped exact and subtree auxiliaries. + */ + public function testFailOnUnknownFieldsAcceptsUniformCollectionAuxiliaryPaths(): void + { + $data = AuxiliaryParentDataFixture::validateAndCreate([ + 'items' => [ + [ + 'id' => 1, + 'serverUser' => 'first', + 'meta' => ['source' => 'import'], + 'literal*' => 'one', + ], + [ + 'id' => 2, + 'serverUser' => 'second', + 'meta' => [], + 'literal*' => 'two', + 'note' => 'later item', + ], + ], + ]); + + $this->assertSame('first', $data->items[0]->serverUser); + $this->assertSame(['source' => 'import'], $data->items[0]->meta); + $this->assertInstanceOf(Optional::class, $data->items[0]->note); + $this->assertSame('two', $data->items[1]->literalStar); + $this->assertSame('later item', $data->items[1]->note); + } + + /** + * Test contextual echoes are known input while server values remain authoritative. + */ + public function testFailOnUnknownFieldsAcceptsContextualEchoesWithoutUsingThem(): void + { + config([ + 'tests.data.server_user' => 42, + 'tests.data.context' => ['source' => 'server'], + ]); + + $data = ContextualStrictDataFixture::validateAndCreate([ + 'name' => 'Taylor', + 'server_user' => 7, + 'context' => ['source' => 'client'], + ]); + + $this->assertSame(42, $data->serverUser); + $this->assertSame(['source' => 'server'], $data->context); + } + + /** + * Test unstructured mixed values and declared arrays retain their contents. + */ + public function testFailOnUnknownFieldsAllowsUnstructuredDeclaredValues(): void + { + $data = UnstructuredStrictDataFixture::validateAndCreate([ + 'meta' => ['source' => ['name' => 'import']], + 'options' => ['one', 'two'], + ]); + + $this->assertSame(['source' => ['name' => 'import']], $data->meta); + $this->assertSame(['one', 'two'], $data->options); + } + + /** + * Test unknown-field checking uses rules added by the root Validator hook. + */ + public function testFailOnUnknownFieldsUsesEffectiveValidatorRules(): void + { + $data = DynamicStrictValidatedDataFixture::validateAndCreate([ + 'name' => 'Taylor', + 'nickname' => 'Tay', + ]); + + $this->assertSame('Taylor', $data->name); + } + + /** + * Test factory validation hooks run once in their documented flow order. + */ + public function testFactoryValidationHooksRunInFlowOrder(): void + { + $calls = []; + + $data = FactoryValidationHooksDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(function (array $payload) use (&$calls): array { + $calls[] = 'before-validation'; + $payload['value'] = 'prepared'; + + return $payload; + }) + ->beforeRules(function ( + DataProperty $property, + ValidationPath $path, + mixed $value, + ) use (&$calls): array { + $calls[] = 'before-rules'; + $this->assertSame('value', $property->name); + $this->assertSame('value', $path->get()); + $this->assertSame('prepared', $value); + + return ['in:prepared']; + }) + ->afterRules(function (array $rules) use (&$calls): array { + $calls[] = 'after-rules'; + + return [...$rules, 'string']; + }) + ->withValidator(function (Validator $validator) use (&$calls): void { + $calls[] = 'with-validator'; + $this->assertArrayHasKey('value', $validator->getRulesWithoutPlaceholders()); + }) + ->afterValidation(function (array $payload) use (&$calls): array { + $calls[] = 'after-validation'; + $payload['value'] = 'validated'; + + return $payload; + }) + ->from(['value' => 'raw']); + + $this->assertSame('validated', $data->value); + $this->assertSame([ + 'before-validation', + 'before-rules', + 'after-rules', + 'with-validator', + 'after-validation', + ], $calls); + } + + /** + * Test validation hooks can add a nested data value before rules are compiled. + */ + public function testBeforeValidationReconcilesHookAddedNestedData(): void + { + try { + HookReconciliationParentDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [ + ...$payload, + 'child' => ['name' => 123], + ]) + ->from([]); + $this->fail('Expected the hook-added nested value to be validated.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('child.name', $exception->errors()); + } + + $data = HookReconciliationParentDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [ + ...$payload, + 'child' => ['name' => 'Taylor'], + ]) + ->from([]); + + $this->assertInstanceOf(HookReconciliationChildDataFixture::class, $data->child); + $this->assertSame('Taylor', $data->child->name); + } + + /** + * Test validation hooks reselect scalar wire keys and canonical absence paths. + */ + public function testBeforeValidationReconcilesScalarMappingsAndRemoval(): void + { + $data = HookMappedScalarDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (): array => [ + 'email_address' => 'new@example.com', + ]) + ->from(['email' => 'old@example.com']); + + $this->assertSame('new@example.com', $data->email); + + try { + HookMappedScalarDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (): array => []) + ->from(['email' => 'old@example.com']); + $this->fail('Expected the removed mapped property to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('email_address', $exception->errors()); + $this->assertArrayNotHasKey('email', $exception->errors()); + } + } + + /** + * Test validation hooks can replace filled data with a named-factory value. + */ + public function testBeforeValidationReconcilesStructuredValuesThroughNamedFactories(): void + { + HookFactoryChildDataFixture::$factoryCalls = 0; + + $data = HookFactoryParentDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [ + ...$payload, + 'child' => 'replacement', + ]) + ->from([ + 'child' => ['name' => 'original'], + ]); + + $this->assertSame('factory:replacement', $data->child->name); + $this->assertSame(1, HookFactoryChildDataFixture::$factoryCalls); + } + + /** + * Test reconciliation does not replay earlier user transforms or sibling factories. + */ + public function testBeforeValidationPreservesEarlierHookAndFactoryResults(): void + { + HookFactoryChildDataFixture::$factoryCalls = 0; + $prepareCalls = 0; + $normalizer = new HookCountingNormalizer; + + $data = HookTransformParentDataFixture::factory() + ->alwaysValidate() + ->withNormalizers($normalizer) + ->prepareData(function (array $payload) use (&$prepareCalls): array { + ++$prepareCalls; + + return $payload; + }) + ->beforeValidation(static function (array $payload): array { + $payload['changed']['name'] = 'updated'; + + return $payload; + }) + ->from([ + 'changed' => ['name' => 'original'], + 'sibling' => 'stable', + ]); + + $this->assertSame('updated', $data->changed->name); + $this->assertSame('factory:stable', $data->sibling->name); + $this->assertSame(2, $prepareCalls); + $this->assertSame(2, $normalizer->calls); + $this->assertSame(1, HookFactoryChildDataFixture::$factoryCalls); + } + + /** + * Test post-validation hooks can add unvalidated values that still cast correctly. + */ + public function testAfterValidationReconcilesHookAddedNestedDataWithoutValidatingIt(): void + { + $data = HookReconciliationParentDataFixture::factory() + ->alwaysValidate() + ->afterValidation(static fn (array $payload): array => [ + ...$payload, + 'child' => ['name' => 123], + ]) + ->from([]); + + $this->assertInstanceOf(HookReconciliationChildDataFixture::class, $data->child); + $this->assertSame('123', $data->child->name); + } + + /** + * Test validation hooks reselect morphs before compiling their rules. + */ + public function testBeforeValidationReconcilesMorphSelectionForRulesAndConstruction(): void + { + try { + HookMorphParentDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (): array => [ + 'asset' => [ + 'type' => 'video', + 'duration' => 123, + ], + ]) + ->from([ + 'asset' => [ + 'type' => 'image', + 'width' => 640, + ], + ]); + $this->fail('Expected the reselected morph rules to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('asset.duration', $exception->errors()); + $this->assertArrayNotHasKey('asset.width', $exception->errors()); + } + + $data = HookMorphParentDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (): array => [ + 'asset' => [ + 'type' => 'video', + 'duration' => 'one minute', + ], + ]) + ->from([ + 'asset' => [ + 'type' => 'image', + 'width' => 640, + ], + ]); + + $this->assertInstanceOf(HookVideoDataFixture::class, $data->asset); + $this->assertSame('one minute', $data->asset->duration); + } + + /** + * Test hook-added models use fixed normalization without custom normalizer replay. + */ + public function testBeforeValidationUsesFixedModelNormalizationForChangedValues(): void + { + $model = new HookSourceModel; + $model->setRawAttributes(['name' => 'Taylor']); + + $data = HookReconciliationParentDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [ + ...$payload, + 'child' => $model, + ]) + ->from([]); + + $this->assertSame('Taylor', $data->child->name); + } + + /** + * Test validation hooks replace per-item mapping overrides. + */ + public function testBeforeValidationReconcilesCollectionItemMappings(): void + { + $data = HookMappedCollectionDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (): array => [ + 'items' => [ + ['email_address' => 'new@example.com'], + ], + ]) + ->from([ + 'items' => [ + ['email' => 'old@example.com'], + ], + ]); + + $this->assertSame('new@example.com', $data->items[0]->email); + + try { + HookMappedCollectionDataFixture::factory() + ->alwaysValidate() + ->beforeValidation(static fn (): array => [ + 'items' => [[]], + ]) + ->from([ + 'items' => [ + ['email' => 'old@example.com'], + ], + ]); + $this->fail('Expected the removed item property to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('items.0.email_address', $exception->errors()); + $this->assertArrayNotHasKey('items.0.email', $exception->errors()); + } + } + + /** + * Test class-owned and factory Validator hooks both run at the root. + */ + public function testClassAndFactoryValidatorHooksRunInOrder(): void + { + LifecycleValidatorHooksDataFixture::$calls = []; + $this->app->instance( + ValidationLifecycleDependency::class, + new ValidationLifecycleDependency, + ); + + $data = LifecycleValidatorHooksDataFixture::factory() + ->alwaysValidate() + ->withValidator(function (): void { + LifecycleValidatorHooksDataFixture::$calls[] = 'factory-with-validator'; + }) + ->from(['value' => 'valid']); + + $this->assertSame('valid', $data->value); + $this->assertSame([ + 'class-with-validator', + 'factory-with-validator', + 'class-after', + ], LifecycleValidatorHooksDataFixture::$calls); + } + + /** + * Test nested messages and labels follow each selected wire path. + */ + public function testTranslatesNestedMessagesAndAttributesToObservedWirePaths(): void + { + $this->app->instance( + ValidationLifecycleDependency::class, + new ValidationLifecycleDependency( + message: 'Invalid :attribute.', + attribute: 'display name', + ), + ); + + try { + LifecycleMessagesParentDataFixture::validateAndCreate([ + 'children' => [ + ['profile' => ['name' => 123]], + ['name' => 456], + ], + ]); + $this->fail('Expected nested validation to fail.'); + } catch (ValidationException $exception) { + $this->assertSame( + ['Invalid display name.'], + $exception->errors()['children.0.profile.name'], + ); + $this->assertSame( + ['Invalid display name.'], + $exception->errors()['children.1.name'], + ); + } + } + + /** + * Test lifecycle methods override declarative validation-failure attributes. + */ + public function testLifecycleMethodsOverrideFailureAttributes(): void + { + $this->app->instance( + ValidationLifecycleDependency::class, + new ValidationLifecycleDependency( + redirect: '/method-redirect', + errorBag: 'method-bag', + ), + ); + + try { + MethodConfiguredFailureDataFixture::validateAndCreate([ + 'first' => 1, + 'second' => 2, + ]); + $this->fail('Expected validation to fail.'); + } catch (ValidationException $exception) { + $this->assertCount(2, $exception->errors()); + $this->assertSame('method-bag', $exception->errorBag); + $this->assertSame('http://localhost/method-redirect', $exception->redirectTo); + } + } + + /** + * Test declarative failure settings use route URLs and stop on first failure. + */ + public function testUsesDeclarativeValidationFailureSettings(): void + { + $this->app->make(Registrar::class) + ->get('/attribute-redirect', static fn (): string => 'ok') + ->name('attribute-redirect'); + + try { + AttributeConfiguredFailureDataFixture::validateAndCreate([ + 'first' => 1, + 'second' => 2, + ]); + $this->fail('Expected validation to fail.'); + } catch (ValidationException $exception) { + $this->assertCount(1, $exception->errors()); + $this->assertSame('attribute-bag', $exception->errorBag); + $this->assertSame('http://localhost/attribute-redirect', $exception->redirectTo); + } + } + + /** + * Test null dependent values retain Laravel Validator semantics through Data. + */ + public function testRequiredUnlessAcceptsNullAndMissingComparedFields(): void + { + $this->assertSame( + ['status' => null], + NullDependentValidationDataFixture::validate(['status' => null]), + ); + $this->assertSame([], NullDependentValidationDataFixture::validate([])); + } + + /** + * Test successful validate-only Precognition exits before construction. + */ + public function testPrecognitionValidateOnlyExitsBeforeConstruction(): void + { + PrecognitiveValidatedDataFixture::$constructorCalls = 0; + $beforeCreationCalls = 0; + $request = Request::create('/', 'POST', ['value' => 'valid']); + $request->attributes->set('precognitive', true); + $request->headers->set('Precognition-Validate-Only', 'value'); + + try { + PrecognitiveValidatedDataFixture::factory() + ->beforeCreation(function (array $properties) use (&$beforeCreationCalls): array { + ++$beforeCreationCalls; + + return $properties; + }) + ->from($request); + $this->fail('Expected Precognition to abort with a successful response.'); + } catch (HttpException $exception) { + $this->assertSame(204, $exception->getStatusCode()); + $this->assertSame( + 'true', + $exception->getHeaders()['Precognition-Success'], + ); + } + + $this->assertSame(0, $beforeCreationCalls); + $this->assertSame(0, PrecognitiveValidatedDataFixture::$constructorCalls); + } + + /** + * Test a full-form precognitive request still constructs its data object. + */ + public function testFullPrecognitiveRequestContinuesThroughConstruction(): void + { + PrecognitiveValidatedDataFixture::$constructorCalls = 0; + $request = Request::create('/', 'POST', ['value' => 'valid']); + $request->attributes->set('precognitive', true); + + $data = PrecognitiveValidatedDataFixture::from($request); + + $this->assertSame('valid', $data->value); + $this->assertSame(1, PrecognitiveValidatedDataFixture::$constructorCalls); + } + + /** + * Test class after callbacks run before the Precognition success check. + */ + public function testClassAfterCallbacksCanFailPrecognition(): void + { + $request = Request::create('/', 'POST', ['value' => 'valid']); + $request->attributes->set('precognitive', true); + $request->headers->set('Precognition-Validate-Only', 'value'); + + try { + PrecognitiveAfterCallbackDataFixture::from($request); + $this->fail('Expected the class after callback to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame( + ['Rejected by the after callback.'], + $exception->errors()['value'], + ); + } + } + + /** + * Test Precognition filtering does not make declared fields unknown. + */ + public function testPrecognitionUnknownFieldsUsesUnfilteredRules(): void + { + $request = Request::create('/', 'POST', [ + 'name' => [], + 'email' => 'taylor@example.com', + ]); + $request->attributes->set('precognitive', true); + $request->headers->set('Precognition-Validate-Only', 'name'); + + try { + PrecognitiveStrictDataFixture::from($request); + $this->fail('Expected the selected field to fail validation.'); + } catch (ValidationException $exception) { + $this->assertArrayHasKey('name', $exception->errors()); + $this->assertArrayNotHasKey('email', $exception->errors()); + } + } +} + +class ValidatedDataFixture extends Data +{ + public function __construct( + public int $id, + public ?string $nickname, + public string|Optional $note, + public string $label = 'default', + ) { + } +} + +class ValidatedDtoFixture extends Dto +{ + public function __construct( + public int $id, + ) { + } +} + +class ValidatedResourceFixture extends Resource +{ + public function __construct( + public int $id, + ) { + } +} + +class ValidatedChildDataFixture extends Data +{ + public function __construct( + #[MapInputName('profile.name')] + public string $name, + ) { + } +} + +class ValidatedParentDataFixture extends Data +{ + /** + * Create a validated parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(ValidatedChildDataFixture::class)] + public array $children, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['children.*.name' => ['min:5']]; + } +} + +class DynamicRulesChildDataFixture extends Data +{ + public function __construct( + public string $name, + ) { + } + + /** + * Get item-specific validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['name' => ['in:' . $context->payload['name']]]; + } +} + +class DynamicRulesParentDataFixture extends Data +{ + /** + * Create a dynamic-rules parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(DynamicRulesChildDataFixture::class)] + public array $children, + ) { + } +} + +class NestedDynamicRulesItemDataFixture extends Data +{ + public function __construct( + public DynamicRulesChildDataFixture $child, + ) { + } +} + +class NestedDynamicRulesParentDataFixture extends Data +{ + /** + * Create a nested dynamic-rules parent fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(NestedDynamicRulesItemDataFixture::class)] + public array $items, + ) { + } +} + +class NestedDynamicCollectionItemDataFixture extends Data +{ + /** + * Create a nested dynamic-collection item fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(DynamicRulesChildDataFixture::class)] + public array $children, + ) { + } +} + +class NestedDynamicCollectionParentDataFixture extends Data +{ + /** + * Create a nested dynamic-collection parent fixture. + * + * @param array $groups + */ + public function __construct( + #[DataCollectionOf(NestedDynamicCollectionItemDataFixture::class)] + public array $groups, + ) { + } +} + +#[MergeValidationRules] +class NestedDistinctChildDataFixture extends Data +{ + public function __construct( + #[Distinct] + public string $name, + ) { + } + + /** + * Get item-specific validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['name' => ['in:' . $context->payload['name']]]; + } +} + +class NestedDistinctGroupDataFixture extends Data +{ + /** + * Create a nested distinct group fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(NestedDistinctChildDataFixture::class)] + public array $children, + ) { + } +} + +class NestedDistinctParentDataFixture extends Data +{ + /** + * Create a nested distinct parent fixture. + * + * @param array $groups + */ + public function __construct( + #[DataCollectionOf(NestedDistinctGroupDataFixture::class)] + public array $groups, + ) { + } +} + +class FinishedDistinctPropertyItemDataFixture extends Data +{ + public function __construct( + public NestedDistinctChildDataFixture $child, + ) { + } +} + +class FinishedDistinctPropertyParentDataFixture extends Data +{ + /** + * Create a finished distinct property parent fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(FinishedDistinctPropertyItemDataFixture::class)] + public array $items, + ) { + } +} + +class FinishedDistinctContainerItemDataFixture extends Data +{ + /** + * Create a finished distinct container item fixture. + * + * @param Collection $children + */ + public function __construct( + #[DataCollectionOf(NestedDistinctChildDataFixture::class)] + public Collection $children, + ) { + } +} + +class FinishedDistinctContainerParentDataFixture extends Data +{ + /** + * Create a finished distinct container parent fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(FinishedDistinctContainerItemDataFixture::class)] + public array $items, + ) { + } +} + +#[MergeValidationRules] +class MixedNestedDistinctChildDataFixture extends Data +{ + public function __construct( + #[Distinct] + public string $name, + public string $category, + ) { + } + + /** + * Get item-specific validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['category' => ['in:' . $context->payload['category']]]; + } +} + +class MixedNestedDistinctGroupDataFixture extends Data +{ + /** + * Create a mixed nested distinct group fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(MixedNestedDistinctChildDataFixture::class)] + public array $children, + ) { + } +} + +class MixedNestedDistinctParentDataFixture extends Data +{ + /** + * Create a mixed nested distinct parent fixture. + * + * @param array $groups + */ + public function __construct( + #[DataCollectionOf(MixedNestedDistinctGroupDataFixture::class)] + public array $groups, + ) { + } +} + +class HookRulesChildDataFixture extends Data +{ + public function __construct( + public string $name, + ) { + } +} + +class HookRulesParentDataFixture extends Data +{ + /** + * Create a rule-hook parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(HookRulesChildDataFixture::class)] + public array $children, + ) { + } +} + +abstract class OverlappingClassRulesParentDataFixture extends Data +{ + /** + * Create an overlapping-rules parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(HookRulesChildDataFixture::class)] + public array $children, + ) { + } +} + +class ReplaceExactThenWildcardRulesParentDataFixture extends OverlappingClassRulesParentDataFixture +{ + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return [ + 'children.0.name' => ['min:2'], + 'children.*.name' => ['max:9'], + ]; + } +} + +class ReplaceWildcardThenExactRulesParentDataFixture extends OverlappingClassRulesParentDataFixture +{ + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return [ + 'children.*.name' => ['max:9'], + 'children.0.name' => ['min:2'], + ]; + } +} + +#[MergeValidationRules] +class MergeExactThenWildcardRulesParentDataFixture extends OverlappingClassRulesParentDataFixture +{ + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return [ + 'children.0.name' => ['min:2'], + 'children.*.name' => ['max:9'], + ]; + } +} + +#[MergeValidationRules] +class MergeWildcardThenExactRulesParentDataFixture extends OverlappingClassRulesParentDataFixture +{ + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return [ + 'children.*.name' => ['max:9'], + 'children.0.name' => ['min:2'], + ]; + } +} + +#[MergeValidationRules] +class MergePresenceWildcardRulesParentDataFixture extends Data +{ + /** + * Create a merged presence-rules parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(HookRulesChildDataFixture::class)] + public array $children, + public bool $enabled, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return [ + 'children.*.name' => ['required_if:enabled,true', 'max:9'], + ]; + } +} + +abstract class DynamicMorphBaseDataFixture extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Resolve the concrete fixture class. + */ + public static function morph(array $properties): ?string + { + return $properties['type'] === 'named' + ? DynamicMorphChildDataFixture::class + : null; + } +} + +class DynamicMorphChildDataFixture extends DynamicMorphBaseDataFixture +{ + public function __construct( + string $type, + public string $name, + ) { + parent::__construct($type); + } + + /** + * Get item-specific validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['name' => ['in:' . $context->payload['name']]]; + } +} + +class DynamicMorphParentDataFixture extends Data +{ + /** + * Create a dynamic-morph parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(DynamicMorphBaseDataFixture::class)] + public array $children, + ) { + } +} + +class ValidatedLazyParentDataFixture extends Data +{ + /** + * Create a validated lazy parent fixture. + * + * @param LazyCollection $children + */ + public function __construct( + #[DataCollectionOf(ValidatedChildDataFixture::class)] + public LazyCollection $children, + ) { + } +} + +class AttributeValidatedDataFixture extends Data +{ + public function __construct( + #[Required, StringType] + public string $name = 'default', + ) { + } +} + +class UnvalidatedArrayKeysDataFixture extends Data +{ + public function __construct( + public array $meta, + ) { + } + + /** + * Get the validated child key inside the array. + */ + public static function rules(): array + { + return ['meta.known' => ['nullable', 'string']]; + } +} + +class ClassRulesValidatedDataFixture extends Data +{ + public function __construct( + public string $name, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['name' => ['min:3']]; + } +} + +#[MergeValidationRules] +class MergedRequiringRulesDataFixture extends Data +{ + public function __construct( + public string $value, + public bool $enabled, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return [ + 'value' => ['required_if:enabled,true', 'max:10'], + ]; + } +} + +#[MergeValidationRules] +class MergedPresentRuleDataFixture extends Data +{ + public function __construct( + public string $value, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['value' => ['present']]; + } +} + +#[MergeValidationRules] +class ExplicitAndMergedRequiringRulesDataFixture extends Data +{ + public function __construct( + #[Required] + public string $value, + public bool $enabled, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + return ['value' => ['required_if:enabled,true']]; + } +} + +class UnauthorizedValidatedDataFixture extends Data +{ + public function __construct( + public int $id, + ) { + } + + /** + * Determine whether the current Request may create the data object. + */ + public static function authorize(): bool + { + return false; + } +} + +class DeniedDirectFactoryDataFixture extends Data +{ + public static int $factoryCalls = 0; + + public function __construct( + public int $id, + ) { + } + + /** + * Determine whether the current Request may create the data object. + */ + public static function authorize(): AuthorizationResponse + { + return AuthorizationResponse::denyWithStatus( + 403, + 'Denied by policy.', + 'policy-code', + ); + } + + /** + * Create a finished object from a Request. + */ + public static function fromRequest(Request $request): static + { + ++self::$factoryCalls; + + return new static((int) $request->input('id')); + } +} + +class DirectFactoryValidatedDataFixture extends Data +{ + public function __construct( + public int $id, + ) { + } + + /** + * Create a finished object through a named factory. + */ + public static function fromArray(array $payload): static + { + return new static(99); + } +} + +class RuleIntrospectionLifecycleDataFixture extends Data +{ + public static int $rulesCalls = 0; + + public static int $messagesCalls = 0; + + public static int $attributesCalls = 0; + + public function __construct( + public string $value, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(): array + { + ++self::$rulesCalls; + + return ['value' => ['required']]; + } + + /** + * Get custom validation messages. + */ + public static function messages(): array + { + ++self::$messagesCalls; + + return []; + } + + /** + * Get custom validation attribute labels. + */ + public static function attributes(): array + { + ++self::$attributesCalls; + + return []; + } +} + +class FinishedValidatedChildDataFixture extends Data +{ + public function __construct( + #[Required, StringType] + public string $name, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['name' => ['min:3']]; + } +} + +#[FailOnUnknownFields] +class FinishedValidatedParentDataFixture extends Data +{ + public function __construct( + public FinishedValidatedChildDataFixture $child, + ) { + } + + /** + * Get class-owned validation rules. + */ + public static function rules(ValidationContext $context): array + { + return ['child.name' => ['required']]; + } +} + +class FinishedNestedItemDataFixture extends Data +{ + public function __construct( + public FinishedValidatedChildDataFixture $child, + ) { + } +} + +class FinishedNestedParentDataFixture extends Data +{ + /** + * Create a finished nested parent fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(FinishedNestedItemDataFixture::class)] + public array $items, + ) { + } +} + +class FinishedDataCollectionItemDataFixture extends Data +{ + public function __construct( + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public DataCollection $children, + ) { + } +} + +class FinishedDataCollectionParentDataFixture extends Data +{ + /** + * Create a finished data collection parent fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(FinishedDataCollectionItemDataFixture::class)] + public array $items, + ) { + } +} + +class FinishedNativeCollectionItemDataFixture extends Data +{ + /** + * Create a finished native collection item fixture. + * + * @param Collection $children + */ + public function __construct( + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public Collection $children, + ) { + } +} + +class FinishedNativeCollectionParentDataFixture extends Data +{ + /** + * Create a finished native collection parent fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(FinishedNativeCollectionItemDataFixture::class)] + public array $items, + ) { + } +} + +class FinishedNestedCollectionGroupDataFixture extends Data +{ + /** + * Create a finished nested collection group fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(FinishedValidatedChildDataFixture::class)] + public array $children, + ) { + } +} + +class FinishedNestedCollectionParentDataFixture extends Data +{ + /** + * Create a finished nested collection parent fixture. + * + * @param array $groups + */ + public function __construct( + #[DataCollectionOf(FinishedNestedCollectionGroupDataFixture::class)] + public array $groups, + ) { + } +} + +class DirectFinishedChildDataFixture extends Data +{ + public function __construct( + public string $name, + ) { + } + + /** + * Finish selected payloads before validation. + */ + public static function fromPayload(array $payload): static|array + { + return ($payload['finished'] ?? false) === true + ? new static($payload['name']) + : $payload; + } + + /** + * Get raw-value validation rules. + */ + public static function rules(): array + { + return ['name' => ['in:valid']]; + } +} + +class DirectFinishedParentDataFixture extends Data +{ + /** + * Create a direct-finished parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(DirectFinishedChildDataFixture::class)] + public array $children, + ) { + } +} + +#[FailOnUnknownFields] +class StrictValidatedDataFixture extends Data +{ + public function __construct( + public string $name, + ) { + } +} + +class NestedStrictParentDataFixture extends Data +{ + public function __construct( + public StrictValidatedDataFixture $child, + ) { + } +} + +#[FailOnUnknownFields] +class StrictNestedParentDataFixture extends Data +{ + public function __construct( + public NonStrictValidatedChildDataFixture $child, + ) { + } +} + +class NonStrictValidatedChildDataFixture extends Data +{ + public function __construct( + public string $name, + ) { + } +} + +class AuxiliaryParentDataFixture extends Data +{ + /** + * Create an auxiliary parent fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(AuxiliaryChildDataFixture::class)] + public array $items, + ) { + } +} + +#[FailOnUnknownFields] +class AuxiliaryChildDataFixture extends Data +{ + public function __construct( + public int $id, + #[WithoutValidation] + public string $serverUser, + #[WithoutValidation] + public array $meta, + #[MapInputName('literal*'), WithoutValidation] + public string $literalStar, + #[WithoutValidation] + public string|Optional $note, + ) { + } +} + +#[FailOnUnknownFields] +class ContextualStrictDataFixture extends Data +{ + public function __construct( + public string $name, + #[Config('tests.data.server_user'), MapInputName('server_user')] + public int $serverUser, + #[Config('tests.data.context')] + public array $context, + ) { + } +} + +#[FailOnUnknownFields] +class UnstructuredStrictDataFixture extends Data +{ + public function __construct( + public mixed $meta, + public array $options, + ) { + } +} + +#[FailOnUnknownFields] +class DynamicStrictValidatedDataFixture extends Data +{ + public function __construct( + public string $name, + ) { + } + + /** + * Add a dynamically validated input field. + */ + public static function withValidator(Validator $validator): void + { + $validator->setRules([ + ...$validator->getRulesWithoutPlaceholders(), + 'nickname' => ['string'], + ]); + } +} + +class FactoryValidationHooksDataFixture extends Data +{ + public function __construct( + public string $value, + ) { + } +} + +class HookReconciliationChildDataFixture extends Data +{ + public function __construct( + #[StringType] + public string $name, + ) { + } +} + +class HookReconciliationParentDataFixture extends Data +{ + public function __construct( + public HookReconciliationChildDataFixture|Optional $child, + ) { + } +} + +class HookMappedScalarDataFixture extends Data +{ + public function __construct( + #[MapInputName('email_address')] + public string $email, + ) { + } +} + +class HookFactoryChildDataFixture extends Data +{ + public static int $factoryCalls = 0; + + public function __construct( + public string $name, + ) { + } + + /** + * Create a fixture from one token. + */ + public static function fromToken(string $token): self + { + ++self::$factoryCalls; + + return new self("factory:{$token}"); + } +} + +class HookFactoryParentDataFixture extends Data +{ + public function __construct( + public HookFactoryChildDataFixture $child, + ) { + } +} + +class HookTransformParentDataFixture extends Data +{ + public function __construct( + public HookReconciliationChildDataFixture $changed, + public HookFactoryChildDataFixture $sibling, + ) { + } +} + +class HookCountingNormalizer implements Normalizer +{ + public int $calls = 0; + + public function normalize(mixed $value): null + { + ++$this->calls; + + return null; + } +} + +abstract class HookMorphDataFixture extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Resolve the selected hook morph fixture. + */ + public static function morph(array $properties): ?string + { + return match ($properties['type'] ?? null) { + 'image' => HookImageDataFixture::class, + 'video' => HookVideoDataFixture::class, + default => null, + }; + } +} + +class HookImageDataFixture extends HookMorphDataFixture +{ + public function __construct( + string $type, + public int $width, + ) { + parent::__construct($type); + } +} + +class HookVideoDataFixture extends HookMorphDataFixture +{ + public function __construct( + string $type, + #[StringType] + public string $duration, + ) { + parent::__construct($type); + } +} + +class HookMorphParentDataFixture extends Data +{ + public function __construct( + public HookMorphDataFixture $asset, + ) { + } +} + +class HookSourceModel extends Model +{ +} + +class HookMappedCollectionDataFixture extends Data +{ + /** + * Create a mapped collection fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(HookMappedScalarDataFixture::class)] + public array $items, + ) { + } +} + +class ValidationLifecycleDependency +{ + public function __construct( + public string $message = 'Invalid value.', + public string $attribute = 'value', + public string $redirect = '/redirect', + public string $errorBag = 'default', + ) { + } +} + +class LifecycleValidatorHooksDataFixture extends Data +{ + /** @var list */ + public static array $calls = []; + + public function __construct( + public string $value, + ) { + } + + /** + * Configure the root Validator. + */ + public static function withValidator( + Validator $validator, + ?ValidationLifecycleDependency $dependency = null, + ): void { + self::$calls[] = $dependency instanceof ValidationLifecycleDependency + ? 'class-with-validator' + : 'missing-dependency'; + } + + /** + * Get root Validator after callbacks. + */ + public static function after( + ValidationLifecycleDependency $dependency, + Validator $validator, + ): array { + return [static function () use ($dependency, $validator): void { + self::$calls[] = $dependency instanceof ValidationLifecycleDependency + && $validator->getData()['value'] === 'valid' + ? 'class-after' + : 'invalid-after-context'; + }]; + } +} + +class LifecycleMessagesChildDataFixture extends Data +{ + public function __construct( + #[MapInputName('profile.name'), StringType] + public string $name, + ) { + } + + /** + * Get custom validation messages. + */ + public static function messages(ValidationLifecycleDependency $dependency): array + { + return ['name.string' => $dependency->message]; + } + + /** + * Get custom validation attribute labels. + */ + public static function attributes(ValidationLifecycleDependency $dependency): array + { + return ['name' => $dependency->attribute]; + } +} + +class LifecycleMessagesParentDataFixture extends Data +{ + /** + * Create a lifecycle-messages parent fixture. + * + * @param array $children + */ + public function __construct( + #[DataCollectionOf(LifecycleMessagesChildDataFixture::class)] + public array $children, + ) { + } + + /** + * Get fallback validation messages. + */ + public static function messages(): array + { + return ['children.*.name.string' => 'Invalid parent :attribute.']; + } + + /** + * Get fallback validation attribute labels. + */ + public static function attributes(): array + { + return ['children.*.name' => 'parent display name']; + } +} + +#[StopOnFirstFailure] +#[ErrorBag('attribute-bag')] +#[RedirectTo('/attribute-redirect')] +#[RedirectToRoute('missing-attribute-route')] +class MethodConfiguredFailureDataFixture extends Data +{ + public function __construct( + #[StringType] + public string $first, + #[StringType] + public string $second, + ) { + } + + /** + * Determine whether validation stops after the first failure. + */ + public static function stopOnFirstFailure(): bool + { + return false; + } + + /** + * Get the validation failure redirect URL. + */ + public static function redirect(ValidationLifecycleDependency $dependency): string + { + return $dependency->redirect; + } + + /** + * Get the validation failure redirect route. + */ + public static function redirectRoute(): string + { + return 'missing-method-route'; + } + + /** + * Get the validation failure error bag. + */ + public static function errorBag(ValidationLifecycleDependency $dependency): string + { + return $dependency->errorBag; + } +} + +#[StopOnFirstFailure] +#[ErrorBag('attribute-bag')] +#[RedirectToRoute('attribute-redirect')] +class AttributeConfiguredFailureDataFixture extends Data +{ + public function __construct( + #[StringType] + public string $first, + #[StringType] + public string $second, + ) { + } +} + +class NullDependentValidationDataFixture extends Data +{ + public function __construct( + #[RequiredUnless('status', null)] + public string|Optional $name, + public mixed $status = null, + ) { + } +} + +class PrecognitiveValidatedDataFixture extends Data +{ + public static int $constructorCalls = 0; + + public function __construct( + public string $value, + ) { + ++self::$constructorCalls; + } +} + +class PrecognitiveAfterCallbackDataFixture extends Data +{ + public function __construct( + public string $value, + ) { + } + + /** + * Get root Validator after callbacks. + */ + public static function after(): array + { + return [static function (Validator $validator): void { + $validator->errors()->add('value', 'Rejected by the after callback.'); + }]; + } +} + +#[FailOnUnknownFields] +class PrecognitiveStrictDataFixture extends Data +{ + public function __construct( + public string $name, + public string $email, + ) { + } +} diff --git a/tests/Data/Support/Validation/ValidationAccumulatorTest.php b/tests/Data/Support/Validation/ValidationAccumulatorTest.php new file mode 100644 index 000000000..04b647ca3 --- /dev/null +++ b/tests/Data/Support/Validation/ValidationAccumulatorTest.php @@ -0,0 +1,240 @@ +makeAccumulator(); + $other = $this->makeAccumulator(); + + $this->assertTrue($accumulator->equals($other)); + + $change($other); + + $this->assertFalse($accumulator->equals($other)); + } + + /** + * Provide one change for every compiled accumulator output. + */ + public static function differentOutputProvider(): array + { + return [ + 'rules' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->rules['name'] = ['max:10']; + }], + 'inferred required paths' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->inferredRequiredPaths = []; + }], + 'messages' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->messages['name.required'] = 'Another message'; + }], + 'attributes' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->attributes['name'] = 'Another attribute'; + }], + 'preserved paths' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->preservedPaths = [ValidationPath::create('items.*.other')]; + }], + 'additional fields' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->additionalFields = ['items.*.other']; + }], + 'allowed subtrees' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->allowedSubtrees = ['items.*.other']; + }], + 'finished structural paths' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->finishedStructuralPaths = ['items.*.other' => true]; + }], + 'marker candidates' => [static function (ValidationAccumulator $accumulator): void { + $accumulator->addMarkerCandidate( + ValidationPath::create('items.*.name'), + ValidationPath::create('items.1.name'), + ); + }], + ]; + } + + /** + * Test merges retain every marker contributor and finished structural path. + */ + public function testMergesMarkerCandidateContributors(): void + { + $accumulator = new ValidationAccumulator; + $accumulator->addMarkerCandidate( + ValidationPath::create('items.*.name'), + ValidationPath::create('items.0.name'), + ); + $other = new ValidationAccumulator; + $other->addMarkerCandidate( + ValidationPath::create('items.*.name'), + ValidationPath::create('items.1.name'), + ); + $other->finishedStructuralPaths['items.*.profile'] = true; + + $accumulator->merge($other); + + $this->assertSame([ + 'items.*.name' => [ + 'items.0.name' => true, + 'items.1.name' => true, + ], + ], $accumulator->markerCandidates); + $this->assertSame([ + 'items.*.profile' => true, + ], $accumulator->finishedStructuralPaths); + } + + /** + * Test preserved paths compare by their canonical value rather than identity. + */ + public function testComparesPreservedPathsByCanonicalValue(): void + { + $accumulator = new ValidationAccumulator; + $accumulator->preservedPaths[] = ValidationPath::create('items.*.literal\*.name'); + $other = new ValidationAccumulator; + $other->preservedPaths[] = ValidationPath::create() + ->property('items') + ->wildcard() + ->item('literal*') + ->property('name'); + + $this->assertTrue($accumulator->equals($other)); + } + + /** + * Test rules reduced to strings by Validation compare by that result. + */ + public function testComparesStringReducedRuleValuesByTheirRenderedForm(): void + { + $accumulator = new ValidationAccumulator; + $accumulator->rules = ['name' => [new StringableRuleFixture('same')]]; + $other = new ValidationAccumulator; + $other->rules = ['name' => ['same']]; + + $this->assertTrue($accumulator->equals($other)); + + $other->rules = ['name' => [new StringableRuleFixture('different')]]; + + $this->assertFalse($accumulator->equals($other)); + } + + /** + * Test Validator rule objects compare by identity unless Validation stringifies them. + */ + public function testComparesOrdinaryRuleObjectsByIdentity(): void + { + $rule = new IdentityRuleFixture; + $accumulator = new ValidationAccumulator; + $accumulator->rules = ['name' => [$rule]]; + $other = new ValidationAccumulator; + $other->rules = ['name' => [$rule]]; + + $this->assertTrue($accumulator->equals($other)); + + $other->rules = ['name' => [new IdentityRuleFixture]]; + + $this->assertFalse($accumulator->equals($other)); + } + + /** + * Test callback-bearing database rules compare their rendered query and callbacks. + */ + public function testComparesDatabaseRuleCallbacksByIdentity(): void + { + $callback = static fn (): null => null; + $accumulator = new ValidationAccumulator; + $accumulator->rules = [ + 'email' => [(new Exists('users', 'email'))->using($callback)], + ]; + $other = new ValidationAccumulator; + $other->rules = [ + 'email' => [(new Exists('users', 'email'))->using($callback)], + ]; + + $this->assertTrue($accumulator->equals($other)); + + $other->rules = [ + 'email' => [(new Exists('users', 'email'))->using(static fn (): null => null)], + ]; + + $this->assertFalse($accumulator->equals($other)); + } + + /** + * Create one fully populated accumulator. + */ + protected function makeAccumulator(): ValidationAccumulator + { + $accumulator = new ValidationAccumulator; + $accumulator->rules = ['name' => ['required', 'string']]; + $accumulator->inferredRequiredPaths = ['name' => true]; + $accumulator->messages = ['name.required' => 'The name is required.']; + $accumulator->attributes = ['name' => 'display name']; + $accumulator->preservedPaths = [ValidationPath::create('items.*.name')]; + $accumulator->additionalFields = ['items.*.server']; + $accumulator->allowedSubtrees = ['items.*.metadata']; + $accumulator->finishedStructuralPaths = ['items.*.profile' => true]; + $accumulator->addMarkerCandidate( + ValidationPath::create('items.*.name'), + ValidationPath::create('items.0.name'), + ); + + return $accumulator; + } +} + +class StringableRuleFixture implements Stringable +{ + /** + * Create a stringable rule fixture. + */ + public function __construct( + protected readonly string $value, + ) { + } + + /** + * Get the rendered rule. + */ + public function __toString(): string + { + return $this->value; + } +} + +class IdentityRuleFixture implements Rule +{ + /** + * Determine if the rule passes. + */ + public function passes(string $attribute, mixed $value): bool + { + return true; + } + + /** + * Get the validation message. + */ + public function message(): string + { + return 'The value is invalid.'; + } +} diff --git a/tests/Data/Support/Validation/ValidationPathTest.php b/tests/Data/Support/Validation/ValidationPathTest.php new file mode 100644 index 000000000..21de4fbc0 --- /dev/null +++ b/tests/Data/Support/Validation/ValidationPathTest.php @@ -0,0 +1,173 @@ + [ + [ + 'items' => [ + ['name' => 'A'], + ['name' => 'B'], + ], + ], + [ + 'items' => [ + ['name' => 'C'], + ], + ], + ], + ]; + + $path = ValidationPath::create('sections.*.items.*.name'); + $matches = $path->matchingWildcardPayloadValidationPaths($payload); + + $this->assertSame([ + 'sections.0.items.0.name', + 'sections.0.items.1.name', + 'sections.1.items.0.name', + ], array_map( + fn (ValidationPath $match): string => $match->get(), + $matches, + )); + } + + /** + * Test trailing wildcard segments expand to the matching values. + */ + public function testExpandsTrailingWildcardSegments(): void + { + $path = ValidationPath::create('list_items.*'); + $matches = $path->matchingWildcardPayloadValidationPaths([ + 'list_items' => ['First', 'Second'], + ]); + + $this->assertSame([ + 'list_items.0', + 'list_items.1', + ], array_map( + fn (ValidationPath $match): string => (string) $match, + $matches, + )); + } + + /** + * Test literal segments after a wildcard include missing validation leaves. + */ + public function testExpandsMissingLiteralLeavesAfterWildcards(): void + { + $path = ValidationPath::create('items.*.profile.name'); + $matches = $path->matchingWildcardPayloadValidationPaths([ + 'items' => [ + ['profile' => ['name' => 'Taylor']], + ['profile' => []], + [], + ], + ]); + + $this->assertSame([ + 'items.0.profile.name', + 'items.1.profile.name', + 'items.2.profile.name', + ], array_map( + fn (ValidationPath $match): string => $match->get(), + $matches, + )); + } + + /** + * Test mapped properties and raw item keys retain distinct path semantics. + */ + public function testAppendsMappedPropertiesAndRawItemKeys(): void + { + $path = ValidationPath::create() + ->property('profile.names') + ->item('first.item') + ->property('label'); + + $this->assertSame( + ['profile', 'names', 'first.item', 'label'], + $path->segments(), + ); + $this->assertSame('profile.names.first\\.item.label', $path->get()); + $this->assertTrue($path->equals('profile.names.first\\.item.label')); + } + + /** + * Test escaped literal dots are parsed as one segment. + */ + public function testCreatesPathsWithEscapedLiteralDots(): void + { + $path = ValidationPath::create('items.first\\.item.name'); + $wildcards = ValidationPath::create('items.*.literal\\*.name'); + + $this->assertSame(['items', 'first.item', 'name'], $path->segments()); + $this->assertSame('items.first\\.item.name', $path->get()); + $this->assertSame(['items', null, 'literal*', 'name'], $wildcards->rawSegments()); + } + + /** + * Test canonical paths preserve PHP integer array keys when reparsed. + */ + public function testRoundTripsCanonicalIntegerAndEscapedSegments(): void + { + $path = new ValidationPath([ + 'items', + 0, + -1, + null, + 'literal.dot', + 'literal*', + ]); + + $this->assertSame( + $path->rawSegments(), + ValidationPath::create($path->get())->rawSegments(), + ); + } + + /** + * Test numeric-looking strings that PHP retains as array strings stay literal. + */ + public function testRetainsNonCanonicalIntegerStrings(): void + { + $outOfRange = PHP_INT_MAX . '0'; + $path = ValidationPath::create( + 'items.01.+1.-0.1\.0.' . $outOfRange, + ); + + $this->assertSame([ + 'items', + '01', + '+1', + '-0', + '1.0', + $outOfRange, + ], $path->rawSegments()); + } + + /** + * Test trailing backslashes retain Validator's fail-closed path boundary. + */ + public function testTrailingBackslashDoesNotPromiseRoundTripIdentity(): void + { + $path = new ValidationPath(['a\\', 'b']); + + $this->assertSame('a\\.b', $path->get()); + $this->assertSame( + ['a.b'], + ValidationPath::create($path->get())->rawSegments(), + ); + } +} From b0224d8203bd44025ff71d43f7747fdbc5e88f54 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:19:03 +0000 Subject: [PATCH 07/35] Implement fixed Data construction Expose Data, Dto, Resource, Optional, Lazy, and typed DataCollection entry points through one non-recursive creation engine. A root operation owns immutable options, mutable traversal state, normalizer and extension reuse, validation orchestration, and bottom-up object construction. Normalize arrays, JSON, Arrayable objects, public object properties, requests, and Eloquent models without broad serialization. Select named factories once, preserve finished compatible values, reconcile hook payloads, resolve absence in default-Optional-null order, and inject contextual constructor parameters only after validation succeeds. Add built-in scalar, enum, date, iterable-item, and Castable handling with explicit Uncastable fallback. Rebuild declared collection shapes through one DataCollectableFactory, preserve raw keys and paginator reconstruction state, and fail clearly for ambiguous or unsupported declarations. Cover source boundaries, prepared and normalized values, sparse structure state, named and container-assisted factories, contextual precedence, private constructors, defaults, casts, enums, dates, nested values, iterable containers, morph selection, and non-recursive creation. --- src/data/src/Casts/BuiltinTypeCast.php | 76 + src/data/src/Casts/CastableCast.php | 40 + src/data/src/Casts/DateTimeInterfaceCast.php | 155 ++ src/data/src/Casts/EnumCast.php | 104 + src/data/src/Casts/IterableItemCast.php | 22 + src/data/src/Concerns/AppendableData.php | 54 + src/data/src/Concerns/BaseData.php | 94 + src/data/src/Concerns/BaseDataCollectable.php | 60 + src/data/src/Concerns/EmptyData.php | 36 + src/data/src/Concerns/IncludeableData.php | 65 + src/data/src/Concerns/TransformableData.php | 82 + src/data/src/Concerns/ValidateableData.php | 52 + src/data/src/Concerns/WrappableData.php | 41 + src/data/src/Data.php | 31 + src/data/src/DataCollection.php | 187 ++ src/data/src/Dto.php | 16 + src/data/src/Lazy.php | 133 ++ .../Normalized/NormalizedModel.php | 67 + .../Normalized/UnknownProperty.php | 18 + src/data/src/Optional.php | 16 + src/data/src/Resource.php | 28 + .../Support/Creation/ConstructionState.php | 749 ++++++++ .../Creation/CreationContextFactory.php | 367 ++++ .../Creation/DataCollectableFactory.php | 141 ++ src/data/src/Support/Creation/DataCreator.php | 1667 +++++++++++++++++ .../src/Support/Creation/DataInstantiator.php | 79 + .../src/Support/Creation/SourceReader.php | 65 + .../src/Support/Creation/SourceResolver.php | 79 + tests/Data/Casts/BuiltinTypeCastTest.php | 52 + .../Data/Casts/DateTimeInterfaceCastTest.php | 183 ++ tests/Data/Casts/EnumCastTest.php | 144 ++ .../Normalized/NormalizedModelTest.php | 152 ++ .../Creation/ConstructionStateTest.php | 477 +++++ .../Data/Support/Creation/DataCreatorTest.php | 768 ++++++++ .../Support/Creation/DataInstantiatorTest.php | 243 +++ .../Support/Creation/SourceReaderTest.php | 118 ++ .../Support/Creation/SourceResolverTest.php | 113 ++ 37 files changed, 6774 insertions(+) create mode 100644 src/data/src/Casts/BuiltinTypeCast.php create mode 100644 src/data/src/Casts/CastableCast.php create mode 100644 src/data/src/Casts/DateTimeInterfaceCast.php create mode 100644 src/data/src/Casts/EnumCast.php create mode 100644 src/data/src/Casts/IterableItemCast.php create mode 100644 src/data/src/Concerns/AppendableData.php create mode 100644 src/data/src/Concerns/BaseData.php create mode 100644 src/data/src/Concerns/BaseDataCollectable.php create mode 100644 src/data/src/Concerns/EmptyData.php create mode 100644 src/data/src/Concerns/IncludeableData.php create mode 100644 src/data/src/Concerns/TransformableData.php create mode 100644 src/data/src/Concerns/ValidateableData.php create mode 100644 src/data/src/Concerns/WrappableData.php create mode 100644 src/data/src/Data.php create mode 100644 src/data/src/DataCollection.php create mode 100644 src/data/src/Dto.php create mode 100644 src/data/src/Lazy.php create mode 100644 src/data/src/Normalizers/Normalized/NormalizedModel.php create mode 100644 src/data/src/Normalizers/Normalized/UnknownProperty.php create mode 100644 src/data/src/Optional.php create mode 100644 src/data/src/Resource.php create mode 100644 src/data/src/Support/Creation/ConstructionState.php create mode 100644 src/data/src/Support/Creation/CreationContextFactory.php create mode 100644 src/data/src/Support/Creation/DataCollectableFactory.php create mode 100644 src/data/src/Support/Creation/DataCreator.php create mode 100644 src/data/src/Support/Creation/DataInstantiator.php create mode 100644 src/data/src/Support/Creation/SourceReader.php create mode 100644 src/data/src/Support/Creation/SourceResolver.php create mode 100644 tests/Data/Casts/BuiltinTypeCastTest.php create mode 100644 tests/Data/Casts/DateTimeInterfaceCastTest.php create mode 100644 tests/Data/Casts/EnumCastTest.php create mode 100644 tests/Data/Normalizers/Normalized/NormalizedModelTest.php create mode 100644 tests/Data/Support/Creation/ConstructionStateTest.php create mode 100644 tests/Data/Support/Creation/DataCreatorTest.php create mode 100644 tests/Data/Support/Creation/DataInstantiatorTest.php create mode 100644 tests/Data/Support/Creation/SourceReaderTest.php create mode 100644 tests/Data/Support/Creation/SourceResolverTest.php diff --git a/src/data/src/Casts/BuiltinTypeCast.php b/src/data/src/Casts/BuiltinTypeCast.php new file mode 100644 index 000000000..e1ba38f8c --- /dev/null +++ b/src/data/src/Casts/BuiltinTypeCast.php @@ -0,0 +1,76 @@ +runCast($value); + } + + /** + * Cast an iterable item to the configured built-in type. + */ + public function castIterableItem( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): mixed { + return $this->runCast($value); + } + + /** + * Cast one value to the configured type. + */ + protected function runCast(mixed $value): mixed + { + return match ($this->type) { + 'bool' => $this->castToBool($value), + 'int' => (int) $value, + 'float' => (float) $value, + 'array' => (array) $value, + 'string' => (string) $value, + }; + } + + /** + * Cast one value to a boolean. + */ + protected function castToBool(mixed $value): bool + { + if (! is_string($value)) { + return (bool) $value; + } + + return match (strtolower($value)) { + 'true' => true, + 'false' => false, + default => (bool) $value, + }; + } +} diff --git a/src/data/src/Casts/CastableCast.php b/src/data/src/Casts/CastableCast.php new file mode 100644 index 000000000..30913e27e --- /dev/null +++ b/src/data/src/Casts/CastableCast.php @@ -0,0 +1,40 @@ + $castableClass + * @param list $arguments + */ + public function __construct( + public readonly string $castableClass, + public readonly array $arguments = [], + ) { + } + + /** + * Cast a value through the declared Castable type. + */ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): mixed { + $this->cast ??= $this->castableClass::dataCastUsing($this->arguments); + + return $this->cast->cast($property, $value, $state, $context); + } +} diff --git a/src/data/src/Casts/DateTimeInterfaceCast.php b/src/data/src/Casts/DateTimeInterfaceCast.php new file mode 100644 index 000000000..58ee9d532 --- /dev/null +++ b/src/data/src/Casts/DateTimeInterfaceCast.php @@ -0,0 +1,155 @@ + $format + * @param null|class-string $type + */ + public function __construct( + protected readonly null|string|array $format = null, + protected readonly ?string $type = null, + protected readonly ?string $setTimeZone = null, + protected readonly ?string $timeZone = null, + ) { + } + + /** + * Cast a property value to its declared date type. + */ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): DateTimeInterface|Uncastable { + return $this->castValue( + $this->type ?? $property->type->type->findAcceptedTypeForBaseType(DateTimeInterface::class), + $value, + $context, + ); + } + + /** + * Cast an iterable item to its declared date type. + */ + public function castIterableItem( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): DateTimeInterface|Uncastable { + return $this->castValue( + $this->type ?? $this->iterableDateType($property), + $value, + $context, + ); + } + + /** + * Cast one value to a date. + * + * @param null|class-string $type + */ + protected function castValue( + ?string $type, + mixed $value, + CreationContext $context, + ): Uncastable|DateTimeInterface { + if ($type === null) { + return Uncastable::create(); + } + + $formats = $this->format === null + ? $context->dateFormats + : (is_array($this->format) ? $this->format : [$this->format]); + + if (is_string($value)) { + $value = preg_replace('/(\.\d{6})\d+/', '$1', $value); + } + + $sourceTimeZone = $this->timeZone === null ? null : new DateTimeZone($this->timeZone); + + foreach ($formats as $format) { + try { + $datetime = $this->createDate( + $type, + $format, + $value instanceof DateTimeInterface ? $value->format($format) : (string) $value, + $sourceTimeZone, + ); + } catch (Throwable) { + $datetime = null; + } + + if ($datetime === null) { + continue; + } + + $targetTimeZone = $this->setTimeZone ?? $context->dateTimezone; + + return $targetTimeZone === null + ? $datetime + : $datetime->setTimezone(new DateTimeZone($targetTimeZone)); + } + + throw CannotCastDate::create($formats, $type, $value); + } + + /** + * Create a date using the declared concrete type or Hypervel's date factory. + * + * @param class-string $type + */ + protected function createDate( + string $type, + string $format, + string $value, + ?DateTimeZone $timeZone, + ): ?DateTimeInterface { + $reflection = ClassMetadataCache::reflectClass($type); + $datetime = $reflection->isInstantiable() + ? $type::createFromFormat($format, $value, $timeZone) + : Date::createFromFormat($format, $value, $timeZone); + + if (! $datetime instanceof DateTimeInterface || ! $datetime instanceof $type) { + return null; + } + + return $datetime; + } + + /** + * Find the date type declared for iterable items. + * + * @return null|class-string + */ + protected function iterableDateType(DataProperty $property): ?string + { + foreach ($property->type->getIterableTypes() as $type) { + $date = $type->iterableItemType?->findAcceptedTypeForBaseType(DateTimeInterface::class); + + if ($date !== null) { + return $date; + } + } + + return null; + } +} diff --git a/src/data/src/Casts/EnumCast.php b/src/data/src/Casts/EnumCast.php new file mode 100644 index 000000000..abdb4695b --- /dev/null +++ b/src/data/src/Casts/EnumCast.php @@ -0,0 +1,104 @@ + $type + */ + public function __construct( + protected ?string $type = null, + ) { + } + + /** + * Cast a property value to its declared backed enum. + */ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): BackedEnum|Uncastable { + return $this->castValue( + $this->type ?? $property->type->type->findAcceptedTypeForBaseType(BackedEnum::class), + $value, + $property, + ); + } + + /** + * Cast an iterable item to its declared backed enum. + */ + public function castIterableItem( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): BackedEnum|Uncastable { + return $this->castValue( + $this->type ?? $this->iterableEnumType($property), + $value, + $property, + ); + } + + /** + * Cast one value to a backed enum. + * + * @param null|class-string $type + */ + protected function castValue( + ?string $type, + mixed $value, + DataProperty $property, + ): BackedEnum|Uncastable { + if ($type === null) { + return Uncastable::create(); + } + + if ($value instanceof $type) { + return $value; + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + try { + return $type::from($value); + } catch (Throwable) { + throw CannotCastEnum::create($type, $value, $property); + } + } + + /** + * Find the backed enum declared for iterable items. + * + * @return null|class-string + */ + protected function iterableEnumType(DataProperty $property): ?string + { + foreach ($property->type->getIterableTypes() as $type) { + $enum = $type->iterableItemType?->findAcceptedTypeForBaseType(BackedEnum::class); + + if ($enum !== null) { + return $enum; + } + } + + return null; + } +} diff --git a/src/data/src/Casts/IterableItemCast.php b/src/data/src/Casts/IterableItemCast.php new file mode 100644 index 000000000..fd23ab9d4 --- /dev/null +++ b/src/data/src/Casts/IterableItemCast.php @@ -0,0 +1,22 @@ +_additional = array_merge($this->_additional, $additional); + + return $this; + } + + /** + * Get the resolved additional response data. + */ + public function getAdditionalData(): array + { + $additional = $this->with(); + + $computedAdditional = []; + + foreach ($additional as $name => $value) { + $computedAdditional[$name] = $value instanceof Closure + ? ($value)($this) + : $value; + } + + foreach ($this->_additional as $name => $value) { + $computedAdditional[$name] = $value instanceof Closure + ? ($value)($this) + : $value; + } + + return $computedAdditional; + } +} diff --git a/src/data/src/Concerns/BaseData.php b/src/data/src/Concerns/BaseData.php new file mode 100644 index 000000000..03d544b12 --- /dev/null +++ b/src/data/src/Concerns/BaseData.php @@ -0,0 +1,94 @@ +from(...$payloads); + } + + /** + * Collect data objects. + * + * @template TKey of array-key + * @template TValue + * + * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|PaginatorContract $items + */ + public static function collect(mixed $items, ?string $into = null): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection + { + return static::factory()->collect($items, $into); + } + + /** + * Create a fresh data construction factory. + * + * @return CreationContextFactory + */ + public static function factory(): CreationContextFactory + { + /** @var CreationContextFactory $factory */ + $factory = Container::getInstance()->make( + CreationContextFactory::class, + ['dataClass' => static::class], + ); + + return $factory; + } + + /** + * Get the class-owned data normalizers. + */ + public static function normalizers(): array + { + return []; + } + + /** + * Create a data object from the current request. + */ + public static function newInstance(Request $request): static + { + return static::from($request); + } +} diff --git a/src/data/src/Concerns/BaseDataCollectable.php b/src/data/src/Concerns/BaseDataCollectable.php new file mode 100644 index 000000000..5fc0edf9b --- /dev/null +++ b/src/data/src/Concerns/BaseDataCollectable.php @@ -0,0 +1,60 @@ + + */ + public function getDataClass(): string + { + return $this->dataClass; + } + + /** + * Get an iterator for the data items. + * + * @return Generator + */ + public function getIterator(): Generator + { + $partialDefinitions = $this->getPartialsDefinition(); + $partials = $partialDefinitions->isEmpty() + ? null + : $partialDefinitions->resolve($this, consumeTemporary: true); + + foreach ($this->itemsForIteration() as $key => $item) { + if ($partials !== null && $item instanceof IncludeableData) { + $item->getPartialsDefinition()->addResolved($partials); + } + + yield $key => $item; + } + } + + /** + * Get the current partial definitions. + */ + abstract public function getPartialsDefinition(): PartialsDefinition; + + /** + * Get the underlying items without transforming them. + * + * @return iterable + */ + abstract protected function itemsForIteration(): iterable; +} diff --git a/src/data/src/Concerns/EmptyData.php b/src/data/src/Concerns/EmptyData.php new file mode 100644 index 000000000..97fed25b6 --- /dev/null +++ b/src/data/src/Concerns/EmptyData.php @@ -0,0 +1,36 @@ +make(EmptyDataResolver::class) + ->execute(static::class, $extra, $replaceNullValuesWith); + + if ($only !== null) { + $emptyData = Arr::only($emptyData, $only); + } + + if ($except !== null) { + $emptyData = Arr::except($emptyData, $except); + } + + return $emptyData; + } +} diff --git a/src/data/src/Concerns/IncludeableData.php b/src/data/src/Concerns/IncludeableData.php new file mode 100644 index 000000000..16ba0c36b --- /dev/null +++ b/src/data/src/Concerns/IncludeableData.php @@ -0,0 +1,65 @@ +partialDefinitions !== null) { + return $this->partialDefinitions; + } + + $this->partialDefinitions = new PartialsDefinition; + $this->partialDefinitions->addDefaults('include', $this->includeProperties()); + $this->partialDefinitions->addDefaults('exclude', $this->excludeProperties()); + $this->partialDefinitions->addDefaults('only', $this->onlyProperties()); + $this->partialDefinitions->addDefaults('except', $this->exceptProperties()); + + return $this->partialDefinitions; + } + + /** + * Get class-owned permanent include definitions. + */ + protected function includeProperties(): array + { + return []; + } + + /** + * Get class-owned permanent exclude definitions. + */ + protected function excludeProperties(): array + { + return []; + } + + /** + * Get class-owned permanent only definitions. + */ + protected function onlyProperties(): array + { + return []; + } + + /** + * Get class-owned permanent except definitions. + */ + protected function exceptProperties(): array + { + return []; + } +} diff --git a/src/data/src/Concerns/TransformableData.php b/src/data/src/Concerns/TransformableData.php new file mode 100644 index 000000000..fca5bb68b --- /dev/null +++ b/src/data/src/Concerns/TransformableData.php @@ -0,0 +1,82 @@ + + */ + public function transform( + null|TransformationContextFactory|TransformationContext $transformationContext = null, + ): array { + $transformationContext = match (true) { + $transformationContext instanceof TransformationContext => $transformationContext, + $transformationContext instanceof TransformationContextFactory => $transformationContext->get($this), + default => TransformationContextFactory::create()->get($this), + }; + + return Container::getInstance() + ->make(DataTransformer::class) + ->transform($this, $transformationContext); + } + + /** + * Get all visible properties without transforming their values. + * + * @return array + */ + public function all(): array + { + return $this->transform(TransformationContextFactory::create()->withValueTransformation(false)); + } + + /** + * Get the data object as an array. + * + * @return array + */ + public function toArray(): array + { + return $this->transform(); + } + + /** + * Convert the data object to its JSON representation. + */ + public function toJson(int $options = 0): string + { + return Json::encode($this->transform(), $options); + } + + /** + * Get the data that should be serialized to JSON. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->transform(); + } + + /** + * Get the Eloquent caster for the data object. + */ + public static function castUsing(array $arguments): CastsAttributes|CastsInboundAttributes|string + { + return new DataEloquentCast(static::class, $arguments); + } +} diff --git a/src/data/src/Concerns/ValidateableData.php b/src/data/src/Concerns/ValidateableData.php new file mode 100644 index 000000000..2778dfd28 --- /dev/null +++ b/src/data/src/Concerns/ValidateableData.php @@ -0,0 +1,52 @@ +validate($payload); + } + + /** + * Validate a payload and create the data object. + */ + public static function validateAndCreate(Arrayable|array $payload): static + { + return static::factory()->alwaysValidate()->from($payload); + } + + /** + * Configure the Validator used for the data object. + */ + public static function withValidator(Validator $validator): void + { + } + + /** + * Get validation rules for a payload. + */ + public static function getValidationRules(array $payload): array + { + return static::factory()->getValidationRules($payload); + } +} diff --git a/src/data/src/Concerns/WrappableData.php b/src/data/src/Concerns/WrappableData.php new file mode 100644 index 000000000..f5df478ef --- /dev/null +++ b/src/data/src/Concerns/WrappableData.php @@ -0,0 +1,41 @@ +wrap = new Wrap(WrapType::Disabled); + + return $this; + } + + /** + * Wrap the data object with the given key. + */ + public function wrap(string $key): static + { + $this->wrap = new Wrap(WrapType::Defined, $key); + + return $this; + } + + /** + * Get the current wrapping definition. + */ + public function getWrap(): Wrap + { + return $this->wrap ?? new Wrap(WrapType::UseGlobal); + } +} diff --git a/src/data/src/Data.php b/src/data/src/Data.php new file mode 100644 index 000000000..0a650b29f --- /dev/null +++ b/src/data/src/Data.php @@ -0,0 +1,31 @@ + + * @implements BaseDataCollectableContract + */ +class DataCollection implements BaseDataCollectableContract, TransformableDataContract, IncludeableDataContract, WrappableDataContract, Countable, ArrayAccess +{ + /** @use BaseDataCollectableConcern */ + use BaseDataCollectableConcern; + use IncludeableDataConcern; + use TransformableDataConcern; + use WrappableDataConcern; + use Macroable; + + /** @var Enumerable */ + protected Enumerable $items; + + /** + * Create a typed data collection. + * + * @param class-string $dataClass + * @param array|Enumerable|DataCollection|null $items + */ + public function __construct( + public readonly string $dataClass, + Enumerable|array|DataCollection|null $items, + ) { + if (is_array($items) || $items === null) { + $items = new Collection($items); + } + + if ($items instanceof DataCollection) { + $items = $items->toCollection(); + } + + $factory = $this->dataClass::factory(); + $this->items = $items->map( + fn (mixed $item): BaseDataContract => $item instanceof $this->dataClass + ? $item + : $factory->from($item), + ); + } + + /** + * @return array + */ + public function items(): array + { + return $this->items->all(); + } + + /** + * @return Enumerable + */ + public function toCollection(): Enumerable + { + return $this->items; + } + + /** + * Get the number of data items. + */ + public function count(): int + { + return $this->items->count(); + } + + /** + * @param TKey $offset + * + * @return bool + */ + public function offsetExists(mixed $offset): bool + { + if (! $this->items instanceof ArrayAccess) { + throw InvalidDataCollectionOperation::create(); + } + + return $this->items->offsetExists($offset); + } + + /** + * @param TKey $offset + * + * @return TValue + */ + public function offsetGet(mixed $offset): mixed + { + if (! $this->items instanceof ArrayAccess) { + throw InvalidDataCollectionOperation::create(); + } + + $data = $this->items->offsetGet($offset); + $partialDefinitions = $this->getPartialsDefinition(); + + if ($data instanceof IncludeableDataContract && ! $partialDefinitions->isEmpty()) { + $data->getPartialsDefinition()->addResolved( + $partialDefinitions->resolve($this, consumeTemporary: true), + ); + } + + return $data; + } + + /** + * @param TKey|null $offset + * @param TValue $value + * + * @return void + */ + public function offsetSet(mixed $offset, mixed $value): void + { + if (! $this->items instanceof ArrayAccess) { + throw InvalidDataCollectionOperation::create(); + } + + $value = $value instanceof $this->dataClass + ? $value + : $this->dataClass::from($value); + + $this->items->offsetSet($offset, $value); + } + + /** + * @param TKey $offset + * + * @return void + */ + public function offsetUnset(mixed $offset): void + { + if (! $this->items instanceof ArrayAccess) { + throw InvalidDataCollectionOperation::create(); + } + + $this->items->offsetUnset($offset); + } + + /** + * Get the Eloquent caster for the data collection. + */ + public static function castUsing(array $arguments): CastsAttributes|CastsInboundAttributes|string + { + if ($arguments === []) { + throw CannotCastData::dataCollectionTypeRequired(); + } + + return new DataCollectionEloquentCast($arguments[0], static::class, array_slice($arguments, 1)); + } + + /** + * Get the underlying items without transforming them. + * + * @return Enumerable + */ + protected function itemsForIteration(): iterable + { + return $this->items; + } +} diff --git a/src/data/src/Dto.php b/src/data/src/Dto.php new file mode 100644 index 000000000..b890acee4 --- /dev/null +++ b/src/data/src/Dto.php @@ -0,0 +1,16 @@ +defaultIncluded = $defaultIncluded; + + return $this; + } + + /** + * Determine if this value is included by default. + */ + public function isDefaultIncluded(): bool + { + return $this->defaultIncluded ?? false; + } + + /** + * Get an intrinsic inclusion decision when the lazy type owns one. + */ + public function shouldBeIncluded(): ?bool + { + return null; + } + + /** + * Forward property access to the resolved value. + */ + public function __get(string $name): mixed + { + return $this->resolve()->$name; + } + + /** + * Run a registered macro or forward the call to the resolved value. + */ + public function __call(string $name, array $arguments): mixed + { + if (static::hasMacro($name)) { + return $this->callMacro($name, $arguments); + } + + return call_user_func_array([$this->resolve(), $name], $arguments); + } +} diff --git a/src/data/src/Normalizers/Normalized/NormalizedModel.php b/src/data/src/Normalizers/Normalized/NormalizedModel.php new file mode 100644 index 000000000..1735c0c5a --- /dev/null +++ b/src/data/src/Normalizers/Normalized/NormalizedModel.php @@ -0,0 +1,67 @@ + */ + protected array $properties = []; + + /** + * Create a normalized model source. + */ + public function __construct( + protected readonly Model $model, + ) { + } + + /** + * Get one declared property without serializing the model. + */ + public function getProperty(string $name, DataProperty $dataProperty): mixed + { + $propertyName = $this->model::$snakeAttributes ? StrCache::snake($name) : $name; + + return array_key_exists($propertyName, $this->properties) + ? $this->properties[$propertyName] + : $this->fetchNewProperty($propertyName, $dataProperty); + } + + /** + * Read and memoize one model attribute or relation. + */ + protected function fetchNewProperty(string $name, DataProperty $dataProperty): mixed + { + $camelName = StrCache::camel($name); + + if ($dataProperty->loadRelation) { + $relation = $this->model->isRelation($name) + ? $name + : ($this->model->isRelation($camelName) ? $camelName : null); + + if ($relation !== null) { + $this->model->loadMissing($relation); + } + } + + if ($this->model->relationLoaded($name)) { + return $this->properties[$name] = $this->model->getRelation($name); + } + + if ($this->model->relationLoaded($camelName)) { + return $this->properties[$name] = $this->model->getRelation($camelName); + } + + if ($this->model->hasAttribute($name)) { + return $this->properties[$name] = $this->model->getAttribute($name); + } + + return $this->properties[$name] = UnknownProperty::create(); + } +} diff --git a/src/data/src/Normalizers/Normalized/UnknownProperty.php b/src/data/src/Normalizers/Normalized/UnknownProperty.php new file mode 100644 index 000000000..da95a30d4 --- /dev/null +++ b/src/data/src/Normalizers/Normalized/UnknownProperty.php @@ -0,0 +1,18 @@ + */ + protected array $payload = []; + + /** @var null|array */ + protected ?array $unknownInput = null; + + /** + * @var array{ + * class: null|class-string, + * mappings: array, + * children: array, + * paginatorSource?: AbstractPaginator|AbstractCursorPaginator, + * uniform?: false, + * items?: array + * } + */ + protected array $structure; + + /** @var list, structureKey: ?string, itemKey: array-key|null}> */ + protected array $path = []; + + /** + * Create construction state for one root operation. + * + * @param class-string $class + */ + private function __construct( + public readonly CreationContext $context, + string $class, + ) { + $this->structure = self::newStructureNode($class); + } + + /** + * Create construction state for one root operation. + * + * @param class-string $class + */ + public static function create(CreationContext $context, string $class): self + { + return new self($context, $class); + } + + /** + * Enter a nested data property. + */ + public function enterProperty(string $property, string|int|null $mappedKey = null): void + { + $this->path[] = [ + 'payloadPath' => self::segments($mappedKey ?? $property), + 'structureKey' => $property, + 'itemKey' => null, + ]; + } + + /** + * Enter one data collection item. + */ + public function enterItem(string|int $index): void + { + $this->path[] = [ + 'payloadPath' => [$index], + 'structureKey' => null, + 'itemKey' => $index, + ]; + } + + /** + * Leave the current property or collection item. + */ + public function leave(): void + { + array_pop($this->path); + } + + /** + * Get the current traversal depth. + */ + public function depth(): int + { + return count($this->path); + } + + /** + * Get the current wire-key path. + * + * @return list + */ + public function path(): array + { + $path = []; + + foreach ($this->path as $segment) { + array_push($path, ...$segment['payloadPath']); + } + + return $path; + } + + /** + * Write a mapped property value beneath the current path. + */ + public function writePropertyValue(string|int $key, mixed $value): void + { + $this->writeAtPath( + [...$this->path(), ...self::segments($key)], + $value, + false, + ); + } + + /** + * Write a finished mapped property value beneath the current path. + */ + public function writeFinishedPropertyValue(string|int $key, mixed $value): void + { + $this->writeAtPath( + [...$this->path(), ...self::segments($key)], + $value, + true, + ); + } + + /** + * Write a raw collection item beneath the current path. + */ + public function writeItemValue(string|int $key, mixed $value): void + { + $this->writeAtPath([...$this->path(), $key], $value, false); + } + + /** + * Write a finished raw collection item beneath the current path. + */ + public function writeFinishedItemValue(string|int $key, mixed $value): void + { + $this->writeAtPath([...$this->path(), $key], $value, true); + } + + /** + * Determine if a value exists beneath the current path. + */ + public function hasValue(string|int $key): bool + { + $slot = $this->valueAtPath(self::segments($key)); + + return ! $slot instanceof UnknownProperty; + } + + /** + * Get a value beneath the current path. + */ + public function getValue(string|int $key): mixed + { + $value = $this->valueAtPath(self::segments($key)); + + return $value instanceof UnknownProperty ? null : $value; + } + + /** + * Get the complete construction payload. + * + * @return array + */ + public function payload(): array + { + return $this->payload; + } + + /** + * Get the payload at the current traversal path. + */ + public function currentPayload(): mixed + { + return $this->payloadAtCurrentPath(); + } + + /** + * Replace the complete construction payload. + * + * @param array $payload + */ + public function replacePayload(array $payload): void + { + $this->payload = $payload; + } + + /** + * Record strict input beneath the current traversal path. + * + * @param array $input + */ + public function recordUnknownInput(array $input): void + { + $path = $this->path(); + + if ($path === [] && $this->unknownInput === null) { + $this->unknownInput = $input; + + return; + } + + $this->unknownInput ??= []; + $target = &$this->unknownInput; + + foreach ($path as $key) { + if (! array_key_exists($key, $target) || ! is_array($target[$key])) { + $target[$key] = []; + } + + $target = &$target[$key]; + } + + $target = $this->mergeUnknownInput($target, $input); + } + + /** + * Get the merged strict input tree. + * + * @return null|array + */ + public function unknownInput(): ?array + { + return $this->unknownInput; + } + + /** + * Record the chosen wire key for a property on the current node. + */ + public function recordMapping(string $property, string|int $mappedKey): void + { + $template = &$this->ensureStructureNodeAtCurrentPath(); + + if (! array_key_exists($property, $template['mappings'])) { + $template['mappings'][$property] = $mappedKey; + + return; + } + + if ($template['mappings'][$property] === $mappedKey) { + return; + } + + if (! $this->pathContainsItem()) { + $template['mappings'][$property] = $mappedKey; + + return; + } + + $override = &$this->ensureOverrideNodeAtCurrentPath(); + $override['mappings'][$property] = $mappedKey; + $this->markEnclosingCollectionsNonUniform(); + } + + /** + * Replace one selected wire key during hook reconciliation. + */ + public function replaceMapping(string $property, string|int $mappedKey): void + { + $template = &$this->ensureStructureNodeAtCurrentPath(); + + if (! $this->pathContainsItem()) { + $template['mappings'][$property] = $mappedKey; + + return; + } + + $override = &$this->ensureOverrideNodeAtCurrentPath(); + unset($override['mappings'][$property]); + + if (($template['mappings'][$property] ?? null) !== $mappedKey) { + $override['mappings'][$property] = $mappedKey; + $this->markEnclosingCollectionsNonUniform(); + } + } + + /** + * Clear one changed child selection during hook reconciliation. + */ + public function clearChildStructure(string $property): void + { + if (! $this->pathContainsItem()) { + $node = &$this->ensureStructureNodeAtCurrentPath(); + unset($node['children'][$property]); + + return; + } + + $override = &$this->ensureOverrideNodeAtCurrentPath(); + unset($override['children'][$property]); + $this->markEnclosingCollectionsNonUniform(); + } + + /** + * Reset the current class-owned selections during morph reconciliation. + */ + public function resetNodeStructure(): void + { + if ($this->pathContainsItem()) { + $node = &$this->ensureOverrideNodeAtCurrentPath(); + } else { + $node = &$this->ensureStructureNodeAtCurrentPath(); + } + + $node['class'] = null; + $node['mappings'] = []; + $node['children'] = []; + unset($node['paginatorSource']); + + if ($this->pathContainsItem()) { + $this->markEnclosingCollectionsNonUniform(); + } + } + + /** + * Determine if a wire key was recorded for a property on the current node. + */ + public function hasOriginalKey(string $property): bool + { + $override = $this->overrideNodeAtCurrentPath(); + + if ($override !== null && array_key_exists($property, $override['mappings'])) { + return true; + } + + $template = $this->structureNodeAtCurrentPath(); + + return $template !== null && array_key_exists($property, $template['mappings']); + } + + /** + * Get the chosen wire key for a property on the current node. + */ + public function originalKey(string $property): string|int + { + $override = $this->overrideNodeAtCurrentPath(); + + if ($override !== null && array_key_exists($property, $override['mappings'])) { + return $override['mappings'][$property]; + } + + $template = $this->structureNodeAtCurrentPath(); + + if ($template === null) { + return $property; + } + + return $template['mappings'][$property] ?? $property; + } + + /** + * Record the concrete data class selected for the current node. + * + * @param class-string $class + */ + public function setNodeClass(string $class): void + { + $template = &$this->ensureStructureNodeAtCurrentPath(); + + if ($template['class'] === null) { + $template['class'] = $class; + + return; + } + + if ($template['class'] === $class) { + return; + } + + if (! $this->pathContainsItem()) { + $template['class'] = $class; + + return; + } + + $override = &$this->ensureOverrideNodeAtCurrentPath(); + $override['class'] = $class; + $this->markEnclosingCollectionsNonUniform(); + } + + /** + * Get the concrete data class selected for the current node. + * + * @return null|class-string + */ + public function nodeClass(): ?string + { + $override = $this->overrideNodeAtCurrentPath(); + + if ($override !== null && $override['class'] !== null) { + return $override['class']; + } + + return $this->structureNodeAtCurrentPath()['class'] ?? null; + } + + /** + * Record the paginator source for the current node. + */ + public function recordPaginatorSource(AbstractPaginator|AbstractCursorPaginator $source): void + { + if ($this->pathContainsItem()) { + $node = &$this->ensureOverrideNodeAtCurrentPath(); + } else { + $node = &$this->ensureStructureNodeAtCurrentPath(); + } + + $node['paginatorSource'] = $source; + } + + /** + * Get the paginator source for the current node. + * + * Item reads never fall back to the collection template because paginator + * metadata belongs to one concrete source value. + */ + public function paginatorSource(): AbstractPaginator|AbstractCursorPaginator|null + { + $node = $this->pathContainsItem() + ? $this->overrideNodeAtCurrentPath() + : $this->structureNodeAtCurrentPath(); + + return $node['paginatorSource'] ?? null; + } + + /** + * Clear the paginator source for the current node. + */ + public function clearPaginatorSource(): void + { + $node = &$this->structure; + + foreach ($this->path as $segment) { + if ($segment['itemKey'] !== null) { + if (! array_key_exists($segment['itemKey'], $node['items'] ?? [])) { + return; + } + + $node = &$node['items'][$segment['itemKey']]; + + continue; + } + + $key = $segment['structureKey']; + + if (! array_key_exists($key, $node['children'])) { + return; + } + + $node = &$node['children'][$key]; + } + + unset($node['paginatorSource']); + } + + /** + * Determine if the current collection has one uniform recursive structure. + */ + public function isCurrentCollectionUniform(): bool + { + $node = $this->structureNodeAtCurrentPath(); + + return $node === null || ($node['uniform'] ?? true); + } + + /** + * Get the compiled structure tree. + */ + public function structure(): array + { + return $this->structure; + } + + /** + * Get the payload at the current traversal path. + */ + protected function payloadAtCurrentPath(): mixed + { + $slot = $this->payload; + + foreach ($this->path() as $key) { + if (! is_array($slot) || ! array_key_exists($key, $slot)) { + return null; + } + + $slot = $slot[$key]; + } + + return $slot; + } + + /** + * Merge strict input while retaining the most structured observed value. + * + * @param array $target + * @param array $source + * @return array + */ + protected function mergeUnknownInput(array $target, array $source): array + { + foreach ($source as $key => $value) { + if (! array_key_exists($key, $target)) { + $target[$key] = $value; + + continue; + } + + if (! is_array($value)) { + continue; + } + + $target[$key] = is_array($target[$key]) + ? $this->mergeUnknownInput($target[$key], $value) + : $value; + } + + return $target; + } + + /** + * Get a value beneath the current path without collapsing absence into null. + * + * @param non-empty-list $path + */ + protected function valueAtPath(array $path): mixed + { + $slot = $this->payloadAtCurrentPath(); + + foreach ($path as $key) { + if (! is_array($slot) || ! array_key_exists($key, $slot)) { + return UnknownProperty::create(); + } + + $slot = $slot[$key]; + } + + return $slot; + } + + /** + * Write a value at an absolute payload path. + * + * @param non-empty-list $path + */ + protected function writeAtPath(array $path, mixed $value, bool $finished): void + { + $slot = &$this->payload; + $lastKey = array_pop($path); + + foreach ($path as $pathKey) { + if (! array_key_exists($pathKey, $slot) || ! is_array($slot[$pathKey])) { + $slot[$pathKey] = []; + } + + $slot = &$slot[$pathKey]; + } + + $slot[$lastKey] = $value; + + if ($finished && $this->pathContainsItem()) { + $this->markEnclosingCollectionsNonUniform(); + } + } + + /** + * Split one mapped key into its payload path. + * + * @return non-empty-list + */ + protected static function segments(string|int $key): array + { + return is_int($key) ? [$key] : explode('.', $key); + } + + /** + * Get the structure node at the current traversal path. + */ + protected function structureNodeAtCurrentPath(): ?array + { + $node = $this->structure; + + foreach ($this->path as $segment) { + $key = $segment['structureKey']; + + if ($key === null) { + continue; + } + + if (! array_key_exists($key, $node['children'])) { + return null; + } + + $node = $node['children'][$key]; + } + + return $node; + } + + /** + * Get the sparse item override at the current traversal path. + */ + protected function overrideNodeAtCurrentPath(): ?array + { + if (! $this->pathContainsItem()) { + return null; + } + + $node = $this->structure; + + foreach ($this->path as $segment) { + if ($segment['itemKey'] !== null) { + if (! array_key_exists($segment['itemKey'], $node['items'] ?? [])) { + return null; + } + + $node = $node['items'][$segment['itemKey']]; + + continue; + } + + $key = $segment['structureKey']; + + if (! array_key_exists($key, $node['children'])) { + return null; + } + + $node = $node['children'][$key]; + } + + return $node; + } + + /** + * Get or create the structure node at the current traversal path. + */ + protected function &ensureStructureNodeAtCurrentPath(): array + { + $node = &$this->structure; + + foreach ($this->path as $segment) { + $key = $segment['structureKey']; + + if ($key === null) { + continue; + } + + if (! array_key_exists($key, $node['children'])) { + $node['children'][$key] = self::newStructureNode(); + } + + $node = &$node['children'][$key]; + } + + return $node; + } + + /** + * Get or create the sparse item override at the current traversal path. + */ + protected function &ensureOverrideNodeAtCurrentPath(): array + { + $node = &$this->structure; + + foreach ($this->path as $segment) { + if ($segment['itemKey'] !== null) { + $node['items'] ??= []; + $node['items'][$segment['itemKey']] ??= self::newStructureNode(); + $node = &$node['items'][$segment['itemKey']]; + + continue; + } + + $key = $segment['structureKey']; + $node['children'][$key] ??= self::newStructureNode(); + $node = &$node['children'][$key]; + } + + return $node; + } + + /** + * Mark every collection surrounding the current value as non-uniform. + */ + protected function markEnclosingCollectionsNonUniform(): void + { + $this->ensureStructureNodeAtCurrentPath(); + $node = &$this->structure; + + foreach ($this->path as $segment) { + if ($segment['itemKey'] !== null) { + $node['uniform'] = false; + + continue; + } + + $key = $segment['structureKey']; + $node = &$node['children'][$key]; + } + } + + /** + * Determine if the current traversal path contains a collection item. + */ + protected function pathContainsItem(): bool + { + foreach ($this->path as $segment) { + if ($segment['itemKey'] !== null) { + return true; + } + } + + return false; + } + + /** + * Create an empty structure node. + * + * @param null|class-string $class + * @return array{ + * class: null|class-string, + * mappings: array, + * children: array, + * paginatorSource?: AbstractPaginator|AbstractCursorPaginator, + * uniform?: false, + * items?: array + * } + */ + protected static function newStructureNode(?string $class = null): array + { + return [ + 'class' => $class, + 'mappings' => [], + 'children' => [], + ]; + } +} diff --git a/src/data/src/Support/Creation/CreationContextFactory.php b/src/data/src/Support/Creation/CreationContextFactory.php new file mode 100644 index 000000000..660d9b4d5 --- /dev/null +++ b/src/data/src/Support/Creation/CreationContextFactory.php @@ -0,0 +1,367 @@ + */ + protected array $ignoredMagicalMethods = []; + + /** @var array> */ + protected array $casts = []; + + /** @var list> */ + protected array $normalizers = []; + + /** @var list */ + protected array $prepareDataHooks = []; + + /** @var list */ + protected array $beforeValidationHooks = []; + + /** @var list */ + protected array $beforeRulesHooks = []; + + /** @var list */ + protected array $afterRulesHooks = []; + + /** @var list */ + protected array $withValidatorHooks = []; + + /** @var list */ + protected array $afterValidationHooks = []; + + /** @var list */ + protected array $beforeCreationHooks = []; + + /** @var list */ + protected array $afterCreationHooks = []; + + /** + * Create a fresh data construction factory. + * + * @param class-string $dataClass + */ + public function __construct( + protected readonly DataCreator $creator, + protected readonly DataConfig $config, + public readonly string $dataClass, + ) { + $this->validationStrategy = $this->config->validationStrategy; + } + + /** + * Set the validation strategy. + */ + public function validationStrategy(ValidationStrategy $validationStrategy): self + { + $this->validationStrategy = $validationStrategy; + + return $this; + } + + /** + * Disable validation. + */ + public function withoutValidation(): self + { + return $this->validationStrategy(ValidationStrategy::Disabled); + } + + /** + * Validate only Request sources. + */ + public function onlyValidateRequests(): self + { + return $this->validationStrategy(ValidationStrategy::OnlyRequests); + } + + /** + * Validate every source. + */ + public function alwaysValidate(): self + { + return $this->validationStrategy(ValidationStrategy::Always); + } + + /** + * Enable or disable property-name mapping. + */ + public function withPropertyNameMapping(bool $withPropertyNameMapping = true): self + { + $this->mapPropertyNames = $withPropertyNameMapping; + + return $this; + } + + /** + * Disable or enable property-name mapping. + */ + public function withoutPropertyNameMapping(bool $withoutPropertyNameMapping = true): self + { + $this->mapPropertyNames = ! $withoutPropertyNameMapping; + + return $this; + } + + /** + * Disable or enable named creation methods. + */ + public function withoutMagicalCreation(bool $withoutMagicalCreation = true): self + { + $this->disableMagicalCreation = $withoutMagicalCreation; + + return $this; + } + + /** + * Enable or disable named creation methods. + */ + public function withMagicalCreation(bool $withMagicalCreation = true): self + { + $this->disableMagicalCreation = ! $withMagicalCreation; + + return $this; + } + + /** + * Ignore named creation methods for this operation. + */ + public function ignoreMagicalMethod(string ...$methods): self + { + array_push($this->ignoredMagicalMethods, ...$methods); + + return $this; + } + + /** + * Add a cast for a declared base type. + * + * @param Cast|class-string $cast + */ + public function withCast(string $castable, Cast|string $cast): self + { + $this->casts[$castable] = $cast; + + return $this; + } + + /** + * Merge casts for declared base types. + * + * @param array> $casts + */ + public function withCastCollection(array $casts): self + { + $this->casts = array_replace($this->casts, $casts); + + return $this; + } + + /** + * Add custom source normalizers. + * + * @param Normalizer|class-string ...$normalizers + */ + public function withNormalizers(Normalizer|string ...$normalizers): self + { + array_push($this->normalizers, ...$normalizers); + + return $this; + } + + /** + * Add a prepare-data hook. + */ + public function prepareData(Closure $hook): self + { + $this->prepareDataHooks[] = $hook; + + return $this; + } + + /** + * Add a before-validation hook. + */ + public function beforeValidation(Closure $hook): self + { + $this->beforeValidationHooks[] = $hook; + + return $this; + } + + /** + * Add a before-rules hook. + */ + public function beforeRules(Closure $hook): self + { + $this->beforeRulesHooks[] = $hook; + + return $this; + } + + /** + * Add an after-rules hook. + */ + public function afterRules(Closure $hook): self + { + $this->afterRulesHooks[] = $hook; + + return $this; + } + + /** + * Add a validator customization hook. + */ + public function withValidator(Closure $hook): self + { + $this->withValidatorHooks[] = $hook; + + return $this; + } + + /** + * Add an after-validation hook. + */ + public function afterValidation(Closure $hook): self + { + $this->afterValidationHooks[] = $hook; + + return $this; + } + + /** + * Add a before-creation hook. + */ + public function beforeCreation(Closure $hook): self + { + $this->beforeCreationHooks[] = $hook; + + return $this; + } + + /** + * Add an after-creation hook. + */ + public function afterCreation(Closure $hook): self + { + $this->afterCreationHooks[] = $hook; + + return $this; + } + + /** + * Build immutable options for one root operation. + * + * @return CreationContext + */ + public function get(CreationMode $mode = CreationMode::Create): CreationContext + { + return new CreationContext( + dataClass: $this->dataClass, + mode: $mode, + validationStrategy: $mode === CreationMode::Create + ? $this->validationStrategy + : ValidationStrategy::Always, + mapPropertyNames: $this->mapPropertyNames, + disableMagicalCreation: $mode === CreationMode::Create + ? $this->disableMagicalCreation + : true, + ignoredMagicalMethods: $this->ignoredMagicalMethods, + casts: $this->casts, + normalizers: $this->normalizers, + prepareDataHooks: $this->prepareDataHooks, + beforeValidationHooks: $this->beforeValidationHooks, + beforeRulesHooks: $this->beforeRulesHooks, + afterRulesHooks: $this->afterRulesHooks, + withValidatorHooks: $this->withValidatorHooks, + afterValidationHooks: $this->afterValidationHooks, + beforeCreationHooks: $this->beforeCreationHooks, + afterCreationHooks: $this->afterCreationHooks, + dateFormats: $this->config->dateFormats, + dateTimezone: $this->config->dateTimezone, + ); + } + + /** + * Create a data object. + * + * @return TData + */ + public function from(mixed ...$payloads): BaseData + { + return $this->creator->create($this->dataClass, $this->get(), ...$payloads); + } + + /** + * Validate a payload without casting or construction. + */ + public function validate(Arrayable|array $payload): Arrayable|array + { + return $this->creator->validate( + $this->dataClass, + $this->get(CreationMode::Validate), + [$payload], + ); + } + + /** + * Get validation rules for a payload. + * + * @return array> + */ + public function getValidationRules(array $payload): array + { + return $this->creator->getValidationRules( + $this->dataClass, + $this->get(CreationMode::Rules), + [$payload], + ); + } + + /** + * Collect data objects. + * + * @template TCollectKey of array-key + * @template TCollectValue + * + * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|PaginatorContract $items + * + * @return ($into is 'array' ? array : ($into is class-string ? Collection : ($into is class-string ? Collection : ($into is class-string ? LazyCollection : ($into is class-string ? DataCollection : ($into is class-string ? PaginatedDataCollection : ($into is class-string ? CursorPaginatedDataCollection : ($items is EloquentCollection ? Collection : ($items is Collection ? Collection : ($items is LazyCollection ? LazyCollection : ($items is Enumerable ? Enumerable : ($items is array ? array : ($items is AbstractPaginator ? AbstractPaginator : ($items is PaginatorContract ? PaginatorContract : ($items is AbstractCursorPaginator ? AbstractCursorPaginator : ($items is CursorPaginatorContract ? CursorPaginatorContract : ($items is DataCollection ? DataCollection : DataCollection))))))))))))))))) + */ + public function collect( + mixed $items, + ?string $into = null, + ): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection { + return $this->creator->collect($this->dataClass, $this->get(), $items, $into); + } +} diff --git a/src/data/src/Support/Creation/DataCollectableFactory.php b/src/data/src/Support/Creation/DataCollectableFactory.php new file mode 100644 index 000000000..79fcaf08f --- /dev/null +++ b/src/data/src/Support/Creation/DataCollectableFactory.php @@ -0,0 +1,141 @@ + + */ + public function items(mixed $value): ?array + { + return match (true) { + $value instanceof DataCollection => $value->items(), + $value instanceof PaginatedDataCollection, + $value instanceof CursorPaginatedDataCollection => $value->items()->items(), + $value instanceof AbstractPaginator, + $value instanceof AbstractCursorPaginator, + $value instanceof PaginatorContract, + $value instanceof CursorPaginatorContract => $value->items(), + is_array($value) => $value, + $value instanceof Enumerable => $value->all(), + $value instanceof Traversable => iterator_to_array($value), + default => null, + }; + } + + /** + * Rebuild typed data items in a property's declared container. + * + * @param class-string $dataClass + * @param array $items + */ + public function forProperty( + NamedType $type, + string $dataClass, + array $items, + ConstructionState $state, + ): mixed { + return match ($type->kind) { + DataTypeKind::DataArray, + DataTypeKind::DataIterable => $items, + DataTypeKind::DataEnumerable => $this->newEnumerable($type->name, $items), + DataTypeKind::DataCollection => new $type->name($dataClass, $items), + DataTypeKind::DataPaginatedCollection => new $type->name( + $dataClass, + $this->paginator($type, $items, $state), + ), + DataTypeKind::DataCursorPaginatedCollection => new $type->name( + $dataClass, + $this->cursorPaginator($type, $items, $state), + ), + DataTypeKind::DataPaginator => $this->paginator($type, $items, $state), + DataTypeKind::DataCursorPaginator => $this->cursorPaginator($type, $items, $state), + default => throw CannotCreateDataCollectable::create('array', $type->name), + }; + } + + /** + * Rebuild an eager enumerable without retaining an Eloquent model container. + * + * @param class-string|literal-string $class + * @param array $items + */ + protected function newEnumerable(string $class, array $items): Enumerable + { + if ($class === Enumerable::class || is_a($class, EloquentCollection::class, true)) { + return new Collection($items); + } + + if (is_a($class, Collection::class, true) || is_a($class, LazyCollection::class, true)) { + return new $class($items); + } + + throw CannotCreateDataCollectable::create('array', $class); + } + + /** + * Clone the retained paginator and replace only its items. + * + * @param array $items + */ + protected function paginator( + NamedType $type, + array $items, + ConstructionState $state, + ): AbstractPaginator { + $source = $state->paginatorSource(); + + if (! $source instanceof AbstractPaginator) { + throw CannotCreateDataCollectable::create( + get_debug_type($source), + $type->name, + ); + } + + return (clone $source)->setCollection(new Collection($items)); + } + + /** + * Clone the retained cursor paginator and replace only its items. + * + * @param array $items + */ + protected function cursorPaginator( + NamedType $type, + array $items, + ConstructionState $state, + ): AbstractCursorPaginator { + $source = $state->paginatorSource(); + + if (! $source instanceof AbstractCursorPaginator) { + throw CannotCreateDataCollectable::create( + get_debug_type($source), + $type->name, + ); + } + + return (clone $source)->setCollection(new Collection($items)); + } +} diff --git a/src/data/src/Support/Creation/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php new file mode 100644 index 000000000..63df62260 --- /dev/null +++ b/src/data/src/Support/Creation/DataCreator.php @@ -0,0 +1,1667 @@ + $class + * @return TData + */ + public function create( + string $class, + CreationContext $context, + mixed ...$payloads, + ): BaseData { + $data = $this->execute($class, $context, $payloads); + + /** @var BaseData $data */ + return $data; + } + + /** + * Validate a payload without casting or construction. + * + * @param class-string $class + * @param array $payloads + * @return array + */ + public function validate( + string $class, + CreationContext $context, + array $payloads, + ): array { + $validated = $this->execute($class, $context, $payloads); + + /** @var array $validated */ + return $validated; + } + + /** + * Get validation rules for a payload without running the Validator. + * + * @param class-string $class + * @param array $payloads + * @return array> + */ + public function getValidationRules( + string $class, + CreationContext $context, + array $payloads, + ): array { + $rules = $this->execute($class, $context, $payloads); + + /** @var array> $rules */ + return $rules; + } + + /** + * Run the fixed operation through its selected exit point. + * + * @param class-string $class + * @param array $payloads + * @return BaseData|array + */ + protected function execute( + string $class, + CreationContext $context, + array $payloads, + ): BaseData|array { + if ($context->mode === CreationMode::Create + && count($payloads) === 1 + && $payloads[0] instanceof $class + ) { + return $payloads[0]; + } + + $shouldValidate = $context->mode !== CreationMode::Rules + && $this->validator->shouldValidate($context, $payloads); + $compilesRules = $shouldValidate || $context->mode === CreationMode::Rules; + $request = $shouldValidate + ? $this->validator->authorize($class, $payloads) + : null; + + $state = ConstructionState::create($context, $class); + $extensions = []; + $direct = $this->fillNode( + $class, + $payloads, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + + if ($direct !== null) { + return $direct; + } + + if ($compilesRules && $context->beforeValidationHooks !== []) { + $this->applyPayloadHooks( + $class, + $context->beforeValidationHooks, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } + + if ($context->mode === CreationMode::Rules) { + return $this->validator->compile($state)->rules; + } + + if ($shouldValidate) { + $compiled = $this->validator->compile($state); + $this->validator->validate($state, $compiled, $request); + } + + if ($shouldValidate && $context->afterValidationHooks !== []) { + $this->applyPayloadHooks( + $class, + $context->afterValidationHooks, + $state, + $extensions, + false, + false, + reconcile: $context->mode === CreationMode::Create, + ); + } + + if ($context->mode === CreationMode::Validate) { + return $state->payload(); + } + + return $this->castAndInstantiateNode($state, $extensions); + } + + /** + * Normalize and fill one data node into the root construction state. + * + * @param class-string $class + * @param array $payloads + * @param array $extensions + */ + protected function fillNode( + string $class, + array $payloads, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + ): ?BaseData { + $dataClass = $this->dataClasses->get($class); + $match = $this->matchNamedFactory($dataClass, $state->context, $payloads); + + if ($match !== null) { + $result = $this->invokeNamedFactory($dataClass, ...$match); + + if ($result instanceof $class) { + return $result; + } + + $payloads = [$result]; + } + + $normalizers = $this->resolveNormalizers($dataClass, $state->context, $extensions); + $sources = []; + $unknownInputSources = []; + + foreach ($payloads === [] ? [[]] : $payloads as $payload) { + $source = SourceResolver::resolve($class, $payload, $normalizers); + $sources[] = $source; + $unknownInputSources[] = $payload instanceof Request + ? ($payload->isJson() ? $payload->json()->all() : $payload->request->all()) + : $source; + } + + $propertySources = $sources; + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + + if ($state->context->prepareDataHooks !== []) { + $input = $this->mergeSources($dataClass, $propertySources, $state->context); + + foreach ($state->context->prepareDataHooks as $hook) { + $input = $hook($input); + } + + $propertySources = [$input]; + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + } + + $class = $this->resolveMorphClass($dataClass, $resolvedProperties); + + if ($class !== $dataClass->name) { + $dataClass = $this->dataClasses->get($class); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + } + + $state->setNodeClass($class); + + if ($shouldValidate && $dataClass->failOnUnknownFields) { + $state->recordUnknownInput( + $this->mergeSources($dataClass, $unknownInputSources, $state->context), + ); + } + + $this->fillResolvedProperties( + $dataClass, + $resolvedProperties, + $state, + $extensions, + $shouldValidate, + $compilesRules, + false, + ); + + return null; + } + + /** + * Fill one node introduced or changed by a validation payload hook. + * + * @param class-string $class + * @param array $extensions + */ + protected function fillHookNode( + string $class, + mixed $payload, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + ): ?BaseData { + $dataClass = $this->dataClasses->get($class); + $match = $this->matchNamedFactory($dataClass, $state->context, [$payload]); + + if ($match !== null) { + $payload = $this->invokeNamedFactory($dataClass, ...$match); + + if ($payload instanceof $class) { + return $payload; + } + } + + $source = SourceResolver::resolve($class, $payload, []); + $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context); + $class = $this->resolveMorphClass($dataClass, $resolvedProperties); + + if ($class !== $dataClass->name) { + $dataClass = $this->dataClasses->get($class); + $resolvedProperties = $this->resolveProperties($dataClass, [$source], $state->context); + } + + $state->setNodeClass($class); + $this->fillResolvedProperties( + $dataClass, + $resolvedProperties, + $state, + $extensions, + $shouldValidate, + $compilesRules, + true, + ); + + return null; + } + + /** + * Write one resolved data node and recursively fill its declared children. + * + * @param array $resolvedProperties + * @param array $extensions + */ + protected function fillResolvedProperties( + DataClass $dataClass, + array $resolvedProperties, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + bool $fromValidationHook, + ): void { + $contextualParameters = $this->contextualParameterNames($dataClass); + + foreach ($dataClass->properties as $property) { + [$wireKey, $value] = $resolvedProperties[$property->name]; + $state->recordMapping($property->name, $wireKey); + + if (isset($contextualParameters[$property->name])) { + continue; + } + + if ($value instanceof UnknownProperty) { + continue; + } + + if ($property->computed) { + throw CannotSetComputedValue::create($property); + } + + $dataIterable = $this->dataIterableType($property); + + if ($property->isFinishedValue($value)) { + $state->writeFinishedPropertyValue($wireKey, $value); + + continue; + } + + if ($dataIterable !== null && $value instanceof LazyCollection) { + if (! $compilesRules) { + $state->writePropertyValue($wireKey, $value); + + continue; + } + + $value = $value->all(); + } + + $iterableValues = $dataIterable === null ? null : $this->iterableValues($value); + + if ($dataIterable !== null && $iterableValues !== null) { + /** @var class-string $itemDataClass */ + $itemDataClass = $dataIterable->dataClass; + $state->writePropertyValue($wireKey, []); + $state->enterProperty($property->name, $wireKey); + + try { + foreach ($iterableValues as $key => $item) { + if ($item instanceof $itemDataClass) { + $state->writeFinishedItemValue($key, $item); + + continue; + } + + $state->writeItemValue($key, []); + $state->enterItem($key); + + try { + $direct = $fromValidationHook + ? $this->fillHookNode( + $itemDataClass, + $item, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ) + : $this->fillNode( + $itemDataClass, + [$item], + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } finally { + $state->leave(); + } + + if ($direct !== null) { + $state->writeFinishedItemValue($key, $direct); + } + } + } finally { + $state->leave(); + } + + continue; + } + + $nestedDataClass = $this->nestedDataClass($property); + + if ($nestedDataClass !== null + && $value !== null + && ! $value instanceof Optional + && ! $property->type->acceptsValue($value) + ) { + $state->writePropertyValue($wireKey, []); + $state->enterProperty($property->name, $wireKey); + + try { + $direct = $fromValidationHook + ? $this->fillHookNode( + $nestedDataClass, + $value, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ) + : $this->fillNode( + $nestedDataClass, + [$value], + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } finally { + $state->leave(); + } + + if ($direct !== null) { + $state->writeFinishedPropertyValue($wireKey, $direct); + } + + continue; + } + + $state->writePropertyValue($wireKey, $value); + } + } + + /** + * Apply one root payload-hook stage and reconcile changed selections. + * + * @param class-string $class + * @param list $hooks + * @param array $extensions + */ + protected function applyPayloadHooks( + string $class, + array $hooks, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + bool $reconcile = true, + ): void { + $previousPayload = $state->payload(); + $payload = $previousPayload; + + foreach ($hooks as $hook) { + $payload = $hook($payload); + } + + if ($payload === $previousPayload) { + return; + } + + $state->replacePayload($payload); + + if ($reconcile) { + $this->reconcileNode( + $class, + $previousPayload, + $payload, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } + } + + /** + * Reconcile one changed assembled data node without replaying prepare hooks. + * + * @param class-string $declaredClass + * @param array $previousPayload + * @param array $payload + * @param array $extensions + */ + protected function reconcileNode( + string $declaredClass, + array $previousPayload, + array $payload, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + ): void { + $declaredDataClass = $this->dataClasses->get($declaredClass); + $declaredProperties = $this->resolveProperties( + $declaredDataClass, + [$payload], + $state->context, + ); + $class = $this->resolveMorphClass($declaredDataClass, $declaredProperties); + $previousClass = $state->nodeClass() ?? $declaredClass; + + if ($class !== $previousClass) { + $dataClass = $this->dataClasses->get($class); + $resolvedProperties = $class === $declaredClass + ? $declaredProperties + : $this->resolveProperties($dataClass, [$payload], $state->context); + $state->resetNodeStructure(); + $state->setNodeClass($class); + $this->fillResolvedProperties( + $dataClass, + $resolvedProperties, + $state, + $extensions, + $shouldValidate, + $compilesRules, + true, + ); + + return; + } + + $dataClass = $this->dataClasses->get($class); + $resolvedProperties = $class === $declaredClass + ? $declaredProperties + : $this->resolveProperties($dataClass, [$payload], $state->context); + $contextualParameters = $this->contextualParameterNames($dataClass); + + foreach ($dataClass->properties as $property) { + $previousWireKey = $state->originalKey($property->name); + $previousValue = SourceReader::read($previousPayload, $previousWireKey, $property); + [$wireKey, $value] = $resolvedProperties[$property->name]; + + if ($wireKey !== $previousWireKey) { + $state->replaceMapping($property->name, $wireKey); + } + + if ($value === $previousValue || isset($contextualParameters[$property->name])) { + continue; + } + + if (! $value instanceof UnknownProperty && $property->computed) { + throw CannotSetComputedValue::create($property); + } + + $this->reconcileProperty( + $property, + $previousValue, + $value, + $wireKey, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } + } + + /** + * Reconcile one changed property selection. + * + * @param array $extensions + */ + protected function reconcileProperty( + DataProperty $property, + mixed $previousValue, + mixed $value, + string|int $wireKey, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + ): void { + $dataIterable = $this->dataIterableType($property); + + if ($dataIterable !== null) { + $this->reconcileDataIterable( + $property, + $dataIterable, + $previousValue, + $value, + $wireKey, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + + return; + } + + $nestedDataClass = $this->nestedDataClass($property); + + if ($nestedDataClass === null) { + return; + } + + if ($value instanceof UnknownProperty + || $value === null + || $value instanceof Optional + || $property->type->acceptsValue($value) + ) { + $state->clearChildStructure($property->name); + + if ($value instanceof BaseData) { + $state->writeFinishedPropertyValue($wireKey, $value); + } + + return; + } + + if (is_array($previousValue) && is_array($value)) { + $state->enterProperty($property->name, $wireKey); + + try { + if ($state->nodeClass() !== null) { + $this->reconcileNode( + $nestedDataClass, + $previousValue, + $value, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + + return; + } + } finally { + $state->leave(); + } + } + + $state->clearChildStructure($property->name); + $state->writePropertyValue($wireKey, []); + $state->enterProperty($property->name, $wireKey); + + try { + $state->resetNodeStructure(); + $direct = $this->fillHookNode( + $nestedDataClass, + $value, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } finally { + $state->leave(); + } + + if ($direct !== null) { + $state->writeFinishedPropertyValue($wireKey, $direct); + } + } + + /** + * Reconcile one changed typed data iterable. + * + * @param array $extensions + */ + protected function reconcileDataIterable( + DataProperty $property, + NamedType $type, + mixed $previousValue, + mixed $value, + string|int $wireKey, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + ): void { + if ($property->isFinishedValue($value)) { + $state->clearChildStructure($property->name); + $state->writeFinishedPropertyValue($wireKey, $value); + + return; + } + + if ($value instanceof LazyCollection) { + if (! $compilesRules) { + $state->clearChildStructure($property->name); + + return; + } + + $value = $value->all(); + $state->writePropertyValue($wireKey, $value); + } + + $values = $this->iterableValues($value); + + if ($values === null) { + $state->clearChildStructure($property->name); + + return; + } + + $previousValues = $this->iterableValues($previousValue) ?? []; + /** @var class-string $itemDataClass */ + $itemDataClass = $type->dataClass; + $state->enterProperty($property->name, $wireKey); + + try { + foreach ($values as $key => $item) { + $previousItem = $previousValues[$key] ?? UnknownProperty::create(); + + if ($item === $previousItem) { + continue; + } + + if ($item instanceof $itemDataClass) { + $state->enterItem($key); + + try { + $state->resetNodeStructure(); + } finally { + $state->leave(); + } + + $state->writeFinishedItemValue($key, $item); + + continue; + } + + if (is_array($previousItem) && is_array($item)) { + $state->enterItem($key); + + try { + if ($state->nodeClass() !== null) { + $this->reconcileNode( + $itemDataClass, + $previousItem, + $item, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + + continue; + } + } finally { + $state->leave(); + } + } + + $state->writeItemValue($key, []); + $state->enterItem($key); + + try { + $state->resetNodeStructure(); + $direct = $this->fillHookNode( + $itemDataClass, + $item, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } finally { + $state->leave(); + } + + if ($direct !== null) { + $state->writeFinishedItemValue($key, $direct); + } + } + } finally { + $state->leave(); + } + } + + /** + * Cast and instantiate the node at the current construction path. + * + * @param array $extensions + */ + protected function castAndInstantiateNode( + ConstructionState $state, + array &$extensions, + ): BaseData { + $class = $state->nodeClass() ?? $state->context->dataClass; + $dataClass = $this->dataClasses->get($class); + $properties = []; + $contextualParameters = $this->contextualParameterNames($dataClass); + + foreach ($dataClass->properties as $property) { + if (isset($contextualParameters[$property->name])) { + continue; + } + + $wireKey = $state->originalKey($property->name); + + if (! $state->hasValue($wireKey)) { + if ($property->hasDefaultValue) { + continue; + } + + if ($property->type->isOptional) { + $properties[$property->name] = Optional::create(); + } elseif ($property->type->isNullable) { + $properties[$property->name] = null; + } + + continue; + } + + $properties[$property->name] = $this->castProperty( + $property, + $state->getValue($wireKey), + $state, + $extensions, + ); + } + + foreach ($state->context->beforeCreationHooks as $hook) { + $properties = $hook($properties); + } + + $data = $this->instantiator->instantiate($dataClass, $properties); + + foreach ($state->context->afterCreationHooks as $hook) { + $data = $hook($data); + + if (! $data instanceof $class) { + throw CannotCreateData::invalidAfterCreationResult($dataClass, $data); + } + } + + return $data; + } + + /** + * Cast one supplied property value. + * + * @param array $extensions + */ + protected function castProperty( + DataProperty $property, + mixed $value, + ConstructionState $state, + array &$extensions, + ): mixed { + if ($value === null || $value instanceof Optional) { + return $value; + } + + if ($property->isFinishedValue($value)) { + return $value; + } + + $dataIterable = $this->dataIterableType($property); + $shouldCast = ! is_object($value) + || $dataIterable !== null + || ! $property->type->acceptsValue($value); + $casts = $shouldCast + ? $this->propertyCasts($property, $state->context, $extensions) + : []; + + foreach ($casts as $cast) { + $casted = $cast->cast($property, $value, $state, $state->context); + + if (! $casted instanceof Uncastable) { + return $casted; + } + } + + if ($dataIterable !== null) { + return $this->castDataIterable($property, $dataIterable, $value, $state, $extensions); + } + + $iterable = $this->typedIterableType($property); + + if ($iterable !== null) { + return $this->castTypedIterable($property, $iterable, $value, $state, $extensions, $casts); + } + + if ($property->type->acceptsValue($value)) { + return $value; + } + + $state->enterProperty($property->name, $state->originalKey($property->name)); + + try { + if ($state->nodeClass() !== null) { + return $this->castAndInstantiateNode($state, $extensions); + } + } finally { + $state->leave(); + } + + $dataObjectTypes = $property->type->getDataObjectTypes(); + + if (count($dataObjectTypes) > 1) { + $candidates = []; + + foreach ($dataObjectTypes as $dataObjectType) { + /** @var class-string $candidate */ + $candidate = $dataObjectType->dataClass; + $candidates[] = $candidate; + } + + throw CannotCreateData::ambiguousDataObjectUnion($property, $candidates); + } + + foreach ($property->type->getNamedTypes() as $type) { + if (! $type->isCastable) { + continue; + } + + $key = 'castable:' . $type->name; + $cast = $extensions[$key] ??= new CastableCast($type->name); + $casted = $cast->cast($property, $value, $state, $state->context); + + if (! $casted instanceof Uncastable) { + return $casted; + } + } + + $dateType = $property->type->findAcceptedTypeForBaseType(DateTimeInterface::class); + + if ($dateType !== null) { + $key = 'date:' . $dateType; + $cast = $extensions[$key] ??= new DateTimeInterfaceCast(type: $dateType); + + return $cast->cast( + $property, + $value, + $state, + $state->context, + ); + } + + $enumType = $property->type->findAcceptedTypeForBaseType(BackedEnum::class); + + if ($enumType !== null) { + $key = 'enum:' . $enumType; + $cast = $extensions[$key] ??= new EnumCast($enumType); + + return $cast->cast( + $property, + $value, + $state, + $state->context, + ); + } + + $builtin = $this->singleBuiltinType($property); + + if ($builtin !== null) { + $key = 'builtin:' . $builtin; + $cast = $extensions[$key] ??= new BuiltinTypeCast($builtin); + + return $cast->cast( + $property, + $value, + $state, + $state->context, + ); + } + + return $value; + } + + /** + * Cast every item in one declared data iterable. + * + * @param array $extensions + */ + protected function castDataIterable( + DataProperty $property, + NamedType $type, + mixed $value, + ConstructionState $state, + array &$extensions, + ): mixed { + $dataClass = $type->dataClass; + + if ($value instanceof LazyCollection) { + return $value->map( + fn (mixed $item): BaseData => $item instanceof $dataClass + ? $item + : $this->create($dataClass, $state->context, $item), + ); + } + + $values = $this->iterableValues($value); + + if ($values === null) { + return $value; + } + + $items = []; + $state->enterProperty($property->name, $state->originalKey($property->name)); + + try { + foreach ($values as $key => $item) { + if ($item instanceof $dataClass) { + $items[$key] = $item; + + continue; + } + + $state->enterItem($key); + + try { + $items[$key] = $state->nodeClass() === null + ? $this->create($dataClass, $state->context, $item) + : $this->castAndInstantiateNode($state, $extensions); + } finally { + $state->leave(); + } + } + } finally { + $state->leave(); + } + + return $this->dataCollectables->forProperty( + $type, + $dataClass, + $items, + $state, + ); + } + + /** + * Cast every item in one declared non-data iterable. + * + * @param array $extensions + * @param list $casts + */ + protected function castTypedIterable( + DataProperty $property, + NamedType $type, + mixed $value, + ConstructionState $state, + array &$extensions, + array $casts, + ): mixed { + if ($value instanceof LazyCollection) { + return $value->map(function (mixed $item) use ($property, $type, $state, &$extensions, $casts): mixed { + return $this->castIterableItem( + $property, + $type->iterableItemType, + $item, + $state, + $extensions, + $casts, + ); + }); + } + + $values = $this->iterableValues($value); + + if ($values === null) { + return $value; + } + + $items = []; + + foreach ($values as $key => $item) { + $items[$key] = $this->castIterableItem( + $property, + $type->iterableItemType, + $item, + $state, + $extensions, + $casts, + ); + } + + return $this->rebuildIterable($type, $items); + } + + /** + * Cast one declared iterable item. + * + * @param array $extensions + * @param list $casts + */ + protected function castIterableItem( + DataProperty $property, + Type $type, + mixed $value, + ConstructionState $state, + array &$extensions, + array $casts, + ): mixed { + if ($value === null) { + return $value; + } + + foreach ($casts as $cast) { + if (! $cast instanceof IterableItemCast) { + continue; + } + + $casted = $cast->castIterableItem($property, $value, $state, $state->context); + + if (! $casted instanceof Uncastable) { + return $casted; + } + } + + if ($type->acceptsValue($value)) { + return $value; + } + + foreach ($type->getNamedTypes() as $namedType) { + if (! $namedType->isCastable) { + continue; + } + + $key = 'iterable-castable:' . $namedType->name; + $cast = $extensions[$key] ??= new CastableCast($namedType->name); + $casted = $cast->cast($property, $value, $state, $state->context); + + if (! $casted instanceof Uncastable) { + return $casted; + } + } + + $dateType = $type->findAcceptedTypeForBaseType(DateTimeInterface::class); + + if ($dateType !== null) { + $key = 'date:' . $dateType; + $cast = $extensions[$key] ??= new DateTimeInterfaceCast(type: $dateType); + + return $cast->castIterableItem( + $property, + $value, + $state, + $state->context, + ); + } + + $enumType = $type->findAcceptedTypeForBaseType(BackedEnum::class); + + if ($enumType !== null) { + $key = 'enum:' . $enumType; + $cast = $extensions[$key] ??= new EnumCast($enumType); + + return $cast->castIterableItem( + $property, + $value, + $state, + $state->context, + ); + } + + $builtin = $this->singleBuiltinFromType($type); + + if ($builtin !== null) { + $key = 'builtin:' . $builtin; + $cast = $extensions[$key] ??= new BuiltinTypeCast($builtin); + + return $cast->castIterableItem( + $property, + $value, + $state, + $state->context, + ); + } + + return $value; + } + + /** + * Rebuild an eager iterable in its declared container. + * + * @param array $items + */ + protected function rebuildIterable(NamedType $type, array $items): mixed + { + $iterableClass = $type->iterableClass; + + if ($iterableClass !== null && is_a($iterableClass, Collection::class, true)) { + return new $iterableClass($items); + } + + if ($iterableClass !== null && is_a($iterableClass, LazyCollection::class, true)) { + return new $iterableClass($items); + } + + return $items; + } + + /** + * Get the ordered custom casts applicable to a property. + * + * @param array $extensions + * @return list + */ + protected function propertyCasts( + DataProperty $property, + CreationContext $context, + array &$extensions, + ): array { + $casts = []; + + if ($property->cast !== null) { + $key = 'attribute-cast:' . spl_object_id($property->cast); + + if (! isset($extensions[$key])) { + /** @var GetsCast $attribute */ + $attribute = $property->cast->newInstance(); + $extensions[$key] = $attribute->get(); + } + + $casts[] = $extensions[$key]; + } + + foreach ($context->casts as $baseType => $cast) { + if ($property->type->findAcceptedTypeForBaseType($baseType) !== null) { + $casts[] = $this->resolveCast($cast, $extensions); + } + } + + foreach ($property->configuredCasts as $cast) { + $casts[] = $this->resolveCast($cast, $extensions); + } + + return $casts; + } + + /** + * Resolve one cast once for the current root operation. + * + * @param Cast|class-string $cast + * @param array $extensions + */ + protected function resolveCast(Cast|string $cast, array &$extensions): Cast + { + if ($cast instanceof Cast) { + return $cast; + } + + $key = 'cast:' . $cast; + + /** @var Cast */ + return $extensions[$key] ??= $this->container->make($cast); + } + + /** + * Resolve the custom normalizers for one data class. + * + * @param array $extensions + * @return list + */ + protected function resolveNormalizers( + DataClass $dataClass, + CreationContext $context, + array &$extensions, + ): array { + $normalizers = []; + + if ($dataClass->hasLifecycleMethod('normalizers')) { + $class = $dataClass->name; + $normalizers = $this->container->call($class::normalizers(...)); + } + + array_push($normalizers, ...$context->normalizers, ...$this->config->normalizers); + + foreach ($normalizers as $index => $normalizer) { + if ($normalizer instanceof Normalizer) { + continue; + } + + $key = 'normalizer:' . $normalizer; + + /** @var Normalizer */ + $normalizers[$index] = $extensions[$key] ??= $this->container->make($normalizer); + } + + return array_values($normalizers); + } + + /** + * Resolve every declared property against the normalized sources. + * + * @param list $sources + * @return array + */ + protected function resolveProperties( + DataClass $dataClass, + array $sources, + CreationContext $context, + ): array { + $properties = []; + + foreach ($dataClass->properties as $property) { + $properties[$property->name] = $this->resolveProperty($sources, $property, $context); + } + + return $properties; + } + + /** + * Resolve one property with mapped-key precedence inside each source. + * + * @param list $sources + * @return array{array-key, mixed} + */ + protected function resolveProperty( + array $sources, + DataProperty $property, + CreationContext $context, + ): array { + $mappedKey = $context->mapPropertyNames + ? ($property->inputMappedName ?? $property->name) + : $property->name; + + foreach ($sources as $source) { + $value = SourceReader::read($source, $mappedKey, $property); + + if (! $value instanceof UnknownProperty) { + return [$mappedKey, $value]; + } + + if ($mappedKey === $property->name) { + continue; + } + + $value = SourceReader::read($source, $property->name, $property); + + if (! $value instanceof UnknownProperty) { + return [$property->name, $value]; + } + } + + return [$mappedKey, UnknownProperty::create()]; + } + + /** + * Merge normalized sources for a prepare-data hook. + * + * @param list $sources + * @return array + */ + protected function mergeSources( + DataClass $dataClass, + array $sources, + CreationContext $context, + ): array { + $merged = []; + + foreach ($sources as $source) { + $values = is_array($source) + ? $source + : $this->projectNormalizedSource($dataClass, $source, $context); + $merged = $this->mergeMissingValues($merged, $values); + } + + return $merged; + } + + /** + * Project a non-enumerable normalized source into declared wire keys. + * + * @return array + */ + protected function projectNormalizedSource( + DataClass $dataClass, + Normalized $source, + CreationContext $context, + ): array { + $values = []; + + foreach ($dataClass->properties as $property) { + [$key, $value] = $this->resolveProperty([$source], $property, $context); + + if ($value instanceof UnknownProperty) { + continue; + } + + if (is_int($key)) { + $values[$key] = $value; + } else { + data_set($values, $key, $value); + } + } + + return $values; + } + + /** + * Recursively fill only values absent from the earlier source. + * + * @param array $target + * @param array $source + * @return array + */ + protected function mergeMissingValues(array $target, array $source): array + { + foreach ($source as $key => $value) { + if (! array_key_exists($key, $target)) { + $target[$key] = $value; + + continue; + } + + if (is_array($target[$key]) && is_array($value)) { + $target[$key] = $this->mergeMissingValues($target[$key], $value); + } + } + + return $target; + } + + /** + * Find the first compatible named object factory. + * + * @param array $payloads + * @return null|array{DataMethod, DataMethodMatch} + */ + protected function matchNamedFactory( + DataClass $dataClass, + CreationContext $context, + array $payloads, + ): ?array { + if ($context->disableMagicalCreation) { + return null; + } + + foreach ($dataClass->methods as $method) { + if ($method->customCreationMethodType !== CustomCreationMethodType::Object + || in_array($method->name, $context->ignoredMagicalMethods, true) + ) { + continue; + } + + $match = $method->matchPayloads($context, ...$payloads); + + if ($match !== null) { + return [$method, $match]; + } + } + + return null; + } + + /** + * Invoke one matched named factory without method-binding interception. + */ + protected function invokeNamedFactory( + DataClass $dataClass, + DataMethod $method, + DataMethodMatch $match, + ): mixed { + $class = $dataClass->name; + $methodName = $method->name; + + return $match->requiresContainerCall + ? $this->container->call($class::$methodName(...), $match->arguments) + : $class::$methodName(...$match->arguments); + } + + /** + * Resolve the concrete class selected by an abstract property's discriminator. + * + * @param array $resolvedProperties + * @return class-string + */ + protected function resolveMorphClass(DataClass $dataClass, array $resolvedProperties): string + { + if (! $dataClass->isAbstract) { + return $dataClass->name; + } + + if (! $dataClass->propertyMorphable) { + throw CannotCreateAbstractClass::morphClassWasNotResolved($dataClass->name); + } + + $properties = []; + + foreach ($dataClass->properties as $property) { + if (! $property->morphable) { + continue; + } + + $value = $resolvedProperties[$property->name][1]; + + if ($value instanceof UnknownProperty) { + $value = $this->propertyDefaultValue($dataClass, $property); + } + + if ($value instanceof UnknownProperty) { + throw CannotCreateAbstractClass::morphClassWasNotResolved($dataClass->name); + } + + $enum = $property->type->findAcceptedTypeForBaseType(BackedEnum::class); + + if ($enum !== null && (is_int($value) || is_string($value))) { + $value = $enum::tryFrom($value) ?? $value; + } + + $properties[$property->name] = $value; + } + + $class = $dataClass->name; + $resolvedClass = $class::morph($properties); + + if ($resolvedClass === null) { + throw CannotCreateAbstractClass::morphClassWasNotResolved($class); + } + + if (! is_a($resolvedClass, $class, true) + || ! is_a($resolvedClass, BaseData::class, true) + ) { + throw CannotCreateAbstractClass::invalidMorphClass($class, $resolvedClass); + } + + if ($this->dataClasses->get($resolvedClass)->isAbstract) { + throw CannotCreateAbstractClass::invalidMorphClass($class, $resolvedClass); + } + + return $resolvedClass; + } + + /** + * Materialize a property default only when morph selection requires it. + */ + protected function propertyDefaultValue(DataClass $dataClass, DataProperty $property): mixed + { + if (! $property->hasDefaultValue) { + return UnknownProperty::create(); + } + + if (! $property->isConstructorParameter) { + return $property->reflection->getDefaultValue(); + } + + foreach ($dataClass->constructorParameters as $parameter) { + if ($parameter->name === $property->name) { + return $parameter->reflection->getDefaultValue(); + } + } + + return UnknownProperty::create(); + } + + /** + * Get constructor parameter names resolved contextually by the container. + * + * @return array + */ + protected function contextualParameterNames(DataClass $dataClass): array + { + $names = []; + + foreach ($dataClass->constructorParameters as $parameter) { + if ($parameter->contextualAttribute !== null) { + $names[$parameter->name] = true; + } + } + + return $names; + } + + /** + * Get the one unambiguous nested data class declared by a property. + * + * @return null|class-string + */ + protected function nestedDataClass(DataProperty $property): ?string + { + $types = $property->type->getDataObjectTypes(); + + return count($types) === 1 ? $types[0]->dataClass : null; + } + + /** + * Get the one unambiguous data iterable declared by a property. + */ + protected function dataIterableType(DataProperty $property): ?NamedType + { + $types = $property->type->getDataCollectableTypes(); + + return count($types) === 1 ? $types[0] : null; + } + + /** + * Get the one unambiguous non-data iterable declared by a property. + */ + protected function typedIterableType(DataProperty $property): ?NamedType + { + $types = array_values(array_filter( + $property->type->getIterableTypes(), + fn (NamedType $type): bool => ! $type->kind->isDataCollectable(), + )); + + return count($types) === 1 ? $types[0] : null; + } + + /** + * Convert an eager iterable to its keyed values. + * + * @return null|array + */ + protected function iterableValues(mixed $value): ?array + { + return $this->dataCollectables->items($value); + } + + /** + * Get one unambiguous built-in scalar cast target. + * + * @return null|'array'|'bool'|'float'|'int'|'string' + */ + protected function singleBuiltinType(DataProperty $property): ?string + { + return $this->singleBuiltinFromType($property->type->type); + } + + /** + * Get one unambiguous built-in scalar cast target from a type. + * + * @return null|'array'|'bool'|'float'|'int'|'string' + */ + protected function singleBuiltinFromType(Type $type): ?string + { + $types = array_values(array_filter( + $type->getNamedTypes(), + fn (NamedType $type): bool => in_array( + $type->name, + ['array', 'bool', 'float', 'int', 'string'], + true, + ), + )); + + return count($types) === 1 ? $types[0]->name : null; + } +} diff --git a/src/data/src/Support/Creation/DataInstantiator.php b/src/data/src/Support/Creation/DataInstantiator.php new file mode 100644 index 000000000..3831c2998 --- /dev/null +++ b/src/data/src/Support/Creation/DataInstantiator.php @@ -0,0 +1,79 @@ + $properties + */ + public function instantiate(DataClass $dataClass, array $properties): BaseData + { + if ($dataClass->constructor !== null && ! $dataClass->constructor->isPublic()) { + throw CannotCreateData::nonPublicConstructor($dataClass); + } + + $parameters = []; + $requiresContainer = false; + + foreach ($dataClass->constructorParameters as $parameter) { + if ($parameter->contextualAttribute !== null) { + $requiresContainer = true; + + continue; + } + + if (array_key_exists($parameter->name, $properties)) { + $parameters[$parameter->name] = $properties[$parameter->name]; + + continue; + } + + if (! $parameter->hasDefaultValue) { + throw CannotCreateData::constructorMissingParameters($dataClass, $parameters); + } + } + + $class = $dataClass->name; + + /** @var BaseData $data */ + $data = $requiresContainer + ? $this->container->buildWith($class, $parameters) + : new $class(...$parameters); + + foreach ($dataClass->properties as $property) { + if ($property->isConstructorParameter || $property->computed) { + continue; + } + + if (! array_key_exists($property->name, $properties)) { + if (! $property->hasDefaultValue) { + throw CannotCreateData::propertyMissing($dataClass, $property); + } + + continue; + } + + $data->{$property->name} = $properties[$property->name]; + } + + return $data; + } +} diff --git a/src/data/src/Support/Creation/SourceReader.php b/src/data/src/Support/Creation/SourceReader.php new file mode 100644 index 000000000..0da0a1608 --- /dev/null +++ b/src/data/src/Support/Creation/SourceReader.php @@ -0,0 +1,65 @@ +getProperty(array_shift($segments), $property); + + if ($value instanceof UnknownProperty || $segments === []) { + return $value; + } + + return data_get($value, implode('.', $segments), UnknownProperty::create()); + } + + if (is_int($key)) { + return array_key_exists($key, $source) + ? $source[$key] + : UnknownProperty::create(); + } + + return data_get($source, $key, UnknownProperty::create()); + } + + /** + * Read the first source that contains a property. + * + * @param array $sources + */ + public static function readFromMany( + array $sources, + string|int $key, + DataProperty $property, + ): mixed { + foreach ($sources as $source) { + $value = self::read($source, $key, $property); + + if ($value instanceof UnknownProperty) { + continue; + } + + return $value; + } + + return UnknownProperty::create(); + } +} diff --git a/src/data/src/Support/Creation/SourceResolver.php b/src/data/src/Support/Creation/SourceResolver.php new file mode 100644 index 000000000..25307a4fb --- /dev/null +++ b/src/data/src/Support/Creation/SourceResolver.php @@ -0,0 +1,79 @@ + $normalizers + */ + public static function resolve( + string $dataClass, + mixed $value, + array $normalizers, + ): array|Normalized { + if ($value === null) { + return []; + } + + if ($value instanceof Normalized) { + return $value; + } + + foreach ($normalizers as $normalizer) { + $normalized = $normalizer->normalize($value); + + if ($normalized !== null) { + return $normalized; + } + } + + if (is_array($value)) { + return $value; + } + + if ($value instanceof Model) { + return new NormalizedModel($value); + } + + if ($value instanceof Request) { + return $value->all(); + } + + if ($value instanceof Arrayable) { + return $value->toArray(); + } + + if (is_object($value)) { + return get_object_vars($value); + } + + if (is_string($value)) { + try { + $decoded = Json::decode($value); + + if (is_array($decoded)) { + return $decoded; + } + } catch (JsonException) { + } + } + + throw CannotCreateData::noNormalizerFound($dataClass, $value); + } +} diff --git a/tests/Data/Casts/BuiltinTypeCastTest.php b/tests/Data/Casts/BuiltinTypeCastTest.php new file mode 100644 index 000000000..7474c8aef --- /dev/null +++ b/tests/Data/Casts/BuiltinTypeCastTest.php @@ -0,0 +1,52 @@ +createStub(DataProperty::class); + $cast = new BuiltinTypeCast($type); + + $this->assertSame($expected, $cast->cast($property, $value, $state, $context)); + $this->assertSame($expected, $cast->castIterableItem($property, $value, $state, $context)); + } + + /** + * Provide built-in cast values. + */ + public static function castProvider(): array + { + return [ + 'true string' => ['bool', 'TRUE', true], + 'false string' => ['bool', 'False', false], + 'zero string' => ['bool', '0', false], + 'truthy string' => ['bool', 'yes', true], + 'integer' => ['int', '42', 42], + 'float' => ['float', '4.2', 4.2], + 'array' => ['array', 'value', ['value']], + 'string' => ['string', 42, '42'], + ]; + } +} + +abstract class BuiltinTypeCastDataFixture implements BaseData +{ +} diff --git a/tests/Data/Casts/DateTimeInterfaceCastTest.php b/tests/Data/Casts/DateTimeInterfaceCastTest.php new file mode 100644 index 000000000..83a8237a2 --- /dev/null +++ b/tests/Data/Casts/DateTimeInterfaceCastTest.php @@ -0,0 +1,183 @@ +operation(['Y-m-d', 'Y-m-d H:i:s.uP']); + $cast = new DateTimeInterfaceCast; + + $immutable = $cast->cast($this->property('immutable'), '2026-08-30', $state, $context); + $custom = $cast->cast($this->property('custom'), '2026-08-30', $state, $context); + + $this->assertInstanceOf(DateTimeImmutable::class, $immutable); + $this->assertSame('2026-08-30', $immutable->format('Y-m-d')); + $this->assertInstanceOf(CustomDateTimeImmutable::class, $custom); + } + + /** + * Test interface declarations use Hypervel's configured date factory. + */ + public function testCastsDateInterfacesThroughTheDateFactory(): void + { + [$state, $context] = $this->operation(['Y-m-d']); + + $date = (new DateTimeInterfaceCast)->cast( + $this->property('interface'), + '2026-08-30', + $state, + $context, + ); + + $this->assertInstanceOf(DateTimeInterface::class, $date); + $this->assertSame('2026-08-30', $date->format('Y-m-d')); + } + + /** + * Test source and target timezones and nanosecond truncation. + */ + public function testAppliesTimezonesAndTruncatesNanoseconds(): void + { + [$state, $context] = $this->operation(['Y-m-d H:i:s.uP']); + $cast = new DateTimeInterfaceCast( + format: 'Y-m-d H:i:s.uP', + type: DateTimeImmutable::class, + setTimeZone: 'America/New_York', + timeZone: 'UTC', + ); + + $date = $cast->cast( + $this->property('immutable'), + '2026-08-30 12:00:00.123456789+00:00', + $state, + $context, + ); + + $this->assertSame('2026-08-30 08:00:00.123456-04:00', $date->format('Y-m-d H:i:s.uP')); + } + + /** + * Test iterable date declarations and non-date declarations. + */ + public function testCastsIterableDatesAndDeclinesNonDateProperties(): void + { + [$state, $context] = $this->operation(['Y-m-d']); + $cast = new DateTimeInterfaceCast; + + $date = $cast->castIterableItem($this->property('dates'), '2026-08-30', $state, $context); + + $this->assertInstanceOf(DateTimeImmutable::class, $date); + $this->assertSame( + Uncastable::create(), + $cast->cast($this->property('name'), '2026-08-30', $state, $context), + ); + } + + /** + * Test invalid dates name the target and accepted formats. + */ + public function testThrowsWhenNoDateFormatMatches(): void + { + [$state, $context] = $this->operation(['Y-m-d']); + + $this->expectException(CannotCastDate::class); + $this->expectExceptionMessage(DateTimeImmutable::class); + $this->expectExceptionMessage('Y-m-d'); + + (new DateTimeInterfaceCast)->cast( + $this->property('immutable'), + 'not-a-date', + $state, + $context, + ); + } + + /** + * Build one property definition. + */ + protected function property(string $name): DataProperty + { + $defaults = require __DIR__ . '/../../../src/data/config/data.php'; + $config = new DataConfig(new Repository(['data' => $defaults])); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $reflectionClass = new ReflectionClass(DateCastDataFixture::class); + $reflectionProperty = $reflectionClass->getProperty($name); + + return (new DataPropertyFactory( + $typeFactory, + $config, + new NameMapperResolver(new Container), + ))->build( + $reflectionProperty, + $reflectionClass, + classDefinedDataIterableAnnotations: (new DataIterableAnnotationReader)->getForProperty( + $reflectionProperty, + ), + ); + } + + /** + * Create one date cast operation. + * + * @param non-empty-list $formats + * @return array{ConstructionState, CreationContext} + */ + protected function operation(array $formats): array + { + $context = new CreationContext( + dataClass: DateCastDataContract::class, + dateFormats: $formats, + ); + + return [ConstructionState::create($context, DateCastDataContract::class), $context]; + } +} + +class DateCastDataFixture +{ + public DateTimeImmutable $immutable; + + public DateTimeInterface $interface; + + public CustomDateTimeImmutable $custom; + + /** @var list */ + public array $dates; + + public string $name; +} + +class CustomDateTimeImmutable extends DateTimeImmutable +{ +} + +abstract class DateCastDataContract implements BaseData +{ +} diff --git a/tests/Data/Casts/EnumCastTest.php b/tests/Data/Casts/EnumCastTest.php new file mode 100644 index 000000000..0d3d9bb9d --- /dev/null +++ b/tests/Data/Casts/EnumCastTest.php @@ -0,0 +1,144 @@ +operation(); + $property = $this->property('status'); + $cast = new EnumCast; + + $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, 'ready', $state, $context)); + $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, EnumCastStatus::Ready, $state, $context)); + $this->assertSame(EnumCastStatus::Ready, $cast->cast($property, OtherEnumCastStatus::Ready, $state, $context)); + } + + /** + * Test iterable item enum metadata is used. + */ + public function testCastsIterableBackedEnumValues(): void + { + [$state, $context] = $this->operation(); + + $this->assertSame( + EnumCastStatus::Done, + (new EnumCast)->castIterableItem( + $this->property('statuses'), + 'done', + $state, + $context, + ), + ); + } + + /** + * Test a declaration without a backed enum declines the cast. + */ + public function testReturnsUncastableWithoutABackedEnumDeclaration(): void + { + [$state, $context] = $this->operation(); + + $this->assertSame( + Uncastable::create(), + (new EnumCast)->cast($this->property('name'), 'ready', $state, $context), + ); + } + + /** + * Test invalid enum values produce a property-specific exception. + */ + public function testThrowsForAnInvalidBackedEnumValue(): void + { + [$state, $context] = $this->operation(); + + $this->expectException(CannotCastEnum::class); + $this->expectExceptionMessage('EnumCastDataFixture::$status'); + + (new EnumCast)->cast($this->property('status'), 'invalid', $state, $context); + } + + /** + * Build one property definition. + */ + protected function property(string $name): DataProperty + { + $defaults = require __DIR__ . '/../../../src/data/config/data.php'; + $config = new DataConfig(new Repository(['data' => $defaults])); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $reflectionClass = new ReflectionClass(EnumCastDataFixture::class); + + return (new DataPropertyFactory( + $typeFactory, + $config, + new NameMapperResolver(new Container), + ))->build( + $reflectionClass->getProperty($name), + $reflectionClass, + classDefinedDataIterableAnnotations: (new DataIterableAnnotationReader)->getForProperty( + $reflectionClass->getProperty($name), + ), + ); + } + + /** + * Create one cast operation. + * + * @return array{ConstructionState, CreationContext} + */ + protected function operation(): array + { + $context = new CreationContext(EnumCastDataContract::class); + + return [ConstructionState::create($context, EnumCastDataContract::class), $context]; + } +} + +enum EnumCastStatus: string +{ + case Ready = 'ready'; + case Done = 'done'; +} + +enum OtherEnumCastStatus: string +{ + case Ready = 'ready'; +} + +class EnumCastDataFixture +{ + public EnumCastStatus $status; + + /** @var list */ + public array $statuses; + + public string $name; +} + +abstract class EnumCastDataContract implements BaseData +{ +} diff --git a/tests/Data/Normalizers/Normalized/NormalizedModelTest.php b/tests/Data/Normalizers/Normalized/NormalizedModelTest.php new file mode 100644 index 000000000..cb6b3cab9 --- /dev/null +++ b/tests/Data/Normalizers/Normalized/NormalizedModelTest.php @@ -0,0 +1,152 @@ +setRawAttributes([ + 'first_name' => 'Taylor', + 'nullable_name' => null, + 'unrequested' => 'ignored', + ]); + $source = new NormalizedModel($model); + + $this->assertSame('Taylor', $source->getProperty('firstName', $this->property('firstName'))); + $this->assertNull($source->getProperty('nullableName', $this->property('nullableName'))); + $this->assertSame( + UnknownProperty::create(), + $source->getProperty('missing', $this->property('missing')), + ); + $this->assertSame(0, $model->serializationCount); + } + + /** + * Test loaded relations are returned without another load. + */ + public function testReadsAlreadyLoadedRelations(): void + { + $relation = new stdClass; + $model = new NormalizedModelFixture; + $model->setRelation('profile', $relation); + $source = new NormalizedModel($model); + + $this->assertSame($relation, $source->getProperty('profile', $this->property('profile'))); + $this->assertSame(0, $model->loadMissingCount); + } + + /** + * Test LoadRelation explicitly permits one missing relation load. + */ + public function testLoadsOnlyRelationsMarkedForLoading(): void + { + $model = new NormalizedModelFixture; + $source = new NormalizedModel($model); + + $this->assertSame( + UnknownProperty::create(), + $source->getProperty('profile', $this->property('profile')), + ); + + $loaded = $source->getProperty('loadedProfile', $this->property('loadedProfile')); + + $this->assertInstanceOf(stdClass::class, $loaded); + $this->assertSame(1, $model->loadMissingCount); + $this->assertSame($loaded, $source->getProperty('loadedProfile', $this->property('loadedProfile'))); + $this->assertSame(1, $model->loadMissingCount); + } + + /** + * Build property metadata for the model projection fixture. + */ + protected function property(string $name): DataProperty + { + $defaults = require __DIR__ . '/../../../../src/data/config/data.php'; + $config = new DataConfig(new Repository(['data' => $defaults])); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $reflectionClass = new ReflectionClass(NormalizedModelDataFixture::class); + + return (new DataPropertyFactory( + $typeFactory, + $config, + new NameMapperResolver(new Container), + ))->build( + $reflectionClass->getProperty($name), + $reflectionClass, + ); + } +} + +class NormalizedModelDataFixture +{ + public string $firstName; + + public ?string $nullableName; + + public string $missing; + + public ?object $profile; + + #[LoadRelation] + public ?object $loadedProfile; +} + +class NormalizedModelFixture extends Model +{ + public int $serializationCount = 0; + + public int $loadMissingCount = 0; + + /** + * Determine if a fixture relation exists. + */ + public function isRelation(string $key): bool + { + return in_array($key, ['profile', 'loadedProfile'], true); + } + + /** + * Mark a requested fixture relation as loaded. + */ + public function loadMissing(array|string $relations): static + { + ++$this->loadMissingCount; + $relation = is_array($relations) ? $relations[0] : $relations; + $this->setRelation($relation, new stdClass); + + return $this; + } + + /** + * Fail if model-wide serialization is attempted. + */ + public function toArray(): array + { + ++$this->serializationCount; + + return parent::toArray(); + } +} diff --git a/tests/Data/Support/Creation/ConstructionStateTest.php b/tests/Data/Support/Creation/ConstructionStateTest.php new file mode 100644 index 000000000..d15d87cc4 --- /dev/null +++ b/tests/Data/Support/Creation/ConstructionStateTest.php @@ -0,0 +1,477 @@ +state(); + $state->writePropertyValue('title', 'Hello'); + $state->enterProperty('author', 'writer'); + $state->writePropertyValue('name', 'Ruben'); + + $this->assertTrue($state->hasValue('name')); + $this->assertSame('Ruben', $state->getValue('name')); + $this->assertSame(['name' => 'Ruben'], $state->currentPayload()); + $this->assertFalse($state->hasValue('missing')); + $this->assertNull($state->getValue('missing')); + + $state->leave(); + + $this->assertSame([ + 'title' => 'Hello', + 'writer' => ['name' => 'Ruben'], + ], $state->payload()); + $this->assertFalse($state->hasValue('name')); + + $state->replacePayload(['validated' => true]); + + $this->assertSame(['validated' => true], $state->payload()); + } + + /** + * Test collection items retain concrete payload indices and paths. + */ + public function testWritesCollectionItemsAndBuildsWirePaths(): void + { + $state = $this->state(); + $state->enterProperty('posts', 0); + $state->enterItem(3); + $state->writePropertyValue('title', 'Fourth'); + + $this->assertSame([0, 3], $state->path()); + $this->assertSame(2, $state->depth()); + + $state->leave(); + $state->leave(); + + $this->assertSame([0 => [3 => ['title' => 'Fourth']]], $state->payload()); + } + + /** + * Test raw item keys never acquire mapped property path semantics. + */ + public function testWritesRawCollectionItemKeysWithoutFlatteningOrCollisions(): void + { + $state = $this->state(); + $state->enterProperty('tenants'); + + foreach ([ + 'tenant.eu' => 'Europe', + 'tenant' => 'Global', + '*' => 'Wildcard', + ] as $key => $name) { + $state->writeItemValue($key, []); + $state->enterItem($key); + $state->writePropertyValue('name', $name); + $state->leave(); + } + + $state->leave(); + + $this->assertSame([ + 'tenants' => [ + 'tenant.eu' => ['name' => 'Europe'], + 'tenant' => ['name' => 'Global'], + '*' => ['name' => 'Wildcard'], + ], + ], $state->payload()); + $this->assertSame( + ['tenant.eu', 'tenant', '*'], + array_keys($state->payload()['tenants']), + ); + $this->assertArrayNotHasKey('eu', $state->payload()['tenants']['tenant']); + } + + /** + * Test mapped dot paths address nested payload values. + */ + public function testReadsAndWritesMappedDotPaths(): void + { + $state = $this->state(); + $state->writePropertyValue('profile.name', 'Taylor'); + + $this->assertTrue($state->hasValue('profile.name')); + $this->assertSame('Taylor', $state->getValue('profile.name')); + + $state->enterProperty('author', 'people.0'); + $state->writePropertyValue('contact.email', 'taylor@example.com'); + + $this->assertSame(['people', '0'], $state->path()); + $this->assertSame('taylor@example.com', $state->getValue('contact.email')); + + $this->assertSame([ + 'profile' => ['name' => 'Taylor'], + 'people' => [ + 0 => [ + 'contact' => ['email' => 'taylor@example.com'], + ], + ], + ], $state->payload()); + } + + /** + * Test mappings and node classes are recorded in property structure space. + */ + public function testRecordsStructureWithoutCollectionIndices(): void + { + $state = $this->state(); + $state->recordMapping('author', 'writer'); + + $this->assertSame('writer', $state->originalKey('author')); + $this->assertSame('title', $state->originalKey('title')); + $this->assertSame(ConstructionStateDataFixture::class, $state->nodeClass()); + + $state->enterProperty('posts'); + $state->enterItem(3); + $state->recordMapping('title', 'post_title'); + $state->setNodeClass(ConstructionStateDataFixture::class); + + $this->assertSame('post_title', $state->originalKey('title')); + $this->assertSame(ConstructionStateDataFixture::class, $state->nodeClass()); + + $state->leave(); + $state->leave(); + + $this->assertSame([ + 'class' => ConstructionStateDataFixture::class, + 'mappings' => ['author' => 'writer'], + 'children' => [ + 'posts' => [ + 'class' => ConstructionStateDataFixture::class, + 'mappings' => ['title' => 'post_title'], + 'children' => [], + ], + ], + ], $state->structure()); + } + + /** + * Test collection structure stores only values that differ from its template. + */ + public function testRecordsSparseRawKeyItemOverrides(): void + { + $state = $this->state(); + $state->enterProperty('posts'); + $state->enterItem('first'); + $state->setNodeClass(ConstructionStateDataFixture::class); + $state->recordMapping('title', 'post_title'); + $state->leave(); + $state->enterItem('same.item'); + $state->setNodeClass(ConstructionStateDataFixture::class); + $state->recordMapping('title', 'post_title'); + + $this->assertSame(ConstructionStateDataFixture::class, $state->nodeClass()); + $this->assertSame('post_title', $state->originalKey('title')); + $this->assertTrue($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->enterItem('different.item'); + $state->setNodeClass(AlternateConstructionStateDataFixture::class); + $state->recordMapping('title', 'title'); + + $this->assertSame(AlternateConstructionStateDataFixture::class, $state->nodeClass()); + $this->assertSame('title', $state->originalKey('title')); + $this->assertFalse($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->enterItem('first'); + + $this->assertSame(ConstructionStateDataFixture::class, $state->nodeClass()); + $this->assertSame('post_title', $state->originalKey('title')); + + $state->leave(); + $state->leave(); + + $posts = $state->structure()['children']['posts']; + + $this->assertFalse($posts['uniform']); + $this->assertSame( + AlternateConstructionStateDataFixture::class, + $posts['items']['different.item']['class'], + ); + $this->assertSame('title', $posts['items']['different.item']['mappings']['title']); + $this->assertArrayNotHasKey('same.item', $posts['items']); + } + + /** + * Test a nested difference latches every enclosing collection. + */ + public function testNestedOverridesLatchEveryEnclosingCollection(): void + { + $state = $this->state(); + $state->enterProperty('posts'); + $state->enterItem(0); + $state->enterProperty('comments'); + $state->enterItem(0); + $state->recordMapping('label', 'label'); + $state->leave(); + $state->leave(); + $state->leave(); + $state->enterItem(1); + $state->enterProperty('comments'); + $state->enterItem(0); + $state->recordMapping('label', 'comment_label'); + + $this->assertSame('comment_label', $state->originalKey('label')); + $this->assertFalse($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->leave(); + $state->leave(); + + $this->assertFalse($state->isCurrentCollectionUniform()); + + $state->leave(); + + $posts = $state->structure()['children']['posts']; + + $this->assertFalse($posts['uniform']); + $this->assertFalse($posts['children']['comments']['uniform']); + $this->assertSame( + 'comment_label', + $posts['items'][1]['children']['comments']['items'][0]['mappings']['label'], + ); + } + + /** + * Test finished data values make the containing collection non-uniform. + */ + public function testFinishedDataValuesLatchContainingCollection(): void + { + $state = $this->state(); + $state->enterProperty('posts'); + $state->enterItem(0); + $state->writeFinishedPropertyValue('author', new ConstructionStateFinishedDataFixture()); + + $this->assertFalse($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->leave(); + + $this->assertFalse($state->structure()['children']['posts']['uniform']); + } + + /** + * Test finished data values create their structural path before latching. + */ + public function testFinishedDataValuesCreateCurrentStructurePath(): void + { + $state = $this->state(); + $state->enterProperty('posts'); + $state->enterItem(0); + $state->enterProperty('author'); + $state->writeFinishedPropertyValue('profile', new ConstructionStateFinishedDataFixture()); + + $state->leave(); + $state->leave(); + $state->leave(); + + $posts = $state->structure()['children']['posts']; + + $this->assertFalse($posts['uniform']); + $this->assertArrayHasKey('author', $posts['children']); + } + + /** + * Test finished data collectables make the containing collection non-uniform. + */ + public function testFinishedDataCollectablesLatchContainingCollection(): void + { + $state = $this->state(); + $state->enterProperty('posts'); + $state->enterItem(0); + $state->writeFinishedPropertyValue('comments', new ConstructionStateFinishedDataCollectableFixture()); + + $this->assertFalse($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->leave(); + + $this->assertFalse($state->structure()['children']['posts']['uniform']); + } + + /** + * Test paginator sources are isolated from validation structure and sibling items. + */ + public function testRecordsPaginatorSourcesWithoutChangingCollectionUniformity(): void + { + $first = new Paginator([1], 10, 1); + $second = new Paginator([2], 10, 1); + $state = $this->state(); + $state->enterProperty('posts'); + $state->enterItem(0); + $state->enterProperty('comments'); + $state->recordPaginatorSource($first); + + $this->assertSame($first, $state->paginatorSource()); + + $state->leave(); + + $this->assertTrue($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->enterItem(1); + $state->enterProperty('comments'); + + $this->assertNull($state->paginatorSource()); + + $state->recordPaginatorSource($second); + + $this->assertSame($second, $state->paginatorSource()); + + $state->leave(); + + $this->assertTrue($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->leave(); + + $comments = $state->structure()['children']['posts']['items']; + + $this->assertSame($first, $comments[0]['children']['comments']['paginatorSource']); + $this->assertSame($second, $comments[1]['children']['comments']['paginatorSource']); + $this->assertArrayNotHasKey('uniform', $state->structure()['children']['posts']); + } + + /** + * Test paginator sources clear with their owning structure node. + */ + public function testClearsPaginatorSourcesWithoutAllocatingMissingOverrides(): void + { + $state = $this->state(); + $state->enterProperty('comments'); + $state->recordPaginatorSource(new Paginator([1], 10, 1)); + + $this->assertNotNull($state->paginatorSource()); + + $state->clearPaginatorSource(); + + $this->assertNull($state->paginatorSource()); + + $state->leave(); + $state->enterProperty('posts'); + $state->enterItem(5); + $state->enterProperty('comments'); + $state->clearPaginatorSource(); + + $this->assertNull($state->paginatorSource()); + + $state->leave(); + $state->leave(); + $state->leave(); + + $this->assertArrayNotHasKey('posts', $state->structure()['children']); + } + + /** + * Test read-only structure lookups do not allocate nodes. + */ + public function testReadOnlyStructureLookupsDoNotCreateNodes(): void + { + $state = $this->state(); + $state->enterProperty('unvisited'); + + $this->assertFalse($state->hasOriginalKey('name')); + $this->assertSame('name', $state->originalKey('name')); + $this->assertNull($state->nodeClass()); + + $state->recordMapping('name', 'name'); + + $this->assertTrue($state->hasOriginalKey('name')); + $this->assertSame('name', $state->originalKey('name')); + + $state->leave(); + + $this->assertSame(['unvisited'], array_keys($state->structure()['children'])); + } + + /** + * Test strict node input snapshots merge at their observed wire paths. + */ + public function testRecordsRootShapedUnknownInput(): void + { + $state = $this->state(); + + $this->assertNull($state->unknownInput()); + + $state->recordUnknownInput([ + 'child' => ['fromParent' => true], + 'scalarChild' => 'raw', + ]); + $state->enterProperty('child'); + $state->recordUnknownInput(['fromChild' => true]); + $state->leave(); + $state->enterProperty('scalarChild'); + $state->recordUnknownInput(['structured' => true]); + $state->leave(); + + $this->assertSame([ + 'child' => [ + 'fromParent' => true, + 'fromChild' => true, + ], + 'scalarChild' => ['structured' => true], + ], $state->unknownInput()); + } + + /** + * Create state for one fixture operation. + */ + protected function state(): ConstructionState + { + $context = new CreationContext(ConstructionStateDataFixture::class); + + return ConstructionState::create($context, ConstructionStateDataFixture::class); + } +} + +abstract class ConstructionStateDataFixture implements BaseData +{ +} + +abstract class AlternateConstructionStateDataFixture implements BaseData +{ +} + +class ConstructionStateFinishedDataFixture extends Data +{ +} + +/** + * @implements BaseDataCollectable + */ +class ConstructionStateFinishedDataCollectableFixture implements BaseDataCollectable +{ + /** + * Get the data class stored by the collection. + */ + public function getDataClass(): string + { + return ConstructionStateFinishedDataFixture::class; + } + + /** + * Get an iterator for the data items. + */ + public function getIterator(): Traversable + { + return new ArrayIterator(); + } +} diff --git a/tests/Data/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php new file mode 100644 index 000000000..47bcc1aac --- /dev/null +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -0,0 +1,768 @@ + 'fallback', + 'profile' => ['name' => 'Taylor'], + 'age' => '21', + ]); + + $this->assertSame('Taylor', $data->name); + $this->assertSame(21, $data->age); + $this->assertNull($data->nickname); + $this->assertInstanceOf(Optional::class, $data->note); + } + + public function testFirstSourceContainingAPropertyWinsAndMappingCanBeDisabled(): void + { + $first = BasicCreationData::from( + ['name' => 'First'], + ['profile' => ['name' => 'Second']], + ); + $unmapped = BasicCreationData::factory() + ->withoutPropertyNameMapping() + ->from([ + 'name' => 'Plain', + 'profile' => ['name' => 'Mapped'], + ]); + + $this->assertSame('First', $first->name); + $this->assertSame('Plain', $unmapped->name); + $this->assertNotSame(BasicCreationData::factory(), BasicCreationData::factory()); + } + + public function testCreatesNestedDataWithoutReenteringThePublicFactory(): void + { + $data = ParentCreationData::from([ + 'child' => ['id' => '42'], + ]); + + $this->assertInstanceOf(ChildCreationData::class, $data->child); + $this->assertSame(42, $data->child->id); + } + + public function testCreatesTypedDataIterablesAndPreservesDeclaredContainers(): void + { + $data = IterableCreationData::from([ + 'children' => [ + ['id' => '1'], + ['id' => '2'], + ], + 'collection' => new Collection([ + 'first' => ['id' => '3'], + ]), + ]); + + $this->assertContainsOnlyInstancesOf(ChildCreationData::class, $data->children); + $this->assertSame([1, 2], array_column($data->children, 'id')); + $this->assertInstanceOf(Collection::class, $data->collection); + $this->assertSame(3, $data->collection->get('first')->id); + } + + public function testCreatesDeclaredDataCollectionsFromRawItems(): void + { + $data = DataCollectionCreationData::from([ + 'children' => [ + 'first' => ['id' => '7'], + ], + ]); + + $this->assertInstanceOf(DataCollection::class, $data->children); + $this->assertSame(['first'], array_keys($data->children->items())); + $this->assertSame(7, $data->children['first']->id); + } + + public function testPreservesFinishedDataCollectableAndNativeContainers(): void + { + $dataCollection = new DataCollection(ChildCreationData::class, [ + 'first' => new ChildCreationData(1), + ]); + $collection = new Collection([ + 'second' => new ChildCreationData(2), + ]); + + $data = FinishedCollectionCreationData::validateAndCreate([ + 'dataCollection' => $dataCollection, + 'collection' => $collection, + ]); + + $this->assertSame($dataCollection, $data->dataCollection); + $this->assertSame($collection, $data->collection); + } + + public function testPreservesRawCollectionKeysAcrossFillValidationAndConstruction(): void + { + $payload = [ + 'items' => [ + 'tenant.eu' => ['profile' => ['name' => 'Europe']], + 'tenant' => ['name' => 'Global'], + ], + ]; + + $rules = MappedItemListCreationData::getValidationRules($payload); + $data = MappedItemListCreationData::validateAndCreate($payload); + + $this->assertArrayHasKey('items.tenant\\.eu.profile.name', $rules); + $this->assertArrayHasKey('items.tenant.name', $rules); + $this->assertSame(['tenant.eu', 'tenant'], array_keys($data->items)); + $this->assertSame('Europe', $data->items['tenant.eu']->name); + $this->assertSame('Global', $data->items['tenant']->name); + } + + public function testPreservesLazyCollectionTraversalWhenValidationIsNotRunning(): void + { + $evaluated = false; + $source = LazyCollection::make(function () use (&$evaluated): iterable { + $evaluated = true; + + yield ['id' => '5']; + }); + + $data = LazyIterableCreationData::from(['children' => $source]); + + $this->assertFalse($evaluated); + $this->assertSame(5, $data->children->first()->id); + $this->assertTrue($evaluated); + } + + public function testCastsDeclaredBuiltinEnumAndDateIterableItems(): void + { + $data = ScalarIterableCreationData::from([ + 'ids' => ['1', '2'], + 'statuses' => ['active', CreationStatus::Inactive], + 'dates' => ['2026-08-30T12:00:00+00:00'], + ]); + + $this->assertSame([1, 2], $data->ids); + $this->assertSame([CreationStatus::Active, CreationStatus::Inactive], $data->statuses); + $this->assertContainsOnlyInstancesOf(DateTimeImmutable::class, $data->dates); + $this->assertSame('2026-08-30', $data->dates[0]->format('Y-m-d')); + } + + public function testDtoAndResourceUseTheSameFixedConstructionEngine(): void + { + $dto = CreationDto::from(['id' => '1']); + $resource = CreationResource::from(['id' => '2']); + + $this->assertSame(1, $dto->id); + $this->assertSame(2, $resource->id); + } + + public function testNamedFactoriesCanReturnTheTargetOrAnotherNormalizableValue(): void + { + $direct = NamedFactoryCreationData::factory() + ->beforeCreation(fn (): never => throw new CannotCreateData('should not run')) + ->from('Taylor'); + $continued = NamedFactoryCreationData::factory() + ->beforeCreation(fn (array $properties): array => [ + ...$properties, + 'value' => strtoupper($properties['value']), + ]) + ->from(42); + + $this->assertSame('direct:Taylor', $direct->value); + $this->assertSame('NUMBER:42', $continued->value); + } + + public function testNamedFactoryDependenciesUseContainerCallWithoutMethodBindingInterception(): void + { + $this->app->bindMethod( + [InjectedFactoryCreationData::class, 'fromInjected'], + fn (): InjectedFactoryCreationData => new InjectedFactoryCreationData('intercepted'), + ); + + $data = InjectedFactoryCreationData::from('payload'); + + $this->assertSame( + 'payload:dependency:' . InjectedFactoryCreationData::class, + $data->value, + ); + } + + public function testContextualConstructorValuesOverrideClientPayload(): void + { + config()->set('app.name', 'Server'); + + $data = ContextualCreationData::from([ + 'id' => '7', + 'name' => 'Client', + ]); + + $this->assertSame(7, $data->id); + $this->assertSame('Server', $data->name); + } + + public function testResolvesAbstractPropertyMorphsBeforeFillingConcreteProperties(): void + { + $shape = ShapeCreationData::from([ + 'type' => 'circle', + 'radius' => '12', + ]); + + $this->assertInstanceOf(CircleCreationData::class, $shape); + $this->assertSame(12, $shape->radius); + } + + public function testResolvesMorphsFromBackedEnumsDefaultsAndNestedCollections(): void + { + $default = DefaultShapeCreationData::from(['radius' => '3']); + $mapped = DefaultShapeCreationData::from([ + 'status' => 'active', + 'radius' => '4', + ]); + $nested = ShapeListCreationData::from([ + 'shapes' => [ + [ + 'type' => 'circle', + 'radius' => '5', + ], + [ + 'type' => 'square', + 'side' => '6', + ], + ], + ]); + + $this->assertInstanceOf(DefaultCircleCreationData::class, $default); + $this->assertSame(CreationStatus::Active, $default->status); + $this->assertSame(3, $default->radius); + $this->assertInstanceOf(DefaultCircleCreationData::class, $mapped); + $this->assertSame(4, $mapped->radius); + $this->assertInstanceOf(CircleCreationData::class, $nested->shapes[0]); + $this->assertSame(5, $nested->shapes[0]->radius); + $this->assertInstanceOf(SquareCreationData::class, $nested->shapes[1]); + $this->assertSame(6, $nested->shapes[1]->side); + } + + public function testRetainsPerItemWireKeyChoices(): void + { + $data = MappedItemListCreationData::from([ + 'items' => [ + ['profile' => ['name' => 'Mapped']], + ['name' => 'Plain'], + ], + ]); + + $this->assertSame('Mapped', $data->items[0]->name); + $this->assertSame('Plain', $data->items[1]->name); + } + + public function testExistingDataItemSubclassesAreFinishedSubtrees(): void + { + $prepareCalls = 0; + $beforeCreationCalls = 0; + $afterCreationCalls = 0; + $existing = new ChildCreationDataSubtype(9); + + $data = IterableCreationData::factory() + ->prepareData(function (array $input) use (&$prepareCalls): array { + ++$prepareCalls; + + return $input; + }) + ->beforeCreation(function (array $properties) use (&$beforeCreationCalls): array { + ++$beforeCreationCalls; + + return $properties; + }) + ->afterCreation(function (Data $data) use (&$afterCreationCalls): Data { + ++$afterCreationCalls; + + return $data; + }) + ->from([ + 'children' => [$existing], + 'collection' => [], + ]); + + $this->assertSame($existing, $data->children[0]); + $this->assertSame(1, $prepareCalls); + $this->assertSame(1, $beforeCreationCalls); + $this->assertSame(1, $afterCreationCalls); + } + + public function testUnrelatedDataItemsAreNormalizedIntoTheDeclaredItemClass(): void + { + $unrelated = new UnrelatedChildCreationData('14'); + + $data = IterableCreationData::from([ + 'children' => [$unrelated], + 'collection' => [], + ]); + + $this->assertInstanceOf(ChildCreationData::class, $data->children[0]); + $this->assertNotSame($unrelated, $data->children[0]); + $this->assertSame(14, $data->children[0]->id); + } + + public function testRejectsUnresolvedAndInvalidPropertyMorphs(): void + { + foreach (['missing', 'invalid'] as $type) { + try { + ShapeCreationData::from(['type' => $type]); + $this->fail('Expected the abstract data class to be rejected.'); + } catch (CannotCreateAbstractClass $exception) { + $this->assertStringContainsString(ShapeCreationData::class, $exception->getMessage()); + } + } + } + + public function testRejectsAmbiguousDataObjectUnionsWithoutAnExplicitCast(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('ambiguous data-object union'); + + AmbiguousCreationData::from(['child' => ['id' => 1]]); + } + + public function testClassNormalizersCustomCastsAndCreationHooksShareOneOperation(): void + { + $data = CustomizedCreationData::factory() + ->prepareData(fn (array $input): array => [ + ...$input, + 'label' => $input['label'] . '-prepared', + ]) + ->afterCreation(function (CustomizedCreationData $data): CustomizedCreationData { + $data->label = strtoupper($data->label); + + return $data; + }) + ->from(new CreationSource('item', 'identifier')); + + $this->assertSame(123, $data->id); + $this->assertSame('CAST:ITEM-PREPARED', $data->label); + } + + public function testRejectsSuppliedComputedValuesAndInvalidAfterCreationResults(): void + { + try { + ComputedCreationData::from(['id' => 1, 'summary' => 'client']); + $this->fail('Expected computed input to be rejected.'); + } catch (CannotSetComputedValue $exception) { + $this->assertStringContainsString('ComputedCreationData::$summary', $exception->getMessage()); + } + + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('instead of an instance of'); + + BasicCreationData::factory() + ->afterCreation(fn (): ChildCreationData => new ChildCreationData(1)) + ->from(['name' => 'Taylor']); + } +} + +class BasicCreationData extends Data +{ + public function __construct( + #[MapInputName('profile.name')] + public string $name, + public ?string $nickname, + public string|Optional $note, + public int $age = 18, + ) { + } +} + +class ChildCreationData extends Data +{ + public function __construct( + public int $id, + ) { + } +} + +class ChildCreationDataSubtype extends ChildCreationData +{ +} + +class UnrelatedChildCreationData extends Data +{ + public function __construct( + public string $id, + ) { + } +} + +class ParentCreationData extends Data +{ + public function __construct( + public ChildCreationData $child, + ) { + } +} + +class IterableCreationData extends Data +{ + /** + * Create an iterable fixture. + * + * @param array $children + * @param Collection $collection + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public array $children, + #[DataCollectionOf(ChildCreationData::class)] + public Collection $collection, + ) { + } +} + +class LazyIterableCreationData extends Data +{ + /** + * Create a lazy iterable fixture. + * + * @param LazyCollection $children + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public LazyCollection $children, + ) { + } +} + +class FinishedCollectionCreationData extends Data +{ + /** + * Create a finished-collection fixture. + * + * @param Collection $collection + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public DataCollection $dataCollection, + #[DataCollectionOf(ChildCreationData::class)] + public Collection $collection, + ) { + } +} + +class DataCollectionCreationData extends Data +{ + /** + * Create a data-collection construction fixture. + * + * @param DataCollection $children + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public DataCollection $children, + ) { + } +} + +class ScalarIterableCreationData extends Data +{ + /** @var list */ + public array $ids; + + /** @var list */ + public array $statuses; + + /** @var list */ + public array $dates; + + public function __construct(array $ids, array $statuses, array $dates) + { + $this->ids = $ids; + $this->statuses = $statuses; + $this->dates = $dates; + } +} + +enum CreationStatus: string +{ + case Active = 'active'; + case Inactive = 'inactive'; +} + +class CreationDto extends Dto +{ + public function __construct( + public int $id, + ) { + } +} + +class CreationResource extends Resource +{ + public function __construct( + public int $id, + ) { + } +} + +class NamedFactoryCreationData extends Data +{ + public function __construct( + public string $value, + ) { + } + + public static function fromString(string $value): self + { + return new self('direct:' . $value); + } + + public static function fromNumber(int $value): array + { + return ['value' => 'number:' . $value]; + } +} + +class InjectedFactoryCreationData extends Data +{ + public function __construct( + public string $value, + ) { + } + + public static function fromInjected( + string $value, + NamedFactoryCreationDependency $dependency, + CreationContext $context, + ): self { + return new self($value . ':' . $dependency->value . ':' . $context->dataClass); + } +} + +class NamedFactoryCreationDependency +{ + public string $value = 'dependency'; +} + +class ContextualCreationData extends Data +{ + public function __construct( + #[Config('app.name')] + public string $name, + public int $id, + ) { + } +} + +abstract class ShapeCreationData extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['type']) { + 'circle' => CircleCreationData::class, + 'square' => SquareCreationData::class, + 'invalid' => ChildCreationData::class, + default => null, + }; + } +} + +class CircleCreationData extends ShapeCreationData +{ + public function __construct(string $type, public int $radius) + { + parent::__construct($type); + } +} + +class SquareCreationData extends ShapeCreationData +{ + public function __construct(string $type, public int $side) + { + parent::__construct($type); + } +} + +abstract class DefaultShapeCreationData extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public CreationStatus $status = CreationStatus::Active, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['status']) { + CreationStatus::Active => DefaultCircleCreationData::class, + default => null, + }; + } +} + +class DefaultCircleCreationData extends DefaultShapeCreationData +{ + public function __construct( + public int $radius, + CreationStatus $status = CreationStatus::Active, + ) { + parent::__construct($status); + } +} + +class ShapeListCreationData extends Data +{ + /** + * Create a shape-list fixture. + * + * @param array $shapes + */ + public function __construct( + #[DataCollectionOf(ShapeCreationData::class)] + public array $shapes, + ) { + } +} + +class MappedItemCreationData extends Data +{ + public function __construct( + #[MapInputName('profile.name')] + public string $name, + ) { + } +} + +class MappedItemListCreationData extends Data +{ + /** + * Create a mapped-item list fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(MappedItemCreationData::class)] + public array $items, + ) { + } +} + +class AlternateChildCreationData extends Data +{ + public function __construct( + public int $id, + ) { + } +} + +class AmbiguousCreationData extends Data +{ + public function __construct( + public ChildCreationData|AlternateChildCreationData $child, + ) { + } +} + +class CreationSource +{ + public function __construct( + public readonly string $label, + public readonly string $identifier, + ) { + } +} + +class CustomizedCreationData extends Data +{ + public function __construct( + #[WithCast(CreationIdentifierCast::class)] + public int $id, + #[WithCast(CreationLabelCast::class)] + public string $label, + ) { + } + + public static function normalizers(): array + { + return [CreationSourceNormalizer::class]; + } +} + +class CreationSourceNormalizer implements Normalizer +{ + public function normalize(mixed $value): array|Normalized|null + { + return $value instanceof CreationSource + ? ['id' => $value->identifier, 'label' => $value->label] + : null; + } +} + +class CreationIdentifierCast implements Cast +{ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): int { + return 123; + } +} + +class CreationLabelCast implements Cast +{ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): string { + return 'cast:' . $value; + } +} + +class ComputedCreationData extends Data +{ + #[Computed] + public string $summary = 'computed'; + + public function __construct( + public int $id, + ) { + } +} diff --git a/tests/Data/Support/Creation/DataInstantiatorTest.php b/tests/Data/Support/Creation/DataInstantiatorTest.php new file mode 100644 index 000000000..f09f825bc --- /dev/null +++ b/tests/Data/Support/Creation/DataInstantiatorTest.php @@ -0,0 +1,243 @@ +instantiate( + $this->metadata(InstantiatorDataFixture::class), + ['name' => 'taylor', 'note' => 'assigned'], + ); + + $this->assertSame('TAYLOR', $data->name); + $this->assertSame('assigned', $data->note); + } + + /** + * Test constructor and property defaults remain owned by PHP. + */ + public function testOmitsMissingDefaultedValues(): void + { + $data = (new DataInstantiator(new Container))->instantiate( + $this->metadata(InstantiatorDefaultDataFixture::class), + [], + ); + + $this->assertInstanceOf(InstantiatorDefaultValue::class, $data->value); + $this->assertSame('property-default', $data->note); + } + + /** + * Test contextual constructor parameters use the container build path. + */ + public function testResolvesContextualConstructorParameters(): void + { + $data = (new DataInstantiator(new Container))->instantiate( + $this->metadata(InstantiatorContextualDataFixture::class), + [], + ); + + $this->assertSame(42, $data->serverId); + } + + /** + * Test missing constructor values produce a focused creation failure. + */ + public function testThrowsForMissingConstructorValues(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('Parameters missing: name'); + + (new DataInstantiator(new Container))->instantiate( + $this->metadata(InstantiatorDataFixture::class), + [], + ); + } + + /** + * Test contextual parameters are excluded from missing payload diagnostics. + */ + public function testMissingConstructorDiagnosticsExcludeContextualParameters(): void + { + try { + (new DataInstantiator(new Container))->instantiate( + $this->metadata(InstantiatorContextualMissingDataFixture::class), + [], + ); + $this->fail('Expected the missing constructor value to be rejected.'); + } catch (CannotCreateData $exception) { + $this->assertStringContainsString('Parameters missing: name.', $exception->getMessage()); + $this->assertStringNotContainsString('serverId', $exception->getMessage()); + } + } + + /** + * Test missing unbound values produce a focused creation failure. + */ + public function testThrowsForMissingUnboundPropertyValues(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('required property'); + + (new DataInstantiator(new Container))->instantiate( + $this->metadata(InstantiatorUnboundDataFixture::class), + [], + ); + } + + /** + * Test ordinary construction cannot bypass a non-public constructor. + */ + public function testThrowsForNonPublicOrdinaryConstruction(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('constructor is private'); + $this->expectExceptionMessage('matching public static from* method'); + + (new DataInstantiator(new Container))->instantiate( + $this->metadata(InstantiatorPrivateDataFixture::class), + ['name' => 'Taylor'], + ); + } + + /** + * Build metadata for a data fixture. + * + * @param class-string $class + */ + protected function metadata(string $class): DataClass + { + $defaults = require __DIR__ . '/../../../../src/data/config/data.php'; + $config = new DataConfig(new Repository(['data' => $defaults])); + $container = new Container; + $nameMapperResolver = new NameMapperResolver($container); + $typeFactory = new DataTypeFactory(new PhpDocTypeNameResolver); + $parameterFactory = new DataParameterFactory($typeFactory); + + return (new DataClassFactory( + new DataPropertyFactory($typeFactory, $config, $nameMapperResolver), + new DataMethodFactory($parameterFactory, $typeFactory), + $parameterFactory, + new DataIterableAnnotationReader, + $nameMapperResolver, + $config, + ))->build(new ReflectionClass($class)); + } +} + +class InstantiatorDataFixture extends Data +{ + public string $name; + + public string $note = 'property-default'; + + /** + * Create an instantiator fixture. + */ + public function __construct(string $name) + { + $this->name = strtoupper($name); + } +} + +class InstantiatorDefaultDataFixture extends Data +{ + public readonly InstantiatorDefaultValue $value; + + public string $note = 'property-default'; + + /** + * Create a default fixture. + */ + public function __construct(InstantiatorDefaultValue $value = new InstantiatorDefaultValue) + { + $this->value = $value; + } +} + +class InstantiatorDefaultValue +{ +} + +class InstantiatorContextualDataFixture extends Data +{ + /** + * Create a contextual fixture. + */ + public function __construct( + #[InstantiatorContextualValue] + public int $serverId, + ) { + } +} + +class InstantiatorContextualMissingDataFixture extends Data +{ + /** + * Create a contextual fixture with a required payload value. + */ + public function __construct( + #[InstantiatorContextualValue] + public int $serverId, + public string $name, + ) { + } +} + +class InstantiatorUnboundDataFixture extends Data +{ + public string $required; +} + +class InstantiatorPrivateDataFixture extends Data +{ + public readonly string $name; + + /** + * Create a private-constructor fixture. + */ + private function __construct(string $name) + { + $this->name = $name; + } +} + +#[Attribute(Attribute::TARGET_PARAMETER)] +class InstantiatorContextualValue implements ContextualAttribute +{ + /** + * Resolve the server-owned fixture value. + */ + public static function resolve(self $attribute, ContainerContract $container): int + { + return 42; + } +} diff --git a/tests/Data/Support/Creation/SourceReaderTest.php b/tests/Data/Support/Creation/SourceReaderTest.php new file mode 100644 index 000000000..75aad6371 --- /dev/null +++ b/tests/Data/Support/Creation/SourceReaderTest.php @@ -0,0 +1,118 @@ +property(); + + $this->assertSame('Hello', SourceReader::read(['title' => 'Hello'], 'title', $property)); + $this->assertNull(SourceReader::read(['title' => null], 'title', $property)); + $this->assertSame(UnknownProperty::create(), SourceReader::read([], 'title', $property)); + } + + /** + * Test mapped dot paths traverse arrays and normalized root values. + */ + public function testReadsMappedDotPaths(): void + { + $property = $this->property(); + $normalized = new class implements Normalized { + public function getProperty(string $name, DataProperty $dataProperty): mixed + { + return $name === 'profile' + ? ['contact' => ['email' => null]] + : UnknownProperty::create(); + } + }; + + $this->assertSame('Taylor', SourceReader::read( + ['people' => [['name' => 'Taylor']]], + 'people.0.name', + $property, + )); + $this->assertNull(SourceReader::read($normalized, 'profile.contact.email', $property)); + $this->assertSame( + UnknownProperty::create(), + SourceReader::read($normalized, 'profile.contact.phone', $property), + ); + } + + /** + * Test normalized sources receive the property metadata. + */ + public function testReadsNormalizedSources(): void + { + $property = $this->property(); + $normalized = new class ($property) implements Normalized { + public function __construct( + private readonly DataProperty $expectedProperty, + ) { + } + + public function getProperty(string $name, DataProperty $dataProperty): mixed + { + if ($dataProperty !== $this->expectedProperty) { + return UnknownProperty::create(); + } + + return $name === 'title' ? 'Hello' : UnknownProperty::create(); + } + }; + + $this->assertSame('Hello', SourceReader::read($normalized, 'title', $property)); + $this->assertSame(UnknownProperty::create(), SourceReader::read($normalized, 'missing', $property)); + } + + /** + * Test the first source containing a key owns its value. + */ + public function testFirstPresentSourceWinsIncludingNullAndOptional(): void + { + $property = $this->property(); + $optional = Optional::create(); + + $this->assertSame('First', SourceReader::readFromMany( + [[], ['title' => 'First'], ['title' => 'Second']], + 'title', + $property, + )); + $this->assertNull(SourceReader::readFromMany( + [['title' => null], ['title' => 'Second']], + 'title', + $property, + )); + $this->assertSame($optional, SourceReader::readFromMany( + [['title' => $optional], ['title' => 'Second']], + 'title', + $property, + )); + $this->assertSame(UnknownProperty::create(), SourceReader::readFromMany( + [[], []], + 'title', + $property, + )); + } + + /** + * Create property metadata opaque to the reader. + */ + protected function property(): DataProperty + { + return $this->createStub(DataProperty::class); + } +} diff --git a/tests/Data/Support/Creation/SourceResolverTest.php b/tests/Data/Support/Creation/SourceResolverTest.php new file mode 100644 index 000000000..ccebad71d --- /dev/null +++ b/tests/Data/Support/Creation/SourceResolverTest.php @@ -0,0 +1,113 @@ + true]; + } + }; + + $this->assertSame([], SourceResolver::resolve(self::class, null, [$normalizer])); + $this->assertSame($normalized, SourceResolver::resolve(self::class, $normalized, [$normalizer])); + } + + /** + * Test class and configured normalizers run before fixed source handling. + */ + public function testFirstCustomNormalizerWinsBeforeFixedArrayHandling(): void + { + $skipped = new class implements Normalizer { + public function normalize(mixed $value): array|Normalized|null + { + return null; + } + }; + $accepted = new class implements Normalizer { + public function normalize(mixed $value): array|Normalized|null + { + return ['custom' => $value['original']]; + } + }; + + $this->assertSame( + ['custom' => 'value'], + SourceResolver::resolve(self::class, ['original' => 'value'], [$skipped, $accepted]), + ); + } + + /** + * Test all fixed source adapters preserve their intended representation. + */ + public function testResolvesFixedSourceTypes(): void + { + $request = Request::create('/', 'POST', ['request' => true]); + $arrayable = new class implements Arrayable { + public string $source = 'object'; + + public function toArray(): array + { + return ['source' => 'arrayable']; + } + }; + $object = new class { + public string $initialized = 'value'; + + public string $uninitialized; + + private string $hidden = 'hidden'; + }; + $model = new class extends Model { + }; + + $this->assertSame(['array' => true], SourceResolver::resolve(self::class, ['array' => true], [])); + $this->assertSame(['request' => true], SourceResolver::resolve(self::class, $request, [])); + $this->assertSame(['source' => 'arrayable'], SourceResolver::resolve(self::class, $arrayable, [])); + $this->assertSame(['initialized' => 'value'], SourceResolver::resolve(self::class, $object, [])); + $this->assertSame(['json' => true], SourceResolver::resolve(self::class, '{"json":true}', [])); + $this->assertInstanceOf(NormalizedModel::class, SourceResolver::resolve(self::class, $model, [])); + } + + /** + * Test unsupported and invalid JSON values fail with a creation exception. + */ + public function testThrowsWhenNoFixedOrCustomNormalizerAcceptsTheValue(): void + { + foreach ([42, 'not-json', 'null'] as $value) { + try { + SourceResolver::resolve(self::class, $value, []); + $this->fail('Expected the source to be rejected.'); + } catch (CannotCreateData $exception) { + $this->assertStringContainsString('no normalizer accepted', $exception->getMessage()); + } + } + } +} From 441b0215b7119259f14724421964e1540afff00d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:19:21 +0000 Subject: [PATCH 08/35] Implement Data transformation and partials Transform current public values through precompiled metadata without an output cache. Add built-in Arrayable, enum, and date transformers; Optional omission; computed, hidden, appended, and lazy values; JSON output; global and instance wrapping; and configurable maximum-depth failures. Compile include, exclude, only, and except definitions into immutable endpoint, subtree, and child trees. Merge reached nested instance partials at property and iterable-item boundaries, preserve temporary versus permanent ownership, and keep ordinary transforms on the no-partials fast path. Reuse one transformation context and extension memo across nested objects and collection items while keeping per-item partial state isolated. Preserve raw nested identity for all(), consume lazy values only when selected, and expose current values after mutation. Cover partial-tree semantics, nested and repeated instance selections, lazy conditions, wrapping, appended and empty data, live properties, mapped output, typed iterable transformation, context promotion, depth limits, and JSON behavior. --- src/data/src/Support/Lazy/ClosureLazy.php | 27 + src/data/src/Support/Lazy/ConditionalLazy.php | 56 ++ src/data/src/Support/Lazy/DefaultLazy.php | 45 ++ src/data/src/Support/Lazy/RelationalLazy.php | 63 ++ .../Partials/ForwardsToPartialsDefinition.php | 179 +++++ .../Support/Partials/PartialDefinition.php | 72 ++ .../Support/Partials/PartialsDefinition.php | 161 ++++ .../Transformation/DataTransformer.php | 689 ++++++++++++++++++ .../Transformation/EmptyDataResolver.php | 131 ++++ .../Support/Transformation/PartialTree.php | 226 ++++++ .../Transformation/TransformationContext.php | 162 ++++ .../TransformationContextFactory.php | 199 +++++ src/data/src/Support/Wrapping/Wrap.php | 63 ++ .../Support/Wrapping/WrapExecutionType.php | 20 + src/data/src/Support/Wrapping/WrapType.php | 12 + .../src/Transformers/ArrayableTransformer.php | 19 + .../DateTimeInterfaceTransformer.php | 49 ++ src/data/src/Transformers/EnumTransformer.php | 21 + tests/Data/Concerns/AppendableDataTest.php | 130 ++++ tests/Data/Concerns/EmptyDataTest.php | 160 ++++ tests/Data/Concerns/GlobalWrappingTest.php | 63 ++ tests/Data/Concerns/WrappableDataTest.php | 92 +++ tests/Data/LazyTest.php | 137 ++++ .../Transformation/DataTransformerTest.php | 491 +++++++++++++ .../Transformation/PartialTreeTest.php | 168 +++++ .../Transformation/PartialsDefinitionTest.php | 219 ++++++ .../TransformationContextFactoryTest.php | 81 ++ .../TransformationContextTest.php | 66 ++ 28 files changed, 3801 insertions(+) create mode 100644 src/data/src/Support/Lazy/ClosureLazy.php create mode 100644 src/data/src/Support/Lazy/ConditionalLazy.php create mode 100644 src/data/src/Support/Lazy/DefaultLazy.php create mode 100644 src/data/src/Support/Lazy/RelationalLazy.php create mode 100644 src/data/src/Support/Partials/ForwardsToPartialsDefinition.php create mode 100644 src/data/src/Support/Partials/PartialDefinition.php create mode 100644 src/data/src/Support/Partials/PartialsDefinition.php create mode 100644 src/data/src/Support/Transformation/DataTransformer.php create mode 100644 src/data/src/Support/Transformation/EmptyDataResolver.php create mode 100644 src/data/src/Support/Transformation/PartialTree.php create mode 100644 src/data/src/Support/Transformation/TransformationContext.php create mode 100644 src/data/src/Support/Transformation/TransformationContextFactory.php create mode 100644 src/data/src/Support/Wrapping/Wrap.php create mode 100644 src/data/src/Support/Wrapping/WrapExecutionType.php create mode 100644 src/data/src/Support/Wrapping/WrapType.php create mode 100644 src/data/src/Transformers/ArrayableTransformer.php create mode 100644 src/data/src/Transformers/DateTimeInterfaceTransformer.php create mode 100644 src/data/src/Transformers/EnumTransformer.php create mode 100644 tests/Data/Concerns/AppendableDataTest.php create mode 100644 tests/Data/Concerns/EmptyDataTest.php create mode 100644 tests/Data/Concerns/GlobalWrappingTest.php create mode 100644 tests/Data/Concerns/WrappableDataTest.php create mode 100644 tests/Data/LazyTest.php create mode 100644 tests/Data/Support/Transformation/DataTransformerTest.php create mode 100644 tests/Data/Support/Transformation/PartialTreeTest.php create mode 100644 tests/Data/Support/Transformation/PartialsDefinitionTest.php create mode 100644 tests/Data/Support/Transformation/TransformationContextFactoryTest.php create mode 100644 tests/Data/Support/Transformation/TransformationContextTest.php diff --git a/src/data/src/Support/Lazy/ClosureLazy.php b/src/data/src/Support/Lazy/ClosureLazy.php new file mode 100644 index 000000000..e720ad399 --- /dev/null +++ b/src/data/src/Support/Lazy/ClosureLazy.php @@ -0,0 +1,27 @@ + true, $closure); + } + + /** + * Resolve the closure without invoking it. + */ + public function resolve(): Closure + { + return $this->value; + } +} diff --git a/src/data/src/Support/Lazy/ConditionalLazy.php b/src/data/src/Support/Lazy/ConditionalLazy.php new file mode 100644 index 000000000..af9d338b2 --- /dev/null +++ b/src/data/src/Support/Lazy/ConditionalLazy.php @@ -0,0 +1,56 @@ +value)(); + } + + /** + * Determine if the value's own condition includes it. + */ + public function shouldBeIncluded(): bool + { + return (bool) ($this->condition)(); + } + + /** + * Get the serializable lazy state. + */ + public function __serialize(): array + { + return [ + 'condition' => new SerializableClosure($this->condition), + 'value' => new SerializableClosure($this->value), + 'defaultIncluded' => $this->defaultIncluded, + ]; + } + + /** + * Restore serialized lazy state. + */ + public function __unserialize(array $data): void + { + $this->condition = $data['condition']->getClosure(); + $this->value = $data['value']->getClosure(); + $this->defaultIncluded = $data['defaultIncluded']; + } +} diff --git a/src/data/src/Support/Lazy/DefaultLazy.php b/src/data/src/Support/Lazy/DefaultLazy.php new file mode 100644 index 000000000..a281661a3 --- /dev/null +++ b/src/data/src/Support/Lazy/DefaultLazy.php @@ -0,0 +1,45 @@ +value)(); + } + + /** + * Get the serializable lazy state. + */ + public function __serialize(): array + { + return [ + 'value' => new SerializableClosure($this->value), + 'defaultIncluded' => $this->defaultIncluded, + ]; + } + + /** + * Restore serialized lazy state. + */ + public function __unserialize(array $data): void + { + $this->value = $data['value']->getClosure(); + $this->defaultIncluded = $data['defaultIncluded']; + } +} diff --git a/src/data/src/Support/Lazy/RelationalLazy.php b/src/data/src/Support/Lazy/RelationalLazy.php new file mode 100644 index 000000000..97f86f2fc --- /dev/null +++ b/src/data/src/Support/Lazy/RelationalLazy.php @@ -0,0 +1,63 @@ +model->{$this->relation} !== null ? ($this->value)() : null; + } + + /** + * Determine if the relationship is loaded. + */ + public function shouldBeIncluded(): bool + { + return $this->model->relationLoaded($this->relation); + } + + /** + * Get the serializable lazy state. + */ + public function __serialize(): array + { + return [ + 'relation' => $this->relation, + 'model' => $this->model, + 'value' => new SerializableClosure($this->value), + 'defaultIncluded' => $this->defaultIncluded, + ]; + } + + /** + * Restore serialized lazy state. + */ + public function __unserialize(array $data): void + { + $this->relation = $data['relation']; + $this->model = $data['model']; + $this->value = $data['value']->getClosure(); + $this->defaultIncluded = $data['defaultIncluded']; + } +} diff --git a/src/data/src/Support/Partials/ForwardsToPartialsDefinition.php b/src/data/src/Support/Partials/ForwardsToPartialsDefinition.php new file mode 100644 index 000000000..eaa42df68 --- /dev/null +++ b/src/data/src/Support/Partials/ForwardsToPartialsDefinition.php @@ -0,0 +1,179 @@ +getPartialsDefinition()->add('include', $include); + } + + return $this; + } + + /** + * Include properties for every transformation. + */ + public function includePermanently(string ...$includes): static + { + foreach ($includes as $include) { + $this->getPartialsDefinition()->add('include', $include, permanent: true); + } + + return $this; + } + + /** + * Exclude lazy properties for the next transformation. + */ + public function exclude(string ...$excludes): static + { + foreach ($excludes as $exclude) { + $this->getPartialsDefinition()->add('exclude', $exclude); + } + + return $this; + } + + /** + * Exclude lazy properties for every transformation. + */ + public function excludePermanently(string ...$excludes): static + { + foreach ($excludes as $exclude) { + $this->getPartialsDefinition()->add('exclude', $exclude, permanent: true); + } + + return $this; + } + + /** + * Keep only properties for the next transformation. + */ + public function only(string ...$only): static + { + foreach ($only as $onlyDefinition) { + $this->getPartialsDefinition()->add('only', $onlyDefinition); + } + + return $this; + } + + /** + * Keep only properties for every transformation. + */ + public function onlyPermanently(string ...$only): static + { + foreach ($only as $onlyDefinition) { + $this->getPartialsDefinition()->add('only', $onlyDefinition, permanent: true); + } + + return $this; + } + + /** + * Exclude properties for the next transformation. + */ + public function except(string ...$except): static + { + foreach ($except as $exceptDefinition) { + $this->getPartialsDefinition()->add('except', $exceptDefinition); + } + + return $this; + } + + /** + * Exclude properties for every transformation. + */ + public function exceptPermanently(string ...$except): static + { + foreach ($except as $exceptDefinition) { + $this->getPartialsDefinition()->add('except', $exceptDefinition, permanent: true); + } + + return $this; + } + + /** + * Include a property when the condition passes. + */ + public function includeWhen(string $include, bool|Closure $condition, bool $permanent = false): static + { + if ($condition instanceof Closure || $condition) { + $this->getPartialsDefinition()->add( + 'include', + $include, + $permanent, + $condition instanceof Closure ? $condition : null, + ); + } + + return $this; + } + + /** + * Exclude a lazy property when the condition passes. + */ + public function excludeWhen(string $exclude, bool|Closure $condition, bool $permanent = false): static + { + if ($condition instanceof Closure || $condition) { + $this->getPartialsDefinition()->add( + 'exclude', + $exclude, + $permanent, + $condition instanceof Closure ? $condition : null, + ); + } + + return $this; + } + + /** + * Keep only a property when the condition passes. + */ + public function onlyWhen(string $only, bool|Closure $condition, bool $permanent = false): static + { + if ($condition instanceof Closure || $condition) { + $this->getPartialsDefinition()->add( + 'only', + $only, + $permanent, + $condition instanceof Closure ? $condition : null, + ); + } + + return $this; + } + + /** + * Exclude a property when the condition passes. + */ + public function exceptWhen(string $except, bool|Closure $condition, bool $permanent = false): static + { + if ($condition instanceof Closure || $condition) { + $this->getPartialsDefinition()->add( + 'except', + $except, + $permanent, + $condition instanceof Closure ? $condition : null, + ); + } + + return $this; + } +} diff --git a/src/data/src/Support/Partials/PartialDefinition.php b/src/data/src/Support/Partials/PartialDefinition.php new file mode 100644 index 000000000..4f50395ad --- /dev/null +++ b/src/data/src/Support/Partials/PartialDefinition.php @@ -0,0 +1,72 @@ +condition === null || ($this->condition)($data); + } + + /** + * Resolve the definition for a nested property. + */ + public function nested(string $property): ?self + { + $segments = explode('.', trim($this->path), 2); + $current = trim($segments[0]); + + if ($current === '*') { + return new self('*', $this->permanent); + } + + if ($current !== $property || ! isset($segments[1])) { + return null; + } + + return new self($segments[1], $this->permanent); + } + + /** + * Get the serializable representation. + */ + public function __serialize(): array + { + return [ + 'path' => $this->path, + 'permanent' => $this->permanent, + 'condition' => $this->condition === null + ? null + : new SerializableClosure($this->condition), + ]; + } + + /** + * Restore the serialized definition. + */ + public function __unserialize(array $data): void + { + $this->path = $data['path']; + $this->permanent = $data['permanent']; + $this->condition = $data['condition']?->getClosure(); + } +} diff --git a/src/data/src/Support/Partials/PartialsDefinition.php b/src/data/src/Support/Partials/PartialsDefinition.php new file mode 100644 index 000000000..a0a9776f7 --- /dev/null +++ b/src/data/src/Support/Partials/PartialsDefinition.php @@ -0,0 +1,161 @@ + */ + protected array $includes = []; + + /** @var list */ + protected array $excludes = []; + + /** @var list */ + protected array $only = []; + + /** @var list */ + protected array $except = []; + + /** + * Determine whether no partial definitions are registered. + */ + public function isEmpty(): bool + { + return $this->includes === [] + && $this->excludes === [] + && $this->only === [] + && $this->except === []; + } + + /** + * Add a partial definition. + */ + public function add( + string $type, + string $path, + bool $permanent = false, + ?Closure $condition = null, + ): void { + $definitions = &$this->definitions($type); + $definitions[] = new PartialDefinition($path, $permanent, $condition); + } + + /** + * Add class-owned permanent definitions. + * + * @param array $definitions + */ + public function addDefaults(string $type, array $definitions): void + { + foreach ($definitions as $key => $definition) { + if (is_string($definition)) { + $this->add($type, $definition, permanent: true); + + continue; + } + + if (! is_string($key)) { + throw new InvalidArgumentException( + "Conditional {$type} partial definitions require a string path key.", + ); + } + + if ($definition === false) { + continue; + } + + $this->add( + $type, + $key, + permanent: true, + condition: $definition instanceof Closure ? $definition : null, + ); + } + } + + /** + * Add definitions resolved by an enclosing data object. + * + * @param array{include: list, exclude: list, only: list, except: list} $definitions + */ + public function addResolved(array $definitions): void + { + foreach ($definitions as $type => $resolved) { + foreach ($resolved as $definition) { + $this->add($type, $definition->path, $definition->permanent); + } + } + } + + /** + * Resolve active paths and optionally consume temporary definitions. + * + * @return array{include: list, exclude: list, only: list, except: list} + */ + public function resolve(object $data, bool $consumeTemporary = false): array + { + return [ + 'include' => $this->resolveType('include', $data, $consumeTemporary), + 'exclude' => $this->resolveType('exclude', $data, $consumeTemporary), + 'only' => $this->resolveType('only', $data, $consumeTemporary), + 'except' => $this->resolveType('except', $data, $consumeTemporary), + ]; + } + + /** + * Resolve one definition group. + * + * @return list + */ + protected function resolveType( + string $type, + object $data, + bool $consumeTemporary, + ): array { + $definitions = &$this->definitions($type); + $resolved = []; + $retained = []; + + foreach ($definitions as $definition) { + if ($definition->applies($data)) { + $resolved[] = $definition; + } + + if (! $consumeTemporary || $definition->permanent) { + $retained[] = $definition; + } + } + + if ($consumeTemporary) { + $definitions = $retained; + } + + return $resolved; + } + + /** + * Get a mutable definition group. + * + * @return list + */ + protected function &definitions(string $type): array + { + switch ($type) { + case 'include': + return $this->includes; + case 'exclude': + return $this->excludes; + case 'only': + return $this->only; + case 'except': + return $this->except; + default: + throw new InvalidArgumentException("Unknown partial type [{$type}]."); + } + } +} diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php new file mode 100644 index 000000000..cc1e10543 --- /dev/null +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -0,0 +1,689 @@ +dateTimezone = $config->dateTimezone === null + ? null + : new DateTimeZone($config->dateTimezone); + } + + /** + * Transform one data object through the fixed metadata path. + */ + public function transform( + (BaseData&TransformableData)|(BaseDataCollectable&TransformableData) $data, + TransformationContext $context, + ): array { + $extensions = []; + + return $data instanceof BaseDataCollectable + ? $this->transformCollectable($data, $context, $extensions) + : $this->transformData($data, $context, $extensions); + } + + /** + * Transform a nested data object within the current root operation. + * + * @param array $extensions + */ + protected function transformData( + BaseData&TransformableData $data, + TransformationContext $context, + array &$extensions, + ): array { + if ($context->maxDepth !== null && $context->depth >= $context->maxDepth) { + throw MaxTransformationDepthReached::create($context->maxDepth); + } + + $dataClass = $this->dataClasses->get($data::class); + $values = get_object_vars($data); + + if ($dataClass->plainTransform + && ! $context->hasPartials() + && $context->transformers === [] + ) { + return $this->finalizeTransformation( + $data, + $dataClass, + $context, + $this->transformPlain($data, $dataClass, $values), + ); + } + + $transformed = []; + + foreach ($dataClass->properties as $property) { + if ($property->hidden + || $context->except?->selects($property->name) + || ($context->only !== null + && $context->only->children !== [] + && ! isset($context->only->children[$property->name])) + ) { + continue; + } + + if ($property->isVirtual) { + $value = $data->{$property->name}; + } elseif (array_key_exists($property->name, $values)) { + $value = $values[$property->name]; + } else { + continue; + } + + if ($value instanceof Optional) { + continue; + } + + if ($value instanceof Lazy) { + if (! $this->includesLazy($value, $property, $context)) { + continue; + } + + $value = $value->resolve(); + } + + $value = $this->transformPropertyValue( + $property, + $value, + $context, + $extensions, + ); + + $name = $context->mapPropertyNames && $property->outputMappedName !== null + ? $property->outputMappedName + : $property->name; + + $transformed[$name] = $value; + } + + return $this->finalizeTransformation($data, $dataClass, $context, $transformed); + } + + /** + * Transform a data collection within the current root operation. + * + * @param array $extensions + */ + protected function transformCollectable( + BaseDataCollectable&TransformableData $data, + TransformationContext $context, + array &$extensions, + ): array { + if ($context->maxDepth !== null && $context->depth >= $context->maxDepth) { + throw MaxTransformationDepthReached::create($context->maxDepth); + } + + $transformed = []; + + foreach ($this->collectableItems($data) as $key => $item) { + if (! $context->transformValues) { + if ($context->hasPartials() && $item instanceof IncludeableData) { + $item->getPartialsDefinition()->addResolved($context->partialDefinitions); + } + + $transformed[$key] = $item; + + continue; + } + + $itemContext = $context->withWrapExecutionType( + $this->resolveWrapExecutionType($item, $context), + ); + $transformed[$key] = $this->transformData( + $item, + $this->mergeInstancePartials($item, $itemContext), + $extensions, + ); + } + + return $data instanceof WrappableData && $context->wrapExecutionType->shouldExecute() + ? $data->getWrap()->wrap($transformed, $this->config->wrap) + : $transformed; + } + + /** + * Get collection items without triggering public transformation behavior. + * + * @return iterable + */ + protected function collectableItems(BaseDataCollectable $data): iterable + { + return $data instanceof DataCollection + ? $data->toCollection() + : $data; + } + + /** + * Apply wrapping and resolved top-level data. + */ + protected function finalizeTransformation( + BaseData $data, + DataClass $dataClass, + TransformationContext $context, + array $transformed, + ): array { + if ($dataClass->wrappable && $context->wrapExecutionType->shouldExecute()) { + /** @var WrappableData $data */ + $transformed = $data->getWrap()->wrap($transformed, $this->config->wrap); + } + + if (! $dataClass->appendable) { + return $transformed; + } + + /** @var AppendableData $data */ + $additional = $data->getAdditionalData(); + + return $additional === [] + ? $transformed + : array_merge($transformed, $additional); + } + + /** + * Copy values for metadata proven to need no property transformation. + * + * @param array $values + * @return array + */ + protected function transformPlain(BaseData $data, DataClass $dataClass, array $values): array + { + $transformed = []; + + foreach ($dataClass->properties as $property) { + if ($property->isVirtual) { + $transformed[$property->name] = $data->{$property->name}; + } elseif (array_key_exists($property->name, $values)) { + $transformed[$property->name] = $values[$property->name]; + } + } + + return $transformed; + } + + /** + * Determine if a lazy property is visible for this transformation. + */ + protected function includesLazy( + Lazy $lazy, + DataProperty $property, + TransformationContext $context, + ): bool { + if (! $lazy instanceof DefaultLazy) { + return $lazy->shouldBeIncluded() === true; + } + + if ($context->exclude?->selects($property->name)) { + return false; + } + + return $lazy->isDefaultIncluded() + || ($context->include?->contains($property->name) ?? false); + } + + /** + * Transform one visible property value. + * + * @param array $extensions + */ + protected function transformPropertyValue( + DataProperty $property, + mixed $value, + TransformationContext $context, + array &$extensions, + ): mixed { + if ($value === null) { + return null; + } + + if ($context->transformValues) { + $transformer = $this->propertyTransformer($property, $value, $context, $extensions); + + if ($transformer !== null) { + return $transformer->transform($property, $value, $context); + } + } + + if ($value instanceof BaseData || $value instanceof BaseDataCollectable) { + if (! $context->transformValues) { + $this->propagatePartials($value, $context, $property->name); + + return $value; + } + + $nestedContext = $context->child( + $property->name, + $this->resolveWrapExecutionType($value, $context), + ); + + if ($value instanceof BaseData) { + return $this->transformData( + $value, + $this->mergeInstancePartials($value, $nestedContext), + $extensions, + ); + } + + return $this->transformCollectable( + $value, + $this->mergeInstancePartials($value, $nestedContext), + $extensions, + ); + } + + $iterableType = $this->iterableTypeForValue($property, $value); + + if ($iterableType !== null) { + if (! $context->transformValues) { + $this->propagateIterablePartials($value, $context, $property->name); + + return $value; + } + + return $this->transformIterable( + $property, + $value, + $iterableType, + $context->child($property->name), + $extensions, + ); + } + + if (is_array($value)) { + return $this->filterArray( + $value, + $context->only?->child($property->name), + $context->except?->child($property->name), + ); + } + + if (! $context->transformValues) { + return $value; + } + + return $this->transformBuiltIn($value); + } + + /** + * Resolve wrapping behavior for a nested transformable value. + */ + protected function resolveWrapExecutionType( + BaseData|BaseDataCollectable $value, + TransformationContext $context, + ): WrapExecutionType { + if ($context->wrapExecutionType === WrapExecutionType::Disabled) { + return WrapExecutionType::Disabled; + } + + if ($value instanceof BaseData) { + return WrapExecutionType::TemporarilyDisabled; + } + + return $context->wrapExecutionType === WrapExecutionType::Enabled + ? WrapExecutionType::Enabled + : WrapExecutionType::TemporarilyDisabled; + } + + /** + * Merge a reached data instance's partials into its local context. + */ + protected function mergeInstancePartials( + BaseData|BaseDataCollectable $value, + TransformationContext $context, + ): TransformationContext { + if (! $value instanceof IncludeableData) { + return $context; + } + + $partialDefinitions = $value->getPartialsDefinition(); + + if ($partialDefinitions->isEmpty()) { + return $context; + } + + return $context->withMergedPartials($partialDefinitions->resolve( + $value, + consumeTemporary: true, + )); + } + + /** + * Get a custom transformer for a property value. + * + * @param array $extensions + */ + protected function propertyTransformer( + DataProperty $property, + mixed $value, + TransformationContext $context, + array &$extensions, + ): ?Transformer { + if ($property->transformer !== null) { + $key = 'attribute-transformer:' . spl_object_id($property->transformer); + + if (! isset($extensions[$key])) { + /** @var WithCastAndTransformer|WithTransformer $attribute */ + $attribute = $property->transformer->newInstance(); + $extensions[$key] = $attribute->get(); + } + + /** @var Transformer */ + return $extensions[$key]; + } + + if (($transformer = $this->runtimeTransformer($value, $context, $extensions)) !== null) { + return $transformer; + } + + foreach ($property->configuredTransformers as $transformer) { + return $this->resolveTransformer($transformer, $extensions); + } + + return null; + } + + /** + * Get an operation transformer matching a runtime value. + * + * @param array $extensions + */ + protected function runtimeTransformer( + mixed $value, + TransformationContext $context, + array &$extensions, + ): ?Transformer { + foreach ($context->transformers as $transformable => $transformer) { + if (! $this->matchesTransformable($transformable, $value)) { + continue; + } + + return $this->resolveTransformer($transformer, $extensions); + } + + return null; + } + + /** + * Determine if a transformer key matches a runtime value. + */ + protected function matchesTransformable(string $transformable, mixed $value): bool + { + if (! is_object($value)) { + return get_debug_type($value) === $transformable; + } + + return $value::class === $transformable || is_a($value, $transformable); + } + + /** + * Resolve one transformer once for the current root operation. + * + * @param Transformer|class-string $transformer + * @param array $extensions + */ + protected function resolveTransformer( + Transformer|string $transformer, + array &$extensions, + ): Transformer { + if ($transformer instanceof Transformer) { + return $transformer; + } + + $key = 'transformer:' . $transformer; + + /** @var Transformer */ + return $extensions[$key] ??= $this->container->make($transformer); + } + + /** + * Get iterable item metadata accepted by the runtime value. + */ + protected function iterableTypeForValue(DataProperty $property, mixed $value): ?Type + { + foreach ($property->type->getIterableTypes() as $type) { + if ($type->acceptsValue($value)) { + return $type->iterableItemType; + } + } + + return null; + } + + /** + * Transform typed iterable items while preserving supported containers. + * + * @param array $extensions + */ + protected function transformIterable( + DataProperty $property, + iterable $items, + Type $itemType, + TransformationContext $context, + array &$extensions, + ): array { + $transformed = []; + + foreach ($items as $key => $item) { + $transformed[$key] = $this->transformIterableItem( + $property, + $item, + $itemType, + $context, + $extensions, + ); + } + + return $transformed; + } + + /** + * Transform one typed iterable item. + * + * @param array $extensions + */ + protected function transformIterableItem( + DataProperty $property, + mixed $value, + Type $type, + TransformationContext $context, + array &$extensions, + ): mixed { + if ($value === null) { + return null; + } + + if (($transformer = $this->runtimeTransformer($value, $context, $extensions)) !== null) { + return $transformer->transform($property, $value, $context); + } + + if ($value instanceof BaseData || $value instanceof BaseDataCollectable) { + $context = $context->withWrapExecutionType( + $this->resolveWrapExecutionType($value, $context), + ); + + if ($value instanceof BaseData) { + return $this->transformData( + $value, + $this->mergeInstancePartials($value, $context), + $extensions, + ); + } + + return $this->transformCollectable( + $value, + $this->mergeInstancePartials($value, $context), + $extensions, + ); + } + + foreach ($type->getNamedTypes() as $namedType) { + if ($namedType->iterableItemType !== null && $namedType->acceptsValue($value)) { + return $this->transformIterable( + $property, + $value, + $namedType->iterableItemType, + $context, + $extensions, + ); + } + } + + return $this->transformBuiltIn($value); + } + + /** + * Transform one fixed built-in value. + */ + protected function transformBuiltIn(mixed $value): mixed + { + if ($value instanceof DateTimeInterface) { + if ($this->dateTimezone !== null) { + $value = DateTimeImmutable::createFromInterface($value) + ->setTimezone($this->dateTimezone); + } + + return $value->format(ltrim($this->config->dateFormats[0], '!')); + } + + if ($value instanceof BackedEnum) { + return $value->value; + } + + if ($value instanceof Arrayable) { + return $value->toArray(); + } + + return $value; + } + + /** + * Apply resolved partials to an unchanged nested data value. + */ + protected function propagatePartials( + BaseData|BaseDataCollectable $value, + TransformationContext $context, + string $property, + ): void { + if (! $context->hasPartials() || ! $value instanceof IncludeableData) { + return; + } + + $value->getPartialsDefinition()->addResolved( + $context->partialsForNestedProperty($property), + ); + } + + /** + * Apply resolved partials to unchanged data items in a typed iterable. + */ + protected function propagateIterablePartials( + iterable $items, + TransformationContext $context, + string $property, + ): void { + if (! $context->hasPartials()) { + return; + } + + $definitions = $context->partialsForNestedProperty($property); + + foreach ($items as $item) { + if ($item instanceof IncludeableData) { + $item->getPartialsDefinition()->addResolved($definitions); + } + } + } + + /** + * Apply only and except selections to a plain array value. + * + * @param array $value + * @return array + */ + protected function filterArray( + array $value, + ?PartialTree $only, + ?PartialTree $except, + ): array + { + if ($except?->all) { + $value = []; + } elseif ($except !== null) { + foreach ($except->children as $key => $partial) { + if ($partial->selected || $partial->all) { + unset($value[$key]); + } elseif (array_key_exists($key, $value) && is_array($value[$key])) { + $value[$key] = $this->filterArray( + $value[$key], + null, + $partial, + ); + } + } + } + + if ($only === null || $only->children === []) { + return $value; + } + + foreach ($value as $key => $item) { + if (! isset($only->children[$key])) { + unset($value[$key]); + + continue; + } + + $partial = $only->children[$key]; + + if ($partial->children !== [] && is_array($item)) { + $value[$key] = $this->filterArray( + $item, + $partial, + null, + ); + } + } + + return $value; + } +} diff --git a/src/data/src/Support/Transformation/EmptyDataResolver.php b/src/data/src/Support/Transformation/EmptyDataResolver.php new file mode 100644 index 000000000..c3b8679e1 --- /dev/null +++ b/src/data/src/Support/Transformation/EmptyDataResolver.php @@ -0,0 +1,131 @@ + $class + */ + public function execute( + string $class, + array $extra = [], + mixed $defaultReturnValue = null, + ): array + { + $dataClass = $this->dataClasses->get($class); + + $payload = []; + + foreach ($dataClass->properties as $property) { + $name = $property->outputMappedName ?? $property->name; + + if ($property->hasDefaultValue) { + $payload[$name] = $this->getDefaultValue($dataClass, $property); + } else { + $payload[$name] = array_key_exists($property->name, $extra) + ? $extra[$property->name] + : $this->getValueForProperty($property, $defaultReturnValue); + } + } + + return $payload; + } + + /** + * Get a declared default without retaining it in metadata. + */ + protected function getDefaultValue(DataClass $dataClass, DataProperty $property): mixed + { + if (! $property->isConstructorParameter) { + return $property->reflection->getDefaultValue(); + } + + /** @var DataParameter $parameter */ + $parameter = array_find( + $dataClass->constructorParameters, + fn (DataParameter $parameter): bool => $parameter->name === $property->name, + ); + + return $parameter->reflection->getDefaultValue(); + } + + /** + * Resolve an empty value from one property declaration. + */ + protected function getValueForProperty( + DataProperty $property, + mixed $defaultReturnValue = null, + ): mixed + { + $propertyType = $property->type; + + if ($propertyType->isMixed) { + return $defaultReturnValue; + } + + $types = array_values(array_filter( + $propertyType->getNamedTypes(), + static fn (NamedType $type): bool => $type->name !== 'null' + && $type->name !== Optional::class + && ! is_a($type->name, Lazy::class, true), + )); + + if ($types === []) { + return $defaultReturnValue; + } + + if (count($types) > 1) { + throw DataPropertyCanOnlyHaveOneType::create($property); + } + + $type = $types[0]; + + if ($type->acceptsType('array')) { + return []; + } + + if ($type->kind->isDataObject() + && $type->dataClass !== null + && $this->dataClasses->get($type->dataClass)->emptyData + ) { + /** @var class-string $dataClass */ + $dataClass = $type->dataClass; + + return $dataClass::empty(); + } + + if ($type->kind->isDataCollectable()) { + return []; + } + + if ($propertyType->findAcceptedTypeForBaseType(Traversable::class) !== null) { + return []; + } + + return $defaultReturnValue; + } +} diff --git a/src/data/src/Support/Transformation/PartialTree.php b/src/data/src/Support/Transformation/PartialTree.php new file mode 100644 index 000000000..84bf7760d --- /dev/null +++ b/src/data/src/Support/Transformation/PartialTree.php @@ -0,0 +1,226 @@ + $children + */ + private function __construct( + public bool $selected, + public bool $all, + public array $children, + ) { + } + + /** + * Compile partial paths into one immutable tree. + * + * @param list $paths + */ + public static function compile(array $paths): ?self + { + if ($paths === []) { + return null; + } + + $tree = ['selected' => false, 'all' => false, 'children' => []]; + + foreach ($paths as $path) { + foreach (self::parse($path) as $segments) { + self::insert($tree, $segments); + } + } + + return self::hydrate($tree); + } + + /** + * Determine if this tree contains a property endpoint or descendant. + */ + public function contains(string $property): bool + { + return $this->all || isset($this->children[$property]); + } + + /** + * Determine if this tree selects a property at the current level. + */ + public function selects(string $property): bool + { + return $this->all || ($this->children[$property]->selected ?? false); + } + + /** + * Get the nested selection for a property. + */ + public function child(string $property): ?self + { + if (isset($this->children[$property])) { + return $this->children[$property]; + } + + if (! $this->all) { + return null; + } + + return $this->children === [] + ? $this + : new self(selected: false, all: true, children: []); + } + + /** + * Merge another compiled selection into this tree. + */ + public function merge(?self $other): self + { + if ($other === null) { + return $this; + } + + $children = []; + + foreach ($this->children as $property => $child) { + $children[$property] = $child->merge($other->child($property)); + } + + foreach ($other->children as $property => $child) { + $children[$property] ??= $child->merge($this->child($property)); + } + + return new self( + $this->selected || $other->selected, + $this->all || $other->all, + $children, + ); + } + + /** + * Parse one familiar partial path into concrete segment lists. + * + * @return non-empty-list> + */ + private static function parse(string $path): array + { + $path = trim($path); + + if ($path === '') { + throw CannotPerformPartialOnDataField::invalidPath($path); + } + + $segments = explode('.', $path); + $prefix = []; + + foreach ($segments as $index => $segment) { + $segment = trim($segment); + + if ($segment === '') { + throw CannotPerformPartialOnDataField::invalidPath($path); + } + + if ($segment === '*') { + if ($index !== array_key_last($segments)) { + throw CannotPerformPartialOnDataField::invalidPath($path); + } + + return [[...$prefix, '*']]; + } + + if (str_starts_with($segment, '{') || str_ends_with($segment, '}')) { + if ($index !== array_key_last($segments) + || ! str_starts_with($segment, '{') + || ! str_ends_with($segment, '}') + ) { + throw CannotPerformPartialOnDataField::invalidPath($path); + } + + $fields = array_map('trim', explode(',', substr($segment, 1, -1))); + + if ($fields === [] || in_array('', $fields, true)) { + throw CannotPerformPartialOnDataField::invalidPath($path); + } + + $paths = []; + + foreach (array_values(array_unique($fields)) as $field) { + if (str_contains($field, '*') + || str_contains($field, '{') + || str_contains($field, '}') + ) { + throw CannotPerformPartialOnDataField::invalidPath($path); + } + + $paths[] = [...$prefix, $field]; + } + + return $paths; + } + + if (str_contains($segment, '*') + || str_contains($segment, '{') + || str_contains($segment, '}') + ) { + throw CannotPerformPartialOnDataField::invalidPath($path); + } + + $prefix[] = $segment; + } + + return [$prefix]; + } + + /** + * Insert one parsed path into the mutable build tree. + * + * @param array{selected: bool, all: bool, children: array} $tree + * @param non-empty-list $segments + */ + private static function insert(array &$tree, array $segments): void + { + $segment = array_shift($segments); + + if ($segment === '*') { + $tree['all'] = true; + + return; + } + + $tree['children'][$segment] ??= [ + 'selected' => false, + 'all' => false, + 'children' => [], + ]; + + if ($segments === []) { + $tree['children'][$segment]['selected'] = true; + + return; + } + + self::insert($tree['children'][$segment], $segments); + } + + /** + * Hydrate an immutable tree from its mutable build representation. + * + * @param array{selected: bool, all: bool, children: array} $tree + */ + private static function hydrate(array $tree, bool $inheritedAll = false): self + { + $all = $inheritedAll || $tree['all']; + $children = []; + + foreach ($tree['children'] as $property => $child) { + $children[$property] = self::hydrate($child, $all); + } + + return new self($tree['selected'], $all, $children); + } +} diff --git a/src/data/src/Support/Transformation/TransformationContext.php b/src/data/src/Support/Transformation/TransformationContext.php new file mode 100644 index 000000000..e737cb089 --- /dev/null +++ b/src/data/src/Support/Transformation/TransformationContext.php @@ -0,0 +1,162 @@ +, exclude: list, only: list, except: list} $partialDefinitions + * @param array> $transformers + */ + public function __construct( + public bool $transformValues = true, + public bool $mapPropertyNames = true, + public ?PartialTree $include = null, + public ?PartialTree $exclude = null, + public ?PartialTree $only = null, + public ?PartialTree $except = null, + public array $partialDefinitions = [ + 'include' => [], + 'exclude' => [], + 'only' => [], + 'except' => [], + ], + public array $transformers = [], + public WrapExecutionType $wrapExecutionType = WrapExecutionType::Disabled, + public int $depth = 0, + public ?int $maxDepth = null, + ) { + } + + /** + * Determine if this operation has partial selections. + */ + public function hasPartials(): bool + { + return $this->include !== null + || $this->exclude !== null + || $this->only !== null + || $this->except !== null; + } + + /** + * Merge resolved instance partials into this context. + * + * @param array{include: list, exclude: list, only: list, except: list} $partialDefinitions + */ + public function withMergedPartials(array $partialDefinitions): self + { + if ($partialDefinitions['include'] === [] + && $partialDefinitions['exclude'] === [] + && $partialDefinitions['only'] === [] + && $partialDefinitions['except'] === [] + ) { + return $this; + } + + return new self( + transformValues: $this->transformValues, + mapPropertyNames: $this->mapPropertyNames, + include: self::mergeTree($this->include, $partialDefinitions['include']), + exclude: self::mergeTree($this->exclude, $partialDefinitions['exclude']), + only: self::mergeTree($this->only, $partialDefinitions['only']), + except: self::mergeTree($this->except, $partialDefinitions['except']), + partialDefinitions: $this->partialDefinitions, + transformers: $this->transformers, + wrapExecutionType: $this->wrapExecutionType, + depth: $this->depth, + maxDepth: $this->maxDepth, + ); + } + + /** + * Create the same context with different wrapping behavior. + */ + public function withWrapExecutionType(WrapExecutionType $wrapExecutionType): self + { + return new self( + transformValues: $this->transformValues, + mapPropertyNames: $this->mapPropertyNames, + include: $this->include, + exclude: $this->exclude, + only: $this->only, + except: $this->except, + partialDefinitions: $this->partialDefinitions, + transformers: $this->transformers, + wrapExecutionType: $wrapExecutionType, + depth: $this->depth, + maxDepth: $this->maxDepth, + ); + } + + /** + * Resolve partial definitions for a raw nested property. + * + * @return array{include: list, exclude: list, only: list, except: list} + */ + public function partialsForNestedProperty(string $property): array + { + $nested = [ + 'include' => [], + 'exclude' => [], + 'only' => [], + 'except' => [], + ]; + + foreach ($this->partialDefinitions as $type => $definitions) { + foreach ($definitions as $definition) { + if (($definition = $definition->nested($property)) !== null) { + $nested[$type][] = $definition; + } + } + } + + return $nested; + } + + /** + * Create the context for one nested property. + */ + public function child( + string $property, + ?WrapExecutionType $wrapExecutionType = null, + ): self + { + return new self( + transformValues: $this->transformValues, + mapPropertyNames: $this->mapPropertyNames, + include: $this->include?->child($property), + exclude: $this->exclude?->child($property), + only: $this->only?->child($property), + except: $this->except?->child($property), + partialDefinitions: [], + transformers: $this->transformers, + wrapExecutionType: $wrapExecutionType ?? $this->wrapExecutionType, + depth: $this->depth + 1, + maxDepth: $this->maxDepth, + ); + } + + /** + * Merge one resolved definition group into a compiled tree. + * + * @param list $partialDefinitions + */ + private static function mergeTree(?PartialTree $tree, array $partialDefinitions): ?PartialTree + { + $other = PartialTree::compile(array_map( + static fn (PartialDefinition $definition): string => $definition->path, + $partialDefinitions, + )); + + return $tree?->merge($other) ?? $other; + } +} diff --git a/src/data/src/Support/Transformation/TransformationContextFactory.php b/src/data/src/Support/Transformation/TransformationContextFactory.php new file mode 100644 index 000000000..b1616ea3f --- /dev/null +++ b/src/data/src/Support/Transformation/TransformationContextFactory.php @@ -0,0 +1,199 @@ +> */ + protected array $transformers = []; + + protected ?int $maxDepth; + + protected PartialsDefinition $partialDefinitions; + + /** + * Create a transformation context factory. + */ + public function __construct(DataConfig $config) + { + $this->maxDepth = $config->maxTransformationDepth; + $this->partialDefinitions = new PartialsDefinition; + } + + /** + * Create a fresh transformation context factory. + */ + public static function create(): static + { + return Container::getInstance()->make(static::class); + } + + /** + * Build the context for one root transformation. + */ + public function get(object $data): TransformationContext + { + $partials = $this->partialDefinitions->resolve($data); + + if ($data instanceof IncludeableData) { + $dataPartials = $data->getPartialsDefinition()->resolve( + $data, + consumeTemporary: true, + ); + + foreach ($partials as $type => $paths) { + array_push($partials[$type], ...$dataPartials[$type]); + } + } + + return new TransformationContext( + transformValues: $this->transformValues, + mapPropertyNames: $this->mapPropertyNames, + include: PartialTree::compile(self::paths($partials['include'])), + exclude: PartialTree::compile(self::paths($partials['exclude'])), + only: PartialTree::compile(self::paths($partials['only'])), + except: PartialTree::compile(self::paths($partials['except'])), + partialDefinitions: $partials, + transformers: $this->transformers, + wrapExecutionType: $this->wrapExecutionType, + maxDepth: $this->maxDepth, + ); + } + + /** + * Enable or disable value transformation. + */ + public function withValueTransformation(bool $transformValues = true): static + { + $this->transformValues = $transformValues; + + return $this; + } + + /** + * Disable or enable value transformation. + */ + public function withoutValueTransformation(bool $withoutValueTransformation = true): static + { + $this->transformValues = ! $withoutValueTransformation; + + return $this; + } + + /** + * Enable or disable output property-name mapping. + */ + public function withPropertyNameMapping(bool $mapPropertyNames = true): static + { + $this->mapPropertyNames = $mapPropertyNames; + + return $this; + } + + /** + * Disable or enable output property-name mapping. + */ + public function withoutPropertyNameMapping(bool $withoutPropertyNameMapping = true): static + { + $this->mapPropertyNames = ! $withoutPropertyNameMapping; + + return $this; + } + + /** + * Set wrapping behavior for the transformation. + */ + public function withWrapExecutionType(WrapExecutionType $wrapExecutionType): static + { + $this->wrapExecutionType = $wrapExecutionType; + + return $this; + } + + /** + * Disable wrapping for the transformation. + */ + public function withoutWrapping(): static + { + $this->wrapExecutionType = WrapExecutionType::Disabled; + + return $this; + } + + /** + * Enable wrapping for the transformation. + */ + public function withWrapping(): static + { + $this->wrapExecutionType = WrapExecutionType::Enabled; + + return $this; + } + + /** + * Add a transformer for one declared or runtime type. + * + * @param Transformer|class-string $transformer + */ + public function withTransformer(string $transformable, Transformer|string $transformer): static + { + $this->transformers[$transformable] = $transformer; + + return $this; + } + + /** + * Set the maximum nested transformation depth. + */ + public function maxDepth(?int $maxDepth): static + { + $this->maxDepth = $maxDepth; + + return $this; + } + + /** + * Get paths from resolved partial definitions. + * + * @param list $definitions + * @return list + */ + private static function paths(array $definitions): array + { + $paths = []; + + foreach ($definitions as $definition) { + $paths[] = $definition->path; + } + + return $paths; + } + + /** + * Get the partial definition store. + */ + protected function getPartialsDefinition(): PartialsDefinition + { + return $this->partialDefinitions; + } +} diff --git a/src/data/src/Support/Wrapping/Wrap.php b/src/data/src/Support/Wrapping/Wrap.php new file mode 100644 index 000000000..1c2f7ae65 --- /dev/null +++ b/src/data/src/Support/Wrapping/Wrap.php @@ -0,0 +1,63 @@ +getKey($globalKey); + + return $wrapKey === null + ? $data + : [$wrapKey => $data]; + } + + /** + * Get the effective wrapping key. + */ + public function getKey(?string $globalKey): ?string + { + return match ($this->type) { + WrapType::Disabled => null, + WrapType::Defined => $this->key, + WrapType::UseGlobal => $globalKey, + }; + } + + /** + * Get the serializable wrapping definition. + */ + public function toSerializedArray(): array + { + return [ + 'type' => $this->type->value, + 'key' => $this->key, + ]; + } + + /** + * Restore a serialized wrapping definition. + */ + public static function fromSerializedArray(array $wrap): self + { + return new self( + type: WrapType::from($wrap['type']), + key: $wrap['key'] ?? null, + ); + } +} diff --git a/src/data/src/Support/Wrapping/WrapExecutionType.php b/src/data/src/Support/Wrapping/WrapExecutionType.php new file mode 100644 index 000000000..4f19ec666 --- /dev/null +++ b/src/data/src/Support/Wrapping/WrapExecutionType.php @@ -0,0 +1,20 @@ +toArray(); + } +} diff --git a/src/data/src/Transformers/DateTimeInterfaceTransformer.php b/src/data/src/Transformers/DateTimeInterfaceTransformer.php new file mode 100644 index 000000000..895598cc2 --- /dev/null +++ b/src/data/src/Transformers/DateTimeInterfaceTransformer.php @@ -0,0 +1,49 @@ +make(DataConfig::class); + } + + $this->format = $format ?? $config->dateFormats[0]; + $timeZone = $setTimeZone ?? $config->dateTimezone; + $this->timeZone = $timeZone === null ? null : new DateTimeZone($timeZone); + } + + /** + * Transform a date value. + */ + public function transform(DataProperty $property, mixed $value, TransformationContext $context): string + { + /** @var DateTimeInterface $value */ + if ($this->timeZone !== null) { + $value = DateTimeImmutable::createFromInterface($value)->setTimezone($this->timeZone); + } + + return $value->format(ltrim($this->format, '!')); + } +} diff --git a/src/data/src/Transformers/EnumTransformer.php b/src/data/src/Transformers/EnumTransformer.php new file mode 100644 index 000000000..16957c2ea --- /dev/null +++ b/src/data/src/Transformers/EnumTransformer.php @@ -0,0 +1,21 @@ +value; + } +} diff --git a/tests/Data/Concerns/AppendableDataTest.php b/tests/Data/Concerns/AppendableDataTest.php new file mode 100644 index 000000000..ce2cb3be6 --- /dev/null +++ b/tests/Data/Concerns/AppendableDataTest.php @@ -0,0 +1,130 @@ + "{$this->name} from Hypervel"]; + } + }; + + $this->assertSame([ + 'name' => 'Taylor', + 'label' => 'Taylor from Hypervel', + ], $data->toArray()); + } + + /** + * Test additional method closures receive the current data object. + */ + public function testResolvesWithMethodClosures(): void + { + $data = new class('Taylor') extends Data { + public function __construct(public string $name) + { + } + + public function with(): array + { + return [ + 'label' => static fn (self $data): string => "{$data->name} from Hypervel", + ]; + } + }; + + $this->assertSame([ + 'name' => 'Taylor', + 'label' => 'Taylor from Hypervel', + ], $data->toArray()); + } + + /** + * Test additional data may be supplied fluently. + */ + public function testAppendsDataFromAdditionalMethod(): void + { + $data = new class('Taylor') extends Data { + public function __construct(public string $name) + { + } + }; + + $transformed = $data->additional([ + 'company' => 'Hypervel', + 'label' => static fn (Data $data): string => "{$data->name} from Hypervel", + ])->toArray(); + + $this->assertSame([ + 'name' => 'Taylor', + 'company' => 'Hypervel', + 'label' => 'Taylor from Hypervel', + ], $transformed); + } + + /** + * Test fluent additional data takes precedence over class data. + */ + public function testAdditionalMethodTakesPrecedenceOverWithMethod(): void + { + $data = new class('Taylor') extends Data { + public function __construct(public string $name) + { + } + + public function with(): array + { + return ['label' => 'class']; + } + }; + + $this->assertSame([ + 'name' => 'Taylor', + 'label' => 'instance', + ], $data->additional(['label' => 'instance'])->toArray()); + } + + /** + * Test resources expose the same append behavior. + */ + public function testResourceAppendsAdditionalData(): void + { + $resource = new class('Taylor') extends Resource { + public function __construct(public string $name) + { + } + }; + + $this->assertSame([ + 'name' => 'Taylor', + 'company' => 'Hypervel', + ], $resource->additional(['company' => 'Hypervel'])->toArray()); + } +} diff --git a/tests/Data/Concerns/EmptyDataTest.php b/tests/Data/Concerns/EmptyDataTest.php new file mode 100644 index 000000000..efffeeea2 --- /dev/null +++ b/tests/Data/Concerns/EmptyDataTest.php @@ -0,0 +1,160 @@ +assertSame([ + 'property' => null, + 'lazyProperty' => null, + 'array' => [], + 'collection' => [], + 'data' => ['value' => null], + 'lazyData' => ['value' => null], + 'mapped_value' => null, + 'defaultProperty' => true, + ], EmptyShapeData::empty()); + } + + /** + * Test explicit values and a custom empty scalar are supported. + */ + public function testOverridesEmptyValues(): void + { + $this->assertSame([ + 'value' => 'supplied', + ], SimpleEmptyData::empty(['value' => 'supplied'], '?')); + + $this->assertSame([ + 'value' => '?', + ], SimpleEmptyData::empty(replaceNullValuesWith: '?')); + } + + /** + * Test only and except filter the output shape. + */ + public function testFiltersEmptyRepresentation(): void + { + $this->assertSame([ + 'second' => null, + ], FilteredEmptyData::empty(except: ['first'], only: ['first', 'second'])); + } + + /** + * Test ambiguous property types require an explicit value. + */ + public function testRejectsAmbiguousPropertyTypeWithoutOverride(): void + { + $this->expectException(DataPropertyCanOnlyHaveOneType::class); + $this->expectExceptionMessage(AmbiguousEmptyData::class . '::$value'); + + AmbiguousEmptyData::empty(); + } + + /** + * Test explicit values resolve ambiguous property types. + */ + public function testAcceptsOverrideForAmbiguousPropertyType(): void + { + $this->assertSame(['value' => 1], AmbiguousEmptyData::empty(['value' => 1])); + } + + /** + * Test constructor object defaults are fresh for each empty call. + */ + public function testDoesNotRetainDefaultObjectsInMetadata(): void + { + $first = DefaultObjectData::empty()['value']; + $second = DefaultObjectData::empty()['value']; + + $this->assertInstanceOf(EmptyDefaultObject::class, $first); + $this->assertInstanceOf(EmptyDefaultObject::class, $second); + $this->assertNotSame($first, $second); + } + + /** + * Test resources expose the empty representation capability. + */ + public function testResourceCreatesEmptyRepresentation(): void + { + $this->assertSame(['value' => null], EmptyResource::empty()); + } +} + +class EmptyShapeData extends Data +{ + public string $property; + + public string|Lazy $lazyProperty; + + public array $array; + + public Collection $collection; + + public SimpleEmptyData $data; + + public Lazy|SimpleEmptyData $lazyData; + + #[MapOutputName('mapped_value')] + public string $mappedValue; + + public bool $defaultProperty = true; +} + +class SimpleEmptyData extends Data +{ + public string $value; +} + +class FilteredEmptyData extends Data +{ + public string $first; + + public string $second; +} + +class AmbiguousEmptyData extends Data +{ + public int|string $value; +} + +class DefaultObjectData extends Data +{ + public function __construct(public EmptyDefaultObject $value = new EmptyDefaultObject) + { + } +} + +class EmptyDefaultObject +{ +} + +class EmptyResource extends Resource +{ + public string $value; +} diff --git a/tests/Data/Concerns/GlobalWrappingTest.php b/tests/Data/Concerns/GlobalWrappingTest.php new file mode 100644 index 000000000..c85eec11d --- /dev/null +++ b/tests/Data/Concerns/GlobalWrappingTest.php @@ -0,0 +1,63 @@ +make('config')->set('data.wrap', 'payload'); + } + + /** + * Test enabled transformations use the boot-built global wrapper. + */ + public function testUsesGlobalWrapper(): void + { + $data = new GloballyWrappedData('value'); + + $this->assertSame([ + 'payload' => ['value' => 'value'], + ], $data->transform(TransformationContextFactory::create()->withWrapping())); + } + + /** + * Test an object may disable the global wrapper. + */ + public function testDisablesGlobalWrapper(): void + { + $data = (new GloballyWrappedData('value'))->withoutWrapping(); + + $this->assertSame([ + 'value' => 'value', + ], $data->transform(TransformationContextFactory::create()->withWrapping())); + } +} + +class GloballyWrappedData extends Data +{ + public function __construct(public string $value) + { + } +} diff --git a/tests/Data/Concerns/WrappableDataTest.php b/tests/Data/Concerns/WrappableDataTest.php new file mode 100644 index 000000000..cddc5e981 --- /dev/null +++ b/tests/Data/Concerns/WrappableDataTest.php @@ -0,0 +1,92 @@ +wrap('payload'); + + $this->assertSame(['value' => 'value'], $data->toArray()); + } + + /** + * Test wrapping may be enabled for a transformation. + */ + public function testWrapsAnEnabledTransformation(): void + { + $data = (new WrappingData('value'))->wrap('payload'); + + $this->assertSame([ + 'payload' => ['value' => 'value'], + ], $data->transform(TransformationContextFactory::create()->withWrapping())); + } + + /** + * Test nested data remains unwrapped within a wrapped root. + */ + public function testLeavesNestedDataUnwrapped(): void + { + $data = (new NestedWrappingData( + (new WrappingData('nested'))->wrap('ignored'), + ))->wrap('payload'); + + $this->assertSame([ + 'payload' => [ + 'nested' => ['value' => 'nested'], + ], + ], $data->transform(TransformationContextFactory::create()->withWrapping())); + } + + /** + * Test additional data remains outside the root wrapper. + */ + public function testAppendsAdditionalDataOutsideWrapper(): void + { + $data = (new WrappingData('value')) + ->wrap('payload') + ->additional(['meta' => 'data']); + + $this->assertSame([ + 'payload' => ['value' => 'value'], + 'meta' => 'data', + ], $data->transform(TransformationContextFactory::create()->withWrapping())); + } +} + +class WrappingData extends Data +{ + public function __construct(public string $value) + { + } +} + +class NestedWrappingData extends Data +{ + public function __construct(public WrappingData $nested) + { + } +} diff --git a/tests/Data/LazyTest.php b/tests/Data/LazyTest.php new file mode 100644 index 000000000..837b731b3 --- /dev/null +++ b/tests/Data/LazyTest.php @@ -0,0 +1,137 @@ + 'value'); + + $this->assertInstanceOf(DefaultLazy::class, $lazy); + $this->assertSame('value', $lazy->resolve()); + $this->assertFalse($lazy->isDefaultIncluded()); + $this->assertSame($lazy, $lazy->defaultIncluded()); + $this->assertTrue($lazy->isDefaultIncluded()); + } + + public function testItCreatesConditionalLazyValues(): void + { + $included = Lazy::when(fn () => true, fn () => 'included'); + $excluded = Lazy::when(fn () => false, fn () => 'excluded'); + + $this->assertInstanceOf(ConditionalLazy::class, $included); + $this->assertTrue($included->shouldBeIncluded()); + $this->assertSame('included', $included->resolve()); + $this->assertFalse($excluded->shouldBeIncluded()); + } + + public function testItExposesLazyClosuresWithoutInvokingThem(): void + { + $calls = 0; + $lazy = Lazy::closure(function () use (&$calls): string { + ++$calls; + + return 'value'; + }); + + $resolved = $lazy->resolve(); + + $this->assertInstanceOf(ClosureLazy::class, $lazy); + $this->assertInstanceOf(Closure::class, $resolved); + $this->assertSame(0, $calls); + $this->assertSame('value', $resolved()); + $this->assertSame(1, $calls); + } + + public function testItIncludesRelationshipValuesOnlyWhenTheRelationIsLoaded(): void + { + $model = new LazyTestModel(); + $lazy = Lazy::whenLoaded('related', $model, fn () => $model->related); + + $this->assertInstanceOf(RelationalLazy::class, $lazy); + $this->assertFalse($lazy->shouldBeIncluded()); + + $related = new LazyTestModel(); + $model->setRelation('related', $related); + + $this->assertTrue($lazy->shouldBeIncluded()); + $this->assertSame($related, $lazy->resolve()); + } + + public function testItReturnsNullForALoadedNullRelationship(): void + { + $model = new LazyTestModel(); + $model->setRelation('related', null); + + $lazy = Lazy::whenLoaded('related', $model, fn () => 'unreachable'); + + $this->assertTrue($lazy->shouldBeIncluded()); + $this->assertNull($lazy->resolve()); + } + + public function testItForwardsPropertyAndMethodAccessToTheResolvedValue(): void + { + $target = new LazyTestTarget('Taylor'); + $lazy = Lazy::create(fn () => $target); + + $this->assertSame('Taylor', $lazy->name); + $this->assertSame('Hello Taylor', $lazy->greet('Hello')); + } + + public function testRegisteredMacrosTakePriorityOverResolvedMethods(): void + { + Lazy::macro('greet', fn (string $greeting): string => "{$greeting} macro"); + + try { + $lazy = Lazy::create(fn () => new LazyTestTarget('Taylor')); + + $this->assertSame('Hello macro', $lazy->greet('Hello')); + } finally { + Lazy::flushMacros(); + } + } + + public function testSerializableLazyValuesRetainTheirBehavior(): void + { + // Serializable Closure cannot distinguish identical closure signatures on one source line. + $condition = fn () => true; + $value = fn () => 'value'; + $lazy = Lazy::when($condition, $value)->defaultIncluded(); + + $restored = unserialize(serialize($lazy)); + + $this->assertInstanceOf(ConditionalLazy::class, $restored); + $this->assertTrue($restored->shouldBeIncluded()); + $this->assertTrue($restored->isDefaultIncluded()); + $this->assertSame('value', $restored->resolve()); + } +} + +class LazyTestModel extends Model +{ +} + +class LazyTestTarget +{ + public function __construct( + public string $name, + ) { + } + + public function greet(string $greeting): string + { + return "{$greeting} {$this->name}"; + } +} diff --git a/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php new file mode 100644 index 000000000..6bc3f38e7 --- /dev/null +++ b/tests/Data/Support/Transformation/DataTransformerTest.php @@ -0,0 +1,491 @@ +assertSame([ + 'display_name' => 'first', + 'createdAt' => '2026-08-31T10:30:00+00:00', + 'status' => 'ready', + 'nested' => ['value' => 'nested'], + ], $data->toArray()); + + $data->name = 'changed'; + + $this->assertSame('changed', $data->toArray()['display_name']); + $this->assertSame([ + 'display_name' => 'changed', + 'createdAt' => $date, + 'status' => Status::Ready, + 'nested' => $nested, + ], $data->all()); + } + + /** + * Test operation transformers take precedence over fixed built-ins. + */ + public function testUsesOperationTransformers(): void + { + $transformer = new class implements Transformer { + public function transform( + DataProperty $property, + mixed $value, + TransformationContext $context, + ): string { + return strtoupper((string) $value); + } + }; + $data = new SimpleData('value'); + $context = TransformationContextFactory::create() + ->withTransformer('string', $transformer); + + $this->assertSame(['value' => 'VALUE'], $data->transform($context)); + } + + /** + * Test lazy inclusion follows each lazy type's owning rule. + */ + public function testIncludesAndExcludesLazyValues(): void + { + $closure = static fn (): string => 'closure'; + $data = new LazyValuesData( + Lazy::create(static fn (): string => 'default'), + Lazy::create(static fn (): string => 'included'), + Lazy::create(static fn (): string => 'excluded')->defaultIncluded(), + Lazy::when(static fn (): bool => true, static fn (): string => 'conditional'), + Lazy::closure($closure), + ); + + $transformed = $data + ->include('included') + ->exclude('excluded', 'conditional', 'closure') + ->toArray(); + + $this->assertSame('included', $transformed['included']); + $this->assertSame('conditional', $transformed['conditional']); + $this->assertSame($closure, $transformed['closure']); + $this->assertArrayNotHasKey('default', $transformed); + $this->assertArrayNotHasKey('excluded', $transformed); + } + + /** + * Test only and except filter nested plain arrays without losing either mode. + */ + public function testFiltersNestedArrays(): void + { + $data = new ArrayData([ + 'first' => ['keep' => 1, 'remove' => 2], + 'second' => 3, + ]); + + $this->assertSame([ + 'meta' => ['first' => ['keep' => 1]], + ], $data + ->only('meta.first.keep') + ->except('meta.first.remove') + ->toArray()); + } + + /** + * Test shallow nested partials preserve identity and individual lifetimes. + */ + public function testPropagatesTemporaryAndPermanentPartialsFromAll(): void + { + $nested = new NestedLazyData( + Lazy::create(static fn (): string => 'temporary'), + Lazy::create(static fn (): string => 'permanent'), + ); + $data = new PartialOwnerData($nested); + + $returned = $data + ->include('nested.temporary') + ->includePermanently('nested.permanent') + ->all()['nested']; + + $this->assertSame($nested, $returned); + $this->assertSame([ + 'temporary' => 'temporary', + 'permanent' => 'permanent', + ], $returned->toArray()); + $this->assertSame([ + 'permanent' => 'permanent', + ], $returned->toArray()); + } + + /** + * Test propagated conditions are not re-evaluated against nested objects. + */ + public function testPropagatesResolvedConditionsUnconditionally(): void + { + $nested = new NestedLazyData( + Lazy::create(static fn (): string => 'value'), + Lazy::create(static fn (): string => 'other'), + ); + $data = new PartialOwnerData($nested); + + $returned = $data + ->includeWhen( + 'nested.temporary', + static fn (PartialOwnerData $owner): bool => $owner->enabled, + permanent: true, + ) + ->all()['nested']; + + $this->assertSame(['temporary' => 'value'], $returned->toArray()); + $this->assertSame(['temporary' => 'value'], $returned->toArray()); + } + + /** + * Test all four partial modes propagate to unchanged nested data. + */ + public function testPropagatesEveryPartialMode(): void + { + $nested = new NestedVisibleData('first', 'second', 'third'); + $data = new VisibleOwnerData($nested); + + $returned = $data + ->include('nested.*') + ->exclude('nested.lazy') + ->only('nested.{first,second,lazy}') + ->except('nested.second') + ->all()['nested']; + + $this->assertSame([ + 'first' => 'first', + ], $returned->toArray()); + } + + /** + * Test shallow partials propagate to each item in a typed raw array. + */ + public function testPropagatesPartialsToTypedRawDataArrays(): void + { + $first = new NestedLazyData( + Lazy::create(static fn (): string => 'first'), + Lazy::create(static fn (): string => 'ignored'), + ); + $second = new NestedLazyData( + Lazy::create(static fn (): string => 'second'), + Lazy::create(static fn (): string => 'ignored'), + ); + $data = new DataArrayOwner([$first, $second]); + + $returned = $data->include('items.temporary')->all()['items']; + + $this->assertSame([$first, $second], $returned); + $this->assertSame(['temporary' => 'first'], $returned[0]->toArray()); + $this->assertSame(['temporary' => 'second'], $returned[1]->toArray()); + } + + /** + * Test a wildcard include propagates while an endpoint include does not. + */ + public function testWildcardIncludesPropagateThroughNestedData(): void + { + $nested = new NestedLazyData( + Lazy::create(static fn (): string => 'first'), + Lazy::create(static fn (): string => 'second'), + ); + + $this->assertSame([ + 'enabled' => true, + 'nested' => [ + 'temporary' => 'first', + 'permanent' => 'second', + ], + ], (new PartialOwnerData($nested))->include('*')->toArray()); + + $this->assertSame([ + 'enabled' => true, + 'nested' => [], + ], (new PartialOwnerData($nested))->include('nested')->toArray()); + } + + /** + * Test nested instances apply all four of their own partial modes. + */ + public function testAppliesNestedInstancePartials(): void + { + $nested = (new NestedLazyData( + Lazy::create(static fn (): string => 'keep'), + Lazy::create(static fn (): string => 'remove'), + )) + ->include('temporary', 'permanent') + ->exclude('permanent') + ->only('temporary', 'permanent') + ->except('permanent'); + + $this->assertSame([ + 'enabled' => true, + 'nested' => ['temporary' => 'keep'], + ], (new PartialOwnerData($nested))->toArray()); + } + + /** + * Test parent and instance selections compose at the nested node. + */ + public function testMergesParentAndNestedInstancePartials(): void + { + $nested = (new NestedLazyData( + Lazy::create(static fn (): string => 'parent'), + Lazy::create(static fn (): string => 'instance'), + ))->includePermanently('permanent'); + + $this->assertSame([ + 'enabled' => true, + 'nested' => [ + 'temporary' => 'parent', + 'permanent' => 'instance', + ], + ], (new PartialOwnerData($nested)) + ->includePermanently('nested.temporary') + ->toArray()); + + $visible = (new NestedVisibleData('first', 'second', 'third')) + ->onlyPermanently('first'); + + $this->assertSame([ + 'nested' => ['first' => 'first'], + ], (new VisibleOwnerData($visible))->onlyPermanently('*')->toArray()); + } + + /** + * Test repeated references consume temporary partials only at first reach. + */ + public function testNestedInstancePartialLifetimeFollowsEachReachedOccurrence(): void + { + $temporary = (new NestedLazyData( + Lazy::create(static fn (): string => 'temporary'), + Lazy::create(static fn (): string => 'ignored'), + ))->include('temporary'); + $permanent = (new NestedLazyData( + Lazy::create(static fn (): string => 'ignored'), + Lazy::create(static fn (): string => 'permanent'), + ))->includePermanently('permanent'); + + $this->assertSame([ + ['temporary' => 'temporary'], + [], + ['permanent' => 'permanent'], + ['permanent' => 'permanent'], + ], (new DataArrayOwner([ + $temporary, + $temporary, + $permanent, + $permanent, + ]))->toArray()['items']); + } + + /** + * Test deeply nested typed-array items keep instance partials isolated. + */ + public function testKeepsNestedTypedArrayItemPartialsIsolated(): void + { + $items = [ + new PartialArrayItemData([ + (new NestedLazyData( + Lazy::create(static fn (): string => 'B1'), + Lazy::create(static fn (): string => 'ignored'), + ))->include('temporary'), + new NestedLazyData( + Lazy::create(static fn (): string => 'B2'), + Lazy::create(static fn (): string => 'ignored'), + ), + ]), + new PartialArrayItemData([ + new NestedLazyData( + Lazy::create(static fn (): string => 'D1'), + Lazy::create(static fn (): string => 'ignored'), + ), + (new NestedLazyData( + Lazy::create(static fn (): string => 'ignored'), + Lazy::create(static fn (): string => 'D2'), + ))->include('permanent'), + ]), + ]; + + $this->assertSame([ + ['nestedCollection' => [ + ['temporary' => 'B1'], + [], + ]], + ['nestedCollection' => [ + [], + ['permanent' => 'D2'], + ]], + ], (new PartialArrayOwnerData( + Lazy::create(static fn (): array => $items), + ))->include('items')->toArray()['items']); + } + + /** + * Test nested transformation stops at the configured depth. + */ + public function testThrowsAtMaximumTransformationDepth(): void + { + $data = new NestedData(new NestedData(new SimpleData('deep'))); + + $this->expectExceptionMessage('Max transformation depth of 1 reached.'); + + $data->transform(TransformationContextFactory::create()->maxDepth(1)); + } +} + +enum Status: string +{ + case Ready = 'ready'; +} + +class SimpleData extends Data +{ + public function __construct(public string $value) + { + } +} + +class TransformingData extends Data +{ + public function __construct( + #[MapOutputName('display_name')] + public string $name, + public DateTimeImmutable $createdAt, + public BackedEnum $status, + public SimpleData $nested, + ) { + } +} + +class LazyValuesData extends Data +{ + public function __construct( + public Lazy|string $default, + public Lazy|string $included, + public Lazy|string $excluded, + public Lazy|string $conditional, + public Closure|Lazy $closure, + ) { + } +} + +class ArrayData extends Data +{ + public function __construct(public array $meta) + { + } +} + +class NestedLazyData extends Data +{ + public function __construct( + public Lazy|string $temporary, + public Lazy|string $permanent, + ) { + } +} + +class PartialOwnerData extends Data +{ + public bool $enabled = true; + + public function __construct(public NestedLazyData $nested) + { + } +} + +class NestedVisibleData extends Data +{ + public Lazy|string $lazy; + + public function __construct( + public string $first, + public string $second, + public string $third, + ) { + $this->lazy = Lazy::create(static fn (): string => 'lazy')->defaultIncluded(); + } +} + +class VisibleOwnerData extends Data +{ + public function __construct(public NestedVisibleData $nested) + { + } +} + +class DataArrayOwner extends Data +{ + /** + * @param list $items + */ + public function __construct( + #[DataCollectionOf(NestedLazyData::class)] + public array $items, + ) { + } +} + +class PartialArrayItemData extends Data +{ + /** + * @param list $nestedCollection + */ + public function __construct( + #[DataCollectionOf(NestedLazyData::class)] + public array $nestedCollection, + ) { + } +} + +class PartialArrayOwnerData extends Data +{ + /** + * @param Lazy|list $items + */ + public function __construct( + #[DataCollectionOf(PartialArrayItemData::class)] + public Lazy|array $items, + ) { + } +} + +class NestedData extends Data +{ + public function __construct(public Data $nested) + { + } +} diff --git a/tests/Data/Support/Transformation/PartialTreeTest.php b/tests/Data/Support/Transformation/PartialTreeTest.php new file mode 100644 index 000000000..2845588bf --- /dev/null +++ b/tests/Data/Support/Transformation/PartialTreeTest.php @@ -0,0 +1,168 @@ +assertNotNull($tree); + $this->assertTrue($tree->contains('artist')); + $this->assertFalse($tree->selects('artist')); + $this->assertTrue($tree->contains('songs')); + $this->assertFalse($tree->selects('songs')); + $this->assertFalse($tree->selects('year')); + + $artist = $tree->child('artist'); + + $this->assertNotNull($artist); + $this->assertTrue($artist->selects('name')); + $this->assertTrue($artist->selects('email')); + $this->assertTrue($artist->selects('role')); + $this->assertFalse($artist->selects('id')); + + $songs = $tree->child('songs'); + + $this->assertNotNull($songs); + $this->assertTrue($songs->selects('title')); + $this->assertSame($songs, $songs->child('title')); + } + + /** + * Test duplicate paths merge without mutable traversal state. + */ + public function testMergesDuplicatePrefixes(): void + { + $tree = PartialTree::compile([ + 'artist.name', + 'artist.name', + 'artist.email', + ]); + + $this->assertNotNull($tree); + $this->assertSame(['name', 'email'], array_keys($tree->child('artist')->children)); + } + + /** + * Test exact endpoints remain distinct from traversal prefixes. + */ + public function testRetainsExactSelectionAndPropagatingWildcardProvenance(): void + { + $prefix = PartialTree::compile(['artist.name']); + + $this->assertNotNull($prefix); + $this->assertTrue($prefix->contains('artist')); + $this->assertFalse($prefix->selects('artist')); + $this->assertTrue($prefix->child('artist')->selects('name')); + + $exactAndNested = PartialTree::compile(['artist', 'artist.name']); + + $this->assertNotNull($exactAndNested); + $this->assertTrue($exactAndNested->selects('artist')); + $this->assertTrue($exactAndNested->child('artist')->selects('name')); + + $all = PartialTree::compile(['*', 'artist.name']); + + $this->assertNotNull($all); + $this->assertTrue($all->selects('anything')); + $this->assertTrue($all->child('artist')->all); + $this->assertTrue($all->child('artist')->selects('name')); + + $unlisted = $all->child('unlisted'); + + $this->assertNotNull($unlisted); + $this->assertTrue($unlisted->all); + $this->assertSame($unlisted, $unlisted->child('nested')); + } + + /** + * Test compiled selections compose without losing endpoints or descendants. + */ + public function testMergesCompiledSelections(): void + { + $tree = PartialTree::compile(['artist', 'artist.name', 'songs.*']); + $other = PartialTree::compile(['artist.email', 'profile.name']); + + $this->assertNotNull($tree); + $this->assertNotNull($other); + + $merged = $tree->merge($other); + + $this->assertTrue($merged->selects('artist')); + $this->assertSame(['name', 'email'], array_keys($merged->child('artist')->children)); + $this->assertTrue($merged->child('songs')->all); + $this->assertTrue($merged->child('profile')->selects('name')); + $this->assertSame($tree, $tree->merge(null)); + } + + /** + * Test wildcard inheritance is symmetric when selections compose. + */ + public function testMergesWildcardAndExplicitSelectionsInEitherOrder(): void + { + $all = PartialTree::compile(['*']); + $explicit = PartialTree::compile(['artist.name']); + + $this->assertNotNull($all); + $this->assertNotNull($explicit); + + foreach ([$all->merge($explicit), $explicit->merge($all)] as $merged) { + $this->assertTrue($merged->all); + $this->assertSame(['artist'], array_keys($merged->children)); + $this->assertTrue($merged->child('artist')->all); + $this->assertSame(['name'], array_keys($merged->child('artist')->children)); + $this->assertTrue($merged->child('unlisted')->all); + } + } + + /** + * Test an empty definition avoids allocating a tree. + */ + public function testEmptyDefinitionsReturnNull(): void + { + $this->assertNull(PartialTree::compile([])); + } + + /** + * Test malformed paths fail instead of being partially applied. + */ + #[DataProvider('invalidPathProvider')] + public function testRejectsInvalidPaths(string $path): void + { + $this->expectException(CannotPerformPartialOnDataField::class); + + PartialTree::compile([$path]); + } + + /** + * Provide malformed partial paths. + */ + public static function invalidPathProvider(): array + { + return [ + 'empty' => [''], + 'empty segment' => ['artist..name'], + 'wildcard suffix' => ['artist.*.name'], + 'partial wildcard' => ['artist.na*'], + 'unclosed group' => ['artist.{name,email'], + 'empty group field' => ['artist.{name,}'], + 'nested group' => ['artist.{name,email}.value'], + ]; + } +} diff --git a/tests/Data/Support/Transformation/PartialsDefinitionTest.php b/tests/Data/Support/Transformation/PartialsDefinitionTest.php new file mode 100644 index 000000000..a6ad57bb2 --- /dev/null +++ b/tests/Data/Support/Transformation/PartialsDefinitionTest.php @@ -0,0 +1,219 @@ +assertTrue($definitions->isEmpty()); + + foreach (['include', 'exclude', 'only', 'except'] as $type) { + $definitions->add($type, $type); + + $this->assertFalse($definitions->isEmpty()); + + $definitions->resolve(new stdClass, consumeTemporary: true); + + $this->assertTrue($definitions->isEmpty()); + } + } + + /** + * Test temporary definitions apply once while permanent definitions persist. + */ + public function testConsumesOnlyTemporaryDefinitions(): void + { + $definitions = new PartialsDefinition; + $definitions->add('include', 'temporary'); + $definitions->add('include', 'permanent', permanent: true); + $data = new stdClass; + + $resolved = $definitions->resolve($data, consumeTemporary: true)['include']; + + $this->assertSame(['temporary', 'permanent'], self::paths($resolved)); + $this->assertFalse($resolved[0]->permanent); + $this->assertTrue($resolved[1]->permanent); + $this->assertSame( + ['permanent'], + self::paths($definitions->resolve($data, consumeTemporary: true)['include']), + ); + } + + /** + * Test conditional definitions evaluate against the current object. + */ + public function testResolvesConditionalDefinitions(): void + { + $definitions = new PartialsDefinition; + $definitions->add( + 'only', + 'enabled', + condition: static fn (object $data): bool => $data->enabled, + ); + $enabled = (object) ['enabled' => true]; + $disabled = (object) ['enabled' => false]; + + $this->assertSame(['enabled'], self::paths($definitions->resolve($enabled)['only'])); + $this->assertSame([], self::paths($definitions->resolve($disabled)['only'])); + } + + /** + * Test class defaults are permanent and preserve familiar keyed conditions. + */ + public function testAddsPermanentClassDefaults(): void + { + $definitions = new PartialsDefinition; + $definitions->addDefaults('exclude', [ + 'always', + 'enabled' => true, + 'disabled' => false, + 'conditional' => static fn (object $data): bool => $data->enabled, + ]); + $data = (object) ['enabled' => true]; + + $this->assertSame( + ['always', 'enabled', 'conditional'], + self::paths($definitions->resolve($data, consumeTemporary: true)['exclude']), + ); + $this->assertSame( + ['always', 'enabled', 'conditional'], + self::paths($definitions->resolve($data, consumeTemporary: true)['exclude']), + ); + } + + /** + * Test resolved definitions retain their individual lifetimes when merged. + */ + public function testAddsDefinitionsResolvedByAnEnclosingObject(): void + { + $definitions = new PartialsDefinition; + $definitions->addResolved([ + 'include' => [ + new PartialDefinition('temporary'), + new PartialDefinition('permanent', permanent: true), + ], + 'exclude' => [], + 'only' => [], + 'except' => [], + ]); + $data = new stdClass; + + $this->assertSame( + ['temporary', 'permanent'], + self::paths($definitions->resolve($data, consumeTemporary: true)['include']), + ); + $this->assertSame( + ['permanent'], + self::paths($definitions->resolve($data, consumeTemporary: true)['include']), + ); + } + + /** + * Test conditional definitions survive PHP serialization. + */ + public function testSerializesConditionalDefinitions(): void + { + $definitions = new PartialsDefinition; + $definitions->add( + 'except', + 'secret', + permanent: true, + condition: static fn (object $data): bool => $data->hide, + ); + + /** @var PartialsDefinition $restored */ + $restored = unserialize(serialize($definitions)); + + $this->assertSame( + ['secret'], + self::paths($restored->resolve((object) ['hide' => true])['except']), + ); + $this->assertSame( + [], + self::paths($restored->resolve((object) ['hide' => false])['except']), + ); + } + + /** + * Test nested definitions retain their lifetime without their owner condition. + */ + #[DataProvider('nestedDefinitionProvider')] + public function testResolvesDefinitionsForNestedProperties( + string $path, + string $property, + ?string $expected, + ): void { + $definition = new PartialDefinition( + $path, + permanent: true, + condition: static fn (): bool => true, + ); + + $nested = $definition->nested($property); + + if ($expected === null) { + $this->assertNull($nested); + + return; + } + + $this->assertSame($expected, $nested->path); + $this->assertTrue($nested->permanent); + $this->assertNull($nested->condition); + } + + /** + * Provide nested definition paths. + */ + public static function nestedDefinitionProvider(): array + { + return [ + 'terminal selection' => ['nested', 'nested', null], + 'nested wildcard' => ['nested.*', 'nested', '*'], + 'root wildcard' => ['*', 'nested', '*'], + 'nested group' => ['nested.{a,b}', 'nested', '{a,b}'], + 'root group' => ['{nested,other}', 'nested', null], + 'different property' => ['other.value', 'nested', null], + ]; + } + + /** + * Test unknown definition groups fail clearly. + */ + public function testRejectsUnknownPartialTypes(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown partial type [unknown].'); + + (new PartialsDefinition)->add('unknown', 'field'); + } + + /** + * Get paths from resolved partial definitions. + * + * @param list $definitions + * @return list + */ + private static function paths(array $definitions): array + { + return array_map( + static fn (PartialDefinition $definition): string => $definition->path, + $definitions, + ); + } +} diff --git a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php new file mode 100644 index 000000000..6e38fc99f --- /dev/null +++ b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php @@ -0,0 +1,81 @@ + true]; + $context = TransformationContextFactory::create() + ->withoutValueTransformation() + ->withoutPropertyNameMapping() + ->include('profile.avatar') + ->includeWhen( + 'profile.permanent', + static fn (object $owner): bool => $owner->enabled, + permanent: true, + ) + ->exclude('profile.secret') + ->only('profile.{name,email}') + ->except('profile.password') + ->maxDepth(4) + ->get($data); + + $this->assertFalse($context->transformValues); + $this->assertFalse($context->mapPropertyNames); + $this->assertSame(4, $context->maxDepth); + $this->assertTrue($context->hasPartials()); + $this->assertTrue($context->include?->child('profile')?->selects('avatar')); + $this->assertTrue($context->exclude?->child('profile')?->selects('secret')); + $this->assertTrue($context->only?->child('profile')?->selects('name')); + $this->assertTrue($context->only?->child('profile')?->selects('email')); + $this->assertTrue($context->except?->child('profile')?->selects('password')); + + $nested = $context->partialsForNestedProperty('profile'); + + $this->assertSame( + ['avatar', 'permanent'], + array_map( + static fn (PartialDefinition $definition): string => $definition->path, + $nested['include'], + ), + ); + $this->assertFalse($nested['include'][0]->permanent); + $this->assertTrue($nested['include'][1]->permanent); + $this->assertNull($nested['include'][1]->condition); + } + + /** + * Test each static factory call resolves a fresh transient instance. + */ + public function testCreateReturnsFreshFactories(): void + { + $first = TransformationContextFactory::create()->maxDepth(1); + $second = TransformationContextFactory::create(); + + $this->assertNotSame($first, $second); + $this->assertSame(1, $first->get(new stdClass)->maxDepth); + $this->assertNull($second->get(new stdClass)->maxDepth); + } +} diff --git a/tests/Data/Support/Transformation/TransformationContextTest.php b/tests/Data/Support/Transformation/TransformationContextTest.php new file mode 100644 index 000000000..da97e5246 --- /dev/null +++ b/tests/Data/Support/Transformation/TransformationContextTest.php @@ -0,0 +1,66 @@ +withMergedPartials([ + 'include' => [new PartialDefinition('instance')], + 'exclude' => [new PartialDefinition('secret')], + 'only' => [new PartialDefinition('instance')], + 'except' => [new PartialDefinition('password')], + ]); + + $this->assertNotSame($context, $merged); + $this->assertTrue($merged->include?->selects('parent')); + $this->assertTrue($merged->include?->selects('instance')); + $this->assertTrue($merged->exclude?->selects('secret')); + $this->assertTrue($merged->except?->selects('password')); + $this->assertTrue($merged->only?->all); + $this->assertSame(['instance'], array_keys($merged->only?->children ?? [])); + } + + /** + * Test child contexts narrow trees and discard root-relative definitions. + */ + public function testChildClearsRootRelativePartialDefinitions(): void + { + $context = new TransformationContext( + transformValues: false, + include: PartialTree::compile(['nested.value']), + partialDefinitions: [ + 'include' => [new PartialDefinition('nested.value')], + 'exclude' => [], + 'only' => [], + 'except' => [], + ], + depth: 2, + maxDepth: 5, + ); + + $child = $context->child('nested'); + + $this->assertFalse($child->transformValues); + $this->assertTrue($child->include?->selects('value')); + $this->assertSame([], $child->partialDefinitions); + $this->assertSame(3, $child->depth); + $this->assertSame(5, $child->maxDepth); + } +} From 139cdb07e0d3ccadf4cbfaead73c1a21ac3823c3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:19:39 +0000 Subject: [PATCH 09/35] Add a reproducible Data benchmark harness Measure native construction, explicit SDK-style mapping, and the existing flat and nested DataObject paths with warmup, repeated samples, median and p95 latency, operations per second, peak memory, and checksum validation. Record the commit, PHP and OS versions, loaded extensions, OPcache and JIT state, and workload size. Support optional JSON and CSV reports outside the repository so later fixed-engine measurements can be compared on the same machine without encoding arbitrary thresholds into tests. --- tests/Benchmarks/Data/README.md | 26 +++ tests/Benchmarks/Data/benchmark.php | 351 ++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 tests/Benchmarks/Data/README.md create mode 100644 tests/Benchmarks/Data/benchmark.php diff --git a/tests/Benchmarks/Data/README.md b/tests/Benchmarks/Data/README.md new file mode 100644 index 000000000..a302dc0c6 --- /dev/null +++ b/tests/Benchmarks/Data/README.md @@ -0,0 +1,26 @@ +# Data Benchmark + +This developer-only harness measures data-object construction against native constructors and explicit array mapping. It is not registered as an Artisan command and is not part of the PHPUnit suite. + +Run it from the components repository root: + +```shell +php tests/Benchmarks/Data/benchmark.php +``` + +The harness warms each scenario, records repeated samples, and reports operations per second, median and p95 nanoseconds per operation, and peak allocated memory. Its heading records the commit, PHP version, operating system, loaded extensions, OPcache/JIT state, and workload size. + +Raw reports are opt-in and should be written outside the repository so local measurements cannot be committed accidentally: + +```shell +php tests/Benchmarks/Data/benchmark.php \ + --operations=20000 \ + --samples=7 \ + --warmup=1000 \ + --json=/tmp/hypervel-data-benchmark.json \ + --csv=/tmp/hypervel-data-benchmark.csv +``` + +The native constructor is the language floor. The manual mapper represents purpose-built SDK code with no reflection. Warm scenarios measure normal worker-lifetime operation after metadata has been retained, while cold scenarios include metadata analysis. + +Compare ratios on the same machine and commit rather than treating one run as a release claim. Re-run the harness after changes to metadata, creation, validation, transformation, or collection internals, and inspect both throughput and memory before retaining a specialized path. diff --git a/tests/Benchmarks/Data/benchmark.php b/tests/Benchmarks/Data/benchmark.php new file mode 100644 index 000000000..5e91411f3 --- /dev/null +++ b/tests/Benchmarks/Data/benchmark.php @@ -0,0 +1,351 @@ +#!/usr/bin/env php +, results: list>} + */ + public function execute(): array + { + $environment = $this->environment(); + $results = []; + + $flatPayload = [ + 'id' => 1001, + 'name' => 'Taylor Otwell', + 'email' => 'taylor@example.com', + 'active' => true, + 'address' => null, + ]; + $nestedPayload = [ + ...$flatPayload, + 'address' => [ + 'line_one' => '1 Framework Way', + 'city' => 'Little Rock', + 'country_code' => 'US', + ], + ]; + + $scenarios = [ + 'native-constructor' => fn (): int => (new LegacyDataBenchmarkUser( + $flatPayload['id'], + $flatPayload['name'], + $flatPayload['email'], + $flatPayload['active'], + null, + ))->id, + 'manual-flat-mapper' => fn (): int => $this->mapUser($flatPayload)->id, + 'data-object-flat-warm' => fn (): int => LegacyDataBenchmarkUser::make($flatPayload)->id, + 'data-object-flat-cold' => function () use ($flatPayload): int { + DataObject::flushState(); + + return LegacyDataBenchmarkUser::make($flatPayload)->id; + }, + 'manual-nested-mapper' => fn (): int => $this->mapUser($nestedPayload)->address?->countryCode === 'US' ? 1 : 0, + 'data-object-nested-warm' => fn (): int => LegacyDataBenchmarkUser::make($nestedPayload, true)->address?->countryCode === 'US' ? 1 : 0, + ]; + + printf("Hypervel data benchmark\n"); + + foreach ($environment as $key => $value) { + printf("%s: %s\n", $key, is_array($value) ? implode(', ', $value) : (string) $value); + } + + printf( + "\n%-28s %14s %14s %14s %14s\n", + 'scenario', + 'operations/s', + 'p50 ns/op', + 'p95 ns/op', + 'peak memory', + ); + + foreach ($scenarios as $name => $scenario) { + $result = $this->benchmark($name, $scenario); + $results[] = $result; + + printf( + "%-28s %14.0f %14.2f %14.2f %14d\n", + $result['scenario'], + $result['operations_per_second'], + $result['p50_nanoseconds'], + $result['p95_nanoseconds'], + $result['peak_memory_bytes'], + ); + } + + return compact('environment', 'results'); + } + + /** + * Benchmark one operation over repeated samples. + * + * @param Closure(): int $operation + * @return array + */ + private function benchmark(string $name, Closure $operation): array + { + $checksum = 0; + + for ($iteration = 0; $iteration < $this->warmup; ++$iteration) { + $checksum += $operation(); + } + + $nanosecondsPerOperation = []; + $peakMemory = 0; + + for ($sample = 0; $sample < $this->samples; ++$sample) { + memory_reset_peak_usage(); + $startedAt = hrtime(true); + + for ($operationIndex = 0; $operationIndex < $this->operations; ++$operationIndex) { + $checksum += $operation(); + } + + $elapsedNanoseconds = hrtime(true) - $startedAt; + $nanosecondsPerOperation[] = $elapsedNanoseconds / $this->operations; + $peakMemory = max($peakMemory, memory_get_peak_usage(true)); + } + + if ($checksum === 0) { + throw new LogicException("Benchmark scenario [{$name}] produced an empty checksum."); + } + + sort($nanosecondsPerOperation, SORT_NUMERIC); + $median = $this->percentile($nanosecondsPerOperation, 0.50); + + return [ + 'scenario' => $name, + 'operations' => $this->operations, + 'samples' => $this->samples, + 'operations_per_second' => 1_000_000_000 / $median, + 'p50_nanoseconds' => $median, + 'p95_nanoseconds' => $this->percentile($nanosecondsPerOperation, 0.95), + 'peak_memory_bytes' => $peakMemory, + ]; + } + + /** + * Map one representative SDK payload without reflection. + */ + private function mapUser(array $payload): LegacyDataBenchmarkUser + { + $address = $payload['address'] === null + ? null + : new LegacyDataBenchmarkAddress( + $payload['address']['line_one'], + $payload['address']['city'], + $payload['address']['country_code'], + ); + + return new LegacyDataBenchmarkUser( + $payload['id'], + $payload['name'], + $payload['email'], + $payload['active'], + $address, + ); + } + + /** + * Return the nearest-rank percentile from sorted samples. + * + * @param list $samples + */ + private function percentile(array $samples, float $percentile): float + { + $index = (int) ceil(count($samples) * $percentile) - 1; + + return $samples[max(0, min(count($samples) - 1, $index))]; + } + + /** + * Return the reproducibility inputs for this run. + * + * @return array + */ + private function environment(): array + { + $commit = trim((string) shell_exec('git rev-parse HEAD 2>/dev/null')); + + return [ + 'timestamp' => gmdate(DATE_ATOM), + 'commit' => $commit !== '' ? $commit : 'unknown', + 'php' => PHP_VERSION, + 'os' => php_uname(), + 'extensions' => get_loaded_extensions(), + 'opcache_enabled' => ini_get('opcache.enable_cli') ?: '0', + 'jit' => ini_get('opcache.jit') ?: 'disabled', + 'operations_per_sample' => $this->operations, + 'samples' => $this->samples, + 'warmup_operations' => $this->warmup, + ]; + } +} + +/** + * Run the benchmark CLI. + */ +function main(): int +{ + $options = getopt('', ['operations:', 'samples:', 'warmup:', 'json:', 'csv:', 'help']); + + if ($options === false) { + fwrite(STDERR, "Unable to parse benchmark options.\n"); + + return 1; + } + + if (array_key_exists('help', $options)) { + printUsage(); + + return 0; + } + + try { + $operations = parseIntegerOption($options, 'operations', 20_000, 1, 1_000_000); + $samples = parseIntegerOption($options, 'samples', 7, 1, 100); + $warmup = parseIntegerOption($options, 'warmup', 1_000, 0, 100_000); + $report = (new DataBenchmark($operations, $samples, $warmup))->execute(); + + if (array_key_exists('json', $options)) { + writeJsonReport($options['json'], $report); + } + + if (array_key_exists('csv', $options)) { + writeCsvReport($options['csv'], $report['results']); + } + } catch (Throwable $throwable) { + fwrite(STDERR, sprintf("Benchmark failed: %s: %s\n", $throwable::class, $throwable->getMessage())); + + return 1; + } + + return 0; +} + +/** + * Parse and validate one integer option. + * + * @param array|false|string> $options + */ +function parseIntegerOption(array $options, string $name, int $default, int $minimum, int $maximum): int +{ + $value = $options[$name] ?? (string) $default; + + if (! is_string($value) || filter_var($value, FILTER_VALIDATE_INT) === false) { + throw new InvalidArgumentException("--{$name} must be an integer."); + } + + $value = (int) $value; + + if ($value < $minimum || $value > $maximum) { + throw new InvalidArgumentException("--{$name} must be between {$minimum} and {$maximum}."); + } + + return $value; +} + +/** + * Write one JSON report. + * + * @param array $report + */ +function writeJsonReport(mixed $path, array $report): void +{ + if (! is_string($path) || $path === '') { + throw new InvalidArgumentException('--json must be a non-empty path.'); + } + + file_put_contents($path, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL); +} + +/** + * Write one CSV report. + * + * @param list> $results + */ +function writeCsvReport(mixed $path, array $results): void +{ + if (! is_string($path) || $path === '') { + throw new InvalidArgumentException('--csv must be a non-empty path.'); + } + + $stream = fopen($path, 'wb'); + + if ($stream === false) { + throw new RuntimeException("Unable to open CSV report [{$path}]."); + } + + try { + fputcsv($stream, array_keys($results[0])); + + foreach ($results as $result) { + fputcsv($stream, $result); + } + } finally { + fclose($stream); + } +} + +/** + * Print command usage. + */ +function printUsage(): void +{ + echo <<<'TEXT' +Usage: php tests/Benchmarks/Data/benchmark.php [options] + +Options: + --operations=COUNT Operations measured per sample (default: 20000) + --samples=COUNT Number of measured samples (default: 7) + --warmup=COUNT Unmeasured warmup operations (default: 1000) + --json=PATH Write the complete report as JSON + --csv=PATH Write scenario results as CSV + --help Show this help + +TEXT; +} + +exit(main()); From a83abd21d407fc95903eb3fe15fdf507babe509f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:19:45 +0000 Subject: [PATCH 10/35] Track the laravel-data upstream Register Spatie Laravel Data 4.23.0 in the upstream sync manifest and record the reviewed main and v5 draft commits so future release work can distinguish already-considered changes from new upstream work. Repair the sync guide's stale porting-document references so package syncs consistently use the authoritative Porting Packages and stop-and-report rules in AGENTS.md. --- docs/upstream-sync/README.md | 6 +++--- docs/upstream-sync/sync.yaml | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/upstream-sync/README.md b/docs/upstream-sync/README.md index 8c1ba52f8..382c46e17 100644 --- a/docs/upstream-sync/README.md +++ b/docs/upstream-sync/README.md @@ -6,7 +6,7 @@ This guide governs how Hypervel stays current with upstream packages (Laravel fr When the user asks to run an upstream sync, sync session, upstream review, or similar. This guide **overrides** the `/hypervel-pr` skill's default first-time porting flow — follow the sync workflow below instead. -For the mechanics of porting code (namespace changes, container conversion, service provider migration, listener conversion, type modernization, test porting), `docs/ai/porting.md` is authoritative. Re-read it before writing any code. This guide only covers the *surrounding* workflow — discovery, classification, commit structure, PR structure, state tracking. +For the mechanics of porting code (namespace changes, container conversion, service provider migration, listener conversion, type modernization, test porting), the `Porting Packages` section of `AGENTS.md` is authoritative. Re-read it before writing any code. This guide only covers the *surrounding* workflow — discovery, classification, commit structure, PR structure, state tracking. ## Files in this directory @@ -20,7 +20,7 @@ Deliberate, lasting differences from Laravel belong in the affected package READ - **Releases are walked one at a time, oldest to newest.** Never merge multiple releases' PRs into one flat list. Finish release N before opening release N+1. - **Never auto-decide a PR is skippable.** Propose classification, explain reasoning, wait for user approval. The user decides scope; you propose. - **One commit per upstream PR.** Separation is cheap; bad reverts are expensive. -- **Stop-and-ask rules from `porting.md` apply in full** — source bugs, coroutine/container divergence, unusual dependencies, anything surprising. +- **Stop-and-ask rules from `AGENTS.md` apply in full** — source bugs, coroutine/container divergence, unusual dependencies, anything surprising. ## Session workflow @@ -72,7 +72,7 @@ Propose a classification and reasoning: Wait for user approval on every classification. Never silently skip. -If porting: follow `docs/ai/porting.md` for the mechanics. Commit with: +If porting: follow the `Porting Packages` section of `AGENTS.md` for the mechanics. Commit with: ``` Port #: diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 8120faef5..65e05364b 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -85,6 +85,12 @@ orchestral/testbench: sync_date: null notes: Composer package is `orchestra/testbench` (different from gh slug). +spatie/laravel-data: + repo_url: https://github.com/spatie/laravel-data + release: 4.23.0 + sync_date: 2026-08-30 + notes: Initial port also reviewed main through ce296f22 and the v5 draft at ed630ee1. + spatie/laravel-permission: repo_url: https://github.com/spatie/laravel-permission release: null From c7a5c7db664b09b53c62da3589bdbbc646111ba8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:19:51 +0000 Subject: [PATCH 11/35] Record first-party TypeScript transformation work Track TypeScript generation as a general Hypervel package that can inspect ordinary PHP classes and enums as well as Data metadata. Keep filesystem discovery and code generation outside hypervel/data, with an optional adapter for mapping, Optional, lazy, and collection semantics, so runtime Data construction remains independent of that separate tooling concern. --- docs/todo.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/todo.md b/docs/todo.md index 9d9ad9a00..366cb7396 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -42,6 +42,10 @@ - Publish reproducible Hypervel 0.4 benchmarks on a dedicated documentation page before linking them from the introduction. Record the framework, PHP, Swoole, and dependency versions; use the same hardware and load-generation conditions for every runtime; publish the benchmark applications and configuration; and include the raw results, collection date, and limitations. Do not reuse the Hypervel 0.3 results as current data. Once the page is published, add it to `src/docs/documentation.md` and link to it from the introduction. - When the Hypervel 0.4 documentation is published, replace the versioned GitHub source links in both the `hypervel/components` and `hypervel/hypervel` READMEs with the corresponding hypervel.org documentation URLs. The documentation's `{{version}}` cross-links only resolve on the published site, so readers who follow the current links land on pages whose internal navigation is broken. +## TypeScript + +- Port `spatie/typescript-transformer` as a general first-party Hypervel package, then add an optional Hypervel Data adapter that recognizes data classes and their existing mapping, Optional, lazy, and collection metadata. Keep TypeScript generation outside `hypervel/data`: the transformer must also support ordinary PHP classes and enums without making runtime data construction depend on filesystem discovery or code generation. + ## Redis - Revisit the rate limiter's portable fixed-window Lua script once native bounded increment-with-expiry support is mature across the supported Redis-compatible ecosystem. Redis 8.8's `INCREX` can atomically reject increments above an upper bound and set expiry only for a new window, but Redis 8.6 and Valkey 9 do not provide it, [Valkey #3253](https://github.com/valkey-io/valkey/pull/3253) is still an open related proposal rather than equivalent `INCREX` support, and phpredis 6.3 exposes no typed `INCREX` method (while `rawCommand()` bypasses key prefixing and has different Redis Cluster routing semantics). Re-benchmark and switch only when Redis and Valkey expose equivalent semantics and phpredis has prefix-aware, cluster-aware client support; keep the corresponding focused `@TODO` beside the Lua script until then. From ee94c1f1ca15743f8b278ec490214d0e643e58c7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:20:05 +0000 Subject: [PATCH 12/35] Plan the first-party Hypervel Data package Define the intended public Data, Dto, Resource, Optional, Lazy, collection, mapping, casting, validation, transformation, resource, Eloquent, Inertia, Saloon, and VarDumper surfaces using familiar Laravel and Spatie vocabulary where it remains well designed. Specify Hypervel-native fixed creation and transformation engines, immutable worker-lifetime metadata, per-operation state, one-root validation, wildcard and concrete collection compilation, contextual constructor injection, safe abstract morph persistence, and framework-owned extension points without configurable pipelines or deploy metadata caches. Record the complete upstream research and disposition, framework changes, implementation order, edge-case matrix, performance harness, verification commands, and completion audit. The plan favors first-class Hypervel integration, coroutine safety, measured performance, Laravel ergonomics, and direct code over parity-only machinery. --- .../2026-08-30-0349-hypervel-data-package.md | 946 ++++++++++++++++++ 1 file changed, 946 insertions(+) create mode 100644 docs/plans/2026-08-30-0349-hypervel-data-package.md diff --git a/docs/plans/2026-08-30-0349-hypervel-data-package.md b/docs/plans/2026-08-30-0349-hypervel-data-package.md new file mode 100644 index 000000000..c92a0cda5 --- /dev/null +++ b/docs/plans/2026-08-30-0349-hypervel-data-package.md @@ -0,0 +1,946 @@ +# Hypervel Data Package + +## Status + +- Implementation in progress with explicit owner approval. +- Target repository: `contrib/hypervel/components-data`. +- Target branch: `feature/data-package`, created from the greenfield `0.4` branch. +- Package: `hypervel/data`, component directory `src/data`, namespace `Hypervel\Data`. +- Hypervel 0.4 has not been released. Prior Hypervel `DataObject` APIs are not a compatibility contract; no aliases, deprecations, migration shims, or compatibility switches are required. + +## Outcome + +Create a first-party, coroutine-safe data object package with the familiar public shape of `spatie/laravel-data`, adapted for Hypervel and redesigned where the upstream internals impose avoidable latency, allocation, or framework coupling. + +The completed package must provide: + +- concise typed DTO construction from arrays, JSON, objects, Eloquent models, requests, and custom named factories; +- recursive casting for nested data objects, collections, enums, dates, unions, iterables, and custom types; +- Laravel-style validation inference, validation attributes, authorization, messages, attribute names, and validator hooks; +- independent input/output name mapping; +- `Optional`, defaults, lazy properties, computed/hidden properties, partial transformation, and appended values; +- typed data collections and paginator/cursor-paginator support; +- controller injection, FormRequest casting, Eloquent JSON casting, HTTP resources, Precognition, Inertia, and Saloon interoperability; +- clean Symfony VarDumper output that shows each object's current logical view without exposing package internals; +- immutable reflection metadata analyzed once per used class and retained for the worker lifetime; +- predictable performance for large SDK graphs and collections, without runtime discovery, generated metadata, or a service-locator pipeline in ordinary construction; +- first-party documentation, generators, tests, attribution, and component split metadata. + +Acceptance is behavioral and architectural, not merely API-shaped: ordinary `Data::from(array)` and `toArray()` calls must take lean fixed paths without service-locator pipelines, request-specific state must never be stored globally, measured specializations must earn their complexity, and every adopted feature must have focused tests. + +## Governing Constraints + +1. Follow the root `CLAUDE.md` and `contrib/hypervel/components/AGENTS.md` in full. +2. Preserve Laravel and Spatie public vocabulary when it remains well designed. Hypervel-native ownership, coroutine safety, performance, and simpler code take precedence over parity. +3. Prefer the permission component's first-party port structure: local namespace, package provider, README attribution/differences, retained MIT notice, PHPUnit tests, Hypervel-native integration. +4. Port source and matching tests one file or coherent primitive at a time. Maintain a source/test disposition ledger during implementation so no upstream surface is silently forgotten. +5. Use immutable worker-safe metadata and boot-stable typed configuration. Store request, validator, authenticated user, route, include/exclude, and lazy evaluation state only in a root operation or object instance. +6. Do not add replaceable pipelines, compatibility flags, generated or remote metadata caches, broad event systems, or abstractions justified only by hypothetical use. +7. Do not keep the current `Hypervel\Support\DataObject` behavior merely because it exists. Keep a behavior only when it remains useful under the new design. +8. Do not make broad Validation, Container, HTTP, Foundation, Database, Inertia, or Saloon changes unless the change is independently sound for that owning component. +9. Optimize measured hot paths while retaining clarity. No absolute performance claim is accepted without a retained, reproducible benchmark. +10. Remove superseded code, tests, documentation, comments, and cleanup hooks in the same implementation. The result must read as one design. + +## Verified Research Baseline + +### Local sources + +| Source | Relevant conclusion | +| --- | --- | +| Hypervel components `0.4` | The current DTO is one `Support\DataObject` class plus Foundation request casts, a Database cast, docs, and tests. Hypervel already has compiled Validation rule plans and wildcard traversal, container `SelfBuilding` and contextual attributes, JSON resources, Precognition, Inertia props, paginator responses, and worker-lifetime reflection caching. Reuse those boundaries. | +| `examples/spatie/laravel-data` main at `ce296f22` (`4.23.0-3-gce296f22`) | Use its valuable public vocabulary, leaf attributes/casts/transformers, behavior, tests, and documentation as the released feature reference. Do not copy its configurable pipeline/resolver graph, request-lifetime container lookups, structure discovery, or cache-store metadata. The initial sync entry records stable release `4.23.0`; the ledger separately pins this reviewed commit. | +| Spatie `origin/v5` at `ed630ee1` | Adopt the fixed flow described by the draft `docs/superpowers/specs/2026-08-28-data-v5-creation-design.md` and foundations plan: non-recursive internal entry points, validated-payload construction, one validator for a nested tree, recorded wire keys, shared `Data`/`Dto`/`Resource` engine, focused factory hooks, and the `ConstructionState`/`CreationContext` split. The branch implements foundations only and the spec is explicitly draft, so every adoption remains a Hypervel design decision. Do not adopt its unproven generated structure cache: Hypervel retains immutable metadata for the worker lifetime. | +| `examples/laravel-validated-dto` | Its smaller surface confirms the usefulness of explicit mapping and casts, but its mutable central object, magic mutation, partially initialized lazy constructor, and Eloquent coupling are not suitable foundations. No source is ported from it. | +| `spatie/typescript-transformer` | TypeScript generation is a general reflection/discovery concern spanning ordinary classes and enums. It remains a separate package concern; Data exposes no TypeScript-only runtime API. | +| Symfony VarDumper and Hypervel Foundation | `AbstractCloner` resolves interface casters for implementing classes, and Foundation registers worker-global default casters directly with idempotent `??=` assignment. Data can use the same extension boundary without a manager, mode setting, or Foundation change. | + +### Existing Hypervel behavior + +`src/support/src/DataObject.php`, `tests/Support/DataObjectTest.php`, and every direct integration reference were read in full. The useful semantics are: + +- configured date parsing with exact concrete date targets preserved; +- nested typed construction, including enums, date interfaces, unions, DNF values, and existing object instances; +- owner-specific input names and custom dependency conversion; +- custom output serialization; +- FormRequest casts for one object, arrays, and collections; +- JSON-column casting; +- live output after public property mutation; +- plain-object and `WithResponse` use from Saloon. + +The undesirable mechanisms are: + +- `make(array $data, bool $autoResolve)` and a process-wide auto-casting switch; +- mutable global date/config switches; +- base-class reflection arrays exposed as global static state; +- per-instance serialized-array caching plus `refresh()`/`update()` invalidation; +- read-only `ArrayAccess` on the object itself; +- implicit snake-case mapping; +- stringly owner hooks that duplicate attributes, casts, and transformers. + +### Upstream performance evidence + +- A Spatie discussion reports a 200+ class graph taking roughly 89 seconds uncached, 76 seconds with package structure caching, and 2.3 seconds with a purpose-built mapper. Treat these as reporter measurements, not universal numbers; they demonstrate that large DTO graphs require an intentionally lean path: . +- A Spatie discussion reports validation of 5,000 collection items at about 188 ms with Laravel validation and 13,456 ms through the package. Again, the values are reporter measurements; the design response is one nested Validator using wildcard rules: . +- Recursive dispatch through user `from*` methods has caused OOM/segfault behavior upstream. Internal construction must never call an overridable public entry point: . + +### Feature comparison and decision + +| Area | Spatie | Wendell | Hypervel decision | +| --- | --- | --- | --- | +| Core API | `Data`, `Dto`, `Resource`, `from`, `collect`, `factory` | `SimpleDTO` with explicit `fromArray`/`fromRequest` methods | Use Spatie names and class split. | +| Mapping/casting | Rich attributes, mappers, casts, transformers | Small attribute set | Port the rich Spatie surface with fixed internals. | +| Validation | Inference, 90+ attributes, request lifecycle | Rules attributes and validator methods | Port applicable Spatie validation behavior onto Hypervel Validation. | +| Collections | Typed collections and paginator variants | Arrays/collections through the base object | Use dedicated typed collections; no collection behavior on every DTO. | +| HTTP/resources | Responsable, partials, wrapping | Response helpers on the base object | Use Hypervel JSON-resource machinery through an adapter. | +| Eloquent | Castable data and collections | Base class is Eloquent-castable | Keep casts in `hypervel/data`, not Support or Database. | +| Runtime architecture | Configurable normalizer/pipeline/resolver graph | Mutable central class | Fixed engines, immutable metadata, per-operation factory hooks. | +| Metadata | Cache-store structures plus discovery | Reflection per use | Immutable `DataClassRepository` entries built once per used class and retained by the worker. | +| Optional integrations | Inertia, Livewire, TypeScript adapter | None | Port Inertia; omit Livewire because Hypervel has no equivalent; keep TypeScript ownership separate. | + +## Public API Decisions + +### Primary classes + +- `Hypervel\Data\Data`: full creation, validation, transformation, collection, resource, wrapping, lazy, partial, append, and empty-shape behavior. +- `Hypervel\Data\Dto`: creation, automatic Request validation, and explicit validation APIs without transformation, resource, Eloquent-cast, partial, wrapping, or empty-shape concerns. +- `Hypervel\Data\Resource`: creation and transformation/resource behavior without the public validation conveniences. +- `Hypervel\Data\Optional`: sentinel that preserves a property as not supplied. `null` represents a null value, whether supplied explicitly or produced from nullable omission. +- `Hypervel\Data\Lazy`: lazy/conditional/relation/Inertia value wrappers. +- `Hypervel\Data\DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection`: typed, Macroable collection forms. + +Keep the familiar `Optional` name. `Hypervel\Support\Optional` remains Laravel's `optional()` helper wrapper; the classes have different semantics and namespaces, so renaming Data's absence sentinel would reduce Spatie/LLM familiarity without fixing a technical collision. + +`Data`, `Dto`, and `Resource` use `ValidationStrategy::OnlyRequests` by default. For arrays, models, JSON, and other non-Request sources this is as lean as `Disabled`; the distinction matters only for user-controlled Request input. All three classes share `SelfBuilding`, so validating controller-injected objects is the safe and Laravel-familiar default. `Dto` retains `validate()`, `validateAndCreate()`, and factory validation controls. `Resource` may change validation through the shared configuration/factory but does not implement the validation contract or expose the static validation conveniences. This intentionally diverges from the v5 draft's disabled `Dto`/`Resource` defaults without collapsing their distinct capability surfaces. + +```php +` candidate instead of choosing a container precedence or returning raw items. +- Wire payloads never become arbitrary class names automatically. A morph must be returned by the declared Data morph method or a typed named factory and must be a concrete subtype of the declared base. + +Date handling keeps the useful current behavior: interfaces use Hypervel's configured Date factory, an exact concrete `DateTimeInterface` implementation is constructed as that exact type, input accepts the configured format or format list, and output uses the configured format unless a property transformer overrides it. + +### Construction factory and hooks + +`Data::factory()` returns a fresh fluent `CreationContextFactory`; it is never stored globally and does not accept a previous operation context. Reusing an in-flight `CreationContext` would also reuse per-operation hooks and mutable traversal options, so this intentionally narrows v4's `factory(?CreationContext)` signature. Preserve the familiar controls `validationStrategy()`, `withoutValidation()`, `onlyValidateRequests()`, `alwaysValidate()`, `withPropertyNameMapping()`, `withoutPropertyNameMapping()`, `withMagicalCreation()`, `withoutMagicalCreation()`, `ignoreMagicalMethod()`, `withCast()`, and `withCastCollection()`, plus per-factory custom normalizers and these ordered per-operation hooks: + +1. `prepareData` +2. `beforeValidation` +3. `beforeRules` +4. `afterRules` +5. `withValidator` +6. `afterValidation` +7. `beforeCreation` +8. `afterCreation` + +```php +$user = UserData::factory() + ->alwaysValidate() + ->prepareData(fn (array $data): array => [...$data, 'source' => 'import']) + ->withValidator(fn (Validator $validator) => $validator->after($check)) + ->from($payload); +``` + +These are targeted customization points, not a replaceable creation pipeline. Class-owned Laravel-style extension methods remain supported and are compiled as metadata feature bits: + +```php +public static function authorize(): bool|Response; +public static function rules(ValidationContext $context): array; +public static function messages(): array; +public static function attributes(): array; +public static function withValidator(Validator $validator): void; +public static function after(): array; +public static function normalizers(): array; +public static function stopOnFirstFailure(): bool; +public static function redirect(): string; +public static function redirectRoute(): string; +public static function errorBag(): string; +``` + +These are canonical signatures, not an interface; as with FormRequest lifecycle methods, additional typed parameters may be container-resolved for a declared method. + +Hook semantics follow the fixed v5 draft. `prepareData`, `beforeValidation`, and `afterValidation` receive and return assembled payload arrays. `beforeValidation` and `afterValidation` run after the prepare stage, so their output is authoritative post-prepare payload: reconciliation may apply fixed source normalization and a named factory to a genuinely new or changed value, but it never reopens custom normalizers or `prepareData`. Reconciliation reselects every property's wire key from the final payload and reselects morphs, while recursive structure work touches only changed Data-bearing values and preserves unchanged sibling structure and named-factory results. `beforeCreation` receives and returns final casted property values before Hypervel contextual constructor parameters are injected, so it must not return raw values that still require casting and the documented contextual-wins rule remains absolute. `afterCreation` receives and may replace the constructed object with another instance of the target class. `beforeRules` is per property and the first non-null returned rule list replaces inference. `afterRules` is per property and transforms the inferred/replaced rule list. `withValidator` receives the root Validator before it runs, and `after()` returns Validator after-callbacks just as a FormRequest does. Transforming hooks chain in registration order. Keep the class-owned static `withValidator()` as a deliberate Laravel-style divergence from the v5 draft for invariant behavior during controller injection; factory hooks customize one call. The familiar redirect/error-bag/stop methods are retained for container-resolved or runtime-computed values. When both forms exist, a declared method takes precedence over the corresponding Foundation attribute; otherwise the attribute is the zero-call declarative path. Store only method-presence bits and attribute scalar values in metadata and invoke all user methods per root operation, so their results can never become worker state. + +The two creation hooks are not aliases for named factories: `beforeCreation` is the only operation-scoped hook over final casted payload values, and `afterCreation` is the only operation-scoped replacement/decorator point that does not require changing the Data class. They are part of Spatie v5's deliberately bounded hook set, use the same root context arrays as the other hooks, and add only a false feature-bit branch when absent. Do not add adjacent `beforeCast`/`afterCast` hooks or an application-wide hook registry without a demonstrated requirement. + +Use Foundation's existing class attributes for declarative request-validation configuration: `#[StopOnFirstFailure]`, `#[ErrorBag]`, `#[RedirectTo]`, `#[RedirectToRoute]`, and `#[FailOnUnknownFields]`. Compile their scalar values into Data metadata. Retain Spatie's methods only where they add runtime computation; this avoids changing Foundation's attribute signatures or adding another redirect abstraction. For each setting, a declared method overrides its corresponding attribute. On validation failure, resolve the existing `Hypervel\Contracts\Routing\UrlGenerator` contract and then follow FormRequest's URL-before-route precedence: the effective `redirect()`/`#[RedirectTo]` value, the effective `redirectRoute()`/`#[RedirectToRoute]` value, then the previous URL. A computed full URL from `redirect()` covers parameterized routes. The same method-over-attribute rule governs `errorBag()` and `stopOnFirstFailure()`. Do not copy FormRequest's mutable request instance state. + +Port typed public static `from*` named creation methods. Public dispatch chooses the first compatible method in declaration order. `DataMethod::matchPayloads(CreationContext $context, mixed ...$payloads)` performs one deterministic left-to-right match and returns `null` or an immutable `DataMethodMatch` containing the final argument shape and whether invocation requires the container. The walk fills each declared non-variadic `CreationContext` parameter with the operation's exact instance at its declared name or offset without consuming a raw payload; contextual parameters, defaults, and injectable single named class parameters are skipped as appropriate. Union and intersection types are payloads, not implicitly injectable dependencies. Positional values consume the leftmost compatible parameter, while named values match exact parameter names. Unknown names fail unless a declared variadic consumes their values. Do not retain a separate boolean `accepts()` pass, defer context substitution, or let the container fabricate a context. + +Non-variadic arguments use exact parameter-name keys; raw variadic values use a numeric tail. Invoke a match directly with named arguments when no variadic payload exists, or a positional list when a variadic has payload and every preceding parameter was supplied without Container behavior. Use a dynamic first-class callable with `Container::call()` only when a contextual parameter, a non-variadic attributed parameter, an omitted injectable dependency, or a variadic after a skipped parameter requires it; create the callable only on that slow path. This retains the Data class on Hypervel's contextual build stack without exposing `Container::bindMethod()` as an inconsistent factory hook. On a Container path with a non-empty single-class variadic payload, emit the first value under its class-name key and the rest as the numeric tail. `BoundMethod` consumes that value at the variadic recipe before appending the tail, so it cannot fabricate an extra instance. An earlier parameter of the same class cannot steal the key: the matcher either supplied it by parameter name, resolved it contextually, or consumed the leftmost compatible payload before reaching the variadic. With no caller payload, a class-typed variadic follows Laravel and Hypervel's ordinary Container resolution and may receive container-provided instances. + +Returning the target data object finishes that node, so validation attributes, explicit class rules, casts, and creation hooks do not run for the path the method already built. Existing declared Data instances follow the same rule. Returning another normalizable value continues through validation/casting without matching again. Root request authorization still runs before either outcome in create mode; validation-only and rule-introspection modes disable named-method dispatch because their array/rule return contracts cannot represent an object exit. The internal engine never invokes `from()` or `factory()`. + +Retain typed public static `collect*` methods for whole-collection customization. Normalize items once into a source-shaped container, then select one method against that exact invocation value; never match the raw source and replace its payload afterward. Check the requested `$into` target independently through the method's declared return type. Arrays, ordinary collection subclasses, package wrappers, and Hypervel paginator clones retain their source shape; Eloquent sources become base `Hypervel\Support\Collection` for empty and non-empty Data results. A contract-only paginator may still feed a non-paginator `$into` through `items()`, but has no rebuildable source shape and therefore selects no `collect*` method. Matching never enumerates a preserved `LazyCollection`. Inject `CreationContext`/container dependencies through the selected invocation path and never duplicate normalized items across several factory parameters. The magical-creation toggles and ignore list cover both `from*` and `collect*`; item construction never redispatches through the public collection entry point. + +Do not port `withOptionalValues()`/`withoutOptionalValues()`, although the v5 draft retains them. Suppressing `Optional` can leave a declared `string|Optional` property uninitialized, which is not a valid data-object state. Callers that intentionally want another absence representation must declare it in the PHP type/default or reshape the input through a named factory/hook. + +Also deliberately diverge from the v5 draft's global/class/property auto-null mode. One fixed rule is easier to reason about: omitted nullable values become `null`, while `Optional` preserves absence. A JSON contract that requires the key but permits explicit null uses `#[Present]` with the nullable type; generated SDKs can emit that declaration directly. This achieves strict wire presence without a global compatibility mode and two override attributes. + +For multiple payloads, the first source containing a property supplies it, including explicit `null` or `Optional`. Callers express precedence by argument order; no null-specific override exception is hidden in the engine. + +### Validation + +Use Hypervel Validation directly; do not port Spatie's rule-inferrer registry. + +- A deterministic compiler derives presence, nullable, primitive, enum, date, array, collection, and nested data rules from immutable metadata. Every constructable non-`WithoutValidation` property contributes at least a presence rule so `Validator::validated()` cannot silently drop it; `mixed` and object-only declarations receive the same required/default/`Optional`/nullable presence rule as any other property even when no narrower type rule exists. +- An absent nullable property without a default resolves to `null` and receives `nullable`, never an implicit `present`. This matches Laravel validation, current Spatie/Laravel Data behavior, Hypervel's superseded DataObject, and the established DTO corpus. `Optional` remains the explicit way to preserve the distinction between omission and `null`. A default suppresses only inferred presence rules. Explicit class rules and validation attributes still apply when the key is absent, so `#[Required]` and a `rules()` entry have the same unsurprising effect. +- Primitive inference uses `integer` for `int`, `numeric` for `float`, `boolean` for `bool`, and `string` for `string`; it does not weaken integer declarations to `numeric`. +- A `rules()` entry replaces generated rules for that key. Class-level `#[MergeValidationRules]` opts into upstream merge behavior; requiring/present rules suppress only the corresponding inferred requirement, never other explicit rules. Ancestor class rules choose their output shape from the compiled child graph rather than predicting it from hook presence: concrete translations collapse back to their one shared structural wildcard only when that real rule key already exists before the final marker pass. This fixes merge mode's generated-rule lookup and retains wildcard plan reuse in replace mode. Resolve each class's declarations in one local class-owned rule map while leaving generated and nested accumulator rules untouched: a fanned wildcard merges into an existing class-owned exact value without moving it, while any ordinary exact or structural declaration unsets and reassigns its key so it replaces the earlier class contribution at its declaration position. After all declarations, combine each final class-owned value with the untouched accumulator baseline once. Merge mode filters inferred `required` only when the final class-owned rules control presence, then appends those rules to the baseline; replace mode writes the class rules directly and clears stale inferred-presence ownership for that key. Unset and reinsert each combined key in class-owned order, leaving generated-only keys ahead of class-written keys. Keep the map per class invocation so ancestors see child output only as their baseline, and perform structural collapse before any class writes so it observes only real generated or nested wildcard rules. Messages and attributes remain nested-first, first-write-wins exact declarations; their suffixed keys cannot use the rule-map collapse predicate. +- One root `Validator` validates the entire nested payload. Materialize a `LazyCollection` whenever validation or rule introspection must inspect its item graph; preserve laziness only for creation operations that neither validate nor return validation rules. +- During Fill, the first collection item establishes the shared structure template. Equal later items allocate no structure; a different selected class or mapped-key/PHP-key choice records only that value in a sparse `items[$rawIndex]` overlay and latches every active enclosing collection to concrete. Every value accepted by the metadata-owned finished-value predicate is written through an explicit finished-property or finished-item state operation, which atomically ensures the current structure path and latches every active enclosing collection. Ordinary state writes never infer finishedness from runtime types. Fill completes across the whole payload before rule compilation begins, so a difference or finished value observed in any later item governs the first item's wildcard eligibility. In the construction payload, only mapped property keys use dot-path semantics; raw item keys remain one segment, so a string key containing `.` cannot collide with nested keys. Do not build a full per-item structure, finished-state overlay, or separate signature per item. An empty collection compiles the canonical mapped metadata shape as genuine wildcard rules; one static item uses its observed shape. +- A structurally uniform collection with no finished values and no dynamic rule graph compiles its first item directly at the wildcard path. A class has a dynamic rule graph when it or any recursively validated unambiguous Data/DataCollection descendant declares `rules(ValidationContext)`, or when it is property-morphable and its payload-selected subtype cannot be known from metadata. Skip computed, non-validating, and promoted contextual properties. Cache this pure class-graph result as a worker-bounded class-string boolean in `DataClassRepository`; keep operation hook state out of that cache. Factory `beforeRules`/`afterRules` hooks make the current operation dynamic immediately. For a structurally uniform dynamic collection, compile every item speculatively at the structural wildcard path into isolated accumulators containing rules, inferred-required paths, messages, attributes, preserved paths, additional fields, allowed subtrees, finished structural paths, and structural marker candidates. `ValidationAccumulator::equals()` compares these outputs incrementally and stops at the first difference. It projects only `preservedPaths` through `ValidationPath::get()`; additional fields, allowed subtrees, finished paths, and marker-candidate records are already canonical arrays and compare directly. Rule values compare recursively with strict ordered scalar/array equality, object identity by default, and rendered strings only for objects that Validation itself reduces to strings; callback-bearing `Exists`/`Unique` rules also require the same class/string form and identical callback arrays. If every accumulator matches, commit the first result, including any identical nested exact rules and their fully structural marker provenance. At the first mismatch, discard the speculative results and recompile the collection concretely through the authoritative path. Finished values cannot reach speculation because Fill has already latched every enclosing collection non-uniform. This keeps dynamic rules on the fast wildcard path when their complete output is uniform without assuming user code is stable. +- Carry both the emitted validation path and its fully structural path through compiler traversal; collection items append their raw key only to the emitted path and append `*` to the structural path. Record the structural path of every finished property and item in one flat accumulator set. When a final emitted rule path differs from its structural path, record one candidate under that fully structural key with the emitted rule path as a contributor; do not thread finished flags through compiler recursion or marker metadata. After all nested and ancestor class rules have replaced or merged the final rule map, make one final marker pass. A candidate crosses a finished value when it equals or descends from a recorded finished structural path. If it crosses one and any final non-empty contributor retains `Distinct`, throw `CannotBuildValidationRule`, independent of whether payload expansion can see the finished object. Otherwise suppress the marker. For candidates without a finished ancestor, prove every expansion is covered by a non-empty exact contributor or a non-empty wildcard contributor belonging to that candidate. Empty contributors cannot count: Validator would expand an empty wildcard into a rule-map key and `validated()` would retain a value checked by no rule. Prepend every accepted generated empty marker so Laravel's first-declared wildcard identity governs `Distinct`, labels, and dependent-field substitution; never reorder real user/class rules. Identity always uses the fully structural path, so validation is unchanged when the same class and payload compile through full wildcards, partial wildcards, or exact rules. Nested `Distinct` therefore follows Laravel and compares across every wildcard level, not separately within each immediate collection. A finished collection emits no narrower substitute for its suppressed global identity. All-finished collections emit no concrete rules, while empty collections already own genuine wildcard rules and need neither markers nor rejection. Do not infer finished values from preserved paths, which also contain `WithoutValidation` declarations. +- Use concrete per-index rules when wildcard accumulators differ. This is an intentional Hypervel divergence from the v5 draft's always-concrete rules so Hypervel can retain its compiled wildcard walk and repeated string-rule plan reuse. Do not emit `Rule::forEach`/`CompilableRules`; they bypass the direct wildcard path. Hypervel Validation safely batches exact `Exists`/`Unique` rules as well as wildcard rules. `AttributePlan` records how many parsed presence checks can consume a precomputed lookup: callback-bearing rules and non-scalar query shapes are excluded, while an unsafe-to-submit check still counts when ordered execution may later make it consume another check's fact. During every `passes()`, `Validator::compileRules()` resets and sums that immutable count across every concrete plan, including cache hits and repeated wildcard expansions; never memoize the total across executions because `setData()` may re-expand the graph. Enter batching only when the execution contains at least two possible consumers, then precompute every candidate that passes the existing query-shape, mutation, exclusion, nullable, value, upload, callback, field-reference, stop-on-first, validator-class, and verifier gates. Do not apply a submitted-value or per-group minimum: a single safe value may intentionally supply a fact to a later mutation-aware consumer. Do not add a second consumer-identity scheme or eagerly resolve unsafe query metadata. Exact `Distinct` grouping comes from the empty structural marker rather than a second comparison engine. +- Preserve Validation's zero-regex fast path for a bare `*`, and repair its optimized walker so partial-segment wildcards such as `items.a*.value` retain Laravel behavior. A partial-pattern branch matches only children of the current array; literal recursion remains unchanged because it must emit missing nested leaves for rules such as `required`. Complete Validator's existing dot/asterisk placeholder symmetry so `\*` addresses a literal asterisk in rule keys and dependent-field references, wildcard-expanded literal-asterisk keys never leak the worker placeholder hash, and `getRulesWithoutPlaceholders()` returns Validator notation consistently. Data's `ValidationPath` canonical parser must also round-trip PHP integer array keys so reparsing `get()` retains item-path identity: convert only canonical in-range integer strings, leaving leading-zero, plus-prefixed, negative-zero, decimal, and out-of-range strings literal. The round-trip guarantee excludes raw segments ending in a backslash; keep and test the documented Validator-notation fail-closed boundary instead of adding a second escape grammar. Do not add a regex cache for the uncommon partial form or change `explodeRules()`'s result shape. +- User `rules()`, `messages()`, and `attributes()` use PHP property-name space and are translated to the wire keys chosen during Fill, including nested collection paths. `ValidationContext` exposes the current payload, complete payload, and path without storing request state in metadata. +- Construct from the Validator's validated/exclusion-filtered payload, not the original request array. Laravel's wildcard expansion can place concrete leaves after exact rules, so mixed wildcard/exact graphs can make `Validator::validated()` rebuild a source list in rule order and turn it into a non-list JSON object. After restoring deliberate unvalidated values, recursively restore surviving keys to the pre-validation payload's insertion order without reindexing gaps; retain filtered values exactly and append any validator-produced keys absent from the source in their existing order. Do this once at the Data validation payload boundary before `afterValidation` hooks, not in Validation, the compiler, collection construction, or `CompiledValidation`. Honor the application-wide `includeUnvalidatedArrayKeys()`/`excludeUnvalidatedArrayKeys()` setting rather than overriding Laravel's factory behavior. +- Preserve filled values only for properties explicitly marked `WithoutValidation` and observed finished Data values. A finished value owns its complete mapped path: inferred rules, validation attributes, and explicit class rules do not run for that path or descendants. Record declared `WithoutValidation` paths even when the wildcard template item omits the property, plus exact observed finished-value paths. After `validated()`, copy only existing values at those paths from the complete pre-validation payload. Traverse `ValidationPath::rawSegments()` directly: `null` expands over actual collection keys, while string/int segments remain literal, including a collection key named `*`; use `array_key_exists()` throughout so present null is restored. Do not retain first-item values, materialize concrete paths, or merge arbitrary unvalidated input back into construction. +- Resolve constructor defaults, `Optional`, and nullable omission only after validation. +- Preserve Laravel error bags, messages, translated property names, redirects, stop-on-first-failure, and Precognition filtering/authorization behavior where their owning Hypervel APIs support them. Data uses `#[FailOnUnknownFields]` per class only; it does not inherit FormRequest's process-global `failOnUnknownFields()` toggle or add another global/config setting. +- Compile nested rules/messages/attribute labels across the whole tree, but invoke request authorization and `withValidator()` only for the root object, matching the documented upstream behavior. +- Resolve parameters declared by validation lifecycle methods through the container once per root validation. This cost exists only when a class declares such a method. +- When `#[FailOnUnknownFields]` enables rejection, call `UnknownFields::validate(Validator $validator, array $input, ?array $unfilteredRules = null, array $additionalFields = [], array $allowedSubtrees = [])`. The Validation-owned helper derives exact known paths from the Validator's already-expanded effective rules and adds confirmation fields. A path with an `array` rule and no descendant rule is an opaque allowed subtree. Data also passes mapped unstructured `mixed` and non-enum/non-date object paths, structured/mixed `WithoutValidation` paths, structured/mixed contextual promoted paths, and observed finished Data paths through `$allowedSubtrees`; scalar `WithoutValidation` and scalar contextual promoted paths use `$additionalFields`. Ordinary nested Data and typed Data collections remain structured and never become opaque merely because they are nested. Echoed contextual input is therefore known but discarded, and the server-resolved value still wins. An allowed subtree matches the path itself and dot-separated descendants, so a declared `array $meta` accepts `meta.foo` without weakening a structured `items.*.id` schema. +- An ambiguous Data-object or Data-container property becomes an opaque allowed subtree only when a property attribute cast, configured cast, or applicable operation cast owns that wire shape. Without a cast, strict validation remains fail-closed; validation-only mode deliberately treats a cast-owned ambiguous shape as opaque even though it does not invoke the cast. A single selected nested Data schema still validates before its cast. Use `WithoutValidation` when a single-arm cast needs a different wire shape. +- `UnknownFields` walks input leaves once instead of flattening them with `Arr::dot()`. It carries escaped Validator notation for exact/subtree comparison and ordinary unescaped dotted notation for messages and error keys, preserving existing error ergonomics. Escape literal dots and asterisks in each raw key, and derive ancestors using only unescaped dots so a literal-dot key cannot collide with a nested path. Match Validator's existing fail-closed interpretation when a raw segment ends in a backslash before a child: Validator notation does not escape backslashes, and inventing a second grammar only inside unknown-field checking would remain inconsistent with effective rule keys. +- Unknown-field checking is per selected Data class. Only a node marked `#[FailOnUnknownFields]` records its normalized source input before its own `prepareData`; a strict parent therefore still sees a caller key that its hook removes before a child is filled, while hook-added keys are not treated as caller input. Direct Request sources contribute only JSON/body input and intentionally ignore query parameters, matching FormRequest's tested boundary; every non-Request source contributes its complete normalized input even when it originated from a parent Request's nested value. Preserve the original normalized sources while resolving properties from the prepared payload so snapshots require no deep copy. Merge strict-node snapshots at their observed root paths into one root-shaped input tree and run one root Validator after-callback. +- Rule wildcards remain expanded by Validator. Data-owned auxiliary field and subtree paths may contain whole structural `*` segments, which `UnknownFields` matches segment by segment against the input walk; a field pattern must have equal length, while a subtree pattern accepts its own path and descendants. Escaped `\*` is literal. An unescaped `*` inside any non-whole segment is unsupported and fails closed without matching either a wildcard or a literal input key. Carry raw input segments from the recursive walk so auxiliary matching does not reparse ambiguous escaped notation or materialize per-item paths. +- Foundation is a direct dependency because Data's FormRequest casts implement its contracts and Precognition uses its concrete hook. Use the request's existing Precognition rule filtering and Foundation's after-validation hook directly; do not duplicate or conditionally probe for that behavior inside Data. +- Any contextual attribute on a constructor parameter is recorded with `ReflectionParameter::getAttributes(ContextualAttribute::class, ReflectionAttribute::IS_INSTANCEOF)` and resolved through `Container::resolveFromAttribute()`. Do not depend on Container's internal reflection utility. Constructor recipe resolution must treat the resolved value as authoritative, including `null`, matching `Container::call()` and route-dependency resolution. It takes precedence over primitive/class contextual bindings and declared defaults. This prevents a missing route value from falling through to an empty model and an unauthenticated nullable contract from falling through to an impossible container build. Document this intentional Laravel difference for porters; do not add a null-fallback compatibility mode. +- Extend Container's `RouteParameter` and `Authenticated`/`CurrentUser` attributes with an optional second `property` path so `#[RouteParameter('post', 'id')] int $postId` and `#[CurrentUser(property: 'id')] int $userId` extract a value from the whole route-model/user object without Data-owned injection aliases. These two attributes need the argument because they return whole domain objects; `Config` and `Context` already own dotted lookup, while `RequestAttribute` returns the explicitly selected request-bag value. Omitted, each attribute retains its current whole-object behavior. A shared `Attributes\Concerns\ExtractsPropertyValue` concern returns the whole value when the path is `null`, throws an actionable `BindingResolutionException` when a path is requested from a scalar, and otherwise delegates to `data_get()` for arrays, `ArrayAccess`, public/magic objects, and nested paths. A missing path or null source returns `null`. Object accessors and Eloquent relations may therefore execute while traversing a path; document this rather than adding incomplete lazy-load guards. Widen `Authenticated::resolve()` from `?Authenticatable` to `mixed`: a requested property may have any declared PHP type, while the whole-user path remains unchanged. +- Port Laravel's exact `RequestAttribute(string $parameter)` API and implement Hypervel's `ExecutionScopedAttribute`; do not add a property-path argument to a value the caller already selected from the request attributes bag. +- Port Laravel's exact `BindWhen` API. Its source parses on Hypervel's PHP 8.4 floor, while closure-bearing attribute declarations require PHP 8.5; load the fixture conditionally and mark its tests with `#[RequiresPhp('>= 8.5.0')]` so they run on Hypervel's existing PHP 8.5 CI leg. Resolve `Bind` and `BindWhen` in declaration order, retain the first wildcard `Bind`, and clear checked misses when a condition may later match. A successful condition becomes an ordinary worker-lifetime container registration, so conditions must depend only on boot-stable state; a failed condition remains eligible for reevaluation. Document this architecture constraint without adding a runtime guard, compatibility encoding, or lifecycle cache. +- Contextual injection applies only to constructor parameters. A promoted parameter is a data property; its contextual value always wins, and the property is neither read from payload nor validated. A non-promoted contextual parameter is a constructor-only dependency and must not share a name with a public data property: reject that collision at metadata build and direct the developer to promote the attributed parameter when it is the data property or rename it when it is a separate dependency. Non-promoted public data properties remain payload-assigned and cannot carry contextual injection. Resolve each contextual parameter only at its node's instantiation, with no cross-node memoization, because custom handlers receive the `ReflectionParameter` and may intentionally return a fresh value. +- Do not port Spatie's `From*` aliases, property injection contracts, or `replaceWhenPresentInPayload = false` mode. To let payload win, omit the contextual attribute and reshape/augment through a typed named factory or factory hook. Record the constructor-only target, contextual-wins rule, validation exclusion, and replacement alternative in `Differences From Laravel`. +- `LoadRelation` remains a separate explicit Data attribute because it controls model normalization rather than dependency injection. Ordinary creation performs no container lookup or relation query. +- Model normalization reads only requested attributes and already-loaded relations. It never calls `Model::toArray()`. `LoadRelation` authorizes loading; a collected Eloquent collection batches `loadMissing()` before item construction to prevent N+1 queries. +- Port the fluent database-constraint support used by `Exists` and `Unique` (`where`, `whereIn`, `whereNot`, `whereNotIn`, `whereNull`, `whereNotNull`, and related references) against Hypervel's native rule objects rather than encoding SQL behavior in Data. + +Port every applicable upstream validation attribute whose Laravel rule is supported. The implementation ledger must account for this complete upstream set: + +`Accepted`, `AcceptedIf`, `ActiveUrl`, `After`, `AfterOrEqual`, `Alpha`, `AlphaDash`, `AlphaNumeric`, `ArrayType`, `Bail`, `Before`, `BeforeOrEqual`, `Between`, `BooleanType`, `Confirmed`, `CurrentPassword`, `Date`, `DateEquals`, `DateFormat`, `Declined`, `DeclinedIf`, `Different`, `Digits`, `DigitsBetween`, `Dimensions`, `Distinct`, `DoesntEndWith`, `DoesntStartWith`, `Email`, `EndsWith`, `Enum`, `Exclude`, `ExcludeIf`, `ExcludeUnless`, `ExcludeWith`, `ExcludeWithout`, `Exists`, `File`, `Filled`, `GreaterThan`, `GreaterThanOrEqualTo`, `IP`, `IPv4`, `IPv6`, `Image`, `In`, `InArray`, `IntegerType`, `Json`, `LessThan`, `LessThanOrEqualTo`, `ListType`, `Lowercase`, `MacAddress`, `Max`, `MaxDigits`, `MimeTypes`, `Mimes`, `Min`, `MinDigits`, `MultipleOf`, `NotIn`, `NotRegex`, `Nullable`, `Numeric`, `Password`, `Present`, `Prohibited`, `ProhibitedIf`, `ProhibitedUnless`, `Prohibits`, `Regex`, `Required`, `RequiredArrayKeys`, `RequiredIf`, `RequiredUnless`, `RequiredWith`, `RequiredWithAll`, `RequiredWithout`, `RequiredWithoutAll`, `Rule`, `Same`, `Size`, `Sometimes`, `StartsWith`, `StringType`, `Timezone`, `Ulid`, `Unique`, `Uppercase`, `Url`, and `Uuid`, plus the base/custom validation attribute contracts. + +Audit each ported attribute's production-facing native contract rather than retaining an upstream test-fixture type. In particular, `CurrentPassword` accepts `string|BackedEnum|ExternalReference|null`; upstream's concrete `DummyBackedEnum` test type makes arbitrary application enums fail in installed packages. `RuleDenormalizer` supports backed values, not pure `UnitEnum` names, so do not widen that contract or add global enum-name normalization. Cover an application-owned backed enum functionally and grep `src/data/src` for test-namespace imports after each attribute group; no subprocess autoload harness or source-lint test is needed. + +Every value supplied directly or through `create(string ...$parameters)` must be accepted by both its constructor parameter and its final property or explicitly converted to that destination type; never rely on upstream weak scalar coercion under Hypervel's strict types. Numeric string-rule attributes retain their numeric direct-construction types alongside `string`, and `Dimensions` accepts the same `int|string`/`float|string` constraint types as Hypervel's native rule rather than adding a duplicate numeric parser. `GreaterThan`, `GreaterThanOrEqualTo`, `LessThan`, and `LessThanOrEqualTo` retain numeric strings lexically in an `int|float|string|FieldReference` property while converting only nonnumeric strings to `FieldReference`; an arbitrary-precision numeric-string regression must prove denormalization does not cast or reformat the value before Hypervel's `BigNumber` comparison. The shared string-attribute test parses every expected rule through `ValidationRuleParser`, proves `create()` does not throw, and asserts exact denormalized identity only when the attribute inherits the base `StringValidationAttribute::create()` unchanged. Transforming overrides such as date factories need construction coverage but not textual identity. Keep a focused object-rule round-trip for `Dimensions`, and audit constructor-to-storage flow as each attribute group lands; do not maintain an exclusion list, add blind casts, or catch and fall back from type failures. + +String-rule null encoding has one structural rule. A top-level `null` in `parameters()` omits an optional argument, such as a null `Distinct` mode; a `null` nested inside a value list emits the Validator's literal `null` token, which the twelve dependent validators decode through `ValidatesAttributes::convertValuesToNull()` when the compared field is null. The eight variadic dependent attributes (`RequiredIf`, `RequiredUnless`, `ProhibitedIf`, `ProhibitedUnless`, `MissingIf`, `MissingUnless`, `PresentIf`, and `PresentUnless`) share `null|array|bool|int|float|string|BackedEnum|ExternalReference` comparison values. Single-value dependent attributes keep `null` out because top-level omission would create a malformed rule; callers use the string `'null'`. Field-list variadics keep `array|string|FieldReference`. PHP `null` and the string `'null'` are intentionally indistinguishable in Laravel's Validator string grammar; Data adds no escape syntax. + +Pass-through string attributes leave `ExternalReference` values for `RuleDenormalizer`; any attribute that validates, filters, casts, or feeds a native rule object resolves the value first through the existing helper. Split pipe-delimited declarations unless the trimmed declaration begins case-insensitively with `regex:`, `not_regex:`, or `notregex:`; merely containing `regex:` later in a combined declaration must not turn the whole string into an invalid rule name. Regex patterns containing a literal pipe remain array-form rules. Correct upstream's five broken interpreted paths: `Distinct`, `Email`, `Enum`, `In`, and `NotIn`. A resolved null `Distinct` mode means bare `distinct`; other values use its strict two-value allowlist. `Enum` resolves before native-rule/class-string selection. `In`/`NotIn` resolve top-level references before the native-rule short circuit, convert declared `Arrayable` values, flatten, then resolve/convert/flatten nested list values before constructing the native rule; keep the direct code in both upstream-shaped classes instead of adding a resolver abstraction. Email references remain one scalar mode each. Email supports all native built-ins including `filter_unicode` plus existing class strings, and any unsupported or non-string mode throws instead of silently weakening requested validation. Make the owning raw Validator fail the same way while preserving bare `email`, explicit `rfc`, and custom class strings; document unsupported-mode failure in the Validation guide. Do not add global eager reference resolution, a registry, a second resolver, or Email array fan-out. + +Because this is first-party Hypervel functionality, also add thin attributes for supported native rules that Spatie does not yet expose: `AnyOf`, `Ascii`, `Base64`, `Can`, `Contains`, `Decimal`, `DoesntContain`, `Encoding`, `Extensions`, `HexColor`, `InArrayKeys`, `Missing`, `MissingIf`, `MissingUnless`, `MissingWith`, `MissingWithAll`, `PresentIf`, `PresentUnless`, `PresentWith`, `PresentWithAll`, `ProhibitedIfAccepted`, `ProhibitedIfDeclined`, `RequiredIfAccepted`, and `RequiredIfDeclined`. These are direct declarative wrappers, not a second validation implementation. + +If an attribute maps to a Laravel rule that Hypervel Validation is unintentionally missing, add that rule to Validation with its Laravel behavior/tests and then port the attribute. Do not silently omit the attribute or add a Data-only approximation. Rules tied to a Laravel subsystem Hypervel intentionally does not provide must be recorded as deliberate omissions in the ledger. + +### Transformation + +- `Data::toArray()`, `all()`, `transform()`, `toJson()`, and `jsonSerialize()` always reflect current property values; there is no result cache or refresh protocol. +- The ordinary transform loop reads precompiled property metadata and writes mapped output directly. +- Allocate a full `TransformationContext` only for lazy values, partials, a custom transformer, wrapping/additional resource data, or a configured maximum depth. A simple object does not build include/exclude trees. `PartialsDefinition::isEmpty()` is the required nested-node guard that preserves this invariant: an object with no instance definitions reuses its narrowed child context without resolving definitions or compiling trees. +- Port `Hidden`, `Computed`, `Lazy`, `AutoLazy`, `AutoClosureLazy`, `AutoWhenLoadedLazy`, include/exclude/only/except, conditional inclusion, appended values, and maximum-depth protection. A supplied value for a `#[Computed]` or PHP 8.4 virtual property throws an actionable declaration/input exception; there is no compatibility switch that silently ignores it. +- Compile each partial mode into one immutable tree. Every node retains an exact-endpoint bit, a propagating fully-selected-subtree bit for terminal `*`, and named children, so exact selection cannot be confused with a traversal prefix. `include` retains and traverses either form; `except`/`exclude` remove only exact endpoints or fully selected subtrees; `only` treats an empty child map as unrestricted and otherwise retains named children, matching upstream `only('*')`, `only('*', 'nested.a')`, and `only('nested.*')`. A pure fully-selected node reuses itself while descending. `PartialTree::merge()` composes two selections by unioning endpoints, subtree selection, and children; it is not lifetime provenance and does not require reverse path generation. Propagating `include('*')` deliberately fixes upstream's order-dependent loss of explicit nested includes. Invalid partial paths throw; there is no ignore-invalid-partials compatibility flag. +- Before internally transforming a reached nested Data node, resolve its non-empty instance partial store with temporary consumption and merge those node-relative selections into the narrowed parent context through `TransformationContext::withMergedPartials()`. Do this at the nested-property and typed-iterable-item call sites, outside `transformData()`, so the root store is not resolved twice. Item contexts remain local to `transformIterableItem()`; never hoist an item merge into the shared iterable context, because each item may own different partials. A repeated object consumes a temporary at its first reached occurrence while permanent definitions apply every time. During the collection slice, delete the two currently unreachable non-`BaseData` public-transform branches and route collection containers and items through one internal loop that retains the root transformer extension cache and applies the same per-node rule. +- `all()` preserves raw nested Data and collection identity. When child partials apply, retain the four resolved definition lists beside the compiled trees and add their decoupled remainders to the returned nested object's existing partial store. Temporary and permanent definitions keep their own lifetimes; conditions are evaluated once against the object that declared them and propagate as unconditional resolved definitions. Terminal selections do not propagate, while `nested.*` and a bare `*` propagate the fully selected subtree. These lists are root-relative and exist only for shallow outward propagation: `TransformationContext::child()` clears them, and propagation reads the parent before returning without recursion. Consult definitions only when partials exist; ordinary `all()` and every `toArray()` avoid this work. Do not add lifetime flags or a reverse operation to `PartialTree`. +- Maximum depth defaults to `null`, matching upstream and avoiding an invented limit. When configured, reaching it throws `MaxTransformationDepthReached`; no silent empty-array mode is added. +- Cyclic object graphs are unsupported. Do not impose identity-set work on every ordinary nested SDK transform: the realistic recursive `Lazy`/relationship case creates fresh Data instances and would not be stopped by identity anyway. Applications with recursive includes configure `max_transformation_depth`; the default remains `null` for upstream familiarity and unbounded legitimate trees. +- `Data` is not `ArrayAccess`. Data collections retain collection-style keyed access and enumeration. +- Use `Hypervel\Support\Json` for general JSON normalization and encoding so Data inherits Hypervel's nesting and exception contract. Eloquent casts use the distinct `Hypervel\Database\Eloquent\Casts\Json` codec so application custom encoders/decoders remain authoritative. +- Typed iterable properties recursively cast and transform their declared item type, including custom `IterableItemCast`, Data, enum, and date items. This behavior is enabled from the start; do not port Spatie's compatibility feature flag. Untyped arrays are not recursively guessed into arbitrary object types. +- PHP serialization includes declared data properties and stable per-instance transformation state while excluding request/validator/operation objects. Use `laravel/serializable-closure`, already standard in Hypervel, for package-owned lazy and conditional-partial closures so queued data retains upstream behavior; unsupported captured values fail normally rather than being silently discarded. + +### Collections + +- Port the non-deprecated `DataCollection` API, paginator/cursor-paginator wrappers, `collect()`, collection annotations, and `DataCollectionOf`. +- Keep `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` as distinct public types. They encode different item/container return contracts and paginator operations for PHPStan and callers; one union-backed class would replace those guarantees with runtime `instanceof` branches and methods that are invalid for some instances. Share concerns, item construction, transformation, and private response adapters so three familiar public names do not become three implementations. Spatie performance issue #434 concerns recursive item conversion, not the number of wrapper classes; the fixed engine and collection benchmarks address that actual cost. +- One eager root `collect()` operation owns one `ConstructionState`, one Fill pass over all items, one Validator, and one extension/normalizer memo. Root `prepareData` remains per item; root `beforeValidation` and `afterValidation` receive and return the complete keyed collection payload once. `ValidationStrategy::OnlyRequests` checks the collection source object, not nested items: an array/Collection root does not begin request validation merely because an item is a Request, while `alwaysValidate()` is the explicit untrusted-collection path. Preserve `LazyCollection` laziness when validation and rule introspection are both disabled; either rule-producing mode materializes it once. +- `DataCollectableFactory` is the single owner of safe item extraction, root source-shaped rebuilding, explicit/inferred `$into` targets, paginator cloning, and declared-property reconstruction for Data and non-Data typed iterables. `DataCreator` retains Fill, reconciliation, casting, and instantiation; delete its duplicate eager iterable rebuilder. Cache each Data class's resolved custom normalizer list in the existing operation memo under a class-keyed entry; do not add another cache object or threaded parameter. +- Root `collect()` preserves input keys and the original ordinary collection/paginator shape where it can be rebuilt safely. It explicitly downgrades an Eloquent source to base `Hypervel\Support\Collection` for empty and non-empty Data results; an explicit Eloquent Data target does the same. Property casting is instead declared-shaped: arrays and `iterable` become keyed arrays, declared ordinary collection classes are rebuilt as declared, and unsupported custom `Traversable` containers fail with `CannotCreateDataCollectable`. A declared Eloquent property is valid only when its complete item type guarantees `Model`; otherwise metadata rejects the invalid Eloquent generic. Batch `loadMissing()` for metadata-declared `LoadRelation` paths before collected Eloquent models are normalized. +- One metadata-owned finished-value predicate is shared by Fill, hook reconciliation, validation compilation, and casting. It accepts an assignable `BaseData`; an assignable package `DataCollection`, `PaginatedDataCollection`, or `CursorPaginatedDataCollection` whose declared item class is covariantly compatible; or an assignable eager native object container whose safely extracted items are already instances of one accepting declared Data-container arm's item class. Extract eager items once and try every accepting arm because PHPDoc may assign different item classes to different union containers. Arrays are not finished object containers, and `LazyCollection` is never scanned. Casting returns a finished value before property casts or iterable rebuilding. Fill and reconciliation route every accepted value through explicit `ConstructionState` finished-property/item writes; those writes always latch active enclosing collections, while ordinary writes never inspect value types. This keeps one source of truth and removes finished-value checks from the ordinary write path. +- Put the pure singular metadata queries `getDataObjectType()` and `getDataCollectableType()` on `DataPropertyType`, matching its existing plural vocabulary. Keep the complete finished-value decision on `DataProperty`; arbitrary implementations of `BaseDataCollectable` are not treated as package containers merely because they implement the contract. +- Paginator-shaped properties retain only the reconstruction data they require. `ConstructionState` stores an optional live `AbstractPaginator`/`AbstractCursorPaginator` source on the existing traversed structure node for the root operation, cloning only during rebuild. Outside a collection item it uses the template node; inside an item it uses that item's sparse override and never falls back to the template. The slot is compiler-inert and never changes validation uniformity. The compiler does not read paginator identity, so otherwise identical outer items remain eligible for one wildcard graph. +- Initial Fill and hook reconciliation record or replace that slot for both Data and non-Data typed paginator properties, only from a Hypervel abstract paginator or a package wrapper containing one. An array, eager Enumerable/DataCollection, or LazyCollection may reshape page items only when the exact node already has a retained source; materialize the LazyCollection because the declared paginator target cannot remain lazy. Absence, `null`, and `Optional` retain ordinary property semantics. A genuinely present item-only container with no exact source, a contract-only paginator needing conversion, or another unsupported value fails during Fill/reconciliation through `CannotCreateDataCollectable`, when reconstruction is first known to be impossible. A raw array therefore cannot create a paginated wrapper, while a Hypervel paginator can. Contract-only paginators may feed non-paginator targets through their declared `items()` method and may pass unchanged only when the native declaration accepts them and all items are already the declared Data type. Rebuild-time absence uses a dedicated missing-retained-source error rather than reporting the supplied type as `null`. +- Extract paginator values through the declared `items()` method, never through iteration. Rebuild a Hypervel paginator by cloning it and replacing only its collection, so the caller is not mutated and total, per-page, cursor, path, query, fragment, and other response metadata remain authoritative. Collection hooks may change the current page's item count but do not recalculate paginator metadata; the hook author owns any related metadata change. +- Direct `DataCollection` construction normalizes all eager items in one package-internal root item operation, retains per-item named `from*` methods, shares its operation memo, and defers a LazyCollection. `offsetSet()` uses the same internal item normalization instead of the user-overridable static `from()` entry; a covariantly accepted subclass collection continues to coerce later assignments to its own readonly item class. +- `getIterator()` and `offsetGet()` are side-effect-free reads. They never resolve or consume collection partials or mutate returned items. Explicit collection transformation owns collection partial consumption, while `all()` retains the documented shallow propagation when it deliberately returns raw nested values. This avoids partial definitions being consumed for only an iterated/accessed prefix and preserves Laravel-style collection reads. +- Root `collect*` selection happens once against the normalized source-shaped container, while the requested `$into` is checked independently against the method return type. Invoke the selected match through its direct/container path; `DataMethodMatch` has no payload-replacement API. An exact Eloquent collection parameter simply does not match the normalized base `Hypervel\Support\Collection`; ordinary collection fallback then returns the requested result. Item construction does not redispatch the public collection entry point, but it retains ordinary per-item `from*` selection. +- Do not port the deprecated static `Data::collection()` alias or its compatibility concern. +- Do not port the deprecated `EnumerableMethods` forwarding surface; use `toCollection()` for map/filter/reduce operations while `DataCollection` itself retains typed items, count, iteration, keyed access, transformation, and response behavior. +- Collection transformation reuses one operation context and one item metadata instance. +- `Lazy` and all three Data collection classes expose separate Macroable registries as worker-lifetime boot configuration, matching Hypervel's other `Macroable` surfaces; document registration during provider boot and never use macros for request-specific state. + +### Controller injection + +The package-owned `Hypervel\Data\Contracts\BaseData` extends `Hypervel\Contracts\Container\SelfBuilding`. The shared base concern implements: + +```php +public static function newInstance(Request $request): static +{ + return static::from($request); +} +``` + +Hypervel's existing `SelfBuilding` path creates a fresh instance and resolves the current request without Spatie's provider-level `beforeResolving` rebinding. No framework-wide Data marker is added. Package-owned FormRequest casts use Foundation's existing generic `Castable`/`CastInputs` extension and validate targets against `Hypervel\Data\Contracts\BaseData`. + +```php +protected function casts(): array +{ + return [ + 'address' => AsData::of(AddressData::class), + 'members' => AsDataCollection::of(MemberData::class), + ]; +} +``` + +`AsDataCollection::of()` defaults to the package `DataCollection` and accepts the same explicit `$into` targets as `collect()`, including `'array'` and `Hypervel\Support\Collection::class`. This preserves the useful array/Collection FormRequest cases without retaining the misleading old `AsDataObjectArray` class, which returned `ArrayObject`. + +The wrapper is necessary rather than gratuitous syntax: Foundation's `Castable::castUsing(array): CastInputs|string` and Eloquent's `Castable::castUsing(array): CastsAttributes|CastsInboundAttributes|string` have incompatible return contracts, so `AddressData::class` cannot implement both meanings. Data keeps Laravel's natural bare-class syntax for Eloquent and uses explicit `AsData::of()`/`AsDataCollection::of()` only in FormRequest cast declarations. + +### Eloquent + +The canonical API is Laravel's castable-class syntax: + +```php +protected function casts(): array +{ + return [ + 'profile' => ProfileData::class, + 'members' => DataCollection::class . ':' . MemberData::class, + ]; +} +``` + +- `Data` and `Resource` implement Eloquent `Castable` through their shared transformable contract and return a package-owned `DataEloquentCast`; `Dto` is not persistable because it has no transformation contract. +- `DataCollection` returns a package-owned `DataCollectionEloquentCast` and remains the value returned by collection casts. Keep upstream `encrypted` and `default` cast arguments. +- Use Hypervel's Eloquent JSON codec so custom encoders/decoders apply. Persist the complete representation through a fresh transformation context rather than mutating instance partials with `include('*')`. +- Abstract classes that self-discriminate through `PropertyMorphableData::morph()` persist their ordinary full representation. Other abstract-class values use `{type, data}` envelopes whose `type` is a required alias from the familiar boot-only `DataConfig::enforceMorphMap()` registry. Reads reject unknown aliases and require the result to be a concrete, transformable `BaseData` subtype of the declared abstract class before construction; payload-provided FQCN fallbacks are not accepted. Encrypt abstract collections as well as concrete ones. +- Dirty comparison decodes both stored values and compares their arrays; encrypted casts return unequal while previous encryption keys are configured, matching the framework's rotation behavior. +- Remove `Hypervel\Database\Eloquent\Casts\AsDataObject`; Database must not depend on an optional data package or a Support implementation. + +### HTTP resources and wrapping + +Do not duplicate JsonResource wrapping, response customization, paginator metadata, or response encoding. + +Add one independently useful HTTP extension point: + +```php +namespace Hypervel\Http\Resources\Json; + +interface ProvidesResourceWrapper +{ + public function resourceWrapper(): ?string; +} +``` + +`ResourceResponse::wrapper()` uses the `instanceof ProvidesResourceWrapper` check as the switch: when implemented, the returned value is authoritative, including `null` meaning deliberately unwrapped; otherwise it retains `JsonResource::$wrap`. Data's single and collection adapters redeclare `public static ?string $wrap = null` so the existing force-wrapping test cannot mistake inherited `'data'` for an active default. Package adapters keep the actual selection in per-instance state and never mutate the static during a request. + +`DataResource` delegates transformation, includes/excludes, JSON options, and `withResponse()` to the data object/context. Its JsonResource `with(Request): array` adapter calls the data object's familiar Spatie-style `with(): array` plus `additional()` state; do not change the data method's signature. Override `resolve()` in the Data-owned single and collection adapters to return the already-transformed array directly: Data transformation has removed `Optional` and never emits HTTP `MergeValue`/`MissingValue`, so calling `ConditionallyLoadsAttributes::filter()` would add a redundant recursive pass and reallocation. Keep the rest of Hypervel's `ResourceCollection`, `ResourceResponse`, `PaginatedResourceResponse`, and cursor pagination machinery. The adapter's underlying resource remains the Data object, so `ResourceResponse::calculateStatus()` naturally returns 200; no request-method guess changes it to 201. + +`Data` and `Resource` retain Spatie's `with(): array`, `additional()`, `wrap()`, `withoutWrapping()`, and request-query partial allowlists (`allowedRequestIncludes`, `allowedRequestExcludes`, `allowedRequestOnly`, `allowedRequestExcept`), and add Laravel resource hooks `withResponse(Request, JsonResponse): void` and `jsonOptions(): int`. Query-requested partials are intersected with allowlists evaluated for the current response; their results are never cached in metadata. + +Add regression tests proving unchanged behavior for ordinary JsonResource subclasses and forced wrapping, plus forced-interleaving tests showing two data responses can use different wrappers/additional data without leakage. A spy adapter subclass overrides `filter()` and proves Data adapter `resolve()` never invokes the generic sentinel filter; do not use a meaningless PHP array-identity assertion. + +### Inertia + +Add `hypervel/inertia` under Composer `suggest`, not `require`. + +- Port `Lazy::inertia()`, `Lazy::inertiaDeferred()`, `AutoInertiaLazy`, and `AutoInertiaDeferred` against Hypervel's `OptionalProp` and `DeferProp` APIs. +- Preserve deferred group and rescue options supported by Hypervel. +- Keep Inertia references behind the explicit Inertia factories/adapters so loading Data and running ordinary creation/transformation never resolves an Inertia class. Because Inertia is always installed in the monorepo, verify this ownership by source/dependency audit rather than a doctored-autoloader subprocess test. +- Data remains `Arrayable`; Inertia-specific Lazy variants resolve to existing prop wrapper objects during transformation, which `PropsResolver` handles recursively. Do not implement `ProvidesInertiaProperties`, because `ResponseFactory::render()` intentionally checks `Arrayable` first. +- Register no integration when Inertia is absent and impose no class-resolution work on ordinary transforms. +- Test initial, partial, deferred, grouped, and concurrent request behavior with the Hypervel Inertia component. + +Livewire integration is not included because Hypervel has no Livewire component or analogous first-party contract. This is a platform mismatch, not a reduced DTO design. + +### Saloon + +No Saloon runtime change is needed. Hypervel Saloon accepts any DTO returned by the request/connector and separately attaches its response when the object implements `Hypervel\Saloon\Contracts\DataObjects\WithResponse`. + +Document and test: + +```php +final class GitHubUserData extends Data implements WithResponse +{ + use \Hypervel\Saloon\Traits\Responses\HasResponse; +} + +public function createDtoFromResponse(Response $response): GitHubUserData +{ + return GitHubUserData::from($response->json()); +} +``` + +Use Saloon's existing `Hypervel\Saloon\Traits\Responses\HasResponse`; no new trait or Saloon runtime change is needed. Do not couple `hypervel/data` to Saloon. + +### VarDumper + +Register one stateless Symfony caster for `TransformableData` in `DataServiceProvider::boot()`. Symfony applies an interface caster to every implementing `Data`, `Resource`, and data-collection class. Use `??=` to make provider boot idempotent and preserve an application caster registered earlier; an application may still replace the entry after provider boot. + +```php +class DataVarDumperCaster +{ + public static function cast( + TransformableData $data, + array $properties, + Stub $stub, + bool $isNested, + ): array { + return $data instanceof BaseDataCollectable + ? ['items' => $data->all()] + : $data->all(); + } +} + +AbstractCloner::$defaultCasters[TransformableData::class] + ??= [DataVarDumperCaster::class, 'cast']; +``` + +This deliberately has no manager, config switch, container lookup, event, or cleanup subscriber. `all()` supplies the current logical view: output mapping is applied, `Optional` and excluded `Lazy` values are omitted, and construction/transformation internals remain hidden. `Dto` is not transformable and needs no caster; Symfony's ordinary public-property dump is the useful representation for it. + +### TypeScript ownership + +Do not port `spatie/typescript-transformer`, Laravel's adapter, filesystem watcher, or TypeScript attributes into `hypervel/data`. Those operate across enums, ordinary PHP classes, and multiple packages and therefore belong to a general TypeScript package. + +An external transformer can recognize `Hypervel\Data\Contracts\BaseData` and inspect the same PHP attributes/docblocks; it does not need a framework contract or Data-specific hook. Keep documented metadata useful to casts and tooling, but do not add TypeScript-only runtime contracts. + +## Runtime Architecture + +### Fixed creation sequence + +Each root construction owns the v5-shaped pair of operation objects. `ConstructionState` contains the mutable payload tree, structure tree (selected class and chosen wire keys), and current path. A collection keeps one first-item template plus sparse raw-key per-index overrides for class, mapping, and nested differences; reads use the deepest current-item override and then the template. Recording the first difference or finished value latches every enclosing collection non-uniform, so the same compact facts drive exact bottom-up construction and later wildcard eligibility. Property segments address template `children`; item segments remain distinct and never eagerly create per-index nodes. Its readonly `CreationContext` contains the validation mode, mapping/magic toggles, cast recipes, and factory hooks. Traversal position never leaks into reusable options, and neither object survives the root operation. The public static entry point resolves the worker-shared creator once; the same engine and state are then passed down the complete tree without more service-locator dispatch. The general path is fixed: + +1. When the root source list contains a Request and request validation applies, resolve authorization; `false` throws `AuthorizationException`, while an Auth `Response` delegates to `Response::authorize()` so its message/code/status are preserved. +2. In create mode, select one user `from*` method and retain its `DataMethodMatch`, or continue with normalization. Invoke payload-only matches directly; use a first-class `Container::call()` only when the match requires dependency or attribute resolution. Never rematch or rebuild its argument map. Validation-only and rule-introspection modes force the existing named-method toggle off so their array/rule contracts remain coherent. +3. Normalize the source to a keyed payload without eagerly serializing unrelated model state. +4. Resolve a morph class when declared by the target's property-morph contract. +5. Fill the complete mapped property/structure tree depth first before compiling any rules, running per-node `prepareData`, marking contextual constructor slots as input-independent, and recording chosen wire paths. Existing target instances, including subclasses selected through morphs, are finished subtrees: retain their identity and do not run nested Fill, inferred or explicit validation, casts, or creation hooks. Collection items and accepted finished object containers use the same rule before recursion. The metadata predicate selects an explicit atomic finished write, which latches every active enclosing collection concrete so no wildcard preserved path can restore a raw sibling that was never validated. The validation compiler records each finished mapped path for exact post-validation restoration and unknown-field allowance. Do not resolve contextual values yet. +6. When pre-validation hooks exist, retain the assembled payload by copy-on-write, run the hook chain, and reconcile its final result before rule compilation. For each changed node, resolve every property's selected wire key again; clear only changed class, mapping, and child selections; apply fixed source normalization or a named factory only to genuinely new or changed values; and recursively rebuild only changed Data-bearing selections. A selected morph change rebuilds the complete new class graph. Unchanged sibling structure and user-code results remain intact, while custom normalizers and `prepareData` never rerun. Collection divergence remains conservatively latched to concrete compilation rather than paying for a second uniformity proof on this hook-only path. Compile/reuse the resulting rule graph and validate once when enabled. On success, restore only compiled `WithoutValidation`/finished paths from the copy-on-write pre-validation payload, restore surviving keys recursively to that payload's insertion order without reindexing, and replace state with the filtered result. When post-validation hooks exist, apply the same metadata-guided reconciliation to their final payload with validation disabled before absence resolution and casting. Removed values reselect their canonical absent wire key, so presence errors never retain a stale observed fallback. +7. On a successful precognitive request with `Precognition-Validate-Only`, Foundation's registered after-validation hook aborts with 204 and unwinds the operation before absence resolution, casts, contextual resolution, or construction. Do not add a separate `isPrecognitive()` return branch: an ordinary full-form precognitive request must construct the promised `static` instance, after which the precognition dispatcher owns its 204 response. Validation-only APIs that do not promise an object disable creation through their context instead. +8. Resolve true absence in one order: declared constructor default, then `Optional`, then `null` for a nullable type; otherwise retain absence for a clear missing-value error. +9. Cast scalar leaves recursively using the selected class metadata; nested Data objects are not constructed during this pass. +10. Instantiate objects bottom up through one shared primitive. For each node, run `beforeCreation` over final casted payload values, then resolve contextual parameters into their slots immediately before the constructor so contextual injection always wins; immediately after construction, run `afterCreation`. Ordinary nodes use direct construction, while a node with contextual parameters uses Container `buildWith()` so the target class remains on the contextual build stack. Public `build()`/`buildWith()` are raw-construction APIs and bypass the Data class's `SelfBuilding` factory; only Container resolution dispatches that factory. Existing target instances and direct-returning named factories finish before this primitive. If ordinary construction reaches a private or protected constructor, throw `CannotCreateData` before PHP's access error: report the reflected visibility and that no matching named factory returned an instance, then direct the caller to return the target object from a matching public static `from*` method or make the constructor public. This is a payload-dependent creation failure, not invalid metadata. Keep the guard in the shared primitive so any measured direct-array specialization inherits it; do not catch `Error`, analyze factory return paths, or duplicate constructor visibility as a metadata flag. Omit absent defaulted constructor arguments so PHP supplies their declared defaults, and never resolve contextual values for a graph rejected by validation. +11. Return the root object. + +The engine has private/internal entry points for nested properties and collection items. A new nested node may select one compatible `from*` method, but a method's returned source is never matched again. Internal paths never call public `from()`/`factory()` or container `make()` for the data class. + +Root collection creation uses the same sequence over one keyed payload rather than starting one object operation per item. It batches model relation loading, fills every item into one state, applies collection-level validation hooks once, validates the complete graph once, casts/instantiates items through the shared bottom-up primitives, rebuilds the normalized source-shaped container, and selects any `collect*` method once against that exact container. Direct `DataCollection` construction enters the same item operation without collection-level magical dispatch. Declared paginator properties retain their source only in the optional structure-node slot described above; no paginator, request, or mutable container survives the root operation. + +`prepareData` receives the current node's normalized input-key array before child Fill. Preserve the normalized source list and resolve properties from a separate prepared payload. When that selected class enables `#[FailOnUnknownFields]`, record the pre-hook input at its observed root path by ordinary array copy-on-write for the one later root check; never deep-copy the graph or inspect keys introduced by `prepareData`. Direct Request entries use the tested body/JSON boundary, while other entries retain their complete normalized input. Model sources stay in property-name space and project only metadata-declared attributes and loaded/explicitly loadable relations. This preserves uniform hook behavior and the no-`Model::toArray()` contract. A named `from*` method, rather than model-wide serialization, is the class-owned escape hatch when construction genuinely needs undeclared model state. + +Treat a `FormRequest` as the `Request` it is: normalize `all()` and apply the Data class's validation/authorization lifecycle under the selected validation strategy. Do not add a privileged `FormRequestNormalizer` or silently reuse its validator, because that would make `Data::from($request)` depend on an unrelated request class's rules. A caller that deliberately wants the FormRequest result passes `$request->validated()` (or another explicit array/`Arrayable` projection) to `from()`; that input then follows the factory's selected non-request validation strategy. + +### Measured direct array specialization + +The fixed general engine is implemented and measured first. Add a specialized array branch only when retained same-machine benchmark results show a material CPU or allocation gap that justifies its permanent equivalence-test burden. If justified, eligibility is compiled per Data node: a general-path child does not force an otherwise eligible parent off its direct loop. A node is eligible when its input is an array and metadata says it has no validation, authorization, custom normalizer/cast, named factory, morph, contextual injection, relation loading, or per-operation hook. It then: + +1. Read each precompiled input key. +2. Apply the shared default/`Optional`/nullable/required absence operation. +3. Pass through already-valid values or call the fixed caster. +4. Instantiate with named constructor arguments. + +It does not clone the full payload, construct a pipeline, resolve services per property, build validation paths, or allocate hook/context collections. This is not a second creator/resolver: both branches execute the same precompiled key-selection, absence, cast, and instantiation primitives, while the specialization omits whole stages whose feature bits are false. Keep the motivating measurement and targeted equivalence tests if the branch is added; if it is not measurably worthwhile, retain one lean fixed engine and remove its feature bit and planned branch tests. + +### Metadata + +Use the familiar names `DataClass`, `DataProperty`, `DataMethod`, `DataParameter`, `DataType`, `DataPropertyType`, and `DataAttributesCollection`. Keep them under `Support` and make metadata immutable after hydration; their existence does not make their entire shape a permanent public extension contract. + +`DataClassRepository` is an unbound worker-safe repository, so Hypervel auto-singletons it for the application/worker lifetime. Its only mutation is memoizing immutable metadata under verified `BaseData` class strings, a keyspace naturally bounded by classes declared in the process. It composes `ClassMetadataCache` where sufficient and owns only data-specific metadata such as constructor order, mapper results, type graphs, hook flags, rule templates, and attribute recipes. + +The repository also memoizes the pure `hasDynamicRuleGraph()` result under the same bounded class-string keyspace. Resolve it with cycle-safe depth-first traversal: a visited back edge returns false without caching that intermediate result; direct dynamic results cache true while unwinding; an exhausted top-level traversal caches false for every visited class. This preserves lazy nested metadata hydration and keeps operation hooks out of worker state. + +Metadata rules: + +- no closures, Request, Container, Validator, Model, or resolved service objects; cached `ReflectionClass`/`ReflectionParameter`/`ReflectionAttribute` references are permitted because they are immutable process metadata and are required to preserve Container contextual-attribute semantics without re-reflection; +- package attributes that reduce completely to immutable strings, flags, mapper results, or operation codes are compiled and their instances discarded. Attributes containing object arguments, custom validation rules/references, or extension construction retain only their immutable `ReflectionAttribute` recipe and are materialized per root operation; never retain `ReflectionAttribute::getArguments()` results or an instantiated attribute/rule object in metadata; +- feature bits skip entire subsystems on ordinary classes, including a `plainTransform` bit for objects whose declared values can be copied directly without mapping, partial, lazy, nested, or transformer work; +- built-in cast/transform operation codes and mapper results are stored directly on each property; only application replacements retain extension recipes; +- inferred string rules and declarative rule recipes may be cached, but instantiated rule objects and results from user lifecycle methods, closures, or container calls live only for the current root operation; +- native reflection handles types/defaults/attributes; `phpstan/phpdoc-parser` handles collection generic annotations such as `@var FooData[]`; +- each `DataParameter` compiles whether it is variadic, whether it carries attributes, its contextual recipe, and its public-`Reflector` single named class name. Non-variadic injectability and class-variadic emission derive from that one field plus the variadic flag. Reject a variadic `CreationContext` as an invalid factory declaration at metadata build; one operation has one context, and supporting a context-variadic mode would add ambiguous invocation machinery without a use case. `DataMethodMatch` records the selected argument map/list and container decision once; metadata matching performs no reflection or container lookup; +- Resolve native and PHPDoc types with separate target and declaration scopes. Native `self`/`parent` use the member's declaring class, while a method return `static` uses the target Data class. PHPDoc imports, unqualified names, `self`, and `parent` use the class that declared that annotation; PHPDoc `static` and `$this` use the target Data class. `DataIterableAnnotation` retains the declaring class string so container, item, and key nodes resolve uniformly without parallel scope arguments. +- `DataClassFactory` is the one annotation-source and precedence owner: `DataCollectionOf` first, then a constructor-bound property's same-name constructor `@param`, then the property's inline `@var`, then the nearest class-level `@property` while walking child to ancestors, then native-only typing. Parent annotations are retained when no nearer declaration replaces their complete list. `DataTypeFactory` receives the selected annotations and contains no reader fallback or second precedence path. For a native iterable union arm, `matchingAnnotation()` checks every exact container annotation before considering a base/interface match, so PHPDoc union order cannot let a broader annotation hide the arm's exact item type. +- Resolve fully qualified and same-namespace PHPDoc types without source access. For an imported or group-aliased short name, `PhpDocTypeNameResolver` tokenizes the declaring source file at most once per worker and retains immutable import maps for every namespace in that file. Imports are checked before same-namespace qualification, with no `class_exists()` branch whose result could make immutable metadata depend on worker load order. The resolver is an unbound auto-singleton and owns this naturally bounded cache; routing it through `DataClassRepository` would invert the factory dependency. `DataCollectionOf` avoids source parsing entirely and is preferred for generated SDKs. +- `DataCollectionOf` is the unambiguous attribute alternative to docblocks; +- DNF/intersection/union graphs are compiled once and retain declaration information needed for precise errors. `Type::guaranteesType()` keeps the graph's quantifiers: named classes use `is_a`, every union branch must guarantee the target, and one intersection member is sufficient. Use it at metadata build to reject a selected Eloquent iterable whose item graph does not guarantee `Model`, rather than constructing an invalid Eloquent collection of non-Models; +- nested data metadata stores class strings and resolves them through the repository on demand. It does not embed recursive `DataClass` object graphs, so self-referencing classes are finite; +- `DataProperty::isConstructorParameter` records one data-slot ownership decision. A public property is constructor-bound when the effective constructor has a same-name non-contextual parameter or when the parameter promotes that public property. A matching non-promoted contextual parameter is a declaration conflict, not another binding form. +- constructor-bound properties take default presence and iterable `@param` annotations from their constructor parameter; unbound properties take defaults and inline annotations from the property declaration. Metadata retains only default presence. Construction omits absent defaulted arguments so PHP creates object/enum defaults correctly and no shared default object survives metadata build. +- constructor-bound properties are never assigned after construction, preserving constructor normalization and property-hook side effects. Only supplied, unbound public mutable properties are assigned afterward. `#[Computed]` and PHP 8.4 virtual properties are output-only and cannot be constructor-bound; an unbound non-computed readonly property is invalid because the engine cannot assign it. +- ignore ordinary non-promoted private/protected helper properties and static properties. Reject a non-public promoted property, a non-contextual constructor parameter with no corresponding public data property, or a non-promoted contextual parameter whose name conflicts with a public property; non-promoted contextual parameters with distinct names remain valid constructor-only dependencies, and named factories are the explicit path for other alternate constructor shapes. Do not reject a non-public constructor while building metadata: a direct-returning named factory or an existing target instance can use the class without ordinary construction. +- `DataClassFactory` validates mapping ownership with two local maps while walking the complete inherited public-property metadata once. An input property claims its PHP name and any distinct mapped path; a non-hidden output property claims its mapped name or PHP name. A second different owner throws `InvalidDataDeclaration`, while repeat ownership by the same property is ignored. Move the named-factory variadic-`CreationContext` declaration error onto the same named exception through its own static factory; do not add a registry or runtime collision check. + +No metadata/config cleanup hook replaces the old `DataObject::flushState()`: repository and config instances belong to the application container; metadata is immutable and the morph map is boot-only string configuration. Tests define config before provider boot and receive fresh container instances. Testing conditionally flushes macros on `Lazy`, `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` because each deliberately owns a Laravel-style boot macro registry. + +### Configuration + +The provider builds one typed, boot-stable `DataConfig` using typed config getters. Runtime code reads this object, not `config()` on each property. + +```php +return [ + 'date_format' => DATE_ATOM, + 'date_timezone' => null, + 'validation_strategy' => ValidationStrategy::OnlyRequests->value, + 'name_mapping_strategy' => [ + 'input' => null, + 'output' => null, + ], + 'casts' => [], + 'transformers' => [], + 'normalizers' => [], + 'wrap' => null, + 'max_transformation_depth' => null, +]; +``` + +Hypervel uses shallow config merge semantics. Keep nested defaults complete and retrieve required keys through typed getters. Do not add Spatie's feature compatibility array, configurable rule-inferrer list, built-in normalizer pipeline list, cache-store settings, VarDumper mode, ignore-invalid-partials switch, or silent max-depth switch. VarDumper integration is always registered because its stateless caster runs only for an explicit dump; a three-state mode would add configuration without changing normal runtime cost or safety. + +Configured mappers, cast/transformer overrides, and normalizers are class strings validated against their extension contracts at boot. The Data Objects documentation lists the fixed built-in date, enum, iterable, and Arrayable behavior rather than representing it as configurable defaults. Constructor arguments belong to attributes or per-factory recipes; config does not retain closures or prebuilt mutable service objects. + +The one `validation_strategy` setting configures `Data`, `Dto`, and `Resource` uniformly, matching Spatie's familiar configuration behavior. Its shipped `OnlyRequests` value is the safe controller-injection default; applications may deliberately change it globally, while the shared factory remains the per-call override. + +The provider constructs one typed `DataConfig` from configuration at worker boot. It stores scalar settings and extension recipes, not resolved extension objects. `DataConfig::enforceMorphMap()` is the one documented boot-only mutation retained for upstream-familiar abstract Eloquent aliases; it validates unique aliases and `BaseData` class strings, stores strings only, and must not run during request handling. + +## Current Capability Disposition + +This table is a design audit, not a backward-compatibility matrix. + +| Current capability/mechanism | Decision | Clean replacement | +| --- | --- | --- | +| Typed array construction | Keep | `Data::from($array)` fixed path. | +| Direct construction | Keep | Native constructor. | +| `make()` alias | Remove | `from()` only. | +| Per-call `autoResolve` boolean | Remove | `from()` casts; constructor does not. | +| Global enable/disable auto-casting | Remove | `from()` always applies declared casts; the native constructor does not. Validation strategy is independent. | +| Required/default/null distinctions | Keep | Defaults win, then `Optional`, then nullable omission becomes `null`; other absence fails clearly. | +| Nested DTO/enum/date casting | Keep and broaden | Metadata-driven casts and collections. | +| Exact date target classes | Keep | Direct date cast rules. | +| Owner-specific property keys | Keep capability | `MapName`/`MapInputName`/class mapper. | +| Custom dependency resolution | Keep capability | `Cast`, `Castable`, named factories, and Hypervel contextual attributes. | +| Custom serializers | Keep capability | `Transformer` and mapping attributes. | +| Existing instances pass through | Keep | First cast check. | +| Untyped/intersection/DNF values | Keep where PHP can prove validity | Compiled type graph; explicit cast for ambiguity. | +| Implicit snake-case output | Remove | Opt-in `SnakeCaseMapper`. | +| Serialized result cache | Remove | Always read live properties. | +| `refresh()`/`update()` | Remove | Ordinary public assignment or construct a new object. | +| Data-object `ArrayAccess` | Remove | Public properties and `toArray()`; collection access stays on collections. | +| FormRequest one/collection casts | Keep | Package-owned `Http\Casts\AsData` and `AsDataCollection` through Foundation's generic cast extension. | +| Database-owned `AsDataObject` | Remove | Data/DataCollection implement Eloquent `Castable`. | +| Saloon DTO use/response attachment | Keep | Plain `Data` plus existing `WithResponse`. | +| Process-static DTO/cache flush method | Remove | Container-owned repository/config; only the four independently useful Macroable registries use standard optional-package test cleanup. | + +## Package and Framework File Map + +### New `src/data` component + +Create the permission-style package skeleton: + +- `src/data/composer.json` +- `src/data/LICENSE.md` retaining Spatie and Hypervel MIT notices +- `src/data/README.md` in the required minimal order: header; `Documentation: https://hypervel.org/docs/data-objects`; a concise `Differences From Laravel` section containing only lasting public differences and their Hypervel alternatives, including metadata-time rejection of mapping collisions that Spatie compiles independently; then `Ported from: https://github.com/spatie/laravel-data` +- `src/data/config/data.php` +- `src/data/src/Data.php`, `Dto.php`, `Resource.php`, `Optional.php`, `Lazy.php` +- collection and paginator classes at the package root, matching upstream public names +- `src/data/src/Contracts/*` and `src/data/src/Concerns/*` +- `src/data/src/Enums/*` and `src/data/src/Exceptions/*` +- `src/data/src/Attributes/*` and `src/data/src/Attributes/Validation/*` +- `src/data/src/Casts/*`, `Transformers/*`, `Normalizers/*`, and `Mappers/*` +- `src/data/src/Support/*`, including immutable metadata, creation/transformation/validation subnamespaces, and `VarDumper/DataVarDumperCaster.php` +- `src/data/src/Eloquent/*`, `Http/*`, and `Inertia/*` adapters +- `src/data/src/Console/DataMakeCommand.php` +- `src/data/src/DataServiceProvider.php` +- package stubs for `make:data` + +Register provider discovery in the component composer file. Add the package to root `composer.json` autoload and replace metadata, then verify adjacent package-metadata/split conventions. Require only direct imports. The expected starting set is PHP 8.4, Hypervel Auth, Collections, Console, Container, Contracts, Database, Foundation, HTTP, Macroable, Pagination, Reflection, Support, and Validation, plus `laravel/serializable-closure`, `nesbot/carbon`, `phpstan/phpdoc-parser`, `symfony/console`, and `symfony/var-dumper`. `ClassMetadataCache` uses the `Hypervel\Support` namespace but is owned by `hypervel/reflection`, so Reflection remains its direct dependency. Foundation is required because package-owned FormRequest casts implement `Castable` and use `CastInputs`, and Precognition uses a concrete Foundation hook; an optional-package probe cannot guard an `implements` clause. VarDumper is a direct dependency because the package caster imports `AbstractCloner` and `Stub`. Do not require `composer-runtime-api` merely to probe for Foundation, `hypervel/filesystem` for an inherited `GeneratorCommand` implementation detail, `hypervel/routing` when response redirection imports only `Hypervel\Contracts\Routing\UrlGenerator`, or `hypervel/encryption` when encrypted casts use the Support-owned `Crypt` facade. Put only Inertia under `suggest` for lazy prop features. Do not suggest Saloon because interoperability requires no Saloon-specific package feature. Audit completed imports and remove unused requirements or add a missing direct owner package; do not rely on accidental transitives. + +### Framework-owned changes + +1. Validation: extract FormRequest's unknown-field algorithm into `Hypervel\Validation\UnknownFields` with the exact/additional/subtree contract above. FormRequest preserves its body/JSON boundary and existing exact/structured-field behavior, while intentionally fixing free-form declared arrays: an `array` rule without descendants accepts its contents. Repair the optimized wildcard walk's Laravel partial-segment matching without weakening missing-leaf `required` rules, and complete literal-asterisk placeholder encoding/decoding across rules and dependent references. Restore current Laravel's normalize-before-wildcard-merge invariant in the optimized parser so an earlier wildcard can overlap a raw exact string rule without a `TypeError`; retain Laravel's later exact assignment because Data class-rule replacement and ordinary Validator precedence depend on it. Reset `implicitAttributes` and `implicitAttributeMap` whenever `Validator::setRules()` replaces the graph, and build the reverse implicit-attribute map with first-write-wins semantics so its O(1) lookup preserves Laravel's first-declared wildcard identity. Add the immutable per-plan consumable-presence count and guarded exact-rule database batching described above without changing unsafe fallbacks, and expose one Validation-owned predicate shared by parser preparation and Data's conservative accumulator comparison for objects that reduce to strings. Add owning Validation coverage for matching/non-matching/absent partial patterns, the missing-leaf guard, literal dot/asterisk keys, escaped public error keys, the documented trailing-backslash fail-closed boundary, wildcard/exact declarations in both orders and input forms, stale implicit-state replacement, overlapping broad/narrow wildcard `Distinct`, valid dependent-field substitution, dependent-field substitution arity, exact presence batching, and fallback behavior; add FormRequest coverage for associative and list values plus a structured wildcard regression. Give `NotIn::__construct()` the same `array|Arrayable|UnitEnum|string` native type as adjacent `In`. Split raw email's explicit `rfc` arm from unsupported modes and throw `InvalidArgumentException` with a safe diagnostic for the latter; retain bare/default and custom-class behavior, add focused tests, and document the failure contract. While adding Data's `Can` attribute wrapper, remove the redundant promoted-property self-assignments and replace its non-imperative `Constructor.` docblock with the package convention; retain rule behavior and focused tests. +2. Foundation: remove the `Support\DataObject` branch/import from `Http\Traits\HasCasts` and delete `AsDataObjectArray`/`AsDataObjectCollection`. Its generic `Castable` path remains unchanged; replacements live in Data. +3. HTTP: add `Resources\Json\ProvidesResourceWrapper` and let `ResourceResponse::wrapper()` prefer that per-instance value. This is a general coroutine-safe resource extension; ordinary JsonResource static wrapping and force-wrapping remain unchanged. +4. Database: delete `Eloquent\Casts\AsDataObject`; Eloquent data casting belongs to Data. +5. Support: delete the superseded `DataObject`. +6. Container: make contextual constructor results authoritative including `null`; add `Attributes\Concerns\ExtractsPropertyValue` and optional `data_get()` property paths to `RouteParameter` and `Authenticated`; widen `Authenticated::resolve()` to `mixed`; port execution-scoped `RequestAttribute`; and port PHP-8.5-usable `BindWhen` with declaration-order resolution and conditional-miss reevaluation. Make public `build()`/`buildWith()` raw constructor APIs as documented: move `SelfBuilding` dispatch into one protected resolution-only method used by `resolve()` and generated self-binding closures, while explicit user closure bindings continue to win and interface bindings continue through inner resolution. Declare Container's existing and new direct `hypervel/collections` dependency. Add owning-component tests for constructor/call null parity, null precedence over contextual bindings and defaults, empty-model and nullable-contract regressions, whole-object compatibility, supported property paths, missing/null/scalar behavior, `CurrentUser` inheritance, execution scoping, one combined interleaving, raw construction of `SelfBuilding` classes through `build()`/`buildWith()`, unbound/bound/singleton factory dispatch, explicit closure precedence, interface-to-`SelfBuilding` bindings, contextual build-stack visibility, and the guarded upstream `BindWhen` behavior. Document authoritative null and `BindWhen`'s boot-stable worker-lifetime condition contract in the canonical Container guide, package differences, and Laravel porting guide; document the constraint on the `BindWhen` attribute as well. The raw-build documentation already states the corrected contract. +7. Testing: remove the old Support DataObject flush call and add alphabetic `flushDataState()` to the optional-package group. Through `callIfExists()`, flush `Lazy`, `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection`; each Macroable class owns a separate static registry. Container teardown owns repository/config cleanup. +8. Validation rules: add a missing Laravel rule to Validation only when the port ledger proves Hypervel lacks it. Data itself adds the listed attribute wrappers for rules Hypervel already supports. +9. Repository maintenance: add `spatie/laravel-data` to `docs/upstream-sync/sync.yaml` before permission with `release: 4.23.0`, `sync_date: 2026-08-30`, and an operational note that the initial port also reviewed main through `ce296f22` plus the v5 draft at `ed630ee1`, so overlapping future release work is recognized rather than re-ported. Correct `docs/upstream-sync/README.md` to point Laravel ports at the `AGENTS.md` Porting Packages section instead of nonexistent `docs/ai/porting.md`, and add the separate TypeScript-transformer package to `docs/todo.md` as a real deferred gap. +10. Saloon and Inertia: make no runtime framework edits. Adapt Data to their existing contracts and prop classes. +11. Foundation VarDumper: make no runtime framework edit. Data registers its own interface caster through Symfony's existing default-caster extension point. +12. Pagination: remove the unenforced `through()` `@method` tags from the paginator contracts. The contracts do not declare that method, and Data uses the real `items()` contract plus Hypervel abstract-paginator clone/`setCollection()` support instead. Concrete paginator behavior and the canonical Pagination documentation remain unchanged. + +### Test layout + +- Mirror source under `tests/Data/*`. Pure metadata/parser/value tests use `Hypervel\Tests\TestCase`; API, provider, container, validator, resource, Eloquent, Precognition, Inertia, and command tests use `Hypervel\Testbench\TestCase`. +- Data/Eloquent casting is driver-neutral, so the planned cast suite uses Testbench's SQLite setup under `tests/Data/Eloquent`; do not create empty MySQL/MariaDB/Postgres suites. If implementation proves a real driver-specific contract, put only that coverage under `tests/Integration/Data/Database/{Postgres|MySql|MariaDb}` and wire the used directory into the database workflow. +- Replace useful assertions from `tests/Support/DataObjectTest.php`; delete that file rather than retaining a second API suite. +- Update existing Container, Foundation, Validation, Database, HTTP resource, Testing cleanup, Saloon, and docs tests at their owning integration points. +- Add focused caster/provider coverage under `tests/Data/Support/VarDumper`; every test that mutates `AbstractCloner::$defaultCasters` restores the previous entry in `finally` rather than adding global test cleanup. +- Add `types/Data/Data.php` as the dedicated max-level PHPStan fixture for `from()`, `optional()`, `collect()` target inference, DataCollection/paginator generics, and the distinct `Data`/`Dto`/`Resource` capability contracts. +- `tests/Benchmarks/Data/benchmark.php` and `tests/Benchmarks/Data/README.md` for the retained benchmark harness. + +## Upstream Port Ledger + +Before implementation edits, generate a checked ledger from the pinned Spatie source and test trees (`find src tests -type f | sort`). Every entry receives one of these dispositions and a matching test reference: + +### Port API and behavior + +- `Data`, `Dto`, `Resource`, base contracts/concerns, `Optional`, and `WithData`. +- Data collection, paginated collection, cursor-paginated collection, non-deprecated enumerable behavior. +- Applicable non-validation attributes: `AutoClosureLazy`, `AutoInertiaDeferred`, `AutoInertiaLazy`, `AutoLazy`, `AutoWhenLoadedLazy`, `Computed`, `DataCollectionOf`, `Hidden`, `LoadRelation`, `MapInputName`, `MapName`, `MapOutputName`, `MergeValidationRules`, `PropertyForMorph`, `WithCast`, `WithCastAndTransformer`, `WithCastable`, `WithTransformer`, and `WithoutValidation`; also port the `GetsCast` interface and the remaining cast contracts. Hypervel contextual attributes replace the `From*` and `InjectsPropertyValue` family. +- mapping attributes/mappers, cast/transform attributes/contracts, `IterableItemCast`, and date/enum/typed-iterable/arrayable support. +- lazy values, computed/hidden, includes/excludes/only/except, append/wrap/resource behavior. +- validation attributes, database constraint/reference helpers, messages/attributes/authorization/hooks, request injection, morphs, Precognition. +- Eloquent casts/encryption and loaded-relation behavior. +- Inertia lazy/deferred behavior. +- applicable exceptions with Hypervel namespaces and actionable messages. +- `make:data`, adapted to Hypervel's ordinary generator conventions: `App\Data`, no automatic class suffix, and application stub override support. + +### Preserve public concept, redesign implementation + +- `DataPipeline`, data pipes, general resolvers: replace with fixed engines and small internal operations. +- `DataConfig`: typed boot-built settings and extension recipes, with only the documented boot-only morph map mutable. +- normalizers: fixed built-in dispatch plus explicit custom normalizers. +- rule inferrers: deterministic compiler using Hypervel Validation. +- response construction: Hypervel JsonResource adapters and generic wrapper contract. +- creation context/factory: focused fluent options and ordered hooks, with no replaceable pipeline and no reuse of an in-flight operation context. +- metadata support classes: immutable recipes, feature bits, direct-path data, no live services. +- VarDumper integration: replace Spatie's manager and two colliding registrations with one stateless interface caster that branches for data collections. Upstream assigns both callbacks to the same `TransformableData` key, so its collection callback overwrites its data callback; fix that defect rather than porting it. + +### Deliberately omit + +- Livewire contracts, synths, attributes, tests, and config. +- TypeScript transformer integration and annotations. +- deprecated `Data::collection()`/`WithDeprecatedCollectionMethod` and the deprecated `EnumerableMethods` forwarding surface. +- `WireableData`, which exists for Livewire wire serialization. +- v4 `prepareForPipeline()` and `factory(?CreationContext)`: class-owned reshaping moves to a typed named `from*` method, call-site reshaping uses `prepareData`, and every factory starts a fresh operation context. +- generated/cache-store/remote metadata caches, cache commands, TTL, structure discovery, and reflection watchers. Worker memory is the cache boundary. +- Spatie's `FromAuthenticatedUser*`, `FromContainer*`, `FromRouteParameter*`, and `InjectsPropertyValue` aliases; use Hypervel contextual constructor attributes, including the enhanced route/user property-path form, or a custom contextual attribute/named factory. +- `SerializeTransformer` and `UnserializeCast`. +- configurable pipes/rule inferrers/built-in normalizer ordering. +- compatibility feature flags and permissive invalid-partial/depth behavior. +- `withOptionalValues()`/`withoutOptionalValues()`; declared `Optional` unions always preserve absence instead of allowing an uninitialized property. +- the v5 draft's strict/auto-null compatibility mode and override attributes; use `Optional` to preserve absence and `#[Present]` when a nullable key must be supplied explicitly. +- Pest helpers/snapshots and Laravel package-tools/Testbench plumbing; translate tests to framework conventions. + +For every intentionally omitted upstream public method or feature, place the concise `REMOVED:` source/test notices required by the porting rules at the natural upstream insertion points, and explain only lasting developer-facing differences in the package README. These notices are maintenance markers, not compatibility code. + +The ledger is an implementation artifact kept with the working notes until all entries are represented in code/tests or an explicit omission above. Do not ship a stale port checklist in end-user documentation. + +## Implementation Order + +### 1. Establish baselines and ledger + +- Reconcile the clean feature branch with the then-current greenfield `0.4` branch before edits, then record the baseline and pinned upstream references in working notes. +- Verify the pinned v4 main and v5 draft commits above, add the stable upstream-sync entry, fix the broken sync-guide reference, and add the TypeScript package todo before porting source. +- Materialize the full upstream file/test ledger. +- Run the existing focused Support DataObject, Foundation custom-casting, Database JSON cast, HTTP resource, Saloon, Validation, Container SelfBuilding, Precognition, and Inertia tests. +- Create the retained benchmark harness before replacing the current class, run its current Support DataObject/manual-constructor baselines, and save environment plus raw results outside committed documentation. + +### 2. Extract shared validation behavior and add the package skeleton + +- Move unknown-field checking to Validation and keep focused FormRequest body/query/JSON integration behavior green before Data consumes it. Add regressions proving free-form `meta.foo` and scalar-list `tags.*` values are accepted under leaf `array` rules while structured `items.*.id` still rejects `items.0.unknown`. +- Correct Container's contextual-null constructor behavior, raw `build()`/`buildWith()` handling of `SelfBuilding` classes, route/user extraction, and `RequestAttribute`; declare the Collections dependency; and keep the focused contextual, raw-construction, binding-precedence, scoping, and interleaving tests green before Data consumes them. Port `BindWhen` in the same Container parity slice with a conditionally loaded PHP 8.5 fixture and keep its focused declaration-order, reevaluation, lifetime, and fallback tests green on supported runtimes. +- Add package composer/provider/config/README/license/root registration. +- Bind `DataConfig` with a provider factory because it is built from configuration. Leave `DataClassRepository` and stateless engines unbound so Hypervel auto-singletons them naturally; construct operation contexts and factories fresh. +- Add provider/config discovery, worker-lifetime morph-map, and typed-config tests. + +### 3. Build metadata and type system + +- Port/adapt attributes collection, class/property/method/parameter/type metadata. +- Parse native types, constructor promotion/defaults, attributes, collection docblocks, unions, intersections, DNF types, enums, dates, iterable item types, virtual/computed fields, and named object/collection factories. +- Compile constructor argument order, input/output mapper keys, hook bits, rule templates, cast/transform recipes, and `plainTransform`; compile per-node direct-creation eligibility only if the measured specialization is retained. +- Test immutable metadata; inherited native `self`/`parent` and late-bound `static` declarations across properties, constructors, and named factories; parent/child/constructor/inline generic-annotation precedence with distinct import scopes; import aliases winning over an existing same-namespace class; multi-namespace per-file import caching; recursive class references; ignored helper/static properties; constructor-bound readonly/mutable/defaulted properties; required constructor parameters overriding property defaults; invalid unbound readonly, computed-bound, contextual-name-collision, non-public-promoted, and alternate-constructor declarations; valid non-public-constructor metadata; declaration-order method metadata; bounded repository/resolver keys; cached contextual/extension reflection recipes; fresh object-bearing attribute arguments per operation; and absence of container/request/resolved extension objects. + +### 4. Implement fixed construction + +- Implement normalized source adapters for arrays, JSON, `Arrayable`, plain objects, Model, Request/FormRequest, and custom normalizers. +- Normalize plain objects from initialized public properties only; do not bypass visibility or invoke arbitrary serialization. +- Implement named object/collection factory dispatch and non-recursive internal construction. +- Implement default/`Optional`/nullable/required absence handling in the single documented precedence order. +- Implement casts for built-ins, nested Data, data collections, dates, enums, iterables, unions, custom casts/castables, and morphs. +- Mark contextual constructor slots during Fill, exclude promoted injected properties from payload validation, and resolve their values only at per-node instantiation. Pass non-promoted injected parameters only to the constructor and match normal per-parameter Container resolution without a cross-node value cache. +- Preserve constructor-owned values for every constructor-bound property. Assign only supplied, unbound public mutable properties after construction while leaving computed/virtual properties to the class. +- Benchmark the completed fixed general array path against the retained manual baseline. Add the per-node direct specialization with shared property primitives and focused equivalence tests only if the retained measurement justifies it. +- Add source-specific query-count and allocation-focused tests where measurable. + +### 5. Implement validation + +- Implement deterministic rule compilation and mapped validation paths. +- Port validation attributes in small rule groups with one-to-one tests. +- Add the verified Hypervel-native validation attributes listed above; each delegates to the existing string rule or rule object. +- Add the incremental uniform-wire-choice predicate, isolated dynamic accumulators with first-mismatch concrete recompilation, direct emitted/fully-structural path provenance, contributor-aware marker coverage, marker-first generated identity, exact-rule Validation batching, concrete dynamic/mixed-wire-shape rules, presence-only `mixed`/object retention, messages/attributes, authorization, class/factory validator hooks, validated-payload construction, exclusion rules, escaped pre-`prepareData` unknown-field input, error bags, stop-on-first-failure, and both Precognition success paths. +- Add missing Laravel rules to Hypervel Validation only after verifying the upstream Laravel contract and writing owning-component tests. +- Benchmark 1,000/5,000-item static nested validation and inspect Hypervel's compiled-plan/batched database paths. + +### 6. Implement transformation and collections + +- Implement direct transformation, context promotion, mapping, custom transformers, Optional omission, lazy/computed/hidden/appended values, partials, depth detection, JSON, and serialization. +- Compile one immutable exact/prefix/subtree-aware `PartialTree` per partial mode and use `plainTransform` only when instance state and metadata prove the direct loop is equivalent. +- Add nested instance-partial composition first at the two currently live `BaseData` edges and port the array-shaped, depth-three per-item part of upstream `PartialsTest.php:1068` in that slice. +- Port typed collections/paginators and preserve keys/laziness. Implement one root collection Fill/Validator operation, shared per-operation normalizer/extension memo, normalized source-shaped `collect*` selection, declared-shaped property rebuilding, Eloquent relation batching/root downgrade, exact multi-arm finished-container handling, Data and non-Data paginator source retention, cast-owned ambiguous unknown-field subtrees, and the Fill-time failure boundaries described above. Route every eager typed iterable through `DataCollectableFactory` and remove the duplicate creator rebuilder. Replace the two unreachable non-`BaseData` public-transform scaffolds with the shared internal collection loop, then port the complete upstream partial graph covering root-, collection-, and item-owned selections. +- Make collection iteration and keyed reads side-effect-free, route constructor and `offsetSet()` item conversion through the package-internal item operation, and remove Pagination's unenforced `through()` contract annotations. +- Add the stateless VarDumper caster and direct provider registration after `all()` semantics are complete; do not add a manager or mode setting. +- Add live-property regression tests proving no output cache. +- Benchmark simple/nested/collection output and peak memory. + +### 7. Integrate framework surfaces and command + +- Add package-owned FormRequest casts, then remove Foundation's old DataObject branch and casts. +- Add Data/collection Eloquent casts, property-morphable and enforced-alias abstract forms, custom-codec handling, and encrypted variants; delete Database's old cast. +- Add the HTTP wrapper interface and Data resource adapters with existing response/pagination machinery; override adapter `resolve()` to bypass the generic conditional-resource filter. +- Add SelfBuilding request injection and Precognition integration tests. +- Add optional Inertia lazy/deferred adapters and tests. +- Add Saloon integration tests/docs; make no Saloon runtime change. +- Implement `Console\DataMakeCommand` using `Hypervel\Console\GeneratorCommand`, `#[AsCommand]`, a hardcoded `App\Data` default namespace, and the same application stub override convention as Hypervel's existing `make:*` commands. Do not auto-append `Data`; `make:data UserData` should behave like `make:request StoreUserRequest`. +- Test default/nested/explicitly qualified class names, force/no-force, strict-types stub, application stub override, and disposable Testbench paths; there are no namespace/suffix command settings. +- Add and run the dedicated `types/Data/Data.php` fixture before broader static analysis; fix public generic contracts rather than adding PHPStan ignores. + +### 8. Remove old design and rewrite documentation + +- Delete Support DataObject, Database AsDataObject, old tests, old cleanup, and every old import/comment/reference. +- Update `src/docs/data-objects.md` section by section for the new API using targeted edits; do not replace the file wholesale or retain obsolete sections as migration guidance. +- Update `src/docs/validation.md`, `eloquent-mutators.md`, `api-client.md`, `saloon.md`, and code examples. Update `src/docs/container.md`, `src/container/README.md`, and `src/docs/porting-from-laravel.md` for the new Container attributes and Hypervel's boot-stable `BindWhen` condition contract. Confirm the existing Data Objects entry in `src/docs/documentation.md` still resolves to the retained `data-objects` slug; do not add a duplicate navigation entry or invent separate search metadata. +- Document construction and absence semantics, mapping, validation, collections, Eloquent, resources, Inertia, clean VarDumper output, performance, extension contracts, and worker-lifetime constraints. State that dumps show the current logical view, so excluded `Lazy` and `Optional` values do not appear. +- In `Differences From Laravel`, record the fresh factory context, valid-state `Optional` rule, fixed nullable semantics/`#[Present]` alternative, safe `OnlyRequests` defaults for all SelfBuilding base classes, retained class `withValidator`, contextual constructor-only/always-wins behavior, property-extraction alternative, Hypervel wildcard/concrete rule modes, normalized-container `collect*` dispatch, and the distinction between raw-input `validate()` and a direct-returning named factory that owns `validateAndCreate()` validation. Explain that a `collect*` parameter declares the container of normalized Data objects it receives. Include every other lasting ledger divergence without repeating the canonical guide. +- Add a concise `porting-from-laravel.md` entry for applications moving from `spatie/laravel-data`, linking to the canonical Data Objects documentation rather than duplicating it. +- Report that `packages/hypervel/docs/plans/sdk-generator/2026-08-29-1238-sdk-generator.md` still proposes `DataKey`/`MissingValue`/the old `DataObject` enhancement. Amend that separate private plan to `MapName`/`Optional`/`Data::from` and remove obsolete framework work only after the owner explicitly authorizes editing it; retain the generator's strict `Wire` boundary. +- Run broad `grep` searches across active source, tests, types, config, package metadata, and canonical docs. Include the SDK-generator plan only if the owner authorizes its amendment; otherwise report its stale references without editing it. Eliminate stale APIs while retaining required port-maintenance notices for deliberate public omissions. Do not rewrite immutable completed plans, `_archive`, or installed `vendor` copies to imitate the new code. + +### 9. Final audit + +- Reconcile every ledger item and ensure only the deliberate omissions remain. +- Audit public names/signatures/order against the checked-out Spatie source/docs/tests and Laravel conventions. +- Audit dependency direction and component composer requirements. +- Audit all singleton/static properties for request state, closures, container values, and unbounded keys. +- Profile the fixed general paths and any retained measured specialization; remove abstractions that add cost without enabling an adopted feature. +- Run formatters, static analysis, focused suites, package-adjacent suites, then the repository suite according to AGENTS.md. +- Review the final diff for dead compatibility code, duplicated serializers/validators/resources, stale comments/docs, and source unrelated to this package. + +## Test Plan + +### Creation and types + +- array, JSON string, `Arrayable`, plain object, stdClass, Model, Request, FormRequest, multiple payloads, custom normalizer; +- constructor promotion, inherited properties, defaults (including `new` object defaults), nullable omission to `null`, explicit null, Optional-preserved omission, missing non-nullable required values, empty data, public readonly promoted and constructor-bound non-promoted properties, constructor normalization preserved without post-assignment, unbound mutable properties, invalid unbound readonly and computed/virtual-bound declarations, and PHP 8.4 virtual/backed property hooks; +- scalar/builtin coercion rules, including case-insensitive `true`/`false` strings, enums, exact date classes/interfaces/subclasses/timezones/formats; +- declared-class collection items pass through with identity, while an unrelated `BaseData` item is normalized into the declared item class rather than being preserved as a finished value; +- nested Data, arrays, Collection, DataCollection, iterable annotations/attributes, paginator/cursor paginator, LazyCollection; +- nullable/union/intersection/DNF/existing-instance handling and explicit ambiguity failures; +- custom Cast/Castable, constructor arguments, Uncastable fallback, and morph discriminators restricted to declared concrete Data subtypes; +- named object/collection factory declaration order; positional/named matching; exact-key rejection; zero-payload matches; dependency-first/interleaved parameters; union/intersection non-injectability; `CreationContext` identity and first/middle/trailing placement across named and positional invocation shapes; variadic-context declaration rejection; direct supplied-class payloads; omitted dependencies through first-class `Container::call()`; contextual build-stack bindings; non-variadic attribute callbacks; method bindings not intercepting factories; pure and prefixed variadics; skipped-default built-in variadics; class-name-key emission for attributed/injected prefixes; same-class prefix consumption without fabricated arguments; zero-payload class-variadic Container resolution; independent `$into` return matching; direct-object short circuit/authorization; private-constructor direct-return and existing-instance success; unmatched private-constructor and matched-normalizable-source `CannotCreateData` failures; protected visibility diagnostics; unchanged public construction; and recursive-public-entry regression; +- when benchmarks justify the specialization, direct/general branch equivalence for mapping, defaults/nullable omission, casts, nested values, dates, enums, errors, current property state, and a general-path child beneath a direct-path parent. + +### Mapping and validation + +- shared `validation_strategy` and factory overrides for `Data`, `Dto`, and `Resource`: the shipped `OnlyRequests` value validates Request/controller input while non-Request array/model/JSON input takes the disabled-equivalent lean path, and deliberate global/per-call overrides affect all intended classes; +- global/class/property input/output mapping precedence and mapped-key-wins behavior; +- metadata-time rejection of duplicate effective input paths and output keys across direct, inherited, computed, contextual, class-mapped, configured-mapper, and explicit-property-mapped declarations; hidden properties excluded only from output ownership; same-property aliases and dot-prefix overlap accepted; integer/string key equivalence follows PHP array keys; exception messages name the target class plus both declaring properties; +- nested mapped error keys for objects, uniform collection wildcards, and mixed mapped/property-name per-index fallbacks; +- Validator partial-segment wildcards match, miss, and skip absent parents without exceptions while bare wildcards still emit missing nested leaves for `required`; literal-dot and literal-asterisk rule keys, wildcard-expanded literal-asterisk keys, and dependent-field references round-trip without leaking placeholders; Data validation paths round-trip canonical integer and negative item keys while retaining noncanonical numeric-looking strings, with the trailing-backslash fail-closed result pinned separately; +- inferred type/presence rules, explicit attribute/rules behavior on defaulted properties, manual replacement/merge, every validation attribute, and custom rule objects; +- top-level optional-null omission versus nested-list `null` token encoding; direct and externally resolved null comparison values across the variadic dependent family; bool/numeric comparison values under strict types; and a package-level `RequiredUnless` regression proving null and missing compared fields reach the Validator with Laravel semantics; +- one root validator, validated payload use, excluded/prohibited fields absent from construction; mixed wildcard/exact rule order retains numeric and string-keyed source order through both `validate()` and `validateAndCreate()`, keeps excluded numeric gaps, and remains a JSON list when the source was a list; explicitly test both application-wide unvalidated-array-key settings, including filtered and retained unruled siblings, without Data overriding the Validator factory; +- wildcard eligibility for empty/single/many uniform items; identical versus divergent dynamic output at the current node and recursively nested nodes; class rules; operation hooks without worker-cache pollution; cycle-safe static and dynamic metadata graphs; contextual/`WithoutValidation` graph exclusions; homogeneous and heterogeneous morphs; nested finished/direct-factory values in both orders; finished package and eager native object containers versus raw siblings in both orders; and mixed wire-key choices forcing and latching every enclosing collection concrete without per-item structure signatures; accumulator-owned equality across every output field, canonical preserved-path comparison, conservative rule object/callback comparison, and authoritative concrete recompilation after the first difference; ancestor replace/merge behavior through the full eight-cell product of uniform/divergent child output, exact-before-wildcard/wildcard-before-exact declaration order, and replace/merge mode; fanned conditional-presence rules suppress inferred `required`; public key order and list-key marker production; nested message/attribute declarations remain first-write-wins while ancestor rules retain their replace/merge policy; +- fully structural empty markers for common concrete and partial-wildcard contributors after nested and ancestor replace/merge, including parent-introduced wildcard declarations, empty replacements, nested collections, marker-first order ahead of covered generated rules, and no marker for heterogeneous, missing, or empty rule ownership; nested dynamic collections prove later items with more children retain and validate every value while later items with fewer children do not receive spurious `required` failures; uniform, partial-wildcard, and exact compilation give nested `Distinct` the same Laravel-global scope across every wildcard level; mixed partial/exact contributors still emit that one global marker; broad-first Validation identity preserves global `Distinct`, valid dependent-field substitution, and substitution arity; nested finished-property, finished-item, and finished-container suppression plus mixed raw/finished `Distinct` rejection remain identical when outer items are reversed, including both literal and wildcard segments below the finished path and no narrower substitute marker; finished/direct-factory-first inputs cannot corrupt sparse structure, widen a wildcard preserved/unknown-field path, or restore a raw sibling; finished structural-path provenance unions across every accumulator; all-finished and empty-collection behavior; no getter execution, identity loss, validated projection, unknown-field widening, label drift, or Precognition side effect; +- required/default/nullable/`Optional` presence-only retention for `mixed` and object declarations after `validated()`; +- validation-only and rule-introspection modes bypass named factories while `validateAndCreate()` retains direct-object exits; Request authorization still runs for `validate($request)`, `getValidationRules()` remains array-only, and the documented APIs can intentionally disagree when a direct factory owns validation; rule introspection materializes Data-collection `LazyCollection` values and returns the same nested rules as equivalent arrays and validation; +- `WithoutValidation` and existing/direct-factory-returned nested Data values skip their owned rules, restore from exact or uniform-wildcard raw paths after `validated()`, retain present null and object identity, handle a template item that omits a later supplied value, and distinguish literal-dot/literal-asterisk collection keys from structural wildcards without reopening arbitrary unvalidated input; +- messages, attribute labels, per-setting method-over-attribute precedence plus URL-before-route redirect precedence, runtime-computed redirects, `#[ErrorBag]`, `#[RedirectTo]`, `#[RedirectToRoute]`, `#[StopOnFirstFailure]`, `#[FailOnUnknownFields]`, authorization denial, class/factory `withValidator`, and class `after()` callbacks; +- pre- and post-validation payload reconciliation for added, removed, scalar-to-structured, structured-to-scalar, morph-reselected, and collection-item values; reselect mapped and fallback wire keys for every changed node property, including scalars and canonical absent paths; use fixed Model/source normalization and named factories only for final changed values; and prove `prepareData`, custom normalizers, and unchanged sibling factories do not rerun while collection divergence remains safely concrete; +- route/auth/config/request-attribute/custom contextual injection on promoted and distinct-name non-promoted constructor parameters; metadata-time rejection of a non-promoted contextual parameter/public-property name collision so client payload cannot overwrite a server value; route/user whole-value and `data_get()` property-path extraction; authoritative contextual `null` parity across constructor and `call()` resolution plus precedence over primitive/class contextual bindings and declared defaults; nullable auth contracts with and without defaults; missing non-nullable route models failing instead of becoming empty models; contextual-value-wins behavior over payload and `beforeCreation` output; mapped contextual echoes accepted as known-but-ignored exact/subtree input under `#[FailOnUnknownFields]`; exclusion of injected fields from validation; no contextual resolution before failed validation or successful `Precognition-Validate-Only`; normal construction for full-form precognitive submits; fresh handler invocation per constructed node; no container lookup when attributes are absent; and no injection support on a non-promoted public data property; +- unknown fields with nested/exploded rules; strict and non-strict classes at different graph depths; a strict parent's pre-hook key removal; opaque declared arrays/mixed/object subtrees; structured nested Data remaining strict; exact and uniform-collection wildcard `WithoutValidation`/contextual paths; wildcard subtree descendants and empty-array leaves; escaped literal-star auxiliaries; inert unsupported partial-star auxiliaries; finished values; literal-dot/asterisk rule keys; unchanged unescaped public error keys; the trailing-backslash fail-closed boundary; confirmation fields; direct Request query omission versus complete nested array input; JSON/body input; hook-added keys ignored consistently across Request/array sources; and Precognition's unfiltered rules; +- Precognition successful/failed/authorization/filtering behavior, proof that successful `Precognition-Validate-Only` aborts before absence/casts/contextual resolution/construction, and proof that an ordinary precognitive submit constructs parameters before its dispatcher returns 204; +- `Exists`/`Unique`/`Distinct` nested collections in eligible wildcard, dynamic-identical wildcard, and divergent forced-concrete modes; exact and wildcard database query counts; an isolated exact or one-item wildcard presence check retaining the ordinary path; two exact checks and a same-plan `exists|unique` pair entering batching; cache-hit and wildcard-expansion contributions recomputed on every `passes()`; safe facts reused by later mutation-aware consumers; different query shapes; callbacks, exclusions, nullable/missing/upload values, field references, stop-on-first-failure, custom validator/verifier fallbacks, repeated passes, and verifier restoration. Keep ordinary-versus-batched equivalence coverage explicit: a shared test-only `DatabasePresenceVerifier` subclass forces the ordinary arm, every intended batched arm supplies at least two checks, and an `after()` callback observes `PrecomputedPresenceVerifier` before restoration so one-query ordinary execution cannot make the test pass accidentally; +- Validator wildcard/exact overlap in both declaration orders with string and array exact rules, preserving later exact replacement while eliminating the raw-string merge crash; `setRules()` wildcard-to-exact and nonempty-to-empty expansion regressions prove stale implicit state is cleared. + +### Transformation and collection behavior + +- input/output mapper independence, live mutations, Optional omission, null retention; +- custom/date/enum/arrayable transformers and nested collection output; +- Hidden, Computed, appended values, include/exclude/only/except, invalid paths; +- nested instance include/exclude/only/except at depth two or greater; parent/instance tree union including parent pure-all plus instance `only`; array-shaped typed-item isolation matching the B1/B2 portion of upstream `PartialsTest.php:1068`; the same instance referenced twice with a temporary applying only at first reach and a permanent applying at both; collection-container ownership and the complete upstream graph once the internal collection loop exists; +- lazy default/conditional/relation/closure values, no evaluation when excluded, one evaluation when included; +- recursive `Lazy`/relationship graphs stop at the exact configured maximum depth; cyclic object graphs are documented as unsupported rather than guarded with a per-node identity set; +- JSON errors/options and PHP serialization of supported state; +- collection keys, items/toCollection/count/iteration/offset operations; side-effect-free early-break iteration and successive keyed reads; internal constructor/offset assignment normalization; covariant package-collection pass-through and subsequent item coercion; one eager root operation and one Validator; collection hooks over the complete payload; source-object `OnlyRequests` behavior; normalized source-shaped `collect*` matching/invocation, including an exact Eloquent parameter not dispatching for an Eloquent source and ordinary collection fallback returning the requested result; independent `$into` return matching; contract-only paginator fallback; no lazy enumeration; per-operation normalizer reuse; and LazyCollection laziness when neither validation nor rule introspection needs its graph, with deliberate one-time materialization for either rule-producing operation; +- root and nested paginator metadata/links/cursors; Hypervel paginator clone-without-caller-mutation; declared paginated wrappers; raw Hypervel paginator conversion; scalar/date/enum typed paginator reconstruction; matching and mismatched package wrapper item classes; array-to-paginated failure at Fill; contract-only paginator finished/pass-through and conversion-failure boundaries; dedicated missing-retained-source failure; per-item paginator source isolation without template fallback or validation-uniformity loss; hook source replacement; eager/Lazy page-item reshaping; retained metadata when hooks change item count; and nullable/Optional/absent paginator properties; +- ambiguous Data-object/container unions: per-arm PHPDoc item classes and annotation-order independence; finished base/Eloquent containers accepted through any compatible arm; custom attribute/configured/factory casts reached before ambiguity; strict unknown fields remain fail-closed without a cast and treat only cast-owned ambiguous shapes as opaque in create/validation-only modes; single-arm casts retain nested validation unless `WithoutValidation`; unrelated non-Data alternatives pass unchanged; raw Data-container sources fail with every candidate; and `Collection|array` is ambiguous when both arms carry Data item metadata; +- declared property rebuilding: exact `array`/`iterable` return keyed arrays, ordinary/custom source subclasses rebuild as the declared collection class, valid `EloquentCollection` stays Eloquent, invalid Model/scalar/union/intersection/DNF item graphs are rejected by the structural guarantee check, and unsupported `Traversable` declarations fail with `CannotCreateDataCollectable`; + +### Framework integration + +- unbound controller `Data`, `Dto`, and `Resource` parameters resolve from the current request as fresh, request-validating SelfBuilding instances; +- Container `make()` keeps unbound, bound, singleton, explicit-closure, and interface-bound `SelfBuilding` semantics, while public `build()`/`buildWith()` directly construct and contextual attributes still see the target build stack; +- two interleaved requests cannot share construction state, injected users/routes, validators, factory hooks, wrappers, partials, additional fields, or lazy results; +- `BindWhen` first-match, singleton/scoped lifetime, no-match, late-match reevaluation, `Bind` fallback, mixed declaration order, first-wildcard behavior, and worker-lifetime materialization, with closure-bearing fixtures loaded only on PHP 8.5+; +- FormRequest direct/collection casts accept only package `BaseData` classes and reuse `from()`/`collect()` through Foundation's generic cast path; +- Eloquent null/default, empty object/list, full representation without instance mutation, property-morphable payloads, enforced abstract envelopes, unknown alias/FQCN and invalid subtype rejection, dirty tracking, custom codec, encrypted concrete/abstract data and collections, previous encryption keys, invalid JSON, and serialization; +- JsonResource legacy wrapper/force-wrap/additional/status behavior remains unchanged; +- Data responses, wrapping, pagination, `with`, `additional`, JSON options, `withResponse`, default status 200, and a spy proving Data adapters bypass the generic conditional-resource filter; +- Inertia initial/partial/deferred/group/rescue behavior; +- Saloon request/connector DTO priority, `WithResponse`, generics/static-analysis inference, and no package coupling; +- AfterEach cleanup flushes all four Macroable Data classes independently without loading an absent optional package. + +### VarDumper + +- `Data` and `Resource` dumps contain their mapped `all()` view without metadata, factories, contexts, partial trees, or other package internals; +- `Optional` and excluded `Lazy` values are absent, while included values and live public-property changes are visible; +- `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` use one `items` envelope; +- provider boot registers the interface caster idempotently with `??=`, preserves a pre-existing custom caster, and does not disturb Foundation's unrelated default casters or ordinary object dumps; +- tests capture the previous `TransformableData` registry entry and restore it in `finally`, including the absent-key case. + +### Command + +- `make:data` `App\Data` default, nested/explicit class name, no implicit suffix, force/no-force, strict-types stub, application stub override, and disposable Testbench output paths. + +### Performance harness + +Keep a developer-run harness patterned after `tests/Benchmarks/RateLimiter` with warmup, multiple samples, median/p95, operations per second, peak memory, PHP/OS/extensions/config/commit recorded, and raw JSON/CSV output ignored by Git. + +Scenarios: + +1. Native constructor/manual array mapper baseline. +2. Cold and warm simple `Data::from(array)`. +3. Deep and wide SDK-shaped graphs using the retained benchmark fixtures. +4. `collect()` over 1,000 objects and lazy traversal. +5. One 5,000-item nested validation graph. +6. Direct and container-resolved named factory dispatch, including collection-sized runs. +7. Mapped/custom-cast/morph/injection slow paths. +8. Simple and nested `toArray()`, lazy/partial context promotion. +9. Cold metadata construction and first use versus warm worker-lifetime operations, without treating ordinary startup CPU as a defect. +10. Eloquent collection normalization with loaded and explicitly `LoadRelation` relations/query counts. + +Do not encode invented time thresholds. Compare ratios to native/manual baselines and before/after results on the same machine. Correctness tests assert architectural invariants that benchmarks cannot enforce reliably: no pipeline resolution, one Validator per root graph, no metadata filesystem/discovery path, no per-property container access on ordinary DTOs, no `Model::toArray()`, no eager LazyCollection materialization when neither validation nor rule introspection is selected, and no user-produced or mutable object retained in worker metadata. + +## Verification Commands + +Use the repository's discovered Composer scripts and PHPUnit configuration rather than assuming command names. The expected focused sequence is: + +```bash +./vendor/bin/phpunit --no-progress tests/Data/.php +./vendor/bin/phpunit --no-progress tests/Container/ContextualAttributeBindingTest.php +./vendor/bin/phpunit --no-progress tests/Validation/UnknownFieldsTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/FoundationFormRequestTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/Http/CustomCastingTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseEloquentJsonCastTest.php +./vendor/bin/phpunit --no-progress tests/Http/JsonResourceTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Http/ResourceTest.php +./vendor/bin/phpstan analyse -c phpstan.types.neon.dist +php tests/Benchmarks/Data/benchmark.php +composer fix +``` + +Before final signoff, run `composer fix` once as the repository's prescribed formatter/static-analysis/parallel-test/Testbench/dogfood aggregate, after the focused suites are green. Do not repeatedly spend the full-suite cost while iterating on isolated failures. + +## Completion Checklist + +- [ ] Public API is Spatie/Laravel-familiar and every divergence is documented as a Hypervel adaptation. +- [ ] Fixed creation/transformation paths are structurally lean and benchmarked; any retained direct specialization has a recorded benefit and full equivalence coverage. +- [ ] General construction is fixed, non-recursive through public APIs, and built from validated values. +- [ ] Default/Optional/nullable/required absence semantics, mapped validation paths, uniform-shape wildcard graphs, mixed-shape concrete rules, and dynamic rules are correct. +- [ ] Metadata is immutable and worker-scoped; config is stable after boot except its documented morph-map registration; all operation/request state is isolated. +- [ ] Data, Dto, Resource, Optional, Lazy, collections, validation attributes, mapping, casting, resources, Eloquent, Precognition, and Inertia are complete. +- [ ] VarDumper output presents the current logical Data/resource/collection view through one stateless, idempotently registered interface caster with no mode or manager. +- [ ] Container, Foundation, HTTP, Database, Validation, and Testing changes respect ownership and have local tests; Inertia and Saloon need no runtime changes. +- [ ] Old Support DataObject source/tests/docs/cleanup and Database cast are fully removed. +- [ ] Metadata is analyzed once per used class, retained only in worker memory, bounded by declared Data classes, and free of discovery, filesystem, remote I/O, and request-derived state. +- [ ] Upstream source/test ledger is fully reconciled, with only deliberate omissions recorded. +- [ ] README/license attribution and Hypervel difference documentation are complete. +- [ ] The owner has been told exactly which SDK-generator plan sections are superseded; if amendment is authorized, that plan references the final Data API and contains no obsolete proposed framework work. +- [ ] Focused tests, static analysis, formatting, benchmark review, and the final repository suite pass. +- [ ] Final grep/diff audit finds no stale APIs, compatibility switches, dead code, request-state globals, duplicated framework machinery, or unrelated churn. From 8d6783c2dda3fbb36d9faf0aceaf096ff363234d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:46:15 +0000 Subject: [PATCH 13/35] Refine Data declaration metadata Resolve inherited native and PHPDoc types in their declaration scopes, preserve annotation precedence, and reject ambiguous input and output ownership while metadata is built. Compile the additional type, constructor-binding, mapped-path, and named-factory facts needed by the fixed engines. Expand focused coverage for aliases, multi-namespace imports, inheritance, iterable annotations, mapping collisions, and invalid declarations. --- .php-cs-fixer.php | 1 + src/data/src/Attributes/MapName.php | 3 +- src/data/src/Enums/DataTypeKind.php | 20 ++ .../src/Exceptions/InvalidDataDeclaration.php | 22 +- .../DataIterableAnnotationReader.php | 5 +- src/data/src/Support/DataClass.php | 4 +- src/data/src/Support/DataMethod.php | 1 - src/data/src/Support/DataMethodMatch.php | 22 -- src/data/src/Support/DataProperty.php | 37 +++ .../Support/Factories/DataClassFactory.php | 72 ++++- .../Factories/DataParameterFactory.php | 1 + .../Support/Factories/DataPropertyFactory.php | 23 +- .../src/Support/Factories/DataTypeFactory.php | 10 +- .../Support/Types/PhpDocTypeNameResolver.php | 3 +- .../Fixtures/MultiNamespacePhpDocTypes.php | 6 + tests/Data/Fixtures/PhpDocTypeContext.php | 4 + tests/Data/Mappers/NameMapperTest.php | 18 +- .../Support/DataAttributesCollectionTest.php | 2 +- tests/Data/Support/DataClassTest.php | 247 +++++++++++++++++- .../DataIterableAnnotationReaderTest.php | 2 +- tests/Data/Support/DataMethodTest.php | 4 +- tests/Data/Support/DataPropertyTest.php | 52 +++- tests/Data/Support/DataTypeFactoryTest.php | 6 +- .../Support/PhpDocTypeNameResolverTest.php | 6 +- 24 files changed, 501 insertions(+), 70 deletions(-) diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index dba2df56f..278c63424 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -117,6 +117,7 @@ ->exclude('src/testbench/workbench/storage') ->exclude('vendor') ->notPath('#^bin/#') + ->notPath('tests/Data/Fixtures/PhpDocTypeContext.php') ->notPath('tests/Foundation/Fixtures/fake-compiled-view.php') ->name('hypervel-test-profile') ->in(__DIR__) diff --git a/src/data/src/Attributes/MapName.php b/src/data/src/Attributes/MapName.php index ac0e286d1..cac06e4e9 100644 --- a/src/data/src/Attributes/MapName.php +++ b/src/data/src/Attributes/MapName.php @@ -20,8 +20,7 @@ class MapName public function __construct( string|int|NameMapper $input, string|int|NameMapper|null $output = null, - ) - { + ) { $this->input = $input; $this->output = $output ?? $input; } diff --git a/src/data/src/Enums/DataTypeKind.php b/src/data/src/Enums/DataTypeKind.php index 8d7c40c9c..7373f5fa6 100644 --- a/src/data/src/Enums/DataTypeKind.php +++ b/src/data/src/Enums/DataTypeKind.php @@ -80,6 +80,26 @@ public function isNonDataIterable(): bool || $this === self::CursorPaginator; } + /** + * Determine if this kind requires an offset paginator source. + */ + public function isPaginator(): bool + { + return $this === self::Paginator + || $this === self::DataPaginator + || $this === self::DataPaginatedCollection; + } + + /** + * Determine if this kind requires a cursor paginator source. + */ + public function isCursorPaginator(): bool + { + return $this === self::CursorPaginator + || $this === self::DataCursorPaginator + || $this === self::DataCursorPaginatedCollection; + } + /** * Get the equivalent kind containing data objects. */ diff --git a/src/data/src/Exceptions/InvalidDataDeclaration.php b/src/data/src/Exceptions/InvalidDataDeclaration.php index da91a4283..9e760b023 100644 --- a/src/data/src/Exceptions/InvalidDataDeclaration.php +++ b/src/data/src/Exceptions/InvalidDataDeclaration.php @@ -4,8 +4,10 @@ namespace Hypervel\Data\Exceptions; -use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\DataParameter; +use Hypervel\Data\Support\DataProperty; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Support\Collection; use LogicException; class InvalidDataDeclaration extends LogicException @@ -140,4 +142,22 @@ public static function duplicateOutputKey( . 'Give each property a unique output key.' ); } + + /** + * Create an exception for an Eloquent collection with non-model items. + * + * @param class-string $class + * @param class-string $collection + */ + public static function invalidEloquentCollectionItemType( + string $class, + string $collection, + DataProperty $property, + ): self { + return new self( + "Data class [{$class}] property [{$property->className}::\${$property->name}] declares Eloquent " + . "collection [{$collection}], whose item type must guarantee [" . Model::class . ']. ' + . 'Declare only Eloquent model item types or use [' . Collection::class . '] for non-model values.' + ); + } } diff --git a/src/data/src/Support/Annotations/DataIterableAnnotationReader.php b/src/data/src/Support/Annotations/DataIterableAnnotationReader.php index 37f18deab..a70cf31df 100644 --- a/src/data/src/Support/Annotations/DataIterableAnnotationReader.php +++ b/src/data/src/Support/Annotations/DataIterableAnnotationReader.php @@ -139,8 +139,7 @@ protected function extract( TypeNode $type, string $declaringClass, ?string $property = null, - ): array - { + ): array { if ($type instanceof NullableTypeNode) { return $this->extract($type->type, $declaringClass, $property); } @@ -165,7 +164,7 @@ protected function extract( )]; } - if (! $type instanceof GenericTypeNode || ! $type->type instanceof IdentifierTypeNode) { + if (! $type instanceof GenericTypeNode) { return []; } diff --git a/src/data/src/Support/DataClass.php b/src/data/src/Support/DataClass.php index 69883896e..cea9bfd9c 100644 --- a/src/data/src/Support/DataClass.php +++ b/src/data/src/Support/DataClass.php @@ -6,7 +6,6 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Support\Annotations\DataIterableAnnotation; -use ReflectionClass; use ReflectionMethod; /** @@ -24,7 +23,6 @@ * @param array $lifecycleMethods * @param array> $dataIterablePropertyAnnotations * @param array $outputMappedProperties - * @param ReflectionClass $reflection */ public function __construct( public readonly string $name, @@ -51,10 +49,10 @@ public function __construct( public readonly ?string $redirect, public readonly ?string $redirectRoute, public readonly bool $plainTransform, + public readonly bool $directArrayCreation, public readonly DataAttributesCollection $attributes, public readonly array $dataIterablePropertyAnnotations, public readonly array $outputMappedProperties, - public readonly ReflectionClass $reflection, ) { } diff --git a/src/data/src/Support/DataMethod.php b/src/data/src/Support/DataMethod.php index bf551c85a..9f25665f0 100644 --- a/src/data/src/Support/DataMethod.php +++ b/src/data/src/Support/DataMethod.php @@ -168,5 +168,4 @@ public function returns(string $type): bool { return $this->returnType?->acceptsType($type) ?? false; } - } diff --git a/src/data/src/Support/DataMethodMatch.php b/src/data/src/Support/DataMethodMatch.php index 433839e22..8c1ff5a8b 100644 --- a/src/data/src/Support/DataMethodMatch.php +++ b/src/data/src/Support/DataMethodMatch.php @@ -4,8 +4,6 @@ namespace Hypervel\Data\Support; -use LogicException; - final readonly class DataMethodMatch { /** @@ -18,24 +16,4 @@ public function __construct( public bool $requiresContainerCall, ) { } - - /** - * Replace one matched payload without rebuilding the argument map. - */ - public function replacePayload(mixed $payload, mixed $replacement): self - { - $arguments = $this->arguments; - - foreach ($arguments as $key => $argument) { - if ($argument !== $payload) { - continue; - } - - $arguments[$key] = $replacement; - - return new self($arguments, $this->requiresContainerCall); - } - - throw new LogicException('The matched payload is missing from the invocation arguments.'); - } } diff --git a/src/data/src/Support/DataProperty.php b/src/data/src/Support/DataProperty.php index 892a267bc..db5514f8b 100644 --- a/src/data/src/Support/DataProperty.php +++ b/src/data/src/Support/DataProperty.php @@ -10,8 +10,10 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\DataCollection; use Hypervel\Data\Transformers\Transformer; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Enumerable; use Hypervel\Support\LazyCollection; +use Hypervel\Support\StrCache; use ReflectionAttribute; use ReflectionProperty; @@ -24,6 +26,7 @@ class DataProperty * @param null|ReflectionAttribute $autoLazy * @param null|ReflectionAttribute $cast * @param null|ReflectionAttribute $transformer + * @param null|non-empty-list $inputMappedPath * @param list> $configuredCasts * @param list> $configuredTransformers */ @@ -45,6 +48,7 @@ public function __construct( public readonly ?ReflectionAttribute $cast, public readonly ?ReflectionAttribute $transformer, public readonly string|int|null $inputMappedName, + public readonly ?array $inputMappedPath, public readonly string|int|null $outputMappedName, public readonly array $configuredCasts, public readonly array $configuredTransformers, @@ -53,6 +57,20 @@ public function __construct( ) { } + /** + * Get the compiled input path for a selected wire key. + * + * The returned list is shared immutable worker metadata and must not be mutated in place. + * + * @return non-empty-list + */ + public function inputPath(string|int $wireKey): array + { + return $this->inputMappedPath !== null && $wireKey === $this->inputMappedName + ? $this->inputMappedPath + : [$wireKey]; + } + /** * Determine if a supplied value is a finished declared Data value. */ @@ -97,4 +115,23 @@ public function isFinishedValue(mixed $value): bool return true; } + + /** + * Resolve the Eloquent relation selected by this property. + */ + public function resolveModelRelation(Model $model): ?string + { + if (! $this->loadRelation) { + return null; + } + + $name = $model::$snakeAttributes ? StrCache::snake($this->name) : $this->name; + $camelName = StrCache::camel($name); + + return match (true) { + $model->isRelation($name) => $name, + $model->isRelation($camelName) => $camelName, + default => null, + }; + } } diff --git a/src/data/src/Support/Factories/DataClassFactory.php b/src/data/src/Support/Factories/DataClassFactory.php index ef91764e7..e37783329 100644 --- a/src/data/src/Support/Factories/DataClassFactory.php +++ b/src/data/src/Support/Factories/DataClassFactory.php @@ -58,7 +58,7 @@ public function __construct( /** * Build immutable metadata for a data class. * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass */ public function build(ReflectionClass $reflectionClass): DataClass { @@ -103,6 +103,8 @@ public function build(ReflectionClass $reflectionClass): DataClass $errorBag = $attributes->first(ErrorBag::class)?->newInstance(); $redirect = $attributes->first(RedirectTo::class)?->newInstance(); $redirectRoute = $attributes->first(RedirectToRoute::class)?->newInstance(); + $lifecycleMethods = $this->resolveLifecycleMethods($reflectionClass); + $propertyMorphable = $reflectionClass->implementsInterface(PropertyMorphableData::class); return new DataClass( name: $name, @@ -113,7 +115,7 @@ public function build(ReflectionClass $reflectionClass): DataClass isReadonly: $reflectionClass->isReadOnly(), isAbstract: $reflectionClass->isAbstract(), isFinal: $reflectionClass->isFinal(), - propertyMorphable: $reflectionClass->implementsInterface(PropertyMorphableData::class), + propertyMorphable: $propertyMorphable, appendable: $reflectionClass->implementsInterface(AppendableData::class), includeable: $reflectionClass->implementsInterface(IncludeableData::class), responsable: $reflectionClass->implementsInterface(ResponsableData::class), @@ -121,25 +123,31 @@ public function build(ReflectionClass $reflectionClass): DataClass validateable: $reflectionClass->implementsInterface(ValidateableData::class), wrappable: $reflectionClass->implementsInterface(WrappableData::class), emptyData: $reflectionClass->implementsInterface(EmptyData::class), - lifecycleMethods: $this->resolveLifecycleMethods($reflectionClass), + lifecycleMethods: $lifecycleMethods, mergeValidationRules: $attributes->has(MergeValidationRules::class), - failOnUnknownFields: $failOnUnknownFields?->value ?? false, + failOnUnknownFields: $failOnUnknownFields->value ?? false, stopOnFirstFailure: $attributes->has(StopOnFirstFailure::class), errorBag: $errorBag?->name, redirect: $redirect?->url, redirectRoute: $redirectRoute?->route, plainTransform: $this->isPlainTransform($properties), + directArrayCreation: $this->supportsDirectArrayCreation( + $reflectionClass, + $constructorParameters, + $properties, + $lifecycleMethods, + $propertyMorphable, + ), attributes: $attributes, dataIterablePropertyAnnotations: $iterableAnnotations, outputMappedProperties: $this->validateMappings($name, $properties), - reflection: $reflectionClass, ); } /** * Build constructor parameter metadata keyed by parameter name. * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * @return array */ protected function resolveConstructorParameters( @@ -162,7 +170,7 @@ protected function resolveConstructorParameters( /** * Get public, non-static data properties keyed by name. * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * @return array */ protected function resolveReflectionProperties(ReflectionClass $reflectionClass): array @@ -207,7 +215,7 @@ protected function validateConstructorParameters( * Build data properties and their selected iterable annotations. * * @param class-string $class - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * @param array $reflectionProperties * @param array $constructorParameters * @param null|ReflectionAttribute $classAutoLazy @@ -286,7 +294,7 @@ classAutoLazy: $classAutoLazy, /** * Resolve nearest class-level iterable annotations across inheritance. * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * @return array> */ protected function resolveClassAnnotations(ReflectionClass $reflectionClass): array @@ -314,7 +322,7 @@ protected function resolveClassAnnotations(ReflectionClass $reflectionClass): ar /** * Build named creation method metadata in declaration order. * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * @return array */ protected function resolveMethods(ReflectionClass $reflectionClass): array @@ -343,7 +351,7 @@ protected function resolveMethods(ReflectionClass $reflectionClass): array /** * Compile user-owned creation lifecycle method presence. * - * @param ReflectionClass $reflectionClass + * @param ReflectionClass $reflectionClass * @return array */ protected function resolveLifecycleMethods(ReflectionClass $reflectionClass): array @@ -435,6 +443,48 @@ protected function validateMappings(string $class, array $properties): array return $outputMappedProperties; } + /** + * Determine if exact array values can bypass general construction. + * + * @param ReflectionClass $reflectionClass + * @param array $constructorParameters + * @param array $properties + * @param array $lifecycleMethods + */ + protected function supportsDirectArrayCreation( + ReflectionClass $reflectionClass, + array $constructorParameters, + array $properties, + array $lifecycleMethods, + bool $propertyMorphable, + ): bool { + if ($reflectionClass->isAbstract() + || $propertyMorphable + || isset($lifecycleMethods['normalizers']) + || $this->config->normalizers !== []) { + return false; + } + + foreach ($constructorParameters as $parameter) { + if ($parameter->contextualAttribute !== null) { + return false; + } + } + + foreach ($properties as $property) { + if ($property->autoLazy !== null + || $property->loadRelation + || $property->cast !== null + || $property->configuredCasts !== [] + || $property->type->getDataCollectableTypes() !== [] + || $property->type->getIterableTypes() !== []) { + return false; + } + } + + return true; + } + /** * Determine if declared values can be copied directly during transformation. * diff --git a/src/data/src/Support/Factories/DataParameterFactory.php b/src/data/src/Support/Factories/DataParameterFactory.php index dc90f3c73..ebc3c88bd 100644 --- a/src/data/src/Support/Factories/DataParameterFactory.php +++ b/src/data/src/Support/Factories/DataParameterFactory.php @@ -30,6 +30,7 @@ public function build( ReflectionParameter $reflectionParameter, ReflectionClass $reflectionClass, ): DataParameter { + // REMOVED: Data-specific From* aliases; every Hypervel contextual attribute works directly. return new DataParameter( name: $reflectionParameter->name, position: $reflectionParameter->getPosition(), diff --git a/src/data/src/Support/Factories/DataPropertyFactory.php b/src/data/src/Support/Factories/DataPropertyFactory.php index c2ab78be0..264e5454d 100644 --- a/src/data/src/Support/Factories/DataPropertyFactory.php +++ b/src/data/src/Support/Factories/DataPropertyFactory.php @@ -5,6 +5,7 @@ namespace Hypervel\Data\Support\Factories; use Hypervel\Data\Attributes\AutoLazy; +use Hypervel\Data\Attributes\AutoWhenLoadedLazy; use Hypervel\Data\Attributes\Computed; use Hypervel\Data\Attributes\GetsCast; use Hypervel\Data\Attributes\Hidden; @@ -13,6 +14,7 @@ use Hypervel\Data\Attributes\WithCastAndTransformer; use Hypervel\Data\Attributes\WithoutValidation; use Hypervel\Data\Attributes\WithTransformer; +use Hypervel\Data\Exceptions\InvalidDataDeclaration; use Hypervel\Data\Mappers\NameMapper; use Hypervel\Data\Optional; use Hypervel\Data\Support\Annotations\DataIterableAnnotation; @@ -21,6 +23,8 @@ use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\DataPropertyType; use Hypervel\Data\Support\NameMapperResolver; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; use ReflectionAttribute; use ReflectionClass; use ReflectionProperty; @@ -93,12 +97,13 @@ public function build( $isVirtual = $reflectionProperty->isVirtual(); $computed = $attributes->has(Computed::class) || $isVirtual; - return new DataProperty( + $property = new DataProperty( name: $reflectionProperty->name, className: $reflectionProperty->class, type: $type, validate: ! $computed && $constructorParameter?->contextualAttribute === null + && ! $attributes->has(AutoWhenLoadedLazy::class) && ! $attributes->has(WithoutValidation::class), computed: $computed, hidden: $attributes->has(Hidden::class), @@ -114,12 +119,28 @@ className: $reflectionProperty->class, transformer: $attributes->first(WithTransformer::class) ?? $attributes->first(WithCastAndTransformer::class), inputMappedName: $inputMappedName, + inputMappedPath: $inputMappedName === null + ? null + : (is_int($inputMappedName) ? [$inputMappedName] : explode('.', $inputMappedName)), outputMappedName: $outputMappedName, configuredCasts: $this->applicableExtensions($type, $this->config->casts), configuredTransformers: $this->applicableExtensions($type, $this->config->transformers), attributes: $attributes, reflection: $reflectionProperty, ); + + foreach ($type->getIterableTypes() as $iterableType) { + if (is_a($iterableType->name, EloquentCollection::class, true) + && ! $iterableType->iterableItemType->guaranteesType(Model::class)) { + throw InvalidDataDeclaration::invalidEloquentCollectionItemType( + $reflectionClass->getName(), + $iterableType->name, + $property, + ); + } + } + + return $property; } /** diff --git a/src/data/src/Support/Factories/DataTypeFactory.php b/src/data/src/Support/Factories/DataTypeFactory.php index 2b569fdc3..89c5e21ad 100644 --- a/src/data/src/Support/Factories/DataTypeFactory.php +++ b/src/data/src/Support/Factories/DataTypeFactory.php @@ -61,7 +61,7 @@ public function __construct( /** * Build a data property type. * - * @param ReflectionClass|class-string $class + * @param class-string|ReflectionClass $class * @param list $iterableAnnotations */ public function buildProperty( @@ -98,7 +98,7 @@ public function buildProperty( /** * Build a parameter or return data type. * - * @param ReflectionClass|class-string $class + * @param class-string|ReflectionClass $class */ public function build( ?ReflectionType $reflectionType, @@ -123,7 +123,7 @@ public function build( /** * Build a data type from a declared type name. * - * @param ReflectionClass|class-string $class + * @param class-string|ReflectionClass $class */ public function buildFromString( string $type, @@ -290,7 +290,7 @@ protected function buildPhpDocType( ); } - if ($type instanceof GenericTypeNode && $type->type instanceof IdentifierTypeNode) { + if ($type instanceof GenericTypeNode) { $name = $type->type->name; $genericTypes = $type->genericTypes; @@ -597,7 +597,7 @@ protected function declaringClass( /** * Get the reflected class context. * - * @param ReflectionClass|class-string $class + * @param class-string|ReflectionClass $class * @return ReflectionClass */ protected function reflectionClass(ReflectionClass|string $class): ReflectionClass diff --git a/src/data/src/Support/Types/PhpDocTypeNameResolver.php b/src/data/src/Support/Types/PhpDocTypeNameResolver.php index 653fe5110..0ff10cde1 100644 --- a/src/data/src/Support/Types/PhpDocTypeNameResolver.php +++ b/src/data/src/Support/Types/PhpDocTypeNameResolver.php @@ -63,7 +63,7 @@ protected function importsFor(ReflectionClass $class): array /** * Parse class imports from a PHP source file. * - * @return array + * @return array> */ protected function parseImports(string $file): array { @@ -288,5 +288,4 @@ protected static function isBuiltIn(string $type): bool 'void', ], true); } - } diff --git a/tests/Data/Fixtures/MultiNamespacePhpDocTypes.php b/tests/Data/Fixtures/MultiNamespacePhpDocTypes.php index b6c8450ee..ea4709cab 100644 --- a/tests/Data/Fixtures/MultiNamespacePhpDocTypes.php +++ b/tests/Data/Fixtures/MultiNamespacePhpDocTypes.php @@ -5,6 +5,9 @@ namespace Hypervel\Tests\Data\Fixtures\First { use Hypervel\Tests\Data\Fixtures\Types\ImportedType as SharedAlias; + /** + * @property SharedAlias $value + */ class MultiNamespaceFirst { } @@ -13,6 +16,9 @@ class MultiNamespaceFirst namespace Hypervel\Tests\Data\Fixtures\Second { use Hypervel\Tests\Data\Fixtures\Types\GroupedType as SharedAlias; + /** + * @property SharedAlias $value + */ class MultiNamespaceSecond { } diff --git a/tests/Data/Fixtures/PhpDocTypeContext.php b/tests/Data/Fixtures/PhpDocTypeContext.php index eae2ceee5..4116e528d 100644 --- a/tests/Data/Fixtures/PhpDocTypeContext.php +++ b/tests/Data/Fixtures/PhpDocTypeContext.php @@ -7,6 +7,10 @@ use Hypervel\Tests\Data\Fixtures\Types\ImportedType; use Hypervel\Tests\Data\Fixtures\Types\{GroupedType as GroupAlias}; +/** + * @property ImportedType $imported + * @property GroupAlias $grouped + */ class PhpDocTypeContext extends TypeNameResolverParent { } diff --git a/tests/Data/Mappers/NameMapperTest.php b/tests/Data/Mappers/NameMapperTest.php index 16861a315..148b78037 100644 --- a/tests/Data/Mappers/NameMapperTest.php +++ b/tests/Data/Mappers/NameMapperTest.php @@ -17,6 +17,15 @@ class NameMapperTest extends TestCase { + #[DataProvider('caseMapperProvider')] + public function testCaseMappersTransformStringsAndPreserveIntegerKeys( + NameMapper $mapper, + string $expected, + ): void { + $this->assertSame($expected, $mapper->map('first name')); + $this->assertSame(10, $mapper->map(10)); + } + /** * Provide case mapper examples. * @@ -32,15 +41,6 @@ public static function caseMapperProvider(): iterable yield 'upper' => [new UpperCaseMapper, 'FIRST NAME']; } - #[DataProvider('caseMapperProvider')] - public function testCaseMappersTransformStringsAndPreserveIntegerKeys( - NameMapper $mapper, - string $expected, - ): void { - $this->assertSame($expected, $mapper->map('first name')); - $this->assertSame(10, $mapper->map(10)); - } - public function testProvidedNameMapperReturnsItsConfiguredName(): void { $this->assertSame('wire_name', (new ProvidedNameMapper('wire_name'))->map('property')); diff --git a/tests/Data/Support/DataAttributesCollectionTest.php b/tests/Data/Support/DataAttributesCollectionTest.php index fc251ba03..29cd84ccb 100644 --- a/tests/Data/Support/DataAttributesCollectionTest.php +++ b/tests/Data/Support/DataAttributesCollectionTest.php @@ -74,7 +74,7 @@ public function testUnknownAttributesAreIgnored(): void new ReflectionClass(DataAttributesUnknownAttributeFixture::class), ); - $this->assertFalse($attributes->has('Hypervel\\Tests\\Data\\Support\\MissingAttribute')); + $this->assertFalse($attributes->has('Hypervel\Tests\Data\Support\MissingAttribute')); } /** diff --git a/tests/Data/Support/DataClassTest.php b/tests/Data/Support/DataClassTest.php index edaac6e85..d48dec925 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -8,15 +8,28 @@ use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; +use Hypervel\Data\Attributes\AutoLazy; use Hypervel\Data\Attributes\Computed; +use Hypervel\Data\Attributes\DataCollectionOf; use Hypervel\Data\Attributes\Hidden; +use Hypervel\Data\Attributes\LoadRelation; use Hypervel\Data\Attributes\MapInputName; use Hypervel\Data\Attributes\MapName; use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Attributes\MergeValidationRules; +use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Casts\Cast; +use Hypervel\Data\Contracts\PropertyMorphableData; +use Hypervel\Data\Data; +use Hypervel\Data\DataCollection; use Hypervel\Data\Exceptions\InvalidDataDeclaration; +use Hypervel\Data\Lazy; use Hypervel\Data\Mappers\SnakeCaseMapper; +use Hypervel\Data\Normalizers\Normalized\Normalized; +use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; +use Hypervel\Data\Support\Creation\ConstructionState; +use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Factories\DataClassFactory; @@ -26,17 +39,19 @@ use Hypervel\Data\Support\Factories\DataTypeFactory; use Hypervel\Data\Support\NameMapperResolver; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Http\Attributes\ErrorBag; use Hypervel\Foundation\Http\Attributes\FailOnUnknownFields; use Hypervel\Foundation\Http\Attributes\RedirectTo; use Hypervel\Foundation\Http\Attributes\RedirectToRoute; use Hypervel\Foundation\Http\Attributes\StopOnFirstFailure; -use Hypervel\Tests\TestCase; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\ChildScope\ChildAnnotations; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\ChildClassItem; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\ConstructorItem; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\InlineItem; use Hypervel\Tests\Data\Fixtures\DataClassAnnotations\Items\ParentClassItem; +use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use RuntimeException; @@ -120,6 +135,49 @@ public function testContextualParametersUseOneUnambiguousOwnershipForm(): void $this->assertSame('userId', $constructorOnly->constructorParameters[0]->name); } + /** + * Test direct array creation requires a fixed array-safe class shape. + */ + public function testDirectArrayCreationEligibilityUsesCompiledClassAndPropertyFacts(): void + { + $this->assertTrue( + $this->factory()->build(new ReflectionClass(DirectArrayCreationDataFixture::class))->directArrayCreation, + ); + + foreach ([ + AbstractDirectArrayCreationDataFixture::class, + MorphableDirectArrayCreationDataFixture::class, + ClassNormalizerDirectArrayCreationDataFixture::class, + PromotedContextualDataFixture::class, + AutoLazyDirectArrayCreationDataFixture::class, + LoadRelationDirectArrayCreationDataFixture::class, + AttributeCastDirectArrayCreationDataFixture::class, + DataCollectableDirectArrayCreationDataFixture::class, + TypedIterableDirectArrayCreationDataFixture::class, + ] as $class) { + $this->assertFalse( + $this->factory()->build(new ReflectionClass($class))->directArrayCreation, + $class, + ); + } + } + + /** + * Test configured creation extensions disable the direct array path. + */ + public function testDirectArrayCreationEligibilityUsesBootConfiguration(): void + { + $configuredCast = $this->factory([ + 'casts' => ['string' => DirectArrayCreationCast::class], + ])->build(new ReflectionClass(DirectArrayCreationDataFixture::class)); + $configuredNormalizer = $this->factory([ + 'normalizers' => [DirectArrayCreationNormalizer::class], + ])->build(new ReflectionClass(DirectArrayCreationDataFixture::class)); + + $this->assertFalse($configuredCast->directArrayCreation); + $this->assertFalse($configuredNormalizer->directArrayCreation); + } + /** * Test invalid constructor/property ownership declarations. * @@ -201,6 +259,47 @@ public function testPrivateConstructorIsAValidNamedFactoryOnlyDeclaration(): voi $this->assertArrayHasKey('fromString', $class->methods); } + /** + * Test Eloquent collection properties accept guaranteed model items. + */ + public function testEloquentCollectionPropertiesAcceptModelItems(): void + { + $class = $this->factory()->build(new ReflectionClass(EloquentModelCollectionDataFixture::class)); + + $this->assertSame( + EloquentCollection::class, + $class->properties['models']->type->getIterableTypes()[0]->name, + ); + } + + /** + * Test invalid Eloquent collection item declarations. + * + * @param class-string $class + */ + #[DataProvider('invalidEloquentCollectionProvider')] + public function testEloquentCollectionPropertiesRejectItemsThatDoNotGuaranteeModels(string $class): void + { + $this->expectException(InvalidDataDeclaration::class); + $this->expectExceptionMessage('must guarantee'); + $this->expectExceptionMessage(Model::class); + + $this->factory()->build(new ReflectionClass($class)); + } + + /** + * Provide invalid Eloquent collection item declarations. + */ + public static function invalidEloquentCollectionProvider(): array + { + return [ + 'scalar' => [EloquentScalarCollectionDataFixture::class], + 'union' => [EloquentUnionCollectionDataFixture::class], + 'intersection' => [EloquentIntersectionCollectionDataFixture::class], + 'dnf' => [EloquentDnfCollectionDataFixture::class], + ]; + } + /** * Create the metadata factory with boot-stable collaborators. */ @@ -304,6 +403,110 @@ public function __construct( } } +class DirectArrayCreationDataFixture extends Data +{ + /** + * Create a new direct-array fixture. + */ + public function __construct(public string $value = 'default') + { + } +} + +abstract class AbstractDirectArrayCreationDataFixture extends DirectArrayCreationDataFixture +{ +} + +class MorphableDirectArrayCreationDataFixture extends DirectArrayCreationDataFixture implements PropertyMorphableData +{ + /** + * Resolve the concrete fixture class. + */ + public static function morph(array $properties): ?string + { + return static::class; + } +} + +class ClassNormalizerDirectArrayCreationDataFixture extends DirectArrayCreationDataFixture +{ + /** + * Get the class-owned normalizers. + */ + public static function normalizers(): array + { + return [DirectArrayCreationNormalizer::class]; + } +} + +class AutoLazyDirectArrayCreationDataFixture extends Data +{ + /** + * Create a new automatic-lazy fixture. + */ + public function __construct( + #[AutoLazy] + public string|Lazy $value, + ) { + } +} + +class LoadRelationDirectArrayCreationDataFixture extends DirectArrayCreationDataFixture +{ + #[LoadRelation] + public string $relation; +} + +class AttributeCastDirectArrayCreationDataFixture extends DirectArrayCreationDataFixture +{ + #[WithCast(DirectArrayCreationCast::class)] + public string $castValue; +} + +class DataCollectableDirectArrayCreationDataFixture extends Data +{ + /** + * Create a new data-collectable fixture. + */ + public function __construct( + #[DataCollectionOf(DirectArrayCreationDataFixture::class)] + public DataCollection $items, + ) { + } +} + +class TypedIterableDirectArrayCreationDataFixture extends Data +{ + /** @var list */ + public array $items; +} + +class DirectArrayCreationCast implements Cast +{ + /** + * Cast the fixture value. + */ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): mixed { + return $value; + } +} + +class DirectArrayCreationNormalizer implements Normalizer +{ + /** + * Normalize the fixture value. + */ + public function normalize(mixed $value): array|Normalized|null + { + return null; + } +} + class PromotedContextualDataFixture { /** @@ -433,6 +636,48 @@ public static function fromString(string $name): static } } +class DataClassEloquentModel extends Model implements DataClassEloquentMarker +{ +} + +interface DataClassEloquentMarker +{ +} + +interface DataClassEloquentOtherMarker +{ +} + +class EloquentModelCollectionDataFixture +{ + /** @var EloquentCollection */ + public EloquentCollection $models; +} + +class EloquentScalarCollectionDataFixture +{ + /** @var EloquentCollection */ + public EloquentCollection $models; +} + +class EloquentUnionCollectionDataFixture +{ + /** @var EloquentCollection */ + public EloquentCollection $models; +} + +class EloquentIntersectionCollectionDataFixture +{ + /** @var EloquentCollection */ + public EloquentCollection $models; +} + +class EloquentDnfCollectionDataFixture +{ + /** @var EloquentCollection */ + public EloquentCollection $models; +} + #[Attribute(Attribute::TARGET_PARAMETER)] class ContextualValue implements ContextualAttribute { diff --git a/tests/Data/Support/DataIterableAnnotationReaderTest.php b/tests/Data/Support/DataIterableAnnotationReaderTest.php index 3046b173d..885318bdf 100644 --- a/tests/Data/Support/DataIterableAnnotationReaderTest.php +++ b/tests/Data/Support/DataIterableAnnotationReaderTest.php @@ -90,7 +90,7 @@ class DataIterablePropertyFixture /** @var list */ public array $list; - /** @var Collection|null */ + /** @var null|Collection */ public ?object $nullable; /** @var array|Collection */ diff --git a/tests/Data/Support/DataMethodTest.php b/tests/Data/Support/DataMethodTest.php index 3bb4e731c..7c47c3e14 100644 --- a/tests/Data/Support/DataMethodTest.php +++ b/tests/Data/Support/DataMethodTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Data\Support; use Attribute; -use Hypervel\Container\Container; use Hypervel\Container\Attributes\Config; +use Hypervel\Container\Container; use Hypervel\Data\Enums\CustomCreationMethodType; use Hypervel\Data\Exceptions\InvalidDataDeclaration; use Hypervel\Data\Support\Creation\CreationContext; @@ -321,7 +321,7 @@ public function testVariadicCreationContextIsRejectedDuringMetadataBuild(): void { $this->expectException(InvalidDataDeclaration::class); $this->expectExceptionMessage( - 'Data factory [Hypervel\\Tests\\Data\\Support\\DataMethodInvalidFixture::fromContexts] ' + 'Data factory [Hypervel\Tests\Data\Support\DataMethodInvalidFixture::fromContexts] ' . 'cannot declare variadic CreationContext parameter [$contexts]. ' . 'Declare a single CreationContext parameter instead.', ); diff --git a/tests/Data/Support/DataPropertyTest.php b/tests/Data/Support/DataPropertyTest.php index 1df605f7b..ac1091996 100644 --- a/tests/Data/Support/DataPropertyTest.php +++ b/tests/Data/Support/DataPropertyTest.php @@ -15,8 +15,8 @@ use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Attributes\PropertyForMorph; use Hypervel\Data\Attributes\WithCast; -use Hypervel\Data\Attributes\WithTransformer; use Hypervel\Data\Attributes\WithoutValidation; +use Hypervel\Data\Attributes\WithTransformer; use Hypervel\Data\Casts\Cast; use Hypervel\Data\Mappers\KebabCaseMapper; use Hypervel\Data\Mappers\SnakeCaseMapper; @@ -33,6 +33,7 @@ use Hypervel\Data\Support\Transformation\TransformationContext; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; use Hypervel\Data\Transformers\Transformer; +use Hypervel\Database\Eloquent\Model; use Hypervel\Tests\TestCase; use ReflectionAttribute; use ReflectionClass; @@ -60,6 +61,9 @@ public function testPropertyMetadataCompilesFlagsMappingsAndRecipes(): void $this->assertFalse($property->validate); $this->assertTrue($property->hidden); $this->assertSame('wire.name', $property->inputMappedName); + $this->assertSame(['wire', 'name'], $property->inputMappedPath); + $this->assertSame(['wire', 'name'], $property->inputPath('wire.name')); + $this->assertSame(['displayName'], $property->inputPath('displayName')); $this->assertSame('display', $property->outputMappedName); $this->assertSame([PropertyFallbackCast::class], $property->configuredCasts); $this->assertSame([PropertyFallbackTransformer::class], $property->configuredTransformers); @@ -150,11 +154,40 @@ public function testNameMappersAreResolvedOnceWithPropertyPrecedence(): void $numeric = $this->buildProperty($factory, $class, 'numeric', $config, $mapperResolver); $this->assertSame('created_at', $mapped->inputMappedName); + $this->assertSame(['created_at'], $mapped->inputMappedPath); $this->assertSame('created_at', $mapped->outputMappedName); $this->assertSame(0, $numeric->inputMappedName); + $this->assertSame([0], $numeric->inputMappedPath); $this->assertSame('numeric', $numeric->outputMappedName); } + /** + * Test model relation resolution follows the normalized property name. + */ + public function testResolvesOnlyMarkedEloquentRelations(): void + { + [$factory, $config, $mapperResolver] = $this->factory(); + $class = new ReflectionClass(DataPropertyFixture::class); + $model = new PropertyRelationModel; + $relation = $this->buildProperty($factory, $class, 'relation', $config, $mapperResolver); + $camelRelation = $this->buildProperty( + $factory, + $class, + 'loadedProfile', + $config, + $mapperResolver, + ); + $unmarked = $this->buildProperty($factory, $class, 'createdAt', $config, $mapperResolver); + + $this->assertSame('relation', $relation->resolveModelRelation($model)); + $this->assertSame('loadedProfile', $camelRelation->resolveModelRelation($model)); + $this->assertNull($unmarked->resolveModelRelation($model)); + + $model->relations = []; + + $this->assertNull($relation->resolveModelRelation($model)); + } + /** * Build one fixture property with its constructor default metadata. * @@ -249,6 +282,9 @@ public function __construct( #[LoadRelation] public PropertyRelation $relation; + #[LoadRelation] + public PropertyRelation $loadedProfile; + #[PropertyForMorph] public string $type; @@ -271,6 +307,20 @@ class PropertyRelation { } +class PropertyRelationModel extends Model +{ + /** @var list */ + public array $relations = ['relation', 'loadedProfile']; + + /** + * Determine if a fixture relation exists. + */ + public function isRelation(string $key): bool + { + return in_array($key, $this->relations, true); + } +} + class PropertyCast implements Cast { /** diff --git a/tests/Data/Support/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php index 27c063189..93ce3f39f 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -18,7 +18,7 @@ use Hypervel\Data\Support\Types\UnionType; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Support\Collection; -use Hypervel\Tests\Data\Fixtures\Types\{ImportedData as GroupedImportedData}; +use Hypervel\Tests\Data\Fixtures\Types\ImportedData as GroupedImportedData; use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionClass; @@ -307,7 +307,7 @@ class DataTypeFactoryFixture #[DataCollectionOf(DataTypeFactoryItemData::class)] public array $attributed; - /** @var array */ + /** @var array */ public array $unionItems; public DataTypeFactoryItemData $data; @@ -318,7 +318,7 @@ class DataTypeFactoryFixture /** @var Collection|EloquentCollection */ public EloquentCollection|Collection $annotationBaseFirst; - /** @var EloquentCollection|Collection */ + /** @var Collection|EloquentCollection */ public EloquentCollection|Collection $annotationExactFirst; public float $float; diff --git a/tests/Data/Support/PhpDocTypeNameResolverTest.php b/tests/Data/Support/PhpDocTypeNameResolverTest.php index 90c668fc4..8c4c7bee2 100644 --- a/tests/Data/Support/PhpDocTypeNameResolverTest.php +++ b/tests/Data/Support/PhpDocTypeNameResolverTest.php @@ -41,10 +41,14 @@ public function testImportedNamesAreResolvedFromOneCachedSourceMap(): void $resolver = new PhpDocTypeNameResolver; $class = new ReflectionClass(PhpDocTypeContext::class); + $this->assertStringContainsString( + 'use Hypervel\Tests\Data\Fixtures\Types\{GroupedType as GroupAlias};', + file_get_contents(__DIR__ . '/../Fixtures/PhpDocTypeContext.php'), + ); $this->assertTrue(class_exists(SameNamespaceImportedType::class)); $this->assertSame(ImportedType::class, $resolver->resolve('ImportedType', $class)); $this->assertSame(GroupedType::class, $resolver->resolve('GroupAlias', $class)); - $this->assertSame(GroupedType::class . '\\Nested', $resolver->resolve('GroupAlias\\Nested', $class)); + $this->assertSame(GroupedType::class . '\Nested', $resolver->resolve('GroupAlias\Nested', $class)); $this->assertCount(1, $this->imports($resolver)); } From c813e50774c4da59bb71e436ba71a0c22097813c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:46:31 +0000 Subject: [PATCH 14/35] Preserve prepared validation graphs for Precognition Add Validator::retainRules() so Precognition narrows the graph already expanded for the current payload without replacing wildcard identity or the original declarations used by setData(). Move FormRequest, request macros, and request validation helpers onto the new contract. Tighten unknown-field path handling and cover retained dependent rules, wildcard labels, graph rebuilding, and Precognition behavior. --- src/contracts/src/Validation/Validator.php | 10 ++++++ src/foundation/src/Http/FormRequest.php | 4 +-- .../Providers/FoundationServiceProvider.php | 7 ++-- .../src/Validation/ValidatesRequests.php | 8 ++--- src/validation/src/UnknownFields.php | 27 +++++++------- src/validation/src/Validator.php | 21 +++++++++++ .../Integration/Routing/PrecognitionTest.php | 35 +++++++++++++++++++ tests/Validation/ValidationValidatorTest.php | 34 ++++++++++++++++++ 8 files changed, 123 insertions(+), 23 deletions(-) diff --git a/src/contracts/src/Validation/Validator.php b/src/contracts/src/Validation/Validator.php index 5d62f6e6e..5fdaf05c4 100644 --- a/src/contracts/src/Validation/Validator.php +++ b/src/contracts/src/Validation/Validator.php @@ -76,6 +76,16 @@ public function getRulesWithoutPlaceholders(): array; */ public function setRules(array $rules): static; + /** + * Retain the selected rules from the graph prepared for the current data. + * + * Retention applies only to the current prepared graph. Calling setData() + * rebuilds the complete original rule graph. + * + * @param list $attributes + */ + public function retainRules(array $attributes): static; + /** * Get a validated input container for the validated input. */ diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php index 002b5ce1b..413aac551 100644 --- a/src/foundation/src/Http/FormRequest.php +++ b/src/foundation/src/Http/FormRequest.php @@ -221,9 +221,9 @@ protected function createDefaultValidator(ValidationFactory $factory): Validator if ($this->isPrecognitive()) { $this->unfilteredValidationRules = $validator->getRulesWithoutPlaceholders(); - $validator->setRules( + $validator->retainRules(array_keys( $this->filterPrecognitiveRules($this->unfilteredValidationRules) - ); + )); } return $validator; diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php index e4953eaa8..24fb01a78 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -87,6 +87,7 @@ use Hypervel\Foundation\Http\HtmlDumper; use Hypervel\Foundation\Listeners\ReloadDotenvAndConfig; use Hypervel\Foundation\MaintenanceModeManager; +use Hypervel\Foundation\Precognition; use Hypervel\Foundation\WorkerCachedMaintenanceMode; use Hypervel\Http\Request; use Hypervel\Log\Events\MessageLogged; @@ -277,10 +278,10 @@ protected function registerRequestValidation(): void Request::macro('validate', function (array $rules, ...$params) { return tap(validator($this->all(), $rules, ...$params), function ($validator) { if ($this->isPrecognitive()) { - $validator->after(\Hypervel\Foundation\Precognition::afterValidationHook($this)) - ->setRules( + $validator->after(Precognition::afterValidationHook($this)) + ->retainRules(array_keys( $this->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders()) - ); + )); } })->validate(); }); diff --git a/src/foundation/src/Validation/ValidatesRequests.php b/src/foundation/src/Validation/ValidatesRequests.php index 69db293ea..3aa0e2523 100644 --- a/src/foundation/src/Validation/ValidatesRequests.php +++ b/src/foundation/src/Validation/ValidatesRequests.php @@ -27,9 +27,9 @@ public function validateWith(Validator|array $validator, ?Request $request = nul if ($request->isPrecognitive()) { $validator->after(Precognition::afterValidationHook($request)) - ->setRules( + ->retainRules(array_keys( $request->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders()) - ); + )); } return $validator->validate(); @@ -51,9 +51,9 @@ public function validate(Request $request, array $rules, array $messages = [], a if ($request->isPrecognitive()) { $validator->after(Precognition::afterValidationHook($request)) - ->setRules( + ->retainRules(array_keys( $request->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders()) - ); + )); } return $validator->validate(); diff --git a/src/validation/src/UnknownFields.php b/src/validation/src/UnknownFields.php index 0e22b75fa..1979be52c 100644 --- a/src/validation/src/UnknownFields.php +++ b/src/validation/src/UnknownFields.php @@ -26,13 +26,13 @@ public static function validate( ? $validator->getRulesWithoutPlaceholders() : array_replace($unfilteredRules, $validator->getRulesWithoutPlaceholders()); - [$knownFields, $knownSubtrees, $wildcardFields, $wildcardSubtrees] = static::resolveKnownFields( + [$knownFields, $knownSubtrees, $wildcardFields, $wildcardSubtrees] = self::resolveKnownFields( $rules, $additionalFields, $allowedSubtrees, ); - static::validateInput( + self::validateInput( $validator, $input, $knownFields, @@ -66,7 +66,7 @@ private static function validateInput( foreach ($input as $key => $value) { $key = (string) $key; $comparisonKey = $comparisonPrefix - . str_replace(['.', '*'], ['\\.', '\\*'], $key); + . str_replace(['.', '*'], ['\.', '\*'], $key); $displayKey = $displayPrefix . $key; $currentInputSegments = $inputSegments; @@ -75,7 +75,7 @@ private static function validateInput( } if (is_array($value) && $value !== []) { - static::validateInput( + self::validateInput( $validator, $value, $knownFields, @@ -90,7 +90,7 @@ private static function validateInput( continue; } - if (static::isKnownField( + if (self::isKnownField( $comparisonKey, $currentInputSegments, $knownFields, @@ -127,15 +127,15 @@ private static function resolveKnownFields( array $additionalFields, array $allowedSubtrees, ): array { - [$knownFields, $wildcardFields] = static::resolveAuxiliaryPaths($additionalFields); - [$opaqueSubtrees, $wildcardSubtrees] = static::resolveAuxiliaryPaths($allowedSubtrees); + [$knownFields, $wildcardFields] = self::resolveAuxiliaryPaths($additionalFields); + [$opaqueSubtrees, $wildcardSubtrees] = self::resolveAuxiliaryPaths($allowedSubtrees); $fieldsWithDescendants = []; foreach (array_keys($rules) as $attribute) { $attribute = (string) $attribute; $knownFields[$attribute] = true; - foreach (static::parentPaths($attribute) as $parent) { + foreach (self::parentPaths($attribute) as $parent) { $fieldsWithDescendants[$parent] = true; } } @@ -171,7 +171,7 @@ private static function resolveAuxiliaryPaths(array $paths): array $wildcardPaths = []; foreach ($paths as $path) { - $segments = static::parseAuxiliaryPath($path); + $segments = self::parseAuxiliaryPath($path); if ($segments === null) { continue; @@ -208,25 +208,24 @@ private static function isKnownField( return true; } - foreach (static::parentPaths($inputKey) as $parent) { + foreach (self::parentPaths($inputKey) as $parent) { if (isset($allowedSubtrees[$parent])) { return true; } } - if ($wildcardFields === [] && $wildcardSubtrees === []) { + if (($wildcardFields === [] && $wildcardSubtrees === []) || $inputSegments === null) { return false; } - /** @var list $inputSegments */ foreach ($wildcardFields as $pattern) { - if (static::matchesPathPattern($pattern, $inputSegments)) { + if (self::matchesPathPattern($pattern, $inputSegments)) { return true; } } foreach ($wildcardSubtrees as $pattern) { - if (static::matchesPathPattern($pattern, $inputSegments, allowsDescendants: true)) { + if (self::matchesPathPattern($pattern, $inputSegments, allowsDescendants: true)) { return true; } } diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index a4fc1e640..b16bf8821 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -1671,6 +1671,27 @@ public function setRules(array $rules): static return $this; } + /** + * Retain the selected rules from the graph prepared for the current data. + * + * Retention applies only to the current prepared graph. Calling setData() + * rebuilds the complete original rule graph. + * + * @param list $attributes + */ + public function retainRules(array $attributes): static + { + $retained = []; + + foreach ($attributes as $attribute) { + $retained[static::encodeAttributeWithPlaceholder($attribute)] = true; + } + + $this->rules = array_intersect_key($this->rules, $retained); + + return $this; + } + /** * Append new validation rules to the validator. */ diff --git a/tests/Integration/Routing/PrecognitionTest.php b/tests/Integration/Routing/PrecognitionTest.php index 9ab2765b1..754a4eb74 100644 --- a/tests/Integration/Routing/PrecognitionTest.php +++ b/tests/Integration/Routing/PrecognitionTest.php @@ -616,6 +616,27 @@ public function testItCanValidateSpecificIndexWithoutWildcard() ]); } + public function testItRetainsWildcardIdentityWhenValidatingSpecificInputs(): void + { + Route::post('test-route', [PrecognitionTestController::class, 'methodWhereDistinctUsersAreValidated']) + ->middleware(PrecognitionInvokingController::class); + + $response = $this->postJson('test-route', [ + 'users' => [ + ['email' => 'duplicate@example.com', 'email_confirmation' => 'duplicate@example.com'], + ['email' => 'duplicate@example.com', 'email_confirmation' => 'duplicate@example.com'], + ], + ], [ + 'Precognition' => 'true', + 'Precognition-Validate-Only' => 'users.1.email,users.1.email_confirmation', + ]); + + $response->assertUnprocessable(); + $response->assertJsonPath('errors', [ + 'users.1.email' => ['The users.1.email field has a duplicate value.'], + ]); + } + public function testItAppendsAnAdditionalVaryHeaderInsteadOfReplacingAnyExistingVaryHeaders() { Route::get('test-route', function () { @@ -1347,6 +1368,20 @@ public function methodWhereUsersAreValidated(Request $request) fail(); } + public function methodWhereDistinctUsersAreValidated(Request $request) + { + precognitive(function () use ($request) { + $this->validate($request, [ + 'users.*.email' => ['required', 'email', 'distinct'], + 'users.*.email_confirmation' => ['required', 'same:users.*.email'], + ]); + + fail(); + }); + + fail(); + } + public function methodWithMultipleRootKeys(Request $request) { precognitive(function () use ($request) { diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 8d0a95a89..a5df8fb9e 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -8501,6 +8501,40 @@ public function testSetRulesClearsPreviousImplicitAttributeIdentity(): void $this->assertTrue($validator->passes()); } + public function testRetainRulesPreservesImplicitAttributeIdentity(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['foo' => ['duplicate', 'duplicate']], + ['foo.*' => 'distinct', 'bar' => 'required'], + ); + + $validator->retainRules(['foo.0', 'missing']); + + $this->assertSame(['foo.0'], array_keys($validator->getRulesWithoutPlaceholders())); + $this->assertFalse($validator->passes()); + $this->assertSame(['foo.0' => ['Distinct' => []]], $validator->failed()); + } + + public function testSetDataRebuildsOriginalRulesAfterRetention(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['foo' => [1, 2]], + ['foo.*' => 'integer'], + ); + + $validator->retainRules(['foo.0']); + $validator->setData(['foo' => ['first', 'second']]); + + $this->assertSame(['foo.0', 'foo.1'], array_keys($validator->getRulesWithoutPlaceholders())); + $this->assertFalse($validator->passes()); + $this->assertSame([ + 'foo.0' => ['Integer' => []], + 'foo.1' => ['Integer' => []], + ], $validator->failed()); + } + public function testSetDataClearsImplicitAttributesWhenWildcardExpansionBecomesEmpty(): void { $validator = new Validator( From 9c1ad51f6f833731bc5d0b39f57420758ac9632b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:46:51 +0000 Subject: [PATCH 15/35] Complete Data validation compilation Finalize mapped validation paths, finished-value suppression, wildcard identity markers, rule denormalization, and root-validator orchestration for uniform and divergent nested data graphs. Align strict validation attributes with Hypervel's native rule contracts, including database constraints, dependent null values, numeric strings, and object rules. Expand the focused compiler, path, attribute, and constraint regression suites. --- .../Concerns/AppliesDatabaseConstraints.php | 2 +- .../src/Attributes/Validation/ArrayType.php | 2 +- .../src/Attributes/Validation/Dimensions.php | 2 +- .../src/Attributes/Validation/Exclude.php | 2 +- src/data/src/Attributes/Validation/Exists.php | 2 +- .../src/Attributes/Validation/MissingIf.php | 2 +- .../Attributes/Validation/MissingUnless.php | 2 +- .../src/Attributes/Validation/PresentIf.php | 2 +- .../Attributes/Validation/PresentUnless.php | 2 +- .../src/Attributes/Validation/Prohibited.php | 2 +- .../Attributes/Validation/ProhibitedIf.php | 2 +- .../Validation/ProhibitedUnless.php | 2 +- .../src/Attributes/Validation/Required.php | 2 +- .../src/Attributes/Validation/RequiredIf.php | 2 +- .../Attributes/Validation/RequiredUnless.php | 2 +- src/data/src/Attributes/Validation/Unique.php | 2 +- .../Support/Validation/CompiledValidation.php | 2 +- .../Validation/DataValidationCompiler.php | 26 +- .../src/Support/Validation/DataValidator.php | 5 +- .../Support/Validation/RuleDenormalizer.php | 2 +- .../src/Support/Validation/ValidationPath.php | 19 +- .../Validation/ValidationAttributeTest.php | 362 +++++++++--------- .../Constraints/DatabaseConstraintTest.php | 50 +-- .../Support/Validation/DataValidatorTest.php | 56 ++- .../Support/Validation/ValidationPathTest.php | 12 +- 25 files changed, 310 insertions(+), 256 deletions(-) diff --git a/src/data/src/Attributes/Concerns/AppliesDatabaseConstraints.php b/src/data/src/Attributes/Concerns/AppliesDatabaseConstraints.php index c3152445e..a73e73ae8 100644 --- a/src/data/src/Attributes/Concerns/AppliesDatabaseConstraints.php +++ b/src/data/src/Attributes/Concerns/AppliesDatabaseConstraints.php @@ -15,7 +15,7 @@ trait AppliesDatabaseConstraints /** * Apply database constraints to a validation rule. * - * @param Closure|DatabaseConstraint|array $constraints + * @param array|Closure|DatabaseConstraint $constraints */ protected function applyDatabaseConstraints(Exists|Unique $rule, Closure|DatabaseConstraint|array $constraints): void { diff --git a/src/data/src/Attributes/Validation/ArrayType.php b/src/data/src/Attributes/Validation/ArrayType.php index 754dcfa03..b0b124cdc 100644 --- a/src/data/src/Attributes/Validation/ArrayType.php +++ b/src/data/src/Attributes/Validation/ArrayType.php @@ -11,7 +11,7 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class ArrayType extends StringValidationAttribute { - /** @var list */ + /** @var list */ protected array $keys; /** diff --git a/src/data/src/Attributes/Validation/Dimensions.php b/src/data/src/Attributes/Validation/Dimensions.php index 4eced8ea2..07d33bac2 100644 --- a/src/data/src/Attributes/Validation/Dimensions.php +++ b/src/data/src/Attributes/Validation/Dimensions.php @@ -58,7 +58,7 @@ public function getRule(ValidationPath $path): object|string $width = $this->normalizePossibleExternalReferenceParameter($this->width); $height = $this->normalizePossibleExternalReferenceParameter($this->height); - $rule = new BaseDimensions(); + $rule = new BaseDimensions; if ($minWidth !== null) { $rule->minWidth($minWidth); diff --git a/src/data/src/Attributes/Validation/Exclude.php b/src/data/src/Attributes/Validation/Exclude.php index 38381c7f8..6d51d4b33 100644 --- a/src/data/src/Attributes/Validation/Exclude.php +++ b/src/data/src/Attributes/Validation/Exclude.php @@ -39,6 +39,6 @@ public static function keyword(): string */ public static function create(string ...$parameters): static { - return new static(); + return new static; } } diff --git a/src/data/src/Attributes/Validation/Exists.php b/src/data/src/Attributes/Validation/Exists.php index 70fc32c0f..91b8f358d 100644 --- a/src/data/src/Attributes/Validation/Exists.php +++ b/src/data/src/Attributes/Validation/Exists.php @@ -21,7 +21,7 @@ class Exists extends ObjectValidationAttribute /** * Create a new exists validation attribute. * - * @param Closure|DatabaseConstraint|array|null $where + * @param null|array|Closure|DatabaseConstraint $where */ public function __construct( protected string|ExternalReference|null $table = null, diff --git a/src/data/src/Attributes/Validation/MissingIf.php b/src/data/src/Attributes/Validation/MissingIf.php index b6de0d60f..c167a6121 100644 --- a/src/data/src/Attributes/Validation/MissingIf.php +++ b/src/data/src/Attributes/Validation/MissingIf.php @@ -22,7 +22,7 @@ class MissingIf extends StringValidationAttribute */ public function __construct( string|FieldReference $field, - null|array|bool|int|float|string|BackedEnum|ExternalReference ...$values, + array|bool|int|float|string|BackedEnum|ExternalReference|null ...$values, ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/data/src/Attributes/Validation/MissingUnless.php b/src/data/src/Attributes/Validation/MissingUnless.php index 826f969d1..4420bd2ee 100644 --- a/src/data/src/Attributes/Validation/MissingUnless.php +++ b/src/data/src/Attributes/Validation/MissingUnless.php @@ -22,7 +22,7 @@ class MissingUnless extends StringValidationAttribute */ public function __construct( string|FieldReference $field, - null|array|bool|int|float|string|BackedEnum|ExternalReference ...$values, + array|bool|int|float|string|BackedEnum|ExternalReference|null ...$values, ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/data/src/Attributes/Validation/PresentIf.php b/src/data/src/Attributes/Validation/PresentIf.php index d661c3ae8..cee5aa2eb 100644 --- a/src/data/src/Attributes/Validation/PresentIf.php +++ b/src/data/src/Attributes/Validation/PresentIf.php @@ -22,7 +22,7 @@ class PresentIf extends StringValidationAttribute */ public function __construct( string|FieldReference $field, - null|array|bool|int|float|string|BackedEnum|ExternalReference ...$values, + array|bool|int|float|string|BackedEnum|ExternalReference|null ...$values, ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/data/src/Attributes/Validation/PresentUnless.php b/src/data/src/Attributes/Validation/PresentUnless.php index 540c441ed..2ed25387a 100644 --- a/src/data/src/Attributes/Validation/PresentUnless.php +++ b/src/data/src/Attributes/Validation/PresentUnless.php @@ -22,7 +22,7 @@ class PresentUnless extends StringValidationAttribute */ public function __construct( string|FieldReference $field, - null|array|bool|int|float|string|BackedEnum|ExternalReference ...$values, + array|bool|int|float|string|BackedEnum|ExternalReference|null ...$values, ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/data/src/Attributes/Validation/Prohibited.php b/src/data/src/Attributes/Validation/Prohibited.php index d2b279e9c..59f99b4f8 100644 --- a/src/data/src/Attributes/Validation/Prohibited.php +++ b/src/data/src/Attributes/Validation/Prohibited.php @@ -36,6 +36,6 @@ public static function keyword(): string */ public static function create(string ...$parameters): static { - return new static(); + return new static; } } diff --git a/src/data/src/Attributes/Validation/ProhibitedIf.php b/src/data/src/Attributes/Validation/ProhibitedIf.php index 8b41a24e9..660d3d7be 100644 --- a/src/data/src/Attributes/Validation/ProhibitedIf.php +++ b/src/data/src/Attributes/Validation/ProhibitedIf.php @@ -22,7 +22,7 @@ class ProhibitedIf extends StringValidationAttribute */ public function __construct( string|FieldReference $field, - null|array|bool|int|float|string|BackedEnum|ExternalReference ...$values, + array|bool|int|float|string|BackedEnum|ExternalReference|null ...$values, ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/data/src/Attributes/Validation/ProhibitedUnless.php b/src/data/src/Attributes/Validation/ProhibitedUnless.php index 3df494c01..ab78aae46 100644 --- a/src/data/src/Attributes/Validation/ProhibitedUnless.php +++ b/src/data/src/Attributes/Validation/ProhibitedUnless.php @@ -22,7 +22,7 @@ class ProhibitedUnless extends StringValidationAttribute */ public function __construct( string|FieldReference $field, - null|array|bool|int|float|string|BackedEnum|ExternalReference ...$values, + array|bool|int|float|string|BackedEnum|ExternalReference|null ...$values, ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/data/src/Attributes/Validation/Required.php b/src/data/src/Attributes/Validation/Required.php index bcb0f2d3e..a40dcd2b1 100644 --- a/src/data/src/Attributes/Validation/Required.php +++ b/src/data/src/Attributes/Validation/Required.php @@ -40,6 +40,6 @@ public static function keyword(): string */ public static function create(string ...$parameters): static { - return new static(); + return new static; } } diff --git a/src/data/src/Attributes/Validation/RequiredIf.php b/src/data/src/Attributes/Validation/RequiredIf.php index a87be49ad..060c177e0 100644 --- a/src/data/src/Attributes/Validation/RequiredIf.php +++ b/src/data/src/Attributes/Validation/RequiredIf.php @@ -23,7 +23,7 @@ class RequiredIf extends StringValidationAttribute implements RequiringRule */ public function __construct( string|FieldReference $field, - null|array|bool|int|float|string|BackedEnum|ExternalReference ...$values, + array|bool|int|float|string|BackedEnum|ExternalReference|null ...$values, ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/data/src/Attributes/Validation/RequiredUnless.php b/src/data/src/Attributes/Validation/RequiredUnless.php index 67cc94a06..afa192e17 100644 --- a/src/data/src/Attributes/Validation/RequiredUnless.php +++ b/src/data/src/Attributes/Validation/RequiredUnless.php @@ -23,7 +23,7 @@ class RequiredUnless extends StringValidationAttribute implements RequiringRule */ public function __construct( string|FieldReference $field, - null|array|bool|int|float|string|BackedEnum|ExternalReference ...$values, + array|bool|int|float|string|BackedEnum|ExternalReference|null ...$values, ) { $this->field = $this->parseFieldReference($field); $this->values = Arr::flatten($values); diff --git a/src/data/src/Attributes/Validation/Unique.php b/src/data/src/Attributes/Validation/Unique.php index 797e747d2..09f836bfd 100644 --- a/src/data/src/Attributes/Validation/Unique.php +++ b/src/data/src/Attributes/Validation/Unique.php @@ -21,7 +21,7 @@ class Unique extends ObjectValidationAttribute /** * Create a new unique validation attribute. * - * @param Closure|DatabaseConstraint|array|null $where + * @param null|array|Closure|DatabaseConstraint $where */ public function __construct( protected string|ExternalReference|null $table = null, diff --git a/src/data/src/Support/Validation/CompiledValidation.php b/src/data/src/Support/Validation/CompiledValidation.php index c9ad1715d..185eb6405 100644 --- a/src/data/src/Support/Validation/CompiledValidation.php +++ b/src/data/src/Support/Validation/CompiledValidation.php @@ -52,7 +52,7 @@ public function restorePreservedValues(array $payload, array $sourcePayload): ar /** * Restore one exact or wildcard path from the source payload. * - * @param list $segments + * @param list $segments */ private function restoreValueAtPath( mixed &$target, diff --git a/src/data/src/Support/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index 3181072f6..a36edf163 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -147,6 +147,7 @@ protected function compileNode( } $wireKey = $this->wireKey($property, $state, $observed); + $inputPath = $property->inputPath($wireKey); $propertyPath = $path->property($wireKey); $structuralPropertyPath = $structuralPath->property($wireKey); @@ -162,8 +163,8 @@ protected function compileNode( continue; } - $hasValue = $observed && $state->hasValue($wireKey); - $value = $hasValue ? $state->getValue($wireKey) : null; + $hasValue = $observed && $state->hasValue($inputPath); + $value = $hasValue ? $state->getValue($inputPath) : null; if (! $property->validate) { $accumulator->preservedPaths[] = $propertyPath; @@ -225,7 +226,7 @@ protected function compileNode( } if ($nestedDataClass !== null && is_array($value)) { - $state->enterProperty($property->name, $wireKey); + $state->enterProperty($property->name, $inputPath); try { $this->compileNode( @@ -299,7 +300,8 @@ protected function compileDataIterable( array &$lifecycleDeclarations, bool $compileUnknownFields, ): void { - $state->enterProperty($property->name, $state->originalKey($property->name)); + $wireKey = $state->originalKey($property->name); + $state->enterProperty($property->name, $property->inputPath($wireKey)); try { $this->compileDataIterableValues( @@ -550,8 +552,7 @@ protected function inferRules( DataProperty $property, bool $expectsArray, bool $hasPresenceRule = false, - ): array - { + ): array { $rules = match (true) { $property->type->isOptional => ['sometimes'], $property->type->isNullable => ['nullable'], @@ -827,7 +828,7 @@ protected function translateRulePaths( /** * Recursively translate class-rule segments through Data metadata. * - * @param list $segments + * @param list $segments * @return list */ protected function translateRuleSegments( @@ -866,10 +867,11 @@ protected function translateRuleSegments( } $wireKey = $this->wireKey($property, $state, $observed); + $inputPath = $property->inputPath($wireKey); $path = $path->property($wireKey); $structuralPath = $structuralPath->property($wireKey); - $hasValue = $observed && $state->hasValue($wireKey); - $value = $hasValue ? $state->getValue($wireKey) : null; + $hasValue = $observed && $state->hasValue($inputPath); + $value = $hasValue ? $state->getValue($inputPath) : null; if ($this->isFinishedDataValue($property, $value)) { return []; @@ -885,7 +887,7 @@ protected function translateRuleSegments( $nestedDataClass = $this->nestedDataClass($property); if ($nestedDataClass !== null) { - $state->enterProperty($property->name, $wireKey); + $state->enterProperty($property->name, $inputPath); try { return $this->translateRuleSegments( @@ -915,7 +917,7 @@ protected function translateRuleSegments( $itemDataClass = $dataIterable->dataClass; $itemSegment = $segments[$offset + 1]; $values = $hasValue && is_array($value) ? $value : []; - $state->enterProperty($property->name, $wireKey); + $state->enterProperty($property->name, $inputPath); try { if ($itemSegment !== null) { @@ -1020,7 +1022,7 @@ protected function translateRuleSegments( /** * Append rule segments that no longer describe Data properties. * - * @param list $segments + * @param list $segments */ protected function appendUnmappedSegments( ValidationPath $path, diff --git a/src/data/src/Support/Validation/DataValidator.php b/src/data/src/Support/Validation/DataValidator.php index faed1df42..b7b0d9371 100644 --- a/src/data/src/Support/Validation/DataValidator.php +++ b/src/data/src/Support/Validation/DataValidator.php @@ -131,7 +131,9 @@ public function validate( if ($request?->isPrecognitive()) { $unfilteredRules = $validator->getRulesWithoutPlaceholders(); - $validator->setRules($request->filterPrecognitiveRules($unfilteredRules)); + $validator->retainRules(array_keys( + $request->filterPrecognitiveRules($unfilteredRules) + )); } $this->configureValidator($validator, $state, $dataClass); @@ -153,6 +155,7 @@ public function validate( } if ($request?->isPrecognitive()) { + // Unknown-field errors must exist before Precognition decides whether validation succeeded. $validator->after(Precognition::afterValidationHook($request)); } diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index c268b7a4f..39434d6bc 100644 --- a/src/data/src/Support/Validation/RuleDenormalizer.php +++ b/src/data/src/Support/Validation/RuleDenormalizer.php @@ -20,7 +20,7 @@ class RuleDenormalizer /** * Convert one declaration into Validator rules. * - * @return list + * @return list */ public function execute(mixed $rule, ValidationPath $path): array { diff --git a/src/data/src/Support/Validation/ValidationPath.php b/src/data/src/Support/Validation/ValidationPath.php index 18842951b..341d208a8 100644 --- a/src/data/src/Support/Validation/ValidationPath.php +++ b/src/data/src/Support/Validation/ValidationPath.php @@ -11,7 +11,7 @@ class ValidationPath implements Stringable /** * Create a validation path. * - * @param list $path + * @param list $path */ public function __construct( protected readonly array $path = [], @@ -92,7 +92,7 @@ public function equals(string|ValidationPath $other): bool /** * Get the path segments. * - * @return list + * @return list<'*'|array-key> */ public function segments(): array { @@ -105,7 +105,7 @@ public function segments(): array /** * Get structural segments with wildcards represented by null. * - * @return list + * @return list */ public function rawSegments(): array { @@ -121,7 +121,7 @@ public function get(): string fn (string|int|null $segment): string => match (true) { $segment === null => '*', is_int($segment) => (string) $segment, - default => str_replace(['.', '*'], ['\\.', '\\*'], $segment), + default => str_replace(['.', '*'], ['\.', '\*'], $segment), }, $this->path, )); @@ -156,7 +156,7 @@ public function matchingWildcardPayloadValidationPaths(array $fullPayload): arra /** * Recursively expand wildcard segments against a payload. * - * @param list $remainingSegments + * @param list $remainingSegments * @param list $resolvedSegments * @return list */ @@ -164,8 +164,7 @@ protected function expandWildcardPath( array $remainingSegments, mixed $payload, array $resolvedSegments = [], - ): array - { + ): array { if ($remainingSegments === []) { return [new self($resolvedSegments)]; } @@ -202,11 +201,11 @@ protected function expandWildcardPath( /** * Parse Validator dot notation into structural segments. * - * @return list + * @return list */ protected static function parseDotPath(string $path): array { - $segments = preg_split('/(?parameters(); } - /** - * Test enum rejects unsupported resolved declarations. - */ - public function testRejectsInvalidEnumDeclaration(): void - { - $this->expectException(CannotBuildValidationRule::class); - - (new Enum(new ValidationAttributeExternalReference(42)))->getRule(ValidationPath::create()); - } - /** * Provide unsupported email modes. */ @@ -483,170 +640,13 @@ public static function invalidEmailModes(): iterable } /** - * Provide simple string validation attributes. + * Test enum rejects unsupported resolved declarations. */ - public static function stringRules(): iterable + public function testRejectsInvalidEnumDeclaration(): void { - yield [new Accepted, 'accepted']; - yield [new AcceptedIf('status', true), 'accepted_if:status,true']; - yield [new ActiveUrl, 'active_url']; - yield [new After('tomorrow'), 'after:tomorrow']; - yield [new AfterOrEqual('tomorrow'), 'after_or_equal:tomorrow']; - yield [new Alpha, 'alpha']; - yield [new AlphaDash, 'alpha_dash']; - yield [new AlphaNumeric, 'alpha_num']; - yield [new ArrayType(['name', 'email']), 'array:name,email']; - yield [new Ascii, 'ascii']; - yield [new Bail, 'bail']; - yield [new Base64, 'base64']; - yield [new Before('tomorrow'), 'before:tomorrow']; - yield [new BeforeOrEqual('tomorrow'), 'before_or_equal:tomorrow']; - yield [new Between(1, 10), 'between:1,10']; - yield [new BooleanType, 'boolean']; - yield [new Confirmed, 'confirmed']; - yield [new Contains(['admin', [42]], new ValidationAttributeExternalReference('member')), 'contains:admin,42,member']; - yield [new CurrentPassword, 'current_password']; - yield [new CurrentPassword('api'), 'current_password:api']; - yield [new CurrentPassword(ValidationAttributeBackedEnum::Foo), 'current_password:foo']; - yield [new CurrentPassword(new ValidationAttributeExternalReference), 'current_password:admin']; - yield [CurrentPassword::create('api'), 'current_password:api']; - yield [new Date, 'date']; - yield [new DateEquals('tomorrow'), 'date_equals:tomorrow']; - yield [new DateFormat('Y-m-d'), 'date_format:Y-m-d']; - yield [new DateFormat(['Y-m-d', 'Y-m-d H:i:s']), 'date_format:Y-m-d,Y-m-d H:i:s']; - yield [new DateFormat('Y-m-d', 'Y-m-d H:i:s'), 'date_format:Y-m-d,Y-m-d H:i:s']; - yield [new Decimal('2', '4'), 'decimal:2,4']; - yield [new Declined, 'declined']; - yield [new DeclinedIf('status', false), 'declined_if:status,false']; - yield [new Different('password'), 'different:password']; - yield [new Digits(4), 'digits:4']; - yield [new DigitsBetween(2, 6), 'digits_between:2,6']; - yield [new Distinct, 'distinct']; - yield [new Distinct(Distinct::Strict), 'distinct:strict']; - yield [new Distinct(Distinct::IgnoreCase), 'distinct:ignore_case']; - yield [new Distinct(new ValidationAttributeExternalReference(Distinct::Strict)), 'distinct:strict']; - yield [new Distinct(new ValidationAttributeExternalReference(null)), 'distinct']; - yield [ - new DoesntContain(['admin', [42]], new ValidationAttributeExternalReference('member')), - 'doesnt_contain:admin,42,member', - ]; - yield [ - new DoesntEndWith(['.php', ['.exe']], new ValidationAttributeExternalReference('.bat')), - 'doesnt_end_with:.php,.exe,.bat', - ]; - yield [ - new DoesntStartWith(['admin', ['root']], new ValidationAttributeExternalReference('system')), - 'doesnt_start_with:admin,root,system', - ]; - yield [new Email, 'email:rfc']; - yield [ - new Email(Email::DnsCheckValidation, Email::FilterUnicodeEmailValidation), - 'email:dns,filter_unicode', - ]; - yield [new Email(RFCValidation::class), 'email:' . RFCValidation::class]; - yield [new Email(new ValidationAttributeExternalReference(Email::SpoofCheckValidation)), 'email:spoof']; - yield [new Encoding('UTF-8'), 'encoding:UTF-8']; - yield [ - new EndsWith(['.json', ['.yaml']], new ValidationAttributeExternalReference('.yml')), - 'ends_with:.json,.yaml,.yml', - ]; - yield [new ExcludeIf('status', false), 'exclude_if:status,false']; - yield [new ExcludeUnless('status', 'published'), 'exclude_unless:status,published']; - yield [new ExcludeWith('archived_at'), 'exclude_with:archived_at']; - yield [new ExcludeWithout('published_at'), 'exclude_without:published_at']; - yield [new Extensions(['jpg', ['png']], new ValidationAttributeExternalReference('webp')), 'extensions:jpg,png,webp']; - yield [new File, 'file']; - yield [new Filled, 'filled']; - yield [new GreaterThan('other'), 'gt:other']; - yield [new GreaterThan(10), 'gt:10']; - yield [new GreaterThan('99999999999999999999'), 'gt:99999999999999999999']; - yield [new GreaterThanOrEqualTo('other'), 'gte:other']; - yield [new GreaterThanOrEqualTo('10'), 'gte:10']; - yield [new HexColor, 'hex_color']; - yield [new IP, 'ip']; - yield [new IPv4, 'ipv4']; - yield [new IPv6, 'ipv6']; - yield [new Image, 'image']; - yield [new InArray('roles.*'), 'in_array:roles.*']; - yield [new InArrayKeys(['name', [42]], new ValidationAttributeExternalReference('email')), 'in_array_keys:name,42,email']; - yield [new IntegerType, 'integer']; - yield [new Json, 'json']; - yield [new LessThan('other'), 'lt:other']; - yield [new LessThan('10.50'), 'lt:10.50']; - yield [new LessThanOrEqualTo('other'), 'lte:other']; - yield [new LessThanOrEqualTo(10), 'lte:10']; - yield [new ListType, 'list']; - yield [new Lowercase, 'lowercase']; - yield [new MacAddress, 'mac_address']; - yield [new Max('99999999999999999999'), 'max:99999999999999999999']; - yield [new MaxDigits(10), 'max_digits:10']; - yield [ - new MimeTypes(['image/jpeg', ['image/png']], new ValidationAttributeExternalReference('image/webp')), - 'mimetypes:image/jpeg,image/png,image/webp', - ]; - yield [new Mimes(['jpg', ['png']], new ValidationAttributeExternalReference('webp')), 'mimes:jpg,png,webp']; - yield [new Min(1.5), 'min:1.5']; - yield [new MinDigits(2), 'min_digits:2']; - yield [new Missing, 'missing']; - yield [new MissingIf('status', true, null), 'missing_if:status,true,null']; - yield [new MissingUnless('status', 1, 2.5), 'missing_unless:status,1,2.5']; - yield [new MissingWith(['email', ['phone']]), 'missing_with:email,phone']; - yield [new MissingWithAll(['email', ['phone']]), 'missing_with_all:email,phone']; - yield [new MultipleOf('0.000000000000000001'), 'multiple_of:0.000000000000000001']; - yield [new NotRegex('/foo/'), 'not_regex:/foo/']; - yield [new Nullable, 'nullable']; - yield [new Numeric, 'numeric']; - yield [new Present, 'present']; - yield [new PresentIf('status', true, null), 'present_if:status,true,null']; - yield [new PresentUnless('status', 1, 2.5), 'present_unless:status,1,2.5']; - yield [new PresentWith(['email', ['phone']]), 'present_with:email,phone']; - yield [new PresentWithAll(['email', ['phone']]), 'present_with_all:email,phone']; - yield [ - new ProhibitedIf('status', ['draft', ['pending']], new ValidationAttributeExternalReference('published')), - 'prohibited_if:status,draft,pending,published', - ]; - yield [new ProhibitedIf('enabled', true), 'prohibited_if:enabled,true']; - yield [new ProhibitedIfAccepted('terms'), 'prohibited_if_accepted:terms']; - yield [new ProhibitedIfDeclined('terms'), 'prohibited_if_declined:terms']; - yield [ - new ProhibitedUnless('status', ['draft', ['pending']], new ValidationAttributeExternalReference('published')), - 'prohibited_unless:status,draft,pending,published', - ]; - yield [new ProhibitedUnless('count', 1, 2.5), 'prohibited_unless:count,1,2.5']; - yield [new Prohibits(['email', ['phone']]), 'prohibits:email,phone']; - yield [new Regex('/foo/'), 'regex:/foo/']; - yield [ - new RequiredArrayKeys(['name', ['email']], new ValidationAttributeExternalReference('role')), - 'required_array_keys:name,email,role', - ]; - yield [ - new RequiredIf('status', ['draft', ['pending']], new ValidationAttributeExternalReference('published')), - 'required_if:status,draft,pending,published', - ]; - yield [new RequiredIf('enabled', true), 'required_if:enabled,true']; - yield [new RequiredIfAccepted('terms'), 'required_if_accepted:terms']; - yield [new RequiredIfDeclined('terms'), 'required_if_declined:terms']; - yield [ - new RequiredIf('status', 'draft', new ValidationAttributeExternalReference(null)), - 'required_if:status,draft,null', - ]; - yield [new RequiredUnless('status', null), 'required_unless:status,null']; - yield [new RequiredWith(['email', ['phone']]), 'required_with:email,phone']; - yield [new RequiredWithAll(['email', ['phone']]), 'required_with_all:email,phone']; - yield [new RequiredWithout(['email', ['phone']]), 'required_without:email,phone']; - yield [new RequiredWithoutAll(['email', ['phone']]), 'required_without_all:email,phone']; - yield [new Same('password'), 'same:password']; - yield [new Size('99999999999999999999'), 'size:99999999999999999999']; - yield [new Sometimes, 'sometimes']; - yield [ - new StartsWith(['admin', ['root']], new ValidationAttributeExternalReference('system')), - 'starts_with:admin,root,system', - ]; - yield [new Timezone, 'timezone']; - yield [new Ulid, 'ulid']; - yield [new Uppercase, 'uppercase']; - yield [new Url(['http', ['https']], new ValidationAttributeExternalReference('ftp')), 'url:http,https,ftp']; - yield [new Uuid, 'uuid']; + $this->expectException(CannotBuildValidationRule::class); + + (new Enum(new ValidationAttributeExternalReference(42)))->getRule(ValidationPath::create()); } } diff --git a/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php b/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php index ed732ded3..7c4445755 100644 --- a/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php +++ b/tests/Data/Support/Validation/Constraints/DatabaseConstraintTest.php @@ -32,6 +32,22 @@ public function testAppliesScalarConstraints(Exists|Unique $rule, string $expect $this->assertSame($expected, (string) $rule); } + /** + * Provide native database rules and their serialized scalar constraints. + */ + public static function databaseRules(): iterable + { + yield [ + new Exists('users', 'id'), + 'exists:users,id,status,"active",role,"!admin",deleted_at,"NULL",verified_at,"NOT_NULL"', + ]; + + yield [ + new Unique('users', 'email'), + 'unique:users,email,NULL,id,status,"active",role,"!admin",deleted_at,"NULL",verified_at,"NOT_NULL"', + ]; + } + /** * Test callback and set constraints register native query callbacks. */ @@ -45,6 +61,15 @@ public function testAppliesCallbackConstraints(Exists|Unique $rule): void $this->assertCount(3, $rule->queryCallbacks()); } + /** + * Provide native database rule objects. + */ + public static function databaseRuleObjects(): iterable + { + yield [new Exists('users', 'id')]; + yield [new Unique('users', 'email')]; + } + /** * Test constraints resolve external references at application time. */ @@ -59,31 +84,6 @@ public function testResolvesExternalReferences(): void $this->assertSame('exists:users,id,status,"active"', (string) $rule); } - - /** - * Provide native database rules and their serialized scalar constraints. - */ - public static function databaseRules(): iterable - { - yield [ - new Exists('users', 'id'), - 'exists:users,id,status,"active",role,"!admin",deleted_at,"NULL",verified_at,"NOT_NULL"', - ]; - - yield [ - new Unique('users', 'email'), - 'unique:users,email,NULL,id,status,"active",role,"!admin",deleted_at,"NULL",verified_at,"NOT_NULL"', - ]; - } - - /** - * Provide native database rule objects. - */ - public static function databaseRuleObjects(): iterable - { - yield [new Exists('users', 'id')]; - yield [new Unique('users', 'email')]; - } } class DatabaseConstraintExternalReference implements ExternalReference diff --git a/tests/Data/Support/Validation/DataValidatorTest.php b/tests/Data/Support/Validation/DataValidatorTest.php index d8c265bd9..a8e7169d1 100644 --- a/tests/Data/Support/Validation/DataValidatorTest.php +++ b/tests/Data/Support/Validation/DataValidatorTest.php @@ -13,11 +13,11 @@ use Hypervel\Data\Attributes\MapInputName; use Hypervel\Data\Attributes\MergeValidationRules; use Hypervel\Data\Attributes\PropertyForMorph; -use Hypervel\Data\Attributes\WithoutValidation; +use Hypervel\Data\Attributes\Validation\Distinct; use Hypervel\Data\Attributes\Validation\Required; use Hypervel\Data\Attributes\Validation\RequiredUnless; -use Hypervel\Data\Attributes\Validation\Distinct; use Hypervel\Data\Attributes\Validation\StringType; +use Hypervel\Data\Attributes\WithoutValidation; use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; @@ -40,8 +40,8 @@ use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; use Hypervel\Testbench\TestCase; -use Hypervel\Validation\ValidationException; use Hypervel\Validation\Factory as ValidationFactory; +use Hypervel\Validation\ValidationException; use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; @@ -1808,6 +1808,32 @@ public function testPrecognitionUnknownFieldsUsesUnfilteredRules(): void $this->assertArrayNotHasKey('email', $exception->errors()); } } + + /** + * Test Precognition retains wildcard identity for selected Data rules. + */ + public function testPrecognitionRetainsWildcardIdentityForSelectedDataRules(): void + { + $request = Request::create('/', 'POST', [ + 'items' => [ + ['item_code' => 'duplicate'], + ['item_code' => 'duplicate'], + ], + ]); + $request->attributes->set('precognitive', true); + $request->headers->set('Precognition-Validate-Only', 'items.1.item_code'); + + try { + PrecognitiveDistinctDataFixture::from($request); + $this->fail('Expected the selected duplicate field to fail validation.'); + } catch (ValidationException $exception) { + $this->assertSame([ + 'items.1.item_code' => [ + 'The items.1.item_code field has a duplicate value.', + ], + ], $exception->errors()); + } + } } class ValidatedDataFixture extends Data @@ -3119,6 +3145,30 @@ public static function after(): array } } +class PrecognitiveDistinctItemDataFixture extends Data +{ + public function __construct( + #[MapInputName('item_code')] + #[Distinct] + public string $itemCode, + ) { + } +} + +class PrecognitiveDistinctDataFixture extends Data +{ + /** + * Create a Precognition wildcard identity fixture. + * + * @param array $items + */ + public function __construct( + #[DataCollectionOf(PrecognitiveDistinctItemDataFixture::class)] + public array $items, + ) { + } +} + #[FailOnUnknownFields] class PrecognitiveStrictDataFixture extends Data { diff --git a/tests/Data/Support/Validation/ValidationPathTest.php b/tests/Data/Support/Validation/ValidationPathTest.php index 21de4fbc0..676c361c2 100644 --- a/tests/Data/Support/Validation/ValidationPathTest.php +++ b/tests/Data/Support/Validation/ValidationPathTest.php @@ -100,8 +100,8 @@ public function testAppendsMappedPropertiesAndRawItemKeys(): void ['profile', 'names', 'first.item', 'label'], $path->segments(), ); - $this->assertSame('profile.names.first\\.item.label', $path->get()); - $this->assertTrue($path->equals('profile.names.first\\.item.label')); + $this->assertSame('profile.names.first\.item.label', $path->get()); + $this->assertTrue($path->equals('profile.names.first\.item.label')); } /** @@ -109,11 +109,11 @@ public function testAppendsMappedPropertiesAndRawItemKeys(): void */ public function testCreatesPathsWithEscapedLiteralDots(): void { - $path = ValidationPath::create('items.first\\.item.name'); - $wildcards = ValidationPath::create('items.*.literal\\*.name'); + $path = ValidationPath::create('items.first\.item.name'); + $wildcards = ValidationPath::create('items.*.literal\*.name'); $this->assertSame(['items', 'first.item', 'name'], $path->segments()); - $this->assertSame('items.first\\.item.name', $path->get()); + $this->assertSame('items.first\.item.name', $path->get()); $this->assertSame(['items', null, 'literal*', 'name'], $wildcards->rawSegments()); } @@ -164,7 +164,7 @@ public function testTrailingBackslashDoesNotPromiseRoundTripIdentity(): void { $path = new ValidationPath(['a\\', 'b']); - $this->assertSame('a\\.b', $path->get()); + $this->assertSame('a\.b', $path->get()); $this->assertSame( ['a.b'], ValidationPath::create($path->get())->rawSegments(), From f190862f2b8c336359d8fc5be54e9f0dc5caed11 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:48:08 +0000 Subject: [PATCH 16/35] Complete fixed Data construction Finish the single root creation engine for mapped sources, named factories, contextual constructor values, morphs, typed iterables, paginator provenance, validation reconciliation, and bottom-up instantiation. Reuse compiled input paths and a narrow exact-array fast path while preserving the full general path for casts, hooks, lazy values, and complex graphs. Expand regression coverage for defaults, nulls, finished values, factories, mappings, collections, and deferred construction state. --- src/data/src/Casts/BuiltinTypeCast.php | 2 +- src/data/src/Casts/Cast.php | 1 + src/data/src/Casts/DateTimeInterfaceCast.php | 16 +- src/data/src/Exceptions/CannotCastData.php | 82 + src/data/src/Exceptions/CannotCastEnum.php | 2 +- src/data/src/Exceptions/CannotCreateData.php | 11 + .../CannotCreateDataCollectable.php | 11 + .../Normalized/NormalizedModel.php | 12 +- src/data/src/Optional.php | 2 +- .../Support/Creation/AutoLazyReplayMode.php | 11 + .../Support/Creation/ConstructionState.php | 210 ++- .../src/Support/Creation/CreationContext.php | 2 +- .../Creation/CreationContextFactory.php | 99 +- .../Creation/DataCollectableFactory.php | 290 ++- src/data/src/Support/Creation/DataCreator.php | 1561 ++++++++++++++--- .../src/Support/Creation/SourceReader.php | 71 +- .../Data/Casts/DateTimeInterfaceCastTest.php | 99 +- .../Creation/ConstructionStateTest.php | 209 ++- .../Data/Support/Creation/DataCreatorTest.php | 891 +++++++++- .../Support/Creation/SourceReaderTest.php | 105 +- 20 files changed, 3248 insertions(+), 439 deletions(-) create mode 100644 src/data/src/Support/Creation/AutoLazyReplayMode.php diff --git a/src/data/src/Casts/BuiltinTypeCast.php b/src/data/src/Casts/BuiltinTypeCast.php index e1ba38f8c..94d2a5562 100644 --- a/src/data/src/Casts/BuiltinTypeCast.php +++ b/src/data/src/Casts/BuiltinTypeCast.php @@ -13,7 +13,7 @@ class BuiltinTypeCast implements Cast, IterableItemCast /** * Create a built-in type cast. * - * @param 'bool'|'int'|'float'|'array'|'string' $type + * @param 'array'|'bool'|'float'|'int'|'string' $type */ public function __construct( protected string $type, diff --git a/src/data/src/Casts/Cast.php b/src/data/src/Casts/Cast.php index 2c1b06799..962d7f530 100644 --- a/src/data/src/Casts/Cast.php +++ b/src/data/src/Casts/Cast.php @@ -8,6 +8,7 @@ use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataProperty; +// REMOVED: UnserializeCast accepted serialized request input; use a custom Cast for trusted formats. interface Cast { /** diff --git a/src/data/src/Casts/DateTimeInterfaceCast.php b/src/data/src/Casts/DateTimeInterfaceCast.php index 58ee9d532..022efb149 100644 --- a/src/data/src/Casts/DateTimeInterfaceCast.php +++ b/src/data/src/Casts/DateTimeInterfaceCast.php @@ -4,13 +4,14 @@ namespace Hypervel\Data\Casts; +use DateTime; +use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; use Hypervel\Data\Exceptions\CannotCastDate; use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Data\Support\DataProperty; -use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\Facades\Date; use Throwable; @@ -19,11 +20,11 @@ class DateTimeInterfaceCast implements Cast, IterableItemCast /** * Create a date cast. * - * @param null|string|non-empty-list $format + * @param null|non-empty-list|string $format * @param null|class-string $type */ public function __construct( - protected readonly null|string|array $format = null, + protected readonly string|array|null $format = null, protected readonly ?string $type = null, protected readonly ?string $setTimeZone = null, protected readonly ?string $timeZone = null, @@ -122,13 +123,14 @@ protected function createDate( string $format, string $value, ?DateTimeZone $timeZone, - ): ?DateTimeInterface { - $reflection = ClassMetadataCache::reflectClass($type); - $datetime = $reflection->isInstantiable() + ): DateTime|DateTimeImmutable|null { + $datetime = is_a($type, DateTime::class, true) || is_a($type, DateTimeImmutable::class, true) ? $type::createFromFormat($format, $value, $timeZone) : Date::createFromFormat($format, $value, $timeZone); - if (! $datetime instanceof DateTimeInterface || ! $datetime instanceof $type) { + if ((! $datetime instanceof DateTime && ! $datetime instanceof DateTimeImmutable) + || ! $datetime instanceof $type + ) { return null; } diff --git a/src/data/src/Exceptions/CannotCastData.php b/src/data/src/Exceptions/CannotCastData.php index 439c389a5..4bc568887 100644 --- a/src/data/src/Exceptions/CannotCastData.php +++ b/src/data/src/Exceptions/CannotCastData.php @@ -32,6 +32,88 @@ public static function shouldBeTransformableData(string $modelClass, string $att return new self("Attribute `{$attribute}` of model `{$modelClass}` should be a transformable Data object"); } + /** + * Create an exception for a non-transformable Eloquent data class. + */ + public static function dataClassMustBeTransformable(string $dataClass): self + { + return new self( + "Data class `{$dataClass}` should implement TransformableData to be used in an Eloquent cast", + ); + } + + /** + * Create an exception for a data value of the wrong class. + */ + public static function shouldBeDataClass( + string $modelClass, + string $attribute, + string $dataClass, + ): self { + return new self( + "Attribute `{$attribute}` of model `{$modelClass}` should be an instance of `{$dataClass}`", + ); + } + + /** + * Create an exception for an invalid stored data representation. + */ + public static function invalidStoredValue(string $modelClass, string $attribute): self + { + return new self( + "Attribute `{$attribute}` of model `{$modelClass}` should contain a JSON object or array", + ); + } + + /** + * Create an exception for an invalid stored collection item. + */ + public static function invalidStoredCollectionItem( + string $modelClass, + string $attribute, + int|string $itemKey, + ): self { + return new self( + "Item `{$itemKey}` in attribute `{$attribute}` of model `{$modelClass}` should contain a JSON object", + ); + } + + /** + * Create an exception for an invalid abstract data envelope. + */ + public static function invalidMorphEnvelope(string $modelClass, string $attribute): self + { + return new self( + "Attribute `{$attribute}` of model `{$modelClass}` should contain a data morph envelope", + ); + } + + /** + * Create an exception for an unknown data morph alias. + */ + public static function unknownMorphAlias(string $alias, string $dataClass): self + { + return new self("Data morph alias `{$alias}` is not registered for `{$dataClass}`"); + } + + /** + * Create an exception for an invalid data morph class. + */ + public static function invalidMorphClass(string $class, string $dataClass): self + { + return new self( + "Data morph class `{$class}` should be a concrete transformable subtype of `{$dataClass}`", + ); + } + + /** + * Create an exception for a data class without an enforced morph alias. + */ + public static function morphAliasRequired(string $class): self + { + return new self("Data class `{$class}` should have an enforced morph alias"); + } + /** * Create an exception for a missing collection item type. */ diff --git a/src/data/src/Exceptions/CannotCastEnum.php b/src/data/src/Exceptions/CannotCastEnum.php index 4898979f8..10ff10564 100644 --- a/src/data/src/Exceptions/CannotCastEnum.php +++ b/src/data/src/Exceptions/CannotCastEnum.php @@ -17,7 +17,7 @@ class CannotCastEnum extends Exception public static function create(string $type, mixed $value, DataProperty $property): self { return new self( - "Could not cast value [" . self::describe($value) . "] for property " + 'Could not cast value [' . self::describe($value) . '] for property ' . "[{$property->className}::\${$property->name}] to enum [{$type}]." ); } diff --git a/src/data/src/Exceptions/CannotCreateData.php b/src/data/src/Exceptions/CannotCreateData.php index fdcc6c0e2..273d81e70 100644 --- a/src/data/src/Exceptions/CannotCreateData.php +++ b/src/data/src/Exceptions/CannotCreateData.php @@ -83,6 +83,17 @@ public static function propertyMissing(DataClass $dataClass, DataProperty $prope ); } + /** + * Create an exception for an automatic relation lazy without a model source. + */ + public static function autoWhenLoadedRequiresModel(DataProperty $property): self + { + return new self( + "Could not create property [{$property->className}::\${$property->name}] with " + . 'AutoWhenLoadedLazy because no Eloquent model source was supplied.' + ); + } + /** * Create an exception for an ambiguous data-object union. * diff --git a/src/data/src/Exceptions/CannotCreateDataCollectable.php b/src/data/src/Exceptions/CannotCreateDataCollectable.php index 3b0eb4986..c59a3d786 100644 --- a/src/data/src/Exceptions/CannotCreateDataCollectable.php +++ b/src/data/src/Exceptions/CannotCreateDataCollectable.php @@ -17,4 +17,15 @@ public static function create( ): self { return new self("Cannot create data collectable of type `{$into}` from `{$from}`"); } + + /** + * Create an exception for missing paginator reconstruction metadata. + */ + public static function missingPaginatorSource(string $into): self + { + return new self( + "Cannot create data collectable of type `{$into}` without a retained paginator source. " + . 'Supply a Hypervel paginator so its pagination metadata can be preserved.' + ); + } } diff --git a/src/data/src/Normalizers/Normalized/NormalizedModel.php b/src/data/src/Normalizers/Normalized/NormalizedModel.php index 1735c0c5a..2bf9a7082 100644 --- a/src/data/src/Normalizers/Normalized/NormalizedModel.php +++ b/src/data/src/Normalizers/Normalized/NormalizedModel.php @@ -40,14 +40,10 @@ protected function fetchNewProperty(string $name, DataProperty $dataProperty): m { $camelName = StrCache::camel($name); - if ($dataProperty->loadRelation) { - $relation = $this->model->isRelation($name) - ? $name - : ($this->model->isRelation($camelName) ? $camelName : null); - - if ($relation !== null) { - $this->model->loadMissing($relation); - } + if (($relation = $dataProperty->resolveModelRelation($this->model)) !== null + && ! $this->model->relationLoaded($relation) + ) { + $this->model->loadMissing($relation); } if ($this->model->relationLoaded($name)) { diff --git a/src/data/src/Optional.php b/src/data/src/Optional.php index b5c58d3b5..fc2106767 100644 --- a/src/data/src/Optional.php +++ b/src/data/src/Optional.php @@ -11,6 +11,6 @@ class Optional */ public static function create(): self { - return new self(); + return new self; } } diff --git a/src/data/src/Support/Creation/AutoLazyReplayMode.php b/src/data/src/Support/Creation/AutoLazyReplayMode.php new file mode 100644 index 000000000..f2dfe7e72 --- /dev/null +++ b/src/data/src/Support/Creation/AutoLazyReplayMode.php @@ -0,0 +1,11 @@ + */ - protected array $payload = []; + private array $payload = []; /** @var null|array */ - protected ?array $unknownInput = null; + private ?array $unknownInput = null; /** * @var array{ * class: null|class-string, * mappings: array, * children: array, - * paginatorSource?: AbstractPaginator|AbstractCursorPaginator, + * autoLazy?: array, + * paginatorSource?: AbstractCursorPaginator|AbstractPaginator, * uniform?: false, * items?: array * } */ - protected array $structure; + private array $structure; - /** @var list, structureKey: ?string, itemKey: array-key|null}> */ - protected array $path = []; + /** @var list, structureKey: ?string, itemKey: null|array-key}> */ + private array $path = []; /** * Create construction state for one root operation. @@ -56,11 +57,13 @@ public static function create(CreationContext $context, string $class): self /** * Enter a nested data property. + * + * @param non-empty-list $payloadPath */ - public function enterProperty(string $property, string|int|null $mappedKey = null): void + public function enterProperty(string $property, array $payloadPath): void { $this->path[] = [ - 'payloadPath' => self::segments($mappedKey ?? $property), + 'payloadPath' => $payloadPath, 'structureKey' => $property, 'itemKey' => null, ]; @@ -112,11 +115,13 @@ public function path(): array /** * Write a mapped property value beneath the current path. + * + * @param non-empty-list $path */ - public function writePropertyValue(string|int $key, mixed $value): void + public function writePropertyValue(array $path, mixed $value): void { $this->writeAtPath( - [...$this->path(), ...self::segments($key)], + [...$this->path(), ...$path], $value, false, ); @@ -124,11 +129,13 @@ public function writePropertyValue(string|int $key, mixed $value): void /** * Write a finished mapped property value beneath the current path. + * + * @param non-empty-list $path */ - public function writeFinishedPropertyValue(string|int $key, mixed $value): void + public function writeFinishedPropertyValue(array $path, mixed $value): void { $this->writeAtPath( - [...$this->path(), ...self::segments($key)], + [...$this->path(), ...$path], $value, true, ); @@ -152,20 +159,24 @@ public function writeFinishedItemValue(string|int $key, mixed $value): void /** * Determine if a value exists beneath the current path. + * + * @param non-empty-list $path */ - public function hasValue(string|int $key): bool + public function hasValue(array $path): bool { - $slot = $this->valueAtPath(self::segments($key)); + $slot = $this->valueAtPath($path); return ! $slot instanceof UnknownProperty; } /** * Get a value beneath the current path. + * + * @param non-empty-list $path */ - public function getValue(string|int $key): mixed + public function getValue(array $path): mixed { - $value = $this->valueAtPath(self::segments($key)); + $value = $this->valueAtPath($path); return $value instanceof UnknownProperty ? null : $value; } @@ -318,7 +329,7 @@ public function resetNodeStructure(): void $node['class'] = null; $node['mappings'] = []; $node['children'] = []; - unset($node['paginatorSource']); + unset($node['autoLazy'], $node['paginatorSource']); if ($this->pathContainsItem()) { $this->markEnclosingCollectionsNonUniform(); @@ -407,6 +418,45 @@ public function nodeClass(): ?string return $this->structureNodeAtCurrentPath()['class'] ?? null; } + /** + * Record one automatic lazy property recipe. + */ + public function recordAutoLazy( + string $property, + mixed $source, + ?AutoLazyReplayMode $replay = null, + ): void { + if ($this->pathContainsItem()) { + $node = &$this->ensureOverrideNodeAtCurrentPath(); + } else { + $node = &$this->ensureStructureNodeAtCurrentPath(); + } + + $node['autoLazy'][$property] = ['source' => $source]; + + if ($replay !== null) { + $node['autoLazy'][$property]['replay'] = $replay; + } + } + + /** + * Get one automatic lazy property recipe. + * + * @return array{source: mixed, replay?: AutoLazyReplayMode}|UnknownProperty + */ + public function autoLazy(string $property): array|UnknownProperty + { + $node = $this->pathContainsItem() + ? $this->overrideNodeAtCurrentPath() + : $this->structureNodeAtCurrentPath(); + + if ($node !== null && array_key_exists($property, $node['autoLazy'] ?? [])) { + return $node['autoLazy'][$property]; + } + + return UnknownProperty::create(); + } + /** * Record the paginator source for the current node. */ @@ -484,10 +534,45 @@ public function structure(): array return $this->structure; } + /** + * Create a detached baseline for one automatic lazy property. + */ + public function snapshotForProperty(string $property): self + { + $snapshot = clone $this; + /** @var array $payload */ + $payload = $this->payloadAtCurrentPath(); + $snapshot->payload = self::payloadSkeleton($this->path(), $payload); + $snapshot->unknownInput = null; + + $templatePath = []; + $exactPath = []; + + foreach ($this->path as $segment) { + if ($segment['structureKey'] !== null) { + $step = ['children', $segment['structureKey']]; + $templatePath[] = $step; + $exactPath[] = $step; + } elseif ($segment['itemKey'] !== null) { + $exactPath[] = ['items', $segment['itemKey']]; + } + } + + $paths = [$templatePath]; + + if ($exactPath !== $templatePath) { + $paths[] = $exactPath; + } + + $snapshot->structure = $this->pruneStructureNode($this->structure, $paths, $property); + + return $snapshot; + } + /** * Get the payload at the current traversal path. */ - protected function payloadAtCurrentPath(): mixed + private function payloadAtCurrentPath(): mixed { $slot = $this->payload; @@ -509,7 +594,7 @@ protected function payloadAtCurrentPath(): mixed * @param array $source * @return array */ - protected function mergeUnknownInput(array $target, array $source): array + private function mergeUnknownInput(array $target, array $source): array { foreach ($source as $key => $value) { if (! array_key_exists($key, $target)) { @@ -535,7 +620,7 @@ protected function mergeUnknownInput(array $target, array $source): array * * @param non-empty-list $path */ - protected function valueAtPath(array $path): mixed + private function valueAtPath(array $path): mixed { $slot = $this->payloadAtCurrentPath(); @@ -555,7 +640,7 @@ protected function valueAtPath(array $path): mixed * * @param non-empty-list $path */ - protected function writeAtPath(array $path, mixed $value, bool $finished): void + private function writeAtPath(array $path, mixed $value, bool $finished): void { $slot = &$this->payload; $lastKey = array_pop($path); @@ -576,19 +661,77 @@ protected function writeAtPath(array $path, mixed $value, bool $finished): void } /** - * Split one mapped key into its payload path. + * Build a root-shaped payload skeleton ending in one complete node payload. * - * @return non-empty-list + * @param list $path + * @return array */ - protected static function segments(string|int $key): array + private static function payloadSkeleton(array $path, array $payload): array { - return is_int($key) ? [$key] : explode('.', $key); + if ($path === []) { + return $payload; + } + + foreach (array_reverse($path) as $key) { + $payload = [$key => $payload]; + } + + return $payload; + } + + /** + * Retain only the template and exact structure spines for one property. + * + * @param list> $paths + */ + private function pruneStructureNode(array $node, array $paths, string $property): array + { + $pruned = self::newStructureNode($node['class']); + + if (isset($node['uniform'])) { + $pruned['uniform'] = false; + } + + $branches = []; + + foreach ($paths as $path) { + if ($path === []) { + if (array_key_exists($property, $node['mappings'])) { + $pruned['mappings'][$property] = $node['mappings'][$property]; + } + + if (array_key_exists($property, $node['children'])) { + $pruned['children'][$property] = $node['children'][$property]; + } + + continue; + } + + [$collection, $key] = $path[0]; + $branches[$collection][$key][] = array_slice($path, 1); + } + + foreach ($branches as $collection => $children) { + foreach ($children as $key => $childPaths) { + if (! array_key_exists($key, $node[$collection] ?? [])) { + continue; + } + + $pruned[$collection][$key] = $this->pruneStructureNode( + $node[$collection][$key], + $childPaths, + $property, + ); + } + } + + return $pruned; } /** * Get the structure node at the current traversal path. */ - protected function structureNodeAtCurrentPath(): ?array + private function structureNodeAtCurrentPath(): ?array { $node = $this->structure; @@ -612,7 +755,7 @@ protected function structureNodeAtCurrentPath(): ?array /** * Get the sparse item override at the current traversal path. */ - protected function overrideNodeAtCurrentPath(): ?array + private function overrideNodeAtCurrentPath(): ?array { if (! $this->pathContainsItem()) { return null; @@ -646,7 +789,7 @@ protected function overrideNodeAtCurrentPath(): ?array /** * Get or create the structure node at the current traversal path. */ - protected function &ensureStructureNodeAtCurrentPath(): array + private function &ensureStructureNodeAtCurrentPath(): array { $node = &$this->structure; @@ -670,7 +813,7 @@ protected function &ensureStructureNodeAtCurrentPath(): array /** * Get or create the sparse item override at the current traversal path. */ - protected function &ensureOverrideNodeAtCurrentPath(): array + private function &ensureOverrideNodeAtCurrentPath(): array { $node = &$this->structure; @@ -694,7 +837,7 @@ protected function &ensureOverrideNodeAtCurrentPath(): array /** * Mark every collection surrounding the current value as non-uniform. */ - protected function markEnclosingCollectionsNonUniform(): void + private function markEnclosingCollectionsNonUniform(): void { $this->ensureStructureNodeAtCurrentPath(); $node = &$this->structure; @@ -714,7 +857,7 @@ protected function markEnclosingCollectionsNonUniform(): void /** * Determine if the current traversal path contains a collection item. */ - protected function pathContainsItem(): bool + private function pathContainsItem(): bool { foreach ($this->path as $segment) { if ($segment['itemKey'] !== null) { @@ -733,12 +876,13 @@ protected function pathContainsItem(): bool * class: null|class-string, * mappings: array, * children: array, - * paginatorSource?: AbstractPaginator|AbstractCursorPaginator, + * autoLazy?: array, + * paginatorSource?: AbstractCursorPaginator|AbstractPaginator, * uniform?: false, * items?: array * } */ - protected static function newStructureNode(?string $class = null): array + private static function newStructureNode(?string $class = null): array { return [ 'class' => $class, diff --git a/src/data/src/Support/Creation/CreationContext.php b/src/data/src/Support/Creation/CreationContext.php index 0561e72de..7e94b2333 100644 --- a/src/data/src/Support/Creation/CreationContext.php +++ b/src/data/src/Support/Creation/CreationContext.php @@ -22,7 +22,7 @@ * @param class-string $dataClass * @param list $ignoredMagicalMethods * @param array> $casts - * @param list> $normalizers + * @param list|Normalizer> $normalizers * @param list $prepareDataHooks * @param list $beforeValidationHooks * @param list $beforeRulesHooks diff --git a/src/data/src/Support/Creation/CreationContextFactory.php b/src/data/src/Support/Creation/CreationContextFactory.php index 660d9b4d5..4d7a973aa 100644 --- a/src/data/src/Support/Creation/CreationContextFactory.php +++ b/src/data/src/Support/Creation/CreationContextFactory.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; +use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Data\Casts\Cast; @@ -16,11 +17,16 @@ use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\DataConfig; use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Pagination\AbstractCursorPaginator; use Hypervel\Pagination\AbstractPaginator; +use Hypervel\Pagination\CursorPaginator; +use Hypervel\Pagination\LengthAwarePaginator; +use Hypervel\Pagination\Paginator; use Hypervel\Support\Collection; use Hypervel\Support\Enumerable; use Hypervel\Support\LazyCollection; +use Traversable; /** * @template TData of BaseData @@ -39,7 +45,7 @@ class CreationContextFactory /** @var array> */ protected array $casts = []; - /** @var list> */ + /** @var list|Normalizer> */ protected array $normalizers = []; /** @var list */ @@ -81,6 +87,8 @@ public function __construct( /** * Set the validation strategy. + * + * @return $this */ public function validationStrategy(ValidationStrategy $validationStrategy): self { @@ -107,6 +115,8 @@ public function onlyValidateRequests(): self /** * Validate every source. + * + * @return $this */ public function alwaysValidate(): self { @@ -133,6 +143,8 @@ public function withoutPropertyNameMapping(bool $withoutPropertyNameMapping = tr return $this; } + // REMOVED: withOptionalValues()/withoutOptionalValues(); Optional declarations always preserve absence. + /** * Disable or enable named creation methods. */ @@ -190,7 +202,7 @@ public function withCastCollection(array $casts): self /** * Add custom source normalizers. * - * @param Normalizer|class-string ...$normalizers + * @param class-string|Normalizer ...$normalizers */ public function withNormalizers(Normalizer|string ...$normalizers): self { @@ -351,12 +363,74 @@ public function getValidationRules(array $payload): array /** * Collect data objects. * + * Contract-typed sources retain every possible rebuildable runtime shape. + * * @template TCollectKey of array-key * @template TCollectValue + * @template TDataCollectionValue of BaseData + * @template TModelValue of Model * - * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|PaginatorContract $items - * - * @return ($into is 'array' ? array : ($into is class-string ? Collection : ($into is class-string ? Collection : ($into is class-string ? LazyCollection : ($into is class-string ? DataCollection : ($into is class-string ? PaginatedDataCollection : ($into is class-string ? CursorPaginatedDataCollection : ($items is EloquentCollection ? Collection : ($items is Collection ? Collection : ($items is LazyCollection ? LazyCollection : ($items is Enumerable ? Enumerable : ($items is array ? array : ($items is AbstractPaginator ? AbstractPaginator : ($items is PaginatorContract ? PaginatorContract : ($items is AbstractCursorPaginator ? AbstractCursorPaginator : ($items is CursorPaginatorContract ? CursorPaginatorContract : ($items is DataCollection ? DataCollection : DataCollection))))))))))))))))) + * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items + * @param null|'array'|class-string $into + * @return ( + * $into is null + * ? ($items is array + * ? array + * : ($items is PaginatedDataCollection<*, *>|CursorPaginatedDataCollection<*, *>|DataCollection<*, *> + * ? ($items is PaginatedDataCollection<*, *> + * ? PaginatedDataCollection + * : ($items is CursorPaginatedDataCollection<*, *> + * ? CursorPaginatedDataCollection + * : DataCollection)) + * : ($items is AbstractPaginator<*, *> + * ? ($items is LengthAwarePaginator<*, *> + * ? LengthAwarePaginator + * : ($items is Paginator<*, *> + * ? Paginator + * : AbstractPaginator)) + * : ($items is AbstractCursorPaginator<*, *> + * ? ($items is CursorPaginator<*, *> + * ? CursorPaginator + * : AbstractCursorPaginator) + * : ($items is Enumerable<*, *> + * ? ($items is EloquentCollection<*, *> + * ? Collection + * : ($items is LazyCollection<*, *> + * ? LazyCollection + * : ($items is Collection<*, *> + * ? Collection + * : never))) + * : never))))) + * : ($into is 'array' + * ? array + * : ($into is 'Hypervel\Support\Enumerable'|'Hypervel\Database\Eloquent\Collection'|'Hypervel\Support\Collection' + * ? Collection + * : ($into is 'Hypervel\Support\LazyCollection' + * ? LazyCollection + * : ($into is 'Hypervel\Data\PaginatedDataCollection'|'Hypervel\Data\CursorPaginatedDataCollection'|'Hypervel\Data\DataCollection' + * ? ($into is 'Hypervel\Data\PaginatedDataCollection' + * ? PaginatedDataCollection + * : ($into is 'Hypervel\Data\CursorPaginatedDataCollection' + * ? CursorPaginatedDataCollection + * : DataCollection)) + * : ($into is 'Hypervel\Pagination\LengthAwarePaginator'|'Hypervel\Pagination\Paginator'|'Hypervel\Pagination\CursorPaginator'|'Hypervel\Pagination\AbstractPaginator'|'Hypervel\Pagination\AbstractCursorPaginator' + * ? ($into is 'Hypervel\Pagination\LengthAwarePaginator' + * ? LengthAwarePaginator + * : ($into is 'Hypervel\Pagination\Paginator' + * ? Paginator + * : ($into is 'Hypervel\Pagination\CursorPaginator' + * ? CursorPaginator + * : ($into is 'Hypervel\Pagination\AbstractPaginator' + * ? AbstractPaginator + * : AbstractCursorPaginator)))) + * : ($into is 'Hypervel\Contracts\Pagination\LengthAwarePaginator'|'Hypervel\Contracts\Pagination\Paginator'|'Hypervel\Contracts\Pagination\CursorPaginator' + * ? ($into is 'Hypervel\Contracts\Pagination\LengthAwarePaginator' + * ? LengthAwarePaginatorContract + * : ($into is 'Hypervel\Contracts\Pagination\Paginator' + * ? PaginatorContract + * : CursorPaginatorContract)) + * : array|CursorPaginatedDataCollection|DataCollection|PaginatedDataCollection|Enumerable|AbstractCursorPaginator|AbstractPaginator|CursorPaginatorContract|LengthAwarePaginatorContract|PaginatorContract))))))) + * ) */ public function collect( mixed $items, @@ -364,4 +438,19 @@ public function collect( ): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection { return $this->creator->collect($this->dataClass, $this->get(), $items, $into); } + + /** + * Create typed items without whole-collection factory dispatch. + * + * @internal + * + * @template TCollectKey of array-key + * + * @param null|array|DataCollection|Enumerable $items + * @return Enumerable + */ + public function collectItems(mixed $items): Enumerable + { + return $this->creator->collectItems($this->dataClass, $this->get(), $items); + } } diff --git a/src/data/src/Support/Creation/DataCollectableFactory.php b/src/data/src/Support/Creation/DataCollectableFactory.php index 79fcaf08f..9b1a877ff 100644 --- a/src/data/src/Support/Creation/DataCollectableFactory.php +++ b/src/data/src/Support/Creation/DataCollectableFactory.php @@ -11,6 +11,8 @@ use Hypervel\Data\DataCollection; use Hypervel\Data\Enums\DataTypeKind; use Hypervel\Data\Exceptions\CannotCreateDataCollectable; +use Hypervel\Data\Normalizers\Normalized\UnknownProperty; +use Hypervel\Data\Optional; use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\Types\NamedType; use Hypervel\Database\Eloquent\Collection as EloquentCollection; @@ -46,45 +48,188 @@ public function items(mixed $value): ?array } /** - * Rebuild typed data items in a property's declared container. + * Rebuild normalized items in the source shape used by collection factories. + * + * Contract-only paginators intentionally have no method-dispatch shape because + * their metadata cannot be cloned without mutating an unknown implementation. * * @param class-string $dataClass - * @param array $items + * @param array|Enumerable $items + */ + public function forMethodSource( + string $dataClass, + mixed $source, + array|Enumerable $items, + ): mixed { + if (! $this->canRebuildRoot($source)) { + return null; + } + + return $this->rebuildRoot($dataClass, $source, $items); + } + + /** + * Rebuild normalized items in an explicit or source-inferred target. + * + * @param class-string $dataClass + * @param array|Enumerable $items + */ + public function forTarget( + string $dataClass, + mixed $source, + array|Enumerable $items, + ?string $into, + ): mixed { + if ($into === null) { + if (! $this->canRebuildRoot($source)) { + throw CannotCreateDataCollectable::create( + get_debug_type($source), + 'inferred collection', + ); + } + + return $this->rebuildRoot($dataClass, $source, $items); + } + + if ($into === 'array') { + return $this->eagerItems($items); + } + + if ($into === Enumerable::class || is_a($into, EloquentCollection::class, true)) { + return new Collection($this->eagerItems($items)); + } + + if (is_a($into, DataCollection::class, true)) { + return new $into($dataClass, $items); + } + + if (is_a($into, PaginatedDataCollection::class, true)) { + return new $into( + $dataClass, + $this->rebuildPaginator($source, $items, AbstractPaginator::class), + ); + } + + if (is_a($into, CursorPaginatedDataCollection::class, true)) { + return new $into( + $dataClass, + $this->rebuildCursorPaginator($source, $items, AbstractCursorPaginator::class), + ); + } + + if (is_a($into, AbstractPaginator::class, true) + || is_a($into, PaginatorContract::class, true) + ) { + return $this->rebuildPaginator($source, $items, $into); + } + + if (is_a($into, AbstractCursorPaginator::class, true) + || is_a($into, CursorPaginatorContract::class, true) + ) { + return $this->rebuildCursorPaginator($source, $items, $into); + } + + if (is_a($into, LazyCollection::class, true)) { + return $items instanceof $into ? $items : new $into($items); + } + + if (is_a($into, Collection::class, true)) { + return new $into($this->eagerItems($items)); + } + + throw CannotCreateDataCollectable::create(get_debug_type($source), $into); + } + + /** + * Retain the source needed to rebuild one paginated property. + */ + public function retainPaginatorSource( + NamedType $type, + mixed $value, + ConstructionState $state, + ): void { + $sourceClass = match (true) { + $type->kind->isPaginator() => AbstractPaginator::class, + $type->kind->isCursorPaginator() => AbstractCursorPaginator::class, + default => null, + }; + + if ($sourceClass === null) { + return; + } + + $source = match (true) { + $value instanceof PaginatedDataCollection, + $value instanceof CursorPaginatedDataCollection => $value->items(), + $value instanceof AbstractPaginator, + $value instanceof AbstractCursorPaginator => $value, + default => null, + }; + + if ($source instanceof $sourceClass) { + $state->recordPaginatorSource($source); + + return; + } + + if ($value instanceof UnknownProperty || $value instanceof Optional || $value === null) { + $state->clearPaginatorSource(); + + return; + } + + if ($state->paginatorSource() instanceof $sourceClass + && (is_array($value) || $value instanceof DataCollection || $value instanceof Enumerable) + ) { + return; + } + + throw CannotCreateDataCollectable::create(get_debug_type($value), $type->name); + } + + /** + * Rebuild typed items in a property's declared container. + * + * @param array $items */ public function forProperty( NamedType $type, - string $dataClass, array $items, ConstructionState $state, ): mixed { return match ($type->kind) { + DataTypeKind::Array, + DataTypeKind::Iterable, DataTypeKind::DataArray, DataTypeKind::DataIterable => $items, + DataTypeKind::Enumerable, DataTypeKind::DataEnumerable => $this->newEnumerable($type->name, $items), - DataTypeKind::DataCollection => new $type->name($dataClass, $items), + DataTypeKind::DataCollection => new $type->name($type->dataClass, $items), DataTypeKind::DataPaginatedCollection => new $type->name( - $dataClass, + $type->dataClass, $this->paginator($type, $items, $state), ), DataTypeKind::DataCursorPaginatedCollection => new $type->name( - $dataClass, + $type->dataClass, $this->cursorPaginator($type, $items, $state), ), + DataTypeKind::Paginator, DataTypeKind::DataPaginator => $this->paginator($type, $items, $state), + DataTypeKind::CursorPaginator, DataTypeKind::DataCursorPaginator => $this->cursorPaginator($type, $items, $state), default => throw CannotCreateDataCollectable::create('array', $type->name), }; } /** - * Rebuild an eager enumerable without retaining an Eloquent model container. + * Rebuild an eager enumerable in its declared collection class. * * @param class-string|literal-string $class * @param array $items */ protected function newEnumerable(string $class, array $items): Enumerable { - if ($class === Enumerable::class || is_a($class, EloquentCollection::class, true)) { + if ($class === Enumerable::class) { return new Collection($items); } @@ -98,7 +243,7 @@ protected function newEnumerable(string $class, array $items): Enumerable /** * Clone the retained paginator and replace only its items. * - * @param array $items + * @param array $items */ protected function paginator( NamedType $type, @@ -108,10 +253,7 @@ protected function paginator( $source = $state->paginatorSource(); if (! $source instanceof AbstractPaginator) { - throw CannotCreateDataCollectable::create( - get_debug_type($source), - $type->name, - ); + throw CannotCreateDataCollectable::missingPaginatorSource($type->name); } return (clone $source)->setCollection(new Collection($items)); @@ -120,7 +262,7 @@ protected function paginator( /** * Clone the retained cursor paginator and replace only its items. * - * @param array $items + * @param array $items */ protected function cursorPaginator( NamedType $type, @@ -130,12 +272,124 @@ protected function cursorPaginator( $source = $state->paginatorSource(); if (! $source instanceof AbstractCursorPaginator) { - throw CannotCreateDataCollectable::create( - get_debug_type($source), - $type->name, - ); + throw CannotCreateDataCollectable::missingPaginatorSource($type->name); } return (clone $source)->setCollection(new Collection($items)); } + + /** + * Rebuild normalized items in their supported root source shape. + * + * @param class-string $dataClass + * @param array|Enumerable $items + */ + protected function rebuildRoot( + string $dataClass, + mixed $source, + array|Enumerable $items, + ): mixed { + return match (true) { + is_array($source) => $this->eagerItems($items), + $source instanceof PaginatedDataCollection => new $source( + $dataClass, + $this->rebuildPaginator($source, $items, AbstractPaginator::class), + ), + $source instanceof CursorPaginatedDataCollection => new $source( + $dataClass, + $this->rebuildCursorPaginator($source, $items, AbstractCursorPaginator::class), + ), + $source instanceof DataCollection => new $source( + $dataClass, + $items, + ), + $source instanceof AbstractPaginator => $this->rebuildPaginator( + $source, + $items, + $source::class, + ), + $source instanceof AbstractCursorPaginator => $this->rebuildCursorPaginator( + $source, + $items, + $source::class, + ), + $source instanceof EloquentCollection => new Collection($this->eagerItems($items)), + $source instanceof LazyCollection => $items instanceof $source + ? $items + : new $source($items), + $source instanceof Collection => new $source($this->eagerItems($items)), + default => throw CannotCreateDataCollectable::create( + get_debug_type($source), + get_debug_type($source), + ), + }; + } + + /** + * Clone an offset paginator source with normalized items. + * + * @param array|Enumerable $items + */ + protected function rebuildPaginator( + mixed $source, + array|Enumerable $items, + string $into, + ): AbstractPaginator { + if ($source instanceof PaginatedDataCollection) { + $source = $source->items(); + } + + if (! $source instanceof AbstractPaginator || ! $source instanceof $into) { + throw CannotCreateDataCollectable::create(get_debug_type($source), $into); + } + + return (clone $source)->setCollection(new Collection($this->eagerItems($items))); + } + + /** + * Clone a cursor paginator source with normalized items. + * + * @param array|Enumerable $items + */ + protected function rebuildCursorPaginator( + mixed $source, + array|Enumerable $items, + string $into, + ): AbstractCursorPaginator { + if ($source instanceof CursorPaginatedDataCollection) { + $source = $source->items(); + } + + if (! $source instanceof AbstractCursorPaginator || ! $source instanceof $into) { + throw CannotCreateDataCollectable::create(get_debug_type($source), $into); + } + + return (clone $source)->setCollection(new Collection($this->eagerItems($items))); + } + + /** + * Materialize normalized items without changing their keys. + * + * @param array|Enumerable $items + * @return array + */ + protected function eagerItems(array|Enumerable $items): array + { + return is_array($items) ? $items : $items->all(); + } + + /** + * Determine if normalized items can safely retain the root source shape. + */ + protected function canRebuildRoot(mixed $source): bool + { + return is_array($source) + || $source instanceof DataCollection + || $source instanceof PaginatedDataCollection + || $source instanceof CursorPaginatedDataCollection + || $source instanceof AbstractPaginator + || $source instanceof AbstractCursorPaginator + || $source instanceof Collection + || $source instanceof LazyCollection; + } } diff --git a/src/data/src/Support/Creation/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index 63df62260..3fcefb475 100644 --- a/src/data/src/Support/Creation/DataCreator.php +++ b/src/data/src/Support/Creation/DataCreator.php @@ -6,7 +6,13 @@ use BackedEnum; use DateTimeInterface; +use Hypervel\Container\Container as ConcreteContainer; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; +use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; +use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; +use Hypervel\Data\Attributes\AutoLazy; +use Hypervel\Data\Attributes\AutoWhenLoadedLazy; use Hypervel\Data\Attributes\GetsCast; use Hypervel\Data\Casts\BuiltinTypeCast; use Hypervel\Data\Casts\Cast; @@ -16,14 +22,20 @@ use Hypervel\Data\Casts\IterableItemCast; use Hypervel\Data\Casts\Uncastable; use Hypervel\Data\Contracts\BaseData; +use Hypervel\Data\Contracts\PropertyMorphableData; +use Hypervel\Data\CursorPaginatedDataCollection; +use Hypervel\Data\DataCollection; use Hypervel\Data\Enums\CustomCreationMethodType; use Hypervel\Data\Exceptions\CannotCreateAbstractClass; use Hypervel\Data\Exceptions\CannotCreateData; +use Hypervel\Data\Exceptions\CannotCreateDataCollectable; use Hypervel\Data\Exceptions\CannotSetComputedValue; +use Hypervel\Data\Lazy; use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalized\UnknownProperty; use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Optional; +use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\DataClass; use Hypervel\Data\Support\DataClassRepository; use Hypervel\Data\Support\DataConfig; @@ -33,12 +45,20 @@ use Hypervel\Data\Support\Types\NamedType; use Hypervel\Data\Support\Types\Type; use Hypervel\Data\Support\Validation\DataValidator; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; +use Hypervel\Pagination\AbstractCursorPaginator; +use Hypervel\Pagination\AbstractPaginator; +use Hypervel\Pagination\Paginator; use Hypervel\Support\Collection; +use Hypervel\Support\Enumerable; use Hypervel\Support\LazyCollection; +use Traversable; use function data_set; +/** @phpstan-type OperationMemo array> */ class DataCreator { /** @@ -67,9 +87,9 @@ public function create( CreationContext $context, mixed ...$payloads, ): BaseData { + /** @var BaseData $data */ $data = $this->execute($class, $context, $payloads); - /** @var BaseData $data */ return $data; } @@ -85,9 +105,9 @@ public function validate( CreationContext $context, array $payloads, ): array { + /** @var array $validated */ $validated = $this->execute($class, $context, $payloads); - /** @var array $validated */ return $validated; } @@ -103,18 +123,446 @@ public function getValidationRules( CreationContext $context, array $payloads, ): array { + /** @var array> $rules */ $rules = $this->execute($class, $context, $payloads); - /** @var array> $rules */ return $rules; } + /** + * Resolve one deferred automatic lazy property. + * + * @internal + */ + public function resolveAutoLazyProperty( + string $propertyName, + mixed $value, + ConstructionState $state, + ?AutoLazyReplayMode $replay, + ): mixed { + $class = $state->nodeClass() ?? $state->context->dataClass; + $property = $this->dataClasses->get($class)->properties[$propertyName]; + $extensions = []; + + if ($replay !== null && ! $property->isFinishedValue($value)) { + $wireKey = $state->originalKey($propertyName); + $this->fillResolvedProperty( + $property, + $property->inputPath($wireKey), + $value, + $state, + $extensions, + false, + false, + $replay === AutoLazyReplayMode::Hook, + ); + } + + return $this->castProperty($property, $value, $state, $extensions); + } + + /** + * Collect data objects through one root operation. + * + * @template TKey of array-key + * @template TValue + * @template TCollectValue of BaseData + * @template TModelValue of Model + * @template TData of BaseData + * + * @param class-string $class + * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items + * @param null|'array'|class-string $into + * @return AbstractCursorPaginator|AbstractPaginator|array|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|Enumerable|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract + */ + public function collect( + string $class, + CreationContext $context, + mixed $items, + ?string $into = null, + ): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection { + $shouldValidate = $this->validator->shouldValidate($context, [$items]); + + if ($items instanceof LazyCollection && ! $shouldValidate) { + return $this->collectLazy($class, $context, $items, $into); + } + + $values = $this->dataCollectables->items($items); + + if ($values === null) { + throw CannotCreateDataCollectable::create( + get_debug_type($items), + $into ?? 'inferred collection', + ); + } + + return $this->collectEager( + $class, + $context, + $items, + $values, + $into, + $shouldValidate, + ); + } + + /** + * Create typed items through one internal collection operation. + * + * @internal + * + * @template TKey of array-key + * @template TData of BaseData + * + * @param class-string $class + * @param null|array|DataCollection|Enumerable $items + * @return Enumerable + */ + public function collectItems( + string $class, + CreationContext $context, + mixed $items, + ): Enumerable { + if ($items === null) { + return new Collection; + } + + $shouldValidate = $this->validator->shouldValidate($context, [$items]); + + if ($items instanceof LazyCollection && ! $shouldValidate) { + return $this->deferCollectionItems($class, $context, $items); + } + + $values = $this->dataCollectables->items($items); + + if ($values === null) { + throw CannotCreateDataCollectable::create( + get_debug_type($items), + DataCollection::class, + ); + } + + return new Collection($this->createEagerCollectionItems( + $class, + $context, + $items, + $values, + $shouldValidate, + )); + } + + /** + * Collect a deferred source without enumerating it. + * + * @template TKey of array-key + * @template TData of BaseData + * + * @param class-string $class + * @param LazyCollection $source + * @return AbstractCursorPaginator|AbstractPaginator|array|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|Enumerable|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract + */ + protected function collectLazy( + string $class, + CreationContext $context, + LazyCollection $source, + ?string $into, + ): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection { + $items = $this->deferCollectionItems($class, $context, $source); + $methodSource = $this->dataCollectables->forMethodSource($class, $source, $items); + $dataClass = $this->dataClasses->get($class); + $match = $methodSource === null + ? null + : $this->matchNamedCollectionFactory($dataClass, $context, $methodSource, $into); + + return $match !== null + ? $this->invokeNamedFactory($dataClass, ...$match) + : $this->dataCollectables->forTarget($class, $source, $items, $into); + } + + /** + * Create a deferred item collection with one shared operation memo. + * + * @template TKey of array-key + * @template TData of BaseData + * + * @param class-string $class + * @param LazyCollection $source + * @return LazyCollection + */ + protected function deferCollectionItems( + string $class, + CreationContext $context, + LazyCollection $source, + ): LazyCollection { + $extensions = []; + + // Deferred traversal remains part of this root operation, so its resolved extensions stay shared. + return $source->map( + function (mixed $item) use ($class, $context, &$extensions): BaseData { + return $this->createUnvalidatedNode( + $class, + $context, + $item, + $extensions, + ); + }, + ); + } + + /** + * Create one nested node without restarting root validation or authorization. + * + * @template TData of BaseData + * + * @param class-string $class + * @param OperationMemo $extensions + * @return TData + */ + protected function createUnvalidatedNode( + string $class, + CreationContext $context, + mixed $item, + array &$extensions, + ): BaseData { + if ($item instanceof $class) { + return $item; + } + + $state = ConstructionState::create($context, $class); + $direct = $this->fillNode( + $class, + [$item], + $state, + $extensions, + false, + false, + ); + + // Named factories, morph selection, instantiation, and after-creation hooks all enforce this class. + /** @var TData $data */ + $data = $direct ?? $this->castAndInstantiateNode($state, $extensions); + + return $data; + } + + /** + * Collect an eager source through one Fill and validation graph. + * + * @template TKey of array-key + * @template TData of BaseData + * + * @param class-string $class + * @param array $values + * @return AbstractCursorPaginator|AbstractPaginator|array|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|Enumerable|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract + */ + protected function collectEager( + string $class, + CreationContext $context, + mixed $source, + array $values, + ?string $into, + bool $shouldValidate, + ): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection { + $items = $this->createEagerCollectionItems( + $class, + $context, + $source, + $values, + $shouldValidate, + ); + $methodSource = $this->dataCollectables->forMethodSource($class, $source, $items); + $dataClass = $this->dataClasses->get($class); + $match = $methodSource === null + ? null + : $this->matchNamedCollectionFactory($dataClass, $context, $methodSource, $into); + + return $match !== null + ? $this->invokeNamedFactory($dataClass, ...$match) + : $this->dataCollectables->forTarget($class, $source, $items, $into); + } + + /** + * Create every eager item through one Fill and validation graph. + * + * @template TKey of array-key + * @template TData of BaseData + * + * @param class-string $class + * @param array $values + * @return array + */ + protected function createEagerCollectionItems( + string $class, + CreationContext $context, + mixed $source, + array $values, + bool $shouldValidate, + ): array { + $request = $shouldValidate + ? $this->validator->authorize($class, [$source]) + : null; + $state = ConstructionState::create($context, $class); + $extensions = []; + + if ($source instanceof EloquentCollection) { + $this->loadMissingRelations($class, $source); + } + + $this->fillCollectionItems( + $class, + $values, + $state, + $extensions, + $shouldValidate, + ); + + if ($shouldValidate && $context->beforeValidationHooks !== []) { + $this->applyPayloadHooks( + $class, + $context->beforeValidationHooks, + $state, + $extensions, + true, + true, + collection: true, + ); + } + + if ($shouldValidate) { + $compiled = $this->validator->compileCollection($state, $class); + $this->validator->validate($state, $compiled, $request, $class); + } + + if ($shouldValidate && $context->afterValidationHooks !== []) { + $this->applyPayloadHooks( + $class, + $context->afterValidationHooks, + $state, + $extensions, + false, + false, + collection: true, + ); + } + + // Fill and construction preserve source keys and enforce the requested class at every object exit. + /** @var array $items */ + $items = $this->castCollectionItems($class, $state, $extensions); + + return $items; + } + + /** + * Batch relations explicitly selected by data properties. + * + * @param class-string $class + */ + protected function loadMissingRelations(string $class, EloquentCollection $models): void + { + if ($models->isEmpty()) { + return; + } + + $model = $models->first(); + $relations = []; + + foreach ($this->dataClasses->get($class)->properties as $property) { + if (! $property->loadRelation) { + continue; + } + + if (($relation = $property->resolveModelRelation($model)) !== null) { + $relations[] = $relation; + } + } + + if ($relations !== []) { + $models->loadMissing($relations); + } + } + + /** + * Fill every eager root collection item into one construction state. + * + * @param class-string $class + * @param array $values + * @param OperationMemo $extensions + */ + protected function fillCollectionItems( + string $class, + array $values, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + ): void { + foreach ($values as $key => $item) { + if ($item instanceof $class) { + $state->writeFinishedItemValue($key, $item); + + continue; + } + + $state->writeItemValue($key, []); + $state->enterItem($key); + + try { + $direct = $this->fillNode( + $class, + [$item], + $state, + $extensions, + $shouldValidate, + $shouldValidate, + ); + } finally { + $state->leave(); + } + + if ($direct !== null) { + $state->writeFinishedItemValue($key, $direct); + } + } + } + + /** + * Cast and instantiate every eager root collection item. + * + * @param class-string $class + * @param OperationMemo $extensions + * @return array + */ + protected function castCollectionItems( + string $class, + ConstructionState $state, + array &$extensions, + ): array { + $items = []; + + foreach ($state->payload() as $key => $item) { + if ($item instanceof $class) { + $items[$key] = $item; + + continue; + } + + $state->enterItem($key); + + try { + $items[$key] = $this->castAndInstantiateNode($state, $extensions); + } finally { + $state->leave(); + } + } + + return $items; + } + /** * Run the fixed operation through its selected exit point. * * @param class-string $class * @param array $payloads - * @return BaseData|array + * @return array|BaseData */ protected function execute( string $class, @@ -194,7 +642,7 @@ protected function execute( * * @param class-string $class * @param array $payloads - * @param array $extensions + * @param OperationMemo $extensions */ protected function fillNode( string $class, @@ -214,68 +662,170 @@ protected function fillNode( return $result; } - $payloads = [$result]; - } + $payloads = [$result]; + } + + $direct = $this->tryCreateDirectArrayNode( + $dataClass, + $payloads, + $state, + $shouldValidate, + $compilesRules, + ); + + if ($direct !== null) { + return $direct; + } + + $normalizers = $this->resolveNormalizers($dataClass, $state->context, $extensions); + $payloads = $payloads === [] ? [[]] : $payloads; + $sources = []; + $unknownInputSources = []; + + foreach ($payloads as $payload) { + $source = SourceResolver::resolve($class, $payload, $normalizers); + $sources[] = $source; + $unknownInputSources[] = $payload instanceof Request + ? ($payload->isJson() ? $payload->json()->all() : $payload->request->all()) + : $source; + } + + $propertySources = $sources; + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + + if ($state->context->prepareDataHooks !== []) { + $input = $this->mergeSources($dataClass, $propertySources, $state->context); + + foreach ($state->context->prepareDataHooks as $hook) { + $input = $hook($input); + } + + $propertySources = [$input]; + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + } + + $class = $this->resolveMorphClass($dataClass, $resolvedProperties); + + if ($class !== $dataClass->name) { + $dataClass = $this->dataClasses->get($class); + $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + } + + $state->setNodeClass($class); + + if ($shouldValidate && $dataClass->failOnUnknownFields) { + $state->recordUnknownInput( + $this->mergeSources($dataClass, $unknownInputSources, $state->context), + ); + } + + $this->fillResolvedProperties( + $dataClass, + $resolvedProperties, + $sources, + $payloads, + $state, + $extensions, + $shouldValidate, + $compilesRules, + false, + ); + + return null; + } + + /** + * Create one exact array node without entering the general Fill path. + * + * A miss remains in the current invocation so a named factory is never matched twice. + * + * @param array $payloads + */ + protected function tryCreateDirectArrayNode( + DataClass $dataClass, + array $payloads, + ConstructionState $state, + bool $shouldValidate, + bool $compilesRules, + ): ?BaseData { + $context = $state->context; + + if ($context->mode !== CreationMode::Create + || $shouldValidate + || $compilesRules + || ! $dataClass->directArrayCreation + || count($payloads) !== 1 + || $context->normalizers !== [] + || $context->casts !== [] + || $context->prepareDataHooks !== [] + || $context->beforeCreationHooks !== [] + || $context->afterCreationHooks !== []) { + return null; + } + + $payload = $payloads[array_key_first($payloads)]; + + if (! is_array($payload)) { + return null; + } + + $properties = []; + + foreach ($dataClass->properties as $property) { + $mappedKey = $this->propertyInputKey($property, $context); + $match = $this->matchPropertySource($payload, $property, $mappedKey); + $value = $match === null ? UnknownProperty::create() : $match[1]; + + if ($value instanceof UnknownProperty) { + // Computed values are assigned by the class and never enter construction input. + if ($property->computed) { + continue; + } + + if ($property->hasDefaultValue) { + continue; + } - $normalizers = $this->resolveNormalizers($dataClass, $state->context, $extensions); - $sources = []; - $unknownInputSources = []; + if ($property->type->isOptional) { + $properties[$property->name] = Optional::create(); - foreach ($payloads === [] ? [[]] : $payloads as $payload) { - $source = SourceResolver::resolve($class, $payload, $normalizers); - $sources[] = $source; - $unknownInputSources[] = $payload instanceof Request - ? ($payload->isJson() ? $payload->json()->all() : $payload->request->all()) - : $source; - } + continue; + } - $propertySources = $sources; - $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); + if ($property->type->isNullable) { + $properties[$property->name] = null; - if ($state->context->prepareDataHooks !== []) { - $input = $this->mergeSources($dataClass, $propertySources, $state->context); + continue; + } - foreach ($state->context->prepareDataHooks as $hook) { - $input = $hook($input); + return null; } - $propertySources = [$input]; - $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); - } + if ($property->computed) { + return null; + } - $class = $this->resolveMorphClass($dataClass, $resolvedProperties); + if ($value === null || $value instanceof Optional) { + $properties[$property->name] = $value; - if ($class !== $dataClass->name) { - $dataClass = $this->dataClasses->get($class); - $resolvedProperties = $this->resolveProperties($dataClass, $propertySources, $state->context); - } + continue; + } - $state->setNodeClass($class); + if (! $property->type->acceptsValue($value)) { + return null; + } - if ($shouldValidate && $dataClass->failOnUnknownFields) { - $state->recordUnknownInput( - $this->mergeSources($dataClass, $unknownInputSources, $state->context), - ); + $properties[$property->name] = $value; } - $this->fillResolvedProperties( - $dataClass, - $resolvedProperties, - $state, - $extensions, - $shouldValidate, - $compilesRules, - false, - ); - - return null; + return $this->instantiator->instantiate($dataClass, $properties); } /** * Fill one node introduced or changed by a validation payload hook. * * @param class-string $class - * @param array $extensions + * @param OperationMemo $extensions */ protected function fillHookNode( string $class, @@ -309,6 +859,8 @@ protected function fillHookNode( $this->fillResolvedProperties( $dataClass, $resolvedProperties, + [$source], + [$payload], $state, $extensions, $shouldValidate, @@ -323,11 +875,15 @@ protected function fillHookNode( * Write one resolved data node and recursively fill its declared children. * * @param array $resolvedProperties - * @param array $extensions + * @param list $sources + * @param list $payloads + * @param OperationMemo $extensions */ protected function fillResolvedProperties( DataClass $dataClass, array $resolvedProperties, + array $sources, + array $payloads, ConstructionState $state, array &$extensions, bool $shouldValidate, @@ -344,7 +900,33 @@ protected function fillResolvedProperties( continue; } + $inputPath = $property->inputPath($wireKey); + $autoLazySource = null; + + if ($property->autoLazy !== null) { + $autoLazySource = $this->resolveAutoLazySource( + $property, + $sources, + $payloads, + $state->context, + ); + $state->recordAutoLazy($property->name, $autoLazySource); + } + if ($value instanceof UnknownProperty) { + if ($property->autoLazy !== null + && $this->requiresAutoLazyReplay($property) + && ($property->hasDefaultValue || $this->isAutoWhenLoaded($property)) + ) { + $state->recordAutoLazy( + $property->name, + $autoLazySource, + $fromValidationHook + ? AutoLazyReplayMode::Hook + : AutoLazyReplayMode::Normal, + ); + } + continue; } @@ -352,158 +934,320 @@ protected function fillResolvedProperties( throw CannotSetComputedValue::create($property); } - $dataIterable = $this->dataIterableType($property); - if ($property->isFinishedValue($value)) { - $state->writeFinishedPropertyValue($wireKey, $value); + $state->writeFinishedPropertyValue($inputPath, $value); continue; } - if ($dataIterable !== null && $value instanceof LazyCollection) { - if (! $compilesRules) { - $state->writePropertyValue($wireKey, $value); + if ($property->autoLazy !== null + && (! $compilesRules || ! $property->validate) + && $this->requiresAutoLazyReplay($property) + && $value !== null + && ! $value instanceof Optional + && ! $value instanceof Lazy + ) { + $state->recordAutoLazy( + $property->name, + $autoLazySource, + $fromValidationHook + ? AutoLazyReplayMode::Hook + : AutoLazyReplayMode::Normal, + ); + $state->writePropertyValue($inputPath, $value); + + continue; + } - continue; - } + $this->fillResolvedProperty( + $property, + $inputPath, + $value, + $state, + $extensions, + $shouldValidate, + $compilesRules, + $fromValidationHook, + ); + } + } + + /** + * Write one resolved property and recursively fill its declared children. + * + * @param OperationMemo $extensions + * @param non-empty-list $inputPath + */ + protected function fillResolvedProperty( + DataProperty $property, + array $inputPath, + mixed $value, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + bool $fromValidationHook, + ): void { + $dataIterable = $this->dataIterableType($property); + $typedIterable = $dataIterable === null + ? $this->typedIterableType($property) + : null; + + if ($dataIterable !== null || $typedIterable !== null) { + $this->retainPaginatorSource( + $property, + $dataIterable ?? $typedIterable, + $value, + $inputPath, + $state, + ); + } - $value = $value->all(); + if ($dataIterable !== null && $value instanceof LazyCollection) { + if (! $compilesRules) { + $state->writePropertyValue($inputPath, $value); + + return; } - $iterableValues = $dataIterable === null ? null : $this->iterableValues($value); + $value = $value->all(); + } + + if ($dataIterable !== null && $value instanceof EloquentCollection) { + /** @var class-string $itemDataClass */ + $itemDataClass = $dataIterable->dataClass; + $this->loadMissingRelations($itemDataClass, $value); + } - if ($dataIterable !== null && $iterableValues !== null) { - /** @var class-string $itemDataClass */ - $itemDataClass = $dataIterable->dataClass; - $state->writePropertyValue($wireKey, []); - $state->enterProperty($property->name, $wireKey); + $iterableValues = $dataIterable === null ? null : $this->iterableValues($value); - try { - foreach ($iterableValues as $key => $item) { - if ($item instanceof $itemDataClass) { - $state->writeFinishedItemValue($key, $item); + if ($dataIterable !== null && $iterableValues !== null) { + /** @var class-string $itemDataClass */ + $itemDataClass = $dataIterable->dataClass; + $state->writePropertyValue($inputPath, []); + $state->enterProperty($property->name, $inputPath); - continue; - } + try { + foreach ($iterableValues as $key => $item) { + if ($item instanceof $itemDataClass) { + $state->writeFinishedItemValue($key, $item); - $state->writeItemValue($key, []); - $state->enterItem($key); - - try { - $direct = $fromValidationHook - ? $this->fillHookNode( - $itemDataClass, - $item, - $state, - $extensions, - $shouldValidate, - $compilesRules, - ) - : $this->fillNode( - $itemDataClass, - [$item], - $state, - $extensions, - $shouldValidate, - $compilesRules, - ); - } finally { - $state->leave(); - } + continue; + } - if ($direct !== null) { - $state->writeFinishedItemValue($key, $direct); - } + $state->writeItemValue($key, []); + $state->enterItem($key); + + try { + $direct = $fromValidationHook + ? $this->fillHookNode( + $itemDataClass, + $item, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ) + : $this->fillNode( + $itemDataClass, + [$item], + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } finally { + $state->leave(); + } + + if ($direct !== null) { + $state->writeFinishedItemValue($key, $direct); } + } + } finally { + $state->leave(); + } + + return; + } + + $nestedDataClass = $this->nestedDataClass($property); + + if ($nestedDataClass !== null + && $value !== null + && ! $value instanceof Optional + && ! $property->type->acceptsValue($value) + ) { + $state->writePropertyValue($inputPath, []); + $state->enterProperty($property->name, $inputPath); + + try { + $direct = $fromValidationHook + ? $this->fillHookNode( + $nestedDataClass, + $value, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ) + : $this->fillNode( + $nestedDataClass, + [$value], + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } finally { + $state->leave(); + } + + if ($direct !== null) { + $state->writeFinishedPropertyValue($inputPath, $direct); + } + + return; + } + + $state->writePropertyValue($inputPath, $value); + } + + /** + * Apply one root payload-hook stage and reconcile changed selections. + * + * @param class-string $class + * @param list $hooks + * @param OperationMemo $extensions + */ + protected function applyPayloadHooks( + string $class, + array $hooks, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + bool $reconcile = true, + bool $collection = false, + ): void { + $previousPayload = $state->payload(); + $payload = $previousPayload; + + foreach ($hooks as $hook) { + $payload = $hook($payload); + } + + if ($payload === $previousPayload) { + return; + } + + $state->replacePayload($payload); + + if ($reconcile) { + if ($collection) { + $this->reconcileCollection( + $class, + $previousPayload, + $payload, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } else { + $this->reconcileNode( + $class, + $previousPayload, + $payload, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } + } + } + + /** + * Reconcile changed eager root collection items. + * + * @param class-string $class + * @param array $previousPayload + * @param array $payload + * @param OperationMemo $extensions + */ + protected function reconcileCollection( + string $class, + array $previousPayload, + array $payload, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + ): void { + foreach ($payload as $key => $item) { + $previousItem = $previousPayload[$key] ?? UnknownProperty::create(); + + if ($item === $previousItem) { + continue; + } + + if ($item instanceof $class) { + $state->enterItem($key); + + try { + $state->resetNodeStructure(); } finally { $state->leave(); } + $state->writeFinishedItemValue($key, $item); + continue; } - $nestedDataClass = $this->nestedDataClass($property); - - if ($nestedDataClass !== null - && $value !== null - && ! $value instanceof Optional - && ! $property->type->acceptsValue($value) - ) { - $state->writePropertyValue($wireKey, []); - $state->enterProperty($property->name, $wireKey); + if (is_array($previousItem) && is_array($item)) { + $state->enterItem($key); try { - $direct = $fromValidationHook - ? $this->fillHookNode( - $nestedDataClass, - $value, - $state, - $extensions, - $shouldValidate, - $compilesRules, - ) - : $this->fillNode( - $nestedDataClass, - [$value], + if ($state->nodeClass() !== null) { + $this->reconcileNode( + $class, + $previousItem, + $item, $state, $extensions, $shouldValidate, $compilesRules, ); - } finally { - $state->leave(); - } - if ($direct !== null) { - $state->writeFinishedPropertyValue($wireKey, $direct); + continue; + } + } finally { + $state->leave(); } - - continue; } - $state->writePropertyValue($wireKey, $value); - } - } - - /** - * Apply one root payload-hook stage and reconcile changed selections. - * - * @param class-string $class - * @param list $hooks - * @param array $extensions - */ - protected function applyPayloadHooks( - string $class, - array $hooks, - ConstructionState $state, - array &$extensions, - bool $shouldValidate, - bool $compilesRules, - bool $reconcile = true, - ): void { - $previousPayload = $state->payload(); - $payload = $previousPayload; - - foreach ($hooks as $hook) { - $payload = $hook($payload); - } - - if ($payload === $previousPayload) { - return; - } + $state->writeItemValue($key, []); + $state->enterItem($key); - $state->replacePayload($payload); + try { + $state->resetNodeStructure(); + $direct = $this->fillHookNode( + $class, + $item, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + } finally { + $state->leave(); + } - if ($reconcile) { - $this->reconcileNode( - $class, - $previousPayload, - $payload, - $state, - $extensions, - $shouldValidate, - $compilesRules, - ); + if ($direct !== null) { + $state->writeFinishedItemValue($key, $direct); + } } } @@ -513,7 +1257,7 @@ protected function applyPayloadHooks( * @param class-string $declaredClass * @param array $previousPayload * @param array $payload - * @param array $extensions + * @param OperationMemo $extensions */ protected function reconcileNode( string $declaredClass, @@ -543,6 +1287,8 @@ protected function reconcileNode( $this->fillResolvedProperties( $dataClass, $resolvedProperties, + [$payload], + [$payload], $state, $extensions, $shouldValidate, @@ -561,7 +1307,11 @@ protected function reconcileNode( foreach ($dataClass->properties as $property) { $previousWireKey = $state->originalKey($property->name); - $previousValue = SourceReader::read($previousPayload, $previousWireKey, $property); + $previousValue = SourceReader::read( + $previousPayload, + $property->inputPath($previousWireKey), + $property, + ); [$wireKey, $value] = $resolvedProperties[$property->name]; if ($wireKey !== $previousWireKey) { @@ -581,6 +1331,7 @@ protected function reconcileNode( $previousValue, $value, $wireKey, + $payload, $state, $extensions, $shouldValidate, @@ -592,19 +1343,69 @@ protected function reconcileNode( /** * Reconcile one changed property selection. * - * @param array $extensions + * @param OperationMemo $extensions */ protected function reconcileProperty( DataProperty $property, mixed $previousValue, mixed $value, string|int $wireKey, + array $payload, ConstructionState $state, array &$extensions, bool $shouldValidate, bool $compilesRules, ): void { + $inputPath = $property->inputPath($wireKey); + $autoLazySource = null; + + if ($property->autoLazy !== null) { + $autoLazySource = $this->resolveAutoLazySource( + $property, + [$payload], + [$payload], + $state->context, + ); + $state->recordAutoLazy($property->name, $autoLazySource); + } + $dataIterable = $this->dataIterableType($property); + $typedIterable = $dataIterable === null + ? $this->typedIterableType($property) + : null; + + if ($property->autoLazy !== null + && (! $compilesRules || ! $property->validate) + && $this->requiresAutoLazyReplay($property) + && ( + ($value instanceof UnknownProperty + && ($property->hasDefaultValue || $this->isAutoWhenLoaded($property))) + || ($value !== null + && ! $value instanceof UnknownProperty + && ! $value instanceof Optional + && ! $value instanceof Lazy + && ! $property->isFinishedValue($value)) + ) + ) { + $state->clearChildStructure($property->name); + $state->recordAutoLazy( + $property->name, + $autoLazySource, + AutoLazyReplayMode::Hook, + ); + + return; + } + + if ($dataIterable !== null || $typedIterable !== null) { + $this->retainPaginatorSource( + $property, + $dataIterable ?? $typedIterable, + $value, + $inputPath, + $state, + ); + } if ($dataIterable !== null) { $this->reconcileDataIterable( @@ -612,7 +1413,7 @@ protected function reconcileProperty( $dataIterable, $previousValue, $value, - $wireKey, + $inputPath, $state, $extensions, $shouldValidate, @@ -636,14 +1437,14 @@ protected function reconcileProperty( $state->clearChildStructure($property->name); if ($value instanceof BaseData) { - $state->writeFinishedPropertyValue($wireKey, $value); + $state->writeFinishedPropertyValue($inputPath, $value); } return; } if (is_array($previousValue) && is_array($value)) { - $state->enterProperty($property->name, $wireKey); + $state->enterProperty($property->name, $inputPath); try { if ($state->nodeClass() !== null) { @@ -665,8 +1466,8 @@ protected function reconcileProperty( } $state->clearChildStructure($property->name); - $state->writePropertyValue($wireKey, []); - $state->enterProperty($property->name, $wireKey); + $state->writePropertyValue($inputPath, []); + $state->enterProperty($property->name, $inputPath); try { $state->resetNodeStructure(); @@ -683,21 +1484,22 @@ protected function reconcileProperty( } if ($direct !== null) { - $state->writeFinishedPropertyValue($wireKey, $direct); + $state->writeFinishedPropertyValue($inputPath, $direct); } } /** * Reconcile one changed typed data iterable. * - * @param array $extensions + * @param OperationMemo $extensions + * @param non-empty-list $inputPath */ protected function reconcileDataIterable( DataProperty $property, NamedType $type, mixed $previousValue, mixed $value, - string|int $wireKey, + array $inputPath, ConstructionState $state, array &$extensions, bool $shouldValidate, @@ -705,7 +1507,7 @@ protected function reconcileDataIterable( ): void { if ($property->isFinishedValue($value)) { $state->clearChildStructure($property->name); - $state->writeFinishedPropertyValue($wireKey, $value); + $state->writeFinishedPropertyValue($inputPath, $value); return; } @@ -718,7 +1520,7 @@ protected function reconcileDataIterable( } $value = $value->all(); - $state->writePropertyValue($wireKey, $value); + $state->writePropertyValue($inputPath, $value); } $values = $this->iterableValues($value); @@ -732,7 +1534,7 @@ protected function reconcileDataIterable( $previousValues = $this->iterableValues($previousValue) ?? []; /** @var class-string $itemDataClass */ $itemDataClass = $type->dataClass; - $state->enterProperty($property->name, $wireKey); + $state->enterProperty($property->name, $inputPath); try { foreach ($values as $key => $item) { @@ -807,7 +1609,7 @@ protected function reconcileDataIterable( /** * Cast and instantiate the node at the current construction path. * - * @param array $extensions + * @param OperationMemo $extensions */ protected function castAndInstantiateNode( ConstructionState $state, @@ -824,8 +1626,33 @@ protected function castAndInstantiateNode( } $wireKey = $state->originalKey($property->name); + $inputPath = $property->inputPath($wireKey); + + if (! $state->hasValue($inputPath)) { + if ($property->autoLazy !== null && $property->hasDefaultValue) { + $value = $this->propertyDefaultValue($dataClass, $property); + $state->writePropertyValue($inputPath, $value); + $properties[$property->name] = $this->buildAutoLazy( + $property, + $value, + $state, + $extensions, + ); + + continue; + } + + if ($property->autoLazy !== null && $this->isAutoWhenLoaded($property)) { + $properties[$property->name] = $this->buildAutoLazy( + $property, + UnknownProperty::create(), + $state, + $extensions, + ); + + continue; + } - if (! $state->hasValue($wireKey)) { if ($property->hasDefaultValue) { continue; } @@ -839,12 +1666,10 @@ protected function castAndInstantiateNode( continue; } - $properties[$property->name] = $this->castProperty( - $property, - $state->getValue($wireKey), - $state, - $extensions, - ); + $value = $state->getValue($inputPath); + $properties[$property->name] = $property->autoLazy === null + ? $this->castProperty($property, $value, $state, $extensions) + : $this->buildAutoLazy($property, $value, $state, $extensions); } foreach ($state->context->beforeCreationHooks as $hook) { @@ -864,10 +1689,55 @@ protected function castAndInstantiateNode( return $data; } + /** + * Build one automatic lazy property around the ordinary cast path. + * + * @param OperationMemo $extensions + */ + protected function buildAutoLazy( + DataProperty $property, + mixed $value, + ConstructionState $state, + array &$extensions, + ): mixed { + if ($value === null || $value instanceof Optional || $value instanceof Lazy) { + return $value; + } + + /** @var array{source: mixed, replay?: AutoLazyReplayMode} $recipe */ + $recipe = $state->autoLazy($property->name); + $snapshot = $state->snapshotForProperty($property->name); + $propertyName = $property->name; + $replay = $recipe['replay'] ?? null; + $castValue = static function (mixed $resolvedValue) use ( + $propertyName, + $replay, + $snapshot, + ): mixed { + $state = clone $snapshot; + /** @var self $creator */ + $creator = ConcreteContainer::getInstance()->make(self::class); + + return $creator->resolveAutoLazyProperty( + $propertyName, + $resolvedValue, + $state, + $replay, + ); + }; + + return $this->resolveAutoLazy($property, $extensions)->build( + $castValue, + $recipe['source'], + $property, + $value, + ); + } + /** * Cast one supplied property value. * - * @param array $extensions + * @param OperationMemo $extensions */ protected function castProperty( DataProperty $property, @@ -909,11 +1779,13 @@ protected function castProperty( return $this->castTypedIterable($property, $iterable, $value, $state, $extensions, $casts); } + // The exact-array exit relies on accepted values passing before the fallback conversions below. if ($property->type->acceptsValue($value)) { return $value; } - $state->enterProperty($property->name, $state->originalKey($property->name)); + $wireKey = $state->originalKey($property->name); + $state->enterProperty($property->name, $property->inputPath($wireKey)); try { if ($state->nodeClass() !== null) { @@ -943,6 +1815,7 @@ protected function castProperty( } $key = 'castable:' . $type->name; + /** @var CastableCast $cast */ $cast = $extensions[$key] ??= new CastableCast($type->name); $casted = $cast->cast($property, $value, $state, $state->context); @@ -955,6 +1828,7 @@ protected function castProperty( if ($dateType !== null) { $key = 'date:' . $dateType; + /** @var DateTimeInterfaceCast $cast */ $cast = $extensions[$key] ??= new DateTimeInterfaceCast(type: $dateType); return $cast->cast( @@ -969,6 +1843,7 @@ protected function castProperty( if ($enumType !== null) { $key = 'enum:' . $enumType; + /** @var EnumCast $cast */ $cast = $extensions[$key] ??= new EnumCast($enumType); return $cast->cast( @@ -983,6 +1858,7 @@ protected function castProperty( if ($builtin !== null) { $key = 'builtin:' . $builtin; + /** @var BuiltinTypeCast $cast */ $cast = $extensions[$key] ??= new BuiltinTypeCast($builtin); return $cast->cast( @@ -999,7 +1875,7 @@ protected function castProperty( /** * Cast every item in one declared data iterable. * - * @param array $extensions + * @param OperationMemo $extensions */ protected function castDataIterable( DataProperty $property, @@ -1008,13 +1884,22 @@ protected function castDataIterable( ConstructionState $state, array &$extensions, ): mixed { + /** @var class-string $dataClass */ $dataClass = $type->dataClass; - if ($value instanceof LazyCollection) { + if ($value instanceof LazyCollection + && ! $type->kind->isPaginator() + && ! $type->kind->isCursorPaginator() + ) { return $value->map( - fn (mixed $item): BaseData => $item instanceof $dataClass - ? $item - : $this->create($dataClass, $state->context, $item), + function (mixed $item) use ($dataClass, $state, &$extensions): BaseData { + return $this->createUnvalidatedNode( + $dataClass, + $state->context, + $item, + $extensions, + ); + }, ); } @@ -1025,7 +1910,8 @@ protected function castDataIterable( } $items = []; - $state->enterProperty($property->name, $state->originalKey($property->name)); + $wireKey = $state->originalKey($property->name); + $state->enterProperty($property->name, $property->inputPath($wireKey)); try { foreach ($values as $key => $item) { @@ -1039,28 +1925,32 @@ protected function castDataIterable( try { $items[$key] = $state->nodeClass() === null - ? $this->create($dataClass, $state->context, $item) + ? $this->createUnvalidatedNode( + $dataClass, + $state->context, + $item, + $extensions, + ) : $this->castAndInstantiateNode($state, $extensions); } finally { $state->leave(); } } + + return $this->dataCollectables->forProperty( + $type, + $items, + $state, + ); } finally { $state->leave(); } - - return $this->dataCollectables->forProperty( - $type, - $dataClass, - $items, - $state, - ); } /** * Cast every item in one declared non-data iterable. * - * @param array $extensions + * @param OperationMemo $extensions * @param list $casts */ protected function castTypedIterable( @@ -1071,7 +1961,10 @@ protected function castTypedIterable( array &$extensions, array $casts, ): mixed { - if ($value instanceof LazyCollection) { + if ($value instanceof LazyCollection + && ! $type->kind->isPaginator() + && ! $type->kind->isCursorPaginator() + ) { return $value->map(function (mixed $item) use ($property, $type, $state, &$extensions, $casts): mixed { return $this->castIterableItem( $property, @@ -1091,25 +1984,31 @@ protected function castTypedIterable( } $items = []; + $wireKey = $state->originalKey($property->name); + $state->enterProperty($property->name, $property->inputPath($wireKey)); - foreach ($values as $key => $item) { - $items[$key] = $this->castIterableItem( - $property, - $type->iterableItemType, - $item, - $state, - $extensions, - $casts, - ); - } + try { + foreach ($values as $key => $item) { + $items[$key] = $this->castIterableItem( + $property, + $type->iterableItemType, + $item, + $state, + $extensions, + $casts, + ); + } - return $this->rebuildIterable($type, $items); + return $this->dataCollectables->forProperty($type, $items, $state); + } finally { + $state->leave(); + } } /** * Cast one declared iterable item. * - * @param array $extensions + * @param OperationMemo $extensions * @param list $casts */ protected function castIterableItem( @@ -1146,6 +2045,7 @@ protected function castIterableItem( } $key = 'iterable-castable:' . $namedType->name; + /** @var CastableCast $cast */ $cast = $extensions[$key] ??= new CastableCast($namedType->name); $casted = $cast->cast($property, $value, $state, $state->context); @@ -1158,6 +2058,7 @@ protected function castIterableItem( if ($dateType !== null) { $key = 'date:' . $dateType; + /** @var DateTimeInterfaceCast $cast */ $cast = $extensions[$key] ??= new DateTimeInterfaceCast(type: $dateType); return $cast->castIterableItem( @@ -1172,6 +2073,7 @@ protected function castIterableItem( if ($enumType !== null) { $key = 'enum:' . $enumType; + /** @var EnumCast $cast */ $cast = $extensions[$key] ??= new EnumCast($enumType); return $cast->castIterableItem( @@ -1186,6 +2088,7 @@ protected function castIterableItem( if ($builtin !== null) { $key = 'builtin:' . $builtin; + /** @var BuiltinTypeCast $cast */ $cast = $extensions[$key] ??= new BuiltinTypeCast($builtin); return $cast->castIterableItem( @@ -1200,29 +2103,29 @@ protected function castIterableItem( } /** - * Rebuild an eager iterable in its declared container. + * Resolve one automatic lazy attribute for the current root operation. * - * @param array $items + * @param OperationMemo $extensions */ - protected function rebuildIterable(NamedType $type, array $items): mixed + protected function resolveAutoLazy(DataProperty $property, array &$extensions): AutoLazy { - $iterableClass = $type->iterableClass; + $attribute = $property->autoLazy; + $key = 'auto-lazy:' . spl_object_id($attribute); - if ($iterableClass !== null && is_a($iterableClass, Collection::class, true)) { - return new $iterableClass($items); + if (! isset($extensions[$key])) { + $extensions[$key] = $attribute->newInstance(); } - if ($iterableClass !== null && is_a($iterableClass, LazyCollection::class, true)) { - return new $iterableClass($items); - } + /** @var AutoLazy $autoLazy */ + $autoLazy = $extensions[$key]; - return $items; + return $autoLazy; } /** * Get the ordered custom casts applicable to a property. * - * @param array $extensions + * @param OperationMemo $extensions * @return list */ protected function propertyCasts( @@ -1261,7 +2164,7 @@ protected function propertyCasts( * Resolve one cast once for the current root operation. * * @param Cast|class-string $cast - * @param array $extensions + * @param OperationMemo $extensions */ protected function resolveCast(Cast|string $cast, array &$extensions): Cast { @@ -1278,7 +2181,7 @@ protected function resolveCast(Cast|string $cast, array &$extensions): Cast /** * Resolve the custom normalizers for one data class. * - * @param array $extensions + * @param OperationMemo $extensions * @return list */ protected function resolveNormalizers( @@ -1286,10 +2189,20 @@ protected function resolveNormalizers( CreationContext $context, array &$extensions, ): array { + $key = 'normalizers:' . $dataClass->name; + + if (isset($extensions[$key])) { + /** @var list $normalizers */ + $normalizers = $extensions[$key]; + + return $normalizers; + } + $normalizers = []; if ($dataClass->hasLifecycleMethod('normalizers')) { $class = $dataClass->name; + /** @var list|Normalizer> $normalizers */ $normalizers = $this->container->call($class::normalizers(...)); } @@ -1300,13 +2213,20 @@ protected function resolveNormalizers( continue; } - $key = 'normalizer:' . $normalizer; + $normalizerKey = 'normalizer:' . $normalizer; + + if (! isset($extensions[$normalizerKey])) { + /** @var Normalizer $resolvedNormalizer */ + $resolvedNormalizer = $this->container->make($normalizer); + $extensions[$normalizerKey] = $resolvedNormalizer; + } - /** @var Normalizer */ - $normalizers[$index] = $extensions[$key] ??= $this->container->make($normalizer); + /** @var Normalizer $resolvedNormalizer */ + $resolvedNormalizer = $extensions[$normalizerKey]; + $normalizers[$index] = $resolvedNormalizer; } - return array_values($normalizers); + return $extensions[$key] = array_values($normalizers); } /** @@ -1340,29 +2260,89 @@ protected function resolveProperty( DataProperty $property, CreationContext $context, ): array { - $mappedKey = $context->mapPropertyNames - ? ($property->inputMappedName ?? $property->name) - : $property->name; + $mappedKey = $this->propertyInputKey($property, $context); foreach ($sources as $source) { - $value = SourceReader::read($source, $mappedKey, $property); + $match = $this->matchPropertySource($source, $property, $mappedKey); - if (! $value instanceof UnknownProperty) { - return [$mappedKey, $value]; + if ($match !== null) { + return $match; } + } - if ($mappedKey === $property->name) { - continue; + return [$mappedKey, UnknownProperty::create()]; + } + + /** + * Resolve the raw source aligned with one automatic lazy property. + * + * @param list $sources + * @param list $payloads + */ + protected function resolveAutoLazySource( + DataProperty $property, + array $sources, + array $payloads, + CreationContext $context, + ): mixed { + if ($this->isAutoWhenLoaded($property)) { + foreach ($payloads as $payload) { + if ($payload instanceof Model) { + return $payload; + } } - $value = SourceReader::read($source, $property->name, $property); + throw CannotCreateData::autoWhenLoadedRequiresModel($property); + } + + $mappedKey = $this->propertyInputKey($property, $context); - if (! $value instanceof UnknownProperty) { - return [$property->name, $value]; + foreach ($sources as $index => $source) { + if ($this->matchPropertySource($source, $property, $mappedKey) !== null) { + return $payloads[$index]; } } - return [$mappedKey, UnknownProperty::create()]; + return $payloads[0] ?? []; + } + + /** + * Match one property against one normalized source. + * + * @return null|array{array-key, mixed} + */ + protected function matchPropertySource( + array|Normalized $source, + DataProperty $property, + string|int $mappedKey, + ): ?array { + $value = SourceReader::read($source, $property->inputPath($mappedKey), $property); + + if (! $value instanceof UnknownProperty) { + return [$mappedKey, $value]; + } + + if ($mappedKey === $property->name) { + return null; + } + + $value = SourceReader::read($source, $property->inputPath($property->name), $property); + + return $value instanceof UnknownProperty + ? null + : [$property->name, $value]; + } + + /** + * Get the effective input key for one property. + */ + protected function propertyInputKey( + DataProperty $property, + CreationContext $context, + ): string|int { + return $context->mapPropertyNames + ? ($property->inputMappedName ?? $property->name) + : $property->name; } /** @@ -1473,6 +2453,37 @@ protected function matchNamedFactory( return null; } + /** + * Find the first compatible named collection factory. + */ + protected function matchNamedCollectionFactory( + DataClass $dataClass, + CreationContext $context, + mixed $items, + ?string $into, + ): ?array { + if ($context->disableMagicalCreation) { + return null; + } + + foreach ($dataClass->methods as $method) { + if ($method->customCreationMethodType !== CustomCreationMethodType::Collection + || in_array($method->name, $context->ignoredMagicalMethods, true) + || ($into !== null && ! $method->returns($into)) + ) { + continue; + } + + $match = $method->matchPayloads($context, $items); + + if ($match !== null) { + return [$method, $match]; + } + } + + return null; + } + /** * Invoke one matched named factory without method-binding interception. */ @@ -1531,6 +2542,7 @@ protected function resolveMorphClass(DataClass $dataClass, array $resolvedProper $properties[$property->name] = $value; } + /** @var class-string $class */ $class = $dataClass->name; $resolvedClass = $class::morph($properties); @@ -1538,9 +2550,7 @@ protected function resolveMorphClass(DataClass $dataClass, array $resolvedProper throw CannotCreateAbstractClass::morphClassWasNotResolved($class); } - if (! is_a($resolvedClass, $class, true) - || ! is_a($resolvedClass, BaseData::class, true) - ) { + if (! is_a($resolvedClass, $class, true)) { throw CannotCreateAbstractClass::invalidMorphClass($class, $resolvedClass); } @@ -1603,6 +2613,57 @@ protected function nestedDataClass(DataProperty $property): ?string return count($types) === 1 ? $types[0]->dataClass : null; } + /** + * Determine if an automatic lazy property needs deferred Fill replay. + */ + protected function requiresAutoLazyReplay(DataProperty $property): bool + { + if ($this->nestedDataClass($property) !== null + || $this->dataIterableType($property) !== null + ) { + return true; + } + + $type = $this->typedIterableType($property); + + return $type !== null + && ($type->kind->isPaginator() || $type->kind->isCursorPaginator()); + } + + /** + * Determine if a property uses model relation automatic lazy loading. + */ + protected function isAutoWhenLoaded(DataProperty $property): bool + { + return $property->autoLazy !== null + && is_a($property->autoLazy->getName(), AutoWhenLoadedLazy::class, true); + } + + /** + * Retain source metadata for one declared paginator property. + * + * @param non-empty-list $inputPath + */ + protected function retainPaginatorSource( + DataProperty $property, + NamedType $type, + mixed $value, + array $inputPath, + ConstructionState $state, + ): void { + if (! $type->kind->isPaginator() && ! $type->kind->isCursorPaginator()) { + return; + } + + $state->enterProperty($property->name, $inputPath); + + try { + $this->dataCollectables->retainPaginatorSource($type, $value, $state); + } finally { + $state->leave(); + } + } + /** * Get the one unambiguous data iterable declared by a property. */ diff --git a/src/data/src/Support/Creation/SourceReader.php b/src/data/src/Support/Creation/SourceReader.php index 0da0a1608..b6bb47d2d 100644 --- a/src/data/src/Support/Creation/SourceReader.php +++ b/src/data/src/Support/Creation/SourceReader.php @@ -4,62 +4,87 @@ namespace Hypervel\Data\Support\Creation; +use ArrayAccess; use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalized\UnknownProperty; use Hypervel\Data\Support\DataProperty; -use function data_get; - class SourceReader { /** * Read one property from a normalized source. + * + * @param non-empty-list $path */ public static function read( array|Normalized $source, - string|int $key, + array $path, DataProperty $property, ): mixed { if ($source instanceof Normalized) { - $segments = explode('.', (string) $key); - $value = $source->getProperty(array_shift($segments), $property); + $value = $source->getProperty((string) $path[0], $property); - if ($value instanceof UnknownProperty || $segments === []) { + if ($value instanceof UnknownProperty || count($path) === 1) { return $value; } - return data_get($value, implode('.', $segments), UnknownProperty::create()); + return self::readPath($value, $path, 1); } - if (is_int($key)) { - return array_key_exists($key, $source) - ? $source[$key] + if (count($path) === 1) { + return array_key_exists($path[0], $source) + ? $source[$path[0]] : UnknownProperty::create(); } - return data_get($source, $key, UnknownProperty::create()); + return self::readPath($source, $path, 0); } /** - * Read the first source that contains a property. + * Traverse literal path segments through arrays and accessible objects. * - * @param array $sources + * @param non-empty-list $path */ - public static function readFromMany( - array $sources, - string|int $key, - DataProperty $property, - ): mixed { - foreach ($sources as $source) { - $value = self::read($source, $key, $property); + protected static function readPath(mixed $value, array $path, int $offset): mixed + { + $count = count($path); + + for ($index = $offset; $index < $count; ++$index) { + $segment = $path[$index]; + + if (is_array($value) && array_key_exists($segment, $value)) { + $value = $value[$segment]; - if ($value instanceof UnknownProperty) { continue; } - return $value; + if ($value instanceof ArrayAccess && $value->offsetExists($segment)) { + $value = $value[$segment]; + + continue; + } + + if (is_object($value)) { + $name = (string) $segment; + + if (isset($value->{$name})) { + $value = $value->{$name}; + + continue; + } + + $properties = get_object_vars($value); + + if (array_key_exists($name, $properties)) { + $value = $properties[$name]; + + continue; + } + } + + return UnknownProperty::create(); } - return UnknownProperty::create(); + return $value; } } diff --git a/tests/Data/Casts/DateTimeInterfaceCastTest.php b/tests/Data/Casts/DateTimeInterfaceCastTest.php index 83a8237a2..cd4b5f04b 100644 --- a/tests/Data/Casts/DateTimeInterfaceCastTest.php +++ b/tests/Data/Casts/DateTimeInterfaceCastTest.php @@ -4,6 +4,9 @@ namespace Hypervel\Tests\Data\Casts; +use Carbon\Carbon; +use Carbon\CarbonImmutable; +use DateTime; use DateTimeImmutable; use DateTimeInterface; use Hypervel\Config\Repository; @@ -21,6 +24,8 @@ use Hypervel\Data\Support\Factories\DataTypeFactory; use Hypervel\Data\Support\NameMapperResolver; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +use Hypervel\Support\Carbon as HypervelCarbon; +use Hypervel\Support\CarbonImmutable as HypervelCarbonImmutable; use Hypervel\Tests\TestCase; use ReflectionClass; @@ -34,12 +39,23 @@ public function testCastsConfiguredFormatsToExactConcreteTypes(): void [$state, $context] = $this->operation(['Y-m-d', 'Y-m-d H:i:s.uP']); $cast = new DateTimeInterfaceCast; - $immutable = $cast->cast($this->property('immutable'), '2026-08-30', $state, $context); - $custom = $cast->cast($this->property('custom'), '2026-08-30', $state, $context); + $types = [ + 'mutable' => DateTime::class, + 'immutable' => DateTimeImmutable::class, + 'carbon' => Carbon::class, + 'carbonImmutable' => CarbonImmutable::class, + 'hypervelCarbon' => HypervelCarbon::class, + 'hypervelCarbonImmutable' => HypervelCarbonImmutable::class, + 'custom' => CustomDateTime::class, + 'customImmutable' => CustomDateTimeImmutable::class, + ]; - $this->assertInstanceOf(DateTimeImmutable::class, $immutable); - $this->assertSame('2026-08-30', $immutable->format('Y-m-d')); - $this->assertInstanceOf(CustomDateTimeImmutable::class, $custom); + foreach ($types as $property => $type) { + $date = $cast->cast($this->property($property), '2026-08-30', $state, $context); + + $this->assertSame($type, $date::class); + $this->assertSame('2026-08-30', $date->format('Y-m-d')); + } } /** @@ -56,7 +72,7 @@ public function testCastsDateInterfacesThroughTheDateFactory(): void $context, ); - $this->assertInstanceOf(DateTimeInterface::class, $date); + $this->assertSame(HypervelCarbonImmutable::class, $date::class); $this->assertSame('2026-08-30', $date->format('Y-m-d')); } @@ -83,6 +99,35 @@ public function testAppliesTimezonesAndTruncatesNanoseconds(): void $this->assertSame('2026-08-30 08:00:00.123456-04:00', $date->format('Y-m-d H:i:s.uP')); } + /** + * Test timezone conversion preserves exact concrete date targets. + */ + public function testTimezoneConversionPreservesExactConcreteTypes(): void + { + [$state, $context] = $this->operation(['Y-m-d H:i:s']); + $types = [ + 'mutable' => DateTime::class, + 'immutable' => DateTimeImmutable::class, + 'carbon' => Carbon::class, + 'carbonImmutable' => CarbonImmutable::class, + 'hypervelCarbon' => HypervelCarbon::class, + 'hypervelCarbonImmutable' => HypervelCarbonImmutable::class, + 'custom' => CustomDateTime::class, + 'customImmutable' => CustomDateTimeImmutable::class, + ]; + + foreach ($types as $property => $type) { + $date = (new DateTimeInterfaceCast( + format: 'Y-m-d H:i:s', + setTimeZone: 'America/New_York', + timeZone: 'UTC', + ))->cast($this->property($property), '2026-08-30 12:00:00', $state, $context); + + $this->assertSame($type, $date::class); + $this->assertSame('2026-08-30 08:00:00-04:00', $date->format('Y-m-d H:i:sP')); + } + } + /** * Test iterable date declarations and non-date declarations. */ @@ -119,6 +164,24 @@ public function testThrowsWhenNoDateFormatMatches(): void ); } + /** + * Test abstract date targets fail through the ordinary cast exception. + */ + public function testThrowsForAbstractDateTarget(): void + { + [$state, $context] = $this->operation(['Y-m-d']); + + $this->expectException(CannotCastDate::class); + $this->expectExceptionMessage(AbstractDateTimeImmutable::class); + + (new DateTimeInterfaceCast)->cast( + $this->property('abstract'), + '2026-08-30', + $state, + $context, + ); + } + /** * Build one property definition. */ @@ -162,11 +225,25 @@ protected function operation(array $formats): array class DateCastDataFixture { + public DateTime $mutable; + public DateTimeImmutable $immutable; public DateTimeInterface $interface; - public CustomDateTimeImmutable $custom; + public Carbon $carbon; + + public CarbonImmutable $carbonImmutable; + + public HypervelCarbon $hypervelCarbon; + + public HypervelCarbonImmutable $hypervelCarbonImmutable; + + public CustomDateTime $custom; + + public CustomDateTimeImmutable $customImmutable; + + public AbstractDateTimeImmutable $abstract; /** @var list */ public array $dates; @@ -178,6 +255,14 @@ class CustomDateTimeImmutable extends DateTimeImmutable { } +class CustomDateTime extends DateTime +{ +} + +abstract class AbstractDateTimeImmutable extends DateTimeImmutable +{ +} + abstract class DateCastDataContract implements BaseData { } diff --git a/tests/Data/Support/Creation/ConstructionStateTest.php b/tests/Data/Support/Creation/ConstructionStateTest.php index d15d87cc4..349c4b6bb 100644 --- a/tests/Data/Support/Creation/ConstructionStateTest.php +++ b/tests/Data/Support/Creation/ConstructionStateTest.php @@ -8,10 +8,13 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Contracts\BaseDataCollectable; use Hypervel\Data\Data; +use Hypervel\Data\Normalizers\Normalized\UnknownProperty; +use Hypervel\Data\Support\Creation\AutoLazyReplayMode; use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; use Hypervel\Pagination\Paginator; use Hypervel\Tests\TestCase; +use stdClass; use Traversable; class ConstructionStateTest extends TestCase @@ -22,15 +25,15 @@ class ConstructionStateTest extends TestCase public function testReadsAndWritesNestedPayloadValues(): void { $state = $this->state(); - $state->writePropertyValue('title', 'Hello'); - $state->enterProperty('author', 'writer'); - $state->writePropertyValue('name', 'Ruben'); + $state->writePropertyValue(['title'], 'Hello'); + $state->enterProperty('author', ['writer']); + $state->writePropertyValue(['name'], 'Ruben'); - $this->assertTrue($state->hasValue('name')); - $this->assertSame('Ruben', $state->getValue('name')); + $this->assertTrue($state->hasValue(['name'])); + $this->assertSame('Ruben', $state->getValue(['name'])); $this->assertSame(['name' => 'Ruben'], $state->currentPayload()); - $this->assertFalse($state->hasValue('missing')); - $this->assertNull($state->getValue('missing')); + $this->assertFalse($state->hasValue(['missing'])); + $this->assertNull($state->getValue(['missing'])); $state->leave(); @@ -38,7 +41,7 @@ public function testReadsAndWritesNestedPayloadValues(): void 'title' => 'Hello', 'writer' => ['name' => 'Ruben'], ], $state->payload()); - $this->assertFalse($state->hasValue('name')); + $this->assertFalse($state->hasValue(['name'])); $state->replacePayload(['validated' => true]); @@ -51,9 +54,9 @@ public function testReadsAndWritesNestedPayloadValues(): void public function testWritesCollectionItemsAndBuildsWirePaths(): void { $state = $this->state(); - $state->enterProperty('posts', 0); + $state->enterProperty('posts', [0]); $state->enterItem(3); - $state->writePropertyValue('title', 'Fourth'); + $state->writePropertyValue(['title'], 'Fourth'); $this->assertSame([0, 3], $state->path()); $this->assertSame(2, $state->depth()); @@ -70,7 +73,7 @@ public function testWritesCollectionItemsAndBuildsWirePaths(): void public function testWritesRawCollectionItemKeysWithoutFlatteningOrCollisions(): void { $state = $this->state(); - $state->enterProperty('tenants'); + $state->enterProperty('tenants', ['tenants']); foreach ([ 'tenant.eu' => 'Europe', @@ -79,7 +82,7 @@ public function testWritesRawCollectionItemKeysWithoutFlatteningOrCollisions(): ] as $key => $name) { $state->writeItemValue($key, []); $state->enterItem($key); - $state->writePropertyValue('name', $name); + $state->writePropertyValue(['name'], $name); $state->leave(); } @@ -105,16 +108,16 @@ public function testWritesRawCollectionItemKeysWithoutFlatteningOrCollisions(): public function testReadsAndWritesMappedDotPaths(): void { $state = $this->state(); - $state->writePropertyValue('profile.name', 'Taylor'); + $state->writePropertyValue(['profile', 'name'], 'Taylor'); - $this->assertTrue($state->hasValue('profile.name')); - $this->assertSame('Taylor', $state->getValue('profile.name')); + $this->assertTrue($state->hasValue(['profile', 'name'])); + $this->assertSame('Taylor', $state->getValue(['profile', 'name'])); - $state->enterProperty('author', 'people.0'); - $state->writePropertyValue('contact.email', 'taylor@example.com'); + $state->enterProperty('author', ['people', '0']); + $state->writePropertyValue(['contact', 'email'], 'taylor@example.com'); $this->assertSame(['people', '0'], $state->path()); - $this->assertSame('taylor@example.com', $state->getValue('contact.email')); + $this->assertSame('taylor@example.com', $state->getValue(['contact', 'email'])); $this->assertSame([ 'profile' => ['name' => 'Taylor'], @@ -138,7 +141,7 @@ public function testRecordsStructureWithoutCollectionIndices(): void $this->assertSame('title', $state->originalKey('title')); $this->assertSame(ConstructionStateDataFixture::class, $state->nodeClass()); - $state->enterProperty('posts'); + $state->enterProperty('posts', ['posts']); $state->enterItem(3); $state->recordMapping('title', 'post_title'); $state->setNodeClass(ConstructionStateDataFixture::class); @@ -168,7 +171,7 @@ public function testRecordsStructureWithoutCollectionIndices(): void public function testRecordsSparseRawKeyItemOverrides(): void { $state = $this->state(); - $state->enterProperty('posts'); + $state->enterProperty('posts', ['posts']); $state->enterItem('first'); $state->setNodeClass(ConstructionStateDataFixture::class); $state->recordMapping('title', 'post_title'); @@ -216,16 +219,16 @@ public function testRecordsSparseRawKeyItemOverrides(): void public function testNestedOverridesLatchEveryEnclosingCollection(): void { $state = $this->state(); - $state->enterProperty('posts'); + $state->enterProperty('posts', ['posts']); $state->enterItem(0); - $state->enterProperty('comments'); + $state->enterProperty('comments', ['comments']); $state->enterItem(0); $state->recordMapping('label', 'label'); $state->leave(); $state->leave(); $state->leave(); $state->enterItem(1); - $state->enterProperty('comments'); + $state->enterProperty('comments', ['comments']); $state->enterItem(0); $state->recordMapping('label', 'comment_label'); @@ -256,9 +259,9 @@ public function testNestedOverridesLatchEveryEnclosingCollection(): void public function testFinishedDataValuesLatchContainingCollection(): void { $state = $this->state(); - $state->enterProperty('posts'); + $state->enterProperty('posts', ['posts']); $state->enterItem(0); - $state->writeFinishedPropertyValue('author', new ConstructionStateFinishedDataFixture()); + $state->writeFinishedPropertyValue(['author'], new ConstructionStateFinishedDataFixture); $this->assertFalse($state->isCurrentCollectionUniform()); @@ -274,10 +277,10 @@ public function testFinishedDataValuesLatchContainingCollection(): void public function testFinishedDataValuesCreateCurrentStructurePath(): void { $state = $this->state(); - $state->enterProperty('posts'); + $state->enterProperty('posts', ['posts']); $state->enterItem(0); - $state->enterProperty('author'); - $state->writeFinishedPropertyValue('profile', new ConstructionStateFinishedDataFixture()); + $state->enterProperty('author', ['author']); + $state->writeFinishedPropertyValue(['profile'], new ConstructionStateFinishedDataFixture); $state->leave(); $state->leave(); @@ -295,9 +298,9 @@ public function testFinishedDataValuesCreateCurrentStructurePath(): void public function testFinishedDataCollectablesLatchContainingCollection(): void { $state = $this->state(); - $state->enterProperty('posts'); + $state->enterProperty('posts', ['posts']); $state->enterItem(0); - $state->writeFinishedPropertyValue('comments', new ConstructionStateFinishedDataCollectableFixture()); + $state->writeFinishedPropertyValue(['comments'], new ConstructionStateFinishedDataCollectableFixture); $this->assertFalse($state->isCurrentCollectionUniform()); @@ -315,9 +318,9 @@ public function testRecordsPaginatorSourcesWithoutChangingCollectionUniformity() $first = new Paginator([1], 10, 1); $second = new Paginator([2], 10, 1); $state = $this->state(); - $state->enterProperty('posts'); + $state->enterProperty('posts', ['posts']); $state->enterItem(0); - $state->enterProperty('comments'); + $state->enterProperty('comments', ['comments']); $state->recordPaginatorSource($first); $this->assertSame($first, $state->paginatorSource()); @@ -328,7 +331,7 @@ public function testRecordsPaginatorSourcesWithoutChangingCollectionUniformity() $state->leave(); $state->enterItem(1); - $state->enterProperty('comments'); + $state->enterProperty('comments', ['comments']); $this->assertNull($state->paginatorSource()); @@ -356,7 +359,7 @@ public function testRecordsPaginatorSourcesWithoutChangingCollectionUniformity() public function testClearsPaginatorSourcesWithoutAllocatingMissingOverrides(): void { $state = $this->state(); - $state->enterProperty('comments'); + $state->enterProperty('comments', ['comments']); $state->recordPaginatorSource(new Paginator([1], 10, 1)); $this->assertNotNull($state->paginatorSource()); @@ -366,9 +369,9 @@ public function testClearsPaginatorSourcesWithoutAllocatingMissingOverrides(): v $this->assertNull($state->paginatorSource()); $state->leave(); - $state->enterProperty('posts'); + $state->enterProperty('posts', ['posts']); $state->enterItem(5); - $state->enterProperty('comments'); + $state->enterProperty('comments', ['comments']); $state->clearPaginatorSource(); $this->assertNull($state->paginatorSource()); @@ -380,13 +383,139 @@ public function testClearsPaginatorSourcesWithoutAllocatingMissingOverrides(): v $this->assertArrayNotHasKey('posts', $state->structure()['children']); } + /** + * Test automatic lazy recipes retain exact node ownership and explicit null. + */ + public function testRecordsAutomaticLazyRecipesWithoutTemplateFallback(): void + { + $first = new stdClass; + $second = new stdClass; + $state = $this->state(); + $state->recordAutoLazy('nullable', null); + + $this->assertSame(['source' => null], $state->autoLazy('nullable')); + $this->assertInstanceOf(UnknownProperty::class, $state->autoLazy('missing')); + + $state->enterProperty('posts', ['posts']); + $state->enterItem(0); + $state->recordAutoLazy('title', $first, AutoLazyReplayMode::Normal); + + $this->assertSame([ + 'source' => $first, + 'replay' => AutoLazyReplayMode::Normal, + ], $state->autoLazy('title')); + $this->assertTrue($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->enterItem(1); + + $this->assertInstanceOf(UnknownProperty::class, $state->autoLazy('title')); + + $state->recordAutoLazy('title', $second, AutoLazyReplayMode::Hook); + $state->resetNodeStructure(); + + $this->assertInstanceOf(UnknownProperty::class, $state->autoLazy('title')); + $this->assertFalse($state->isCurrentCollectionUniform()); + + $state->leave(); + $state->enterItem(0); + + $this->assertSame([ + 'source' => $first, + 'replay' => AutoLazyReplayMode::Normal, + ], $state->autoLazy('title')); + } + + /** + * Test automatic lazy snapshots retain only the selected payload and structure. + */ + public function testSnapshotsAutomaticLazyPropertyState(): void + { + $rootSource = new stdClass; + $itemSource = new stdClass; + $descendantSource = new stdClass; + $descendantPaginator = new Paginator([1], 10, 1); + $state = $this->state(); + $state->writePropertyValue(['outside'], 'drop'); + $state->recordMapping('posts', 'posts'); + $state->recordMapping('outside', 'outside'); + $state->recordAutoLazy('outside', $rootSource); + $state->recordPaginatorSource(new Paginator([0], 10, 1)); + $state->recordUnknownInput(['outside' => 'drop']); + $state->enterProperty('posts', ['posts']); + $state->writeItemValue(0, ['lazy' => ['value' => 'template']]); + $state->enterItem(0); + $state->setNodeClass(ConstructionStateDataFixture::class); + $state->recordMapping('lazy', 'lazy'); + $state->enterProperty('lazy', ['lazy']); + $state->setNodeClass(ConstructionStateDataFixture::class); + $state->recordMapping('value', 'template_value'); + $state->leave(); + $state->leave(); + $state->writeItemValue(5, [ + 'lazy_value' => ['value' => 'selected'], + 'sibling' => 'retain', + ]); + $state->enterItem(5); + $state->setNodeClass(AlternateConstructionStateDataFixture::class); + $state->recordMapping('lazy', 'lazy_value'); + $state->recordMapping('sibling', 'sibling'); + $state->recordAutoLazy('lazy', $itemSource, AutoLazyReplayMode::Normal); + $state->recordAutoLazy('sibling', new stdClass); + $state->recordPaginatorSource(new Paginator([5], 10, 1)); + $state->enterProperty('lazy', ['lazy_value']); + $state->setNodeClass(ConstructionStateDataFixture::class); + $state->recordMapping('value', 'value'); + $state->recordAutoLazy('nested', $descendantSource, AutoLazyReplayMode::Hook); + $state->recordPaginatorSource($descendantPaginator); + $state->leave(); + + $snapshot = $state->snapshotForProperty('lazy'); + + $this->assertSame(['posts', 5], $snapshot->path()); + $this->assertSame([ + 'posts' => [ + 5 => [ + 'lazy_value' => ['value' => 'selected'], + 'sibling' => 'retain', + ], + ], + ], $snapshot->payload()); + $this->assertNull($snapshot->unknownInput()); + $this->assertInstanceOf(UnknownProperty::class, $snapshot->autoLazy('lazy')); + $this->assertNull($snapshot->paginatorSource()); + + $structure = $snapshot->structure(); + + $this->assertSame(['posts'], array_keys($structure['children'])); + $this->assertArrayNotHasKey('autoLazy', $structure); + $this->assertArrayNotHasKey('paginatorSource', $structure); + $this->assertSame(['lazy'], array_keys($structure['children']['posts']['mappings'])); + $this->assertSame( + ['lazy'], + array_keys($structure['children']['posts']['items'][5]['mappings']), + ); + + $snapshot->enterProperty('lazy', ['lazy_value']); + + $this->assertSame([ + 'source' => $descendantSource, + 'replay' => AutoLazyReplayMode::Hook, + ], $snapshot->autoLazy('nested')); + $this->assertSame($descendantPaginator, $snapshot->paginatorSource()); + $this->assertSame( + ['value'], + array_keys($snapshot->structure()['children']['posts']['items'][5]['children']['lazy']['mappings']), + ); + } + /** * Test read-only structure lookups do not allocate nodes. */ public function testReadOnlyStructureLookupsDoNotCreateNodes(): void { $state = $this->state(); - $state->enterProperty('unvisited'); + $state->enterProperty('unvisited', ['unvisited']); $this->assertFalse($state->hasOriginalKey('name')); $this->assertSame('name', $state->originalKey('name')); @@ -415,10 +544,10 @@ public function testRecordsRootShapedUnknownInput(): void 'child' => ['fromParent' => true], 'scalarChild' => 'raw', ]); - $state->enterProperty('child'); + $state->enterProperty('child', ['child']); $state->recordUnknownInput(['fromChild' => true]); $state->leave(); - $state->enterProperty('scalarChild'); + $state->enterProperty('scalarChild', ['scalarChild']); $state->recordUnknownInput(['structured' => true]); $state->leave(); @@ -472,6 +601,6 @@ public function getDataClass(): string */ public function getIterator(): Traversable { - return new ArrayIterator(); + return new ArrayIterator; } } diff --git a/tests/Data/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index 47bcc1aac..da767b587 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -4,36 +4,59 @@ namespace Hypervel\Tests\Data\Support\Creation; +use Attribute; +use Closure; use DateTimeImmutable; use Hypervel\Container\Attributes\Config; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Data\Attributes\AutoClosureLazy; +use Hypervel\Data\Attributes\AutoInertiaDeferred; +use Hypervel\Data\Attributes\AutoInertiaLazy; +use Hypervel\Data\Attributes\AutoLazy; +use Hypervel\Data\Attributes\AutoWhenLoadedLazy; use Hypervel\Data\Attributes\Computed; use Hypervel\Data\Attributes\DataCollectionOf; use Hypervel\Data\Attributes\MapInputName; use Hypervel\Data\Attributes\PropertyForMorph; use Hypervel\Data\Attributes\WithCast; use Hypervel\Data\Casts\Cast; +use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Dto; -use Hypervel\Data\Exceptions\CannotCreateData; use Hypervel\Data\Exceptions\CannotCreateAbstractClass; +use Hypervel\Data\Exceptions\CannotCreateData; +use Hypervel\Data\Exceptions\CannotCreateDataCollectable; use Hypervel\Data\Exceptions\CannotSetComputedValue; +use Hypervel\Data\Lazy; use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Optional; -use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Resource; +use Hypervel\Data\Support\Creation\AutoLazyReplayMode; use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; +use Hypervel\Data\Support\Creation\CreationMode; +use Hypervel\Data\Support\Creation\DataCreator; +use Hypervel\Data\Support\Creation\ValidationStrategy; use Hypervel\Data\Support\DataProperty; -use Hypervel\Testbench\TestCase; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Http\Request; +use Hypervel\Pagination\Paginator; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; +use Hypervel\Testbench\TestCase; +use ReflectionFunction; +use WeakReference; class DataCreatorTest extends TestCase { + // REMOVED: Configurable pipeline, prepareForPipeline(), and inherited-context factory tests; use the fixed engine and fresh factory hooks. + // REMOVED: withOptionalValues()/withoutOptionalValues() tests; Optional declarations always preserve absence. + // REMOVED: Data-specific From* injection tests; Hypervel contextual attributes cover the same outcomes directly. + // REMOVED: UnserializeCast tests; serialized request input is not accepted by a built-in cast. + protected function getPackageProviders(Application $app): array { return [DataServiceProvider::class]; @@ -71,6 +94,145 @@ public function testFirstSourceContainingAPropertyWinsAndMappingCanBeDisabled(): $this->assertNotSame(BasicCreationData::factory(), BasicCreationData::factory()); } + /** + * Test exact array creation preserves mapping, absence, and accepted values. + */ + public function testDirectArrayCreationPreservesExactValues(): void + { + $child = new ChildCreationData(42); + $date = new DateTimeImmutable('2026-09-02T12:00:00+00:00'); + $source = new CreationSource('source', 'identifier'); + $data = DirectArrayCreationData::from([ + 'profile' => ['name' => 'Mapped'], + 'name' => 'Fallback', + 'defaultedNullable' => null, + 'metadata' => ['role' => 'maintainer'], + 'child' => $child, + 'date' => $date, + 'status' => CreationStatus::Active, + 'source' => $source, + 'assigned' => 'assigned', + ]); + $fallback = DirectArrayCreationData::from([ + 'name' => 'Fallback', + 'child' => $child, + 'date' => $date, + 'status' => CreationStatus::Inactive, + 'source' => $source, + 'assigned' => 'fallback-assigned', + ]); + + $this->assertSame('Mapped', $data->name); + $this->assertNull($data->nullable); + $this->assertInstanceOf(Optional::class, $data->optional); + $this->assertNull($data->defaultedNullable); + $this->assertSame(21, $data->defaultedInteger); + $this->assertSame(['role' => 'maintainer'], $data->metadata); + $this->assertSame($child, $data->child); + $this->assertSame($date, $data->date); + $this->assertSame(CreationStatus::Active, $data->status); + $this->assertSame($source, $data->source); + $this->assertSame('assigned', $data->assigned); + $this->assertSame('unbound-default', $data->unboundDefault); + $this->assertSame('computed', $data->computed); + $this->assertSame('virtual', $data->virtual); + $this->assertSame('Fallback', $fallback->name); + $this->assertSame('fallback', $fallback->defaultedNullable); + $this->assertSame([], $fallback->metadata); + } + + /** + * Test direct array misses retain the authoritative general construction path. + */ + public function testDirectArrayCreationFallsThroughForNestedAndConvertedValues(): void + { + $nested = DirectNestedCreationData::from(['child' => ['id' => 42]]); + $converted = DirectConvertedCreationData::from([ + 'id' => '7', + 'date' => '2026-09-02T12:00:00+00:00', + 'status' => 'active', + ]); + $items = DirectNestedCreationData::collect([ + ['child' => new ChildCreationData(8)], + ], 'array'); + + $this->assertSame(42, $nested->child->id); + $this->assertSame(7, $converted->id); + $this->assertInstanceOf(DateTimeImmutable::class, $converted->date); + $this->assertSame(CreationStatus::Active, $converted->status); + $this->assertSame(8, $items[0]->child->id); + } + + /** + * Test computed and virtual input keeps the existing rejection behavior. + */ + public function testDirectArrayCreationRejectsSuppliedComputedAndVirtualValues(): void + { + $data = DirectOutputOnlyCreationData::from(['id' => 1]); + + $this->assertSame('computed', $data->computed); + $this->assertSame('virtual', $data->virtual); + + foreach ([ + ['computed', 'client'], + ['computed', null], + ['virtual', 'client'], + ['virtual', null], + ] as [$property, $value]) { + try { + DirectOutputOnlyCreationData::from(['id' => 1, $property => $value]); + $this->fail('Expected output-only input to be rejected.'); + } catch (CannotSetComputedValue $exception) { + $this->assertStringContainsString("\${$property}", $exception->getMessage()); + $this->assertStringContainsString('computed', $exception->getMessage()); + } + } + } + + /** + * Test a named factory is invoked once before an exact array exit. + */ + public function testDirectArrayCreationDoesNotRematchNamedFactories(): void + { + DirectNamedFactoryCreationData::$calls = 0; + + $data = DirectNamedFactoryCreationData::from('9'); + + $this->assertSame(9, $data->id); + $this->assertSame(1, DirectNamedFactoryCreationData::$calls); + } + + /** + * Test the direct exit cannot replace an array-returning engine mode. + */ + public function testDirectArrayCreationIsCreateModeOnly(): void + { + $creator = $this->app->make(DataCreator::class); + $payload = ['id' => 11]; + $context = new CreationContext( + dataClass: DirectValidationModeCreationData::class, + mode: CreationMode::Validate, + validationStrategy: ValidationStrategy::Disabled, + ); + + $this->assertSame($payload, $creator->validate( + DirectValidationModeCreationData::class, + $context, + [$payload], + )); + } + + /** + * Test the direct exit uses the shared constructor visibility error. + */ + public function testDirectArrayCreationUsesTheSharedInstantiator(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('constructor is private'); + + DirectPrivateConstructorCreationData::from(['value' => 'private']); + } + public function testCreatesNestedDataWithoutReenteringThePublicFactory(): void { $data = ParentCreationData::from([ @@ -112,6 +274,62 @@ public function testCreatesDeclaredDataCollectionsFromRawItems(): void $this->assertSame(7, $data->children['first']->id); } + public function testRebuildsDeclaredDataPaginatorFromRetainedSource(): void + { + $source = new Paginator( + ['first' => ['id' => '7']], + 15, + 2, + ['path' => '/children', 'query' => ['tenant' => 'one']], + ); + + $data = DataPaginatorCreationData::from(['children' => $source]); + + $this->assertNotSame($source, $data->children); + $this->assertSame(15, $data->children->perPage()); + $this->assertSame(2, $data->children->currentPage()); + $this->assertSame('/children', $data->children->path()); + $this->assertSame('one', $data->children->getOptions()['query']['tenant']); + $this->assertSame(7, $data->children->items()['first']->id); + } + + public function testRebuildsDeclaredScalarPaginatorFromRetainedSource(): void + { + $source = new Paginator(['first' => '7'], 15, 2); + + $data = ScalarPaginatorCreationData::from(['ids' => $source]); + + $this->assertNotSame($source, $data->ids); + $this->assertSame(['first' => 7], $data->ids->items()); + $this->assertSame(2, $data->ids->currentPage()); + } + + public function testValidationHookCanReshapeRetainedPaginatorItems(): void + { + $source = new Paginator([['id' => '7']], 15, 2); + + $data = DataPaginatorCreationData::factory() + ->alwaysValidate() + ->beforeValidation(static fn (array $payload): array => [ + ...$payload, + 'children' => [['id' => '9']], + ]) + ->from(['children' => $source]); + + $this->assertSame(9, $data->children->items()[0]->id); + $this->assertSame(2, $data->children->currentPage()); + } + + public function testPaginatorPropertiesRejectItemOnlySourcesWithoutMetadata(): void + { + $this->expectException(CannotCreateDataCollectable::class); + $this->expectExceptionMessage('from `array`'); + + DataPaginatorCreationData::from([ + 'children' => [['id' => '7']], + ]); + } + public function testPreservesFinishedDataCollectableAndNativeContainers(): void { $dataCollection = new DataCollection(ChildCreationData::class, [ @@ -142,7 +360,7 @@ public function testPreservesRawCollectionKeysAcrossFillValidationAndConstructio $rules = MappedItemListCreationData::getValidationRules($payload); $data = MappedItemListCreationData::validateAndCreate($payload); - $this->assertArrayHasKey('items.tenant\\.eu.profile.name', $rules); + $this->assertArrayHasKey('items.tenant\.eu.profile.name', $rules); $this->assertArrayHasKey('items.tenant.name', $rules); $this->assertSame(['tenant.eu', 'tenant'], array_keys($data->items)); $this->assertSame('Europe', $data->items['tenant.eu']->name); @@ -165,6 +383,232 @@ public function testPreservesLazyCollectionTraversalWhenValidationIsNotRunning() $this->assertTrue($evaluated); } + public function testNestedLazyRequestItemsDoNotRestartRootRequestValidation(): void + { + LazyRequestItemCreationData::$authorizationCalls = 0; + $request = Request::create('/', 'POST', ['id' => '5']); + $data = LazyRequestIterableCreationData::from([ + 'children' => LazyCollection::make([$request]), + ]); + + $this->assertSame(5, $data->children->first()->id); + $this->assertSame(0, LazyRequestItemCreationData::$authorizationCalls); + } + + public function testNestedLazyItemsShareAttributeCastsForTheRootOperation(): void + { + DeferredItemCreationCast::$instances = 0; + $data = LazyCastIterableCreationData::from([ + 'children' => LazyCollection::make([ + ['id' => '5'], + ['id' => '7'], + ]), + ]); + + $this->assertSame([5, 7], $data->children->pluck('id')->all()); + $this->assertSame(1, DeferredItemCreationCast::$instances); + } + + public function testAutomaticLazyReplayIsLimitedToStructuralProperties(): void + { + RecordingAutoLazy::reset(); + $first = new AutoLazyFirstSource('title'); + $second = new AutoLazySecondSource(['one', 'two'], ['id' => '7']); + $paginator = new Paginator([['id' => '8']], 15, 2); + + $data = AutoLazyCreationData::from( + $first, + $second, + ['children' => $paginator], + ); + + $this->assertSame($first, RecordingAutoLazy::$payloads['title']); + $this->assertSame($second, RecordingAutoLazy::$payloads['tags']); + $this->assertNull(RecordingAutoLazy::$replays['title']); + $this->assertNull(RecordingAutoLazy::$replays['tags']); + $this->assertSame(AutoLazyReplayMode::Normal, RecordingAutoLazy::$replays['child']); + $this->assertSame(AutoLazyReplayMode::Normal, RecordingAutoLazy::$replays['children']); + $this->assertSame('title', $data->title->resolve()); + $this->assertSame(['one', 'two'], $data->tags->resolve()); + $this->assertSame(7, $data->child->resolve()->id); + + $children = $data->children->resolve(); + + $this->assertNotSame($paginator, $children); + $this->assertSame(2, $children->currentPage()); + $this->assertSame(8, $children->items()[0]->id); + + RecordingAutoLazy::reset(); + AutoLazyNamedFactoryData::$source = null; + $named = AutoLazyNamedFactoryData::from('named'); + + $this->assertSame(AutoLazyNamedFactoryData::$source, RecordingAutoLazy::$payloads['title']); + $this->assertSame('named', $named->title->resolve()); + } + + public function testAutomaticLazyReplayUsesNormalAndHookSpecificFillPaths(): void + { + AutoLazyCountingNormalizer::$calls = 0; + $normal = AutoLazyNormalizedParentData::from([ + 'child' => ['id' => '7'], + ]); + + $this->assertSame(0, AutoLazyCountingNormalizer::$calls); + $this->assertSame(7, $normal->child->resolve()->id); + $this->assertSame(1, AutoLazyCountingNormalizer::$calls); + + AutoLazyCountingNormalizer::$calls = 0; + $hook = AutoLazyNormalizedParentData::factory() + ->alwaysValidate() + ->afterValidation(static fn (array $payload): array => [ + ...$payload, + 'child' => ['id' => '9'], + ]) + ->from(['child' => ['id' => '8']]); + + $this->assertSame(1, AutoLazyCountingNormalizer::$calls); + $this->assertSame(9, $hook->child->resolve()->id); + $this->assertSame(1, AutoLazyCountingNormalizer::$calls); + } + + public function testAutomaticLazyHookReplayReplacesPaginatorSource(): void + { + $original = new Paginator([['id' => '7']], 15, 2); + $replacement = new Paginator([['id' => '9']], 20, 3); + $data = AutoLazyPaginatorCreationData::factory() + ->alwaysValidate() + ->afterValidation(static fn (array $payload): array => [ + ...$payload, + 'children' => $replacement, + ]) + ->from(['children' => $original]); + + $children = $data->children->resolve(); + + $this->assertNotSame($original, $children); + $this->assertNotSame($replacement, $children); + $this->assertSame(20, $children->perPage()); + $this->assertSame(3, $children->currentPage()); + $this->assertSame(9, $children->items()[0]->id); + } + + public function testAutomaticLoadedRelationLazyUsesItsLiveModelSource(): void + { + $model = new AutoLazyRelationModel; + $data = AutoWhenLoadedCreationData::from($model); + + $this->assertInstanceOf(Lazy::class, $data->child); + $this->assertFalse($data->child->shouldBeIncluded()); + + $model->setRelation('child', ['id' => '11']); + + $this->assertTrue($data->child->shouldBeIncluded()); + $this->assertSame(11, $data->child->resolve()->id); + + $nullModel = new AutoLazyRelationModel; + $nullModel->setRelation('child', null); + + $this->assertNull(AutoWhenLoadedCreationData::from($nullModel)->child); + } + + public function testAutomaticLoadedRelationLazyRequiresAModelSource(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('no Eloquent model source was supplied'); + + AutoWhenLoadedCreationData::from([ + 'child' => ['id' => '1'], + ]); + } + + public function testAutomaticLoadedRelationLazyRejectsAHookSelectedMorphWithoutAModelSource(): void + { + $data = AutoLazyMorphParentCreationData::factory() + ->alwaysValidate() + ->afterValidation(static fn (array $payload): array => [ + ...$payload, + 'child' => ['type' => 'relation'], + ]) + ->from(['child' => ['type' => 'plain']]); + + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessage('no Eloquent model source was supplied'); + + $data->child->resolve(); + } + + public function testAutomaticLazyWrapsDefaultsAndPreservesExplicitSentinels(): void + { + RecordingAutoLazy::reset(); + $data = AutoLazyDefaultCreationData::from([]); + + $this->assertSame([], RecordingAutoLazy::$payloads['title']); + $this->assertInstanceOf(Lazy::class, $data->title); + $this->assertSame('default', $data->title->resolve()); + $this->assertInstanceOf(Lazy::class, $data->child); + $this->assertSame(12, $data->child->resolve()->id); + $this->assertNull($data->nullable); + $this->assertInstanceOf(Optional::class, $data->optional); + + $existing = Lazy::create(static fn (): string => 'existing'); + $supplied = AutoLazyDefaultCreationData::from([ + 'title' => $existing, + 'child' => new ChildCreationData(13), + 'nullable' => null, + 'optional' => Optional::create(), + ]); + + $this->assertSame($existing, $supplied->title); + $this->assertSame(13, $supplied->child->resolve()->id); + $this->assertNull($supplied->nullable); + $this->assertInstanceOf(Optional::class, $supplied->optional); + } + + public function testAutomaticLazyVariantsDeferTheSameCastPath(): void + { + $data = AutoLazyVariantsCreationData::from([ + 'closure' => ['id' => '1'], + 'inertia' => ['id' => '2'], + 'deferred' => ['id' => '3'], + ]); + + $closure = $data->closure->resolve(); + $inertia = $data->inertia->resolve(); + $deferred = $data->deferred->resolve(); + + $this->assertSame(1, $closure()->id); + $this->assertSame(2, $inertia()->id); + $this->assertSame('analytics', $deferred->group()); + $this->assertTrue($deferred->shouldRescue()); + $this->assertSame(3, $deferred()->id); + } + + public function testUnresolvedAutomaticLazyStateCanBeSerialized(): void + { + $data = AutoLazyNormalizedParentData::from([ + 'child' => ['id' => '14'], + ]); + + $restored = unserialize(serialize($data)); + + $this->assertInstanceOf(AutoLazyNormalizedParentData::class, $restored); + $this->assertInstanceOf(Lazy::class, $restored->child); + $this->assertSame(14, $restored->child->resolve()->id); + } + + public function testAutomaticLazySnapshotDoesNotRetainItsOuterSource(): void + { + $source = new AutoLazyOuterSource(['id' => '15']); + $reference = WeakReference::create($source); + $data = AutoLazyNormalizedParentData::from($source); + + unset($source); + gc_collect_cycles(); + + $this->assertNull($reference->get()); + $this->assertSame(15, $data->child->resolve()->id); + } + public function testCastsDeclaredBuiltinEnumAndDateIterableItems(): void { $data = ScalarIterableCreationData::from([ @@ -403,6 +847,99 @@ public function __construct( } } +class DirectArrayCreationData extends Data +{ + public string $assigned; + + public string $unboundDefault = 'unbound-default'; + + #[Computed] + public string $computed = 'computed'; + + public string $virtual { + get => 'virtual'; + } + + public function __construct( + #[MapInputName('profile.name')] + public string $name, + public ?string $nullable, + public string|Optional $optional, + public ChildCreationData $child, + public DateTimeImmutable $date, + public CreationStatus $status, + public CreationSource $source, + public ?string $defaultedNullable = 'fallback', + public int $defaultedInteger = 21, + public array $metadata = [], + ) { + } +} + +class DirectNestedCreationData extends Data +{ + public function __construct(public ChildCreationData $child) + { + } +} + +class DirectConvertedCreationData extends Data +{ + public function __construct( + public int $id, + public DateTimeImmutable $date, + public CreationStatus $status, + ) { + } +} + +class DirectOutputOnlyCreationData extends Data +{ + #[Computed] + public string $computed = 'computed'; + + public string $virtual { + get => 'virtual'; + } + + public function __construct(public int $id) + { + } +} + +class DirectNamedFactoryCreationData extends Data +{ + public static int $calls = 0; + + public function __construct(public int $id) + { + } + + public static function fromString(string $value): array + { + ++self::$calls; + + return ['id' => (int) $value]; + } +} + +class DirectValidationModeCreationData extends Data +{ + public function __construct(public int $id) + { + } +} + +class DirectPrivateConstructorCreationData extends Data +{ + public readonly string $value; + + private function __construct(string $value) + { + $this->value = $value; + } +} + class ChildCreationData extends Data { public function __construct( @@ -462,6 +999,326 @@ public function __construct( } } +class LazyRequestIterableCreationData extends Data +{ + /** + * Create a lazy Request iterable fixture. + * + * @param LazyCollection $children + */ + public function __construct( + #[DataCollectionOf(LazyRequestItemCreationData::class)] + public LazyCollection $children, + ) { + } +} + +class LazyRequestItemCreationData extends Data +{ + public static int $authorizationCalls = 0; + + public function __construct( + public int $id, + ) { + } + + public static function authorize(): bool + { + ++self::$authorizationCalls; + + return false; + } +} + +class LazyCastIterableCreationData extends Data +{ + /** + * Create a lazy cast iterable fixture. + * + * @param LazyCollection $children + */ + public function __construct( + #[DataCollectionOf(LazyCastItemCreationData::class)] + public LazyCollection $children, + ) { + } +} + +class LazyCastItemCreationData extends Data +{ + public function __construct( + #[WithCast(DeferredItemCreationCast::class)] + public int $id, + ) { + } +} + +class DeferredItemCreationCast implements Cast +{ + public static int $instances = 0; + + public function __construct() + { + ++self::$instances; + } + + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): int { + return (int) $value; + } +} + +class AutoLazyCreationData extends Data +{ + /** + * Create an automatic-lazy fixture. + * + * @param Lazy|list $tags + * @param Lazy|Paginator $children + */ + public function __construct( + #[RecordingAutoLazy] + public Lazy|string $title, + #[RecordingAutoLazy] + public Lazy|array $tags, + #[RecordingAutoLazy] + public Lazy|ChildCreationData $child, + #[RecordingAutoLazy, DataCollectionOf(ChildCreationData::class)] + public Lazy|Paginator $children, + ) { + } +} + +class AutoLazyFirstSource +{ + public function __construct( + public readonly string $title, + ) { + } +} + +class AutoLazySecondSource +{ + /** + * Create an automatic-lazy source fixture. + * + * @param list $tags + * @param array{id: string} $child + */ + public function __construct( + public readonly array $tags, + public readonly array $child, + ) { + } +} + +class AutoLazyNamedFactoryData extends Data +{ + public static ?AutoLazyNamedFactorySource $source = null; + + public function __construct( + #[RecordingAutoLazy] + public Lazy|string $title, + ) { + } + + public static function fromString(string $title): AutoLazyNamedFactorySource + { + return self::$source = new AutoLazyNamedFactorySource($title); + } +} + +class AutoLazyNamedFactorySource +{ + public function __construct( + public readonly string $title, + ) { + } +} + +#[Attribute(Attribute::TARGET_PROPERTY)] +class RecordingAutoLazy extends AutoLazy +{ + /** @var array */ + public static array $payloads = []; + + /** @var array */ + public static array $replays = []; + + /** + * Build an inspectable automatic lazy value. + */ + public function build( + Closure $castValue, + mixed $payload, + DataProperty $property, + mixed $value, + ): Lazy { + $variables = (new ReflectionFunction($castValue))->getStaticVariables(); + self::$payloads[$property->name] = $payload; + self::$replays[$property->name] = $variables['replay'] ?? null; + + return parent::build($castValue, $payload, $property, $value); + } + + /** + * Reset captured automatic lazy state. + */ + public static function reset(): void + { + self::$payloads = []; + self::$replays = []; + } +} + +class AutoLazyNormalizedParentData extends Data +{ + public function __construct( + #[AutoLazy] + public Lazy|AutoLazyNormalizedChildData $child, + ) { + } +} + +class AutoLazyOuterSource +{ + /** + * Create an automatic-lazy outer source fixture. + * + * @param array{id: string} $child + */ + public function __construct( + public readonly array $child, + ) { + } +} + +class AutoLazyPaginatorCreationData extends Data +{ + /** + * Create an automatic-lazy paginator fixture. + * + * @param Lazy|Paginator $children + */ + public function __construct( + #[AutoLazy, DataCollectionOf(ChildCreationData::class)] + public Lazy|Paginator $children, + ) { + } +} + +class AutoLazyNormalizedChildData extends Data +{ + public function __construct( + public int $id, + ) { + } + + public static function normalizers(): array + { + return [AutoLazyCountingNormalizer::class]; + } +} + +class AutoLazyCountingNormalizer implements Normalizer +{ + public static int $calls = 0; + + public function normalize(mixed $value): array|Normalized|null + { + ++self::$calls; + + return null; + } +} + +class AutoWhenLoadedCreationData extends Data +{ + public function __construct( + #[AutoWhenLoadedLazy] + public Lazy|AutoLazyNormalizedChildData|null $child, + ) { + } +} + +class AutoLazyRelationModel extends Model +{ +} + +class AutoLazyMorphParentCreationData extends Data +{ + public function __construct( + #[AutoLazy] + public Lazy|AutoLazyMorphCreationData $child, + ) { + } +} + +abstract class AutoLazyMorphCreationData extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['type']) { + 'plain' => AutoLazyPlainMorphCreationData::class, + 'relation' => AutoLazyRelationMorphCreationData::class, + default => null, + }; + } +} + +class AutoLazyPlainMorphCreationData extends AutoLazyMorphCreationData +{ +} + +class AutoLazyRelationMorphCreationData extends AutoLazyMorphCreationData +{ + public function __construct( + string $type, + #[AutoWhenLoadedLazy] + public Lazy|AutoLazyNormalizedChildData|null $child = null, + ) { + parent::__construct($type); + } +} + +class AutoLazyDefaultCreationData extends Data +{ + public function __construct( + #[RecordingAutoLazy] + public Lazy|string $title = 'default', + #[RecordingAutoLazy] + public Lazy|ChildCreationData $child = new ChildCreationData(12), + #[RecordingAutoLazy] + public Lazy|string|null $nullable = null, + #[RecordingAutoLazy] + public Lazy|string|Optional $optional = new Optional, + ) { + } +} + +class AutoLazyVariantsCreationData extends Data +{ + public function __construct( + #[AutoClosureLazy] + public Lazy|ChildCreationData $closure, + #[AutoInertiaLazy] + public Lazy|ChildCreationData $inertia, + #[AutoInertiaDeferred('analytics', rescue: true)] + public Lazy|ChildCreationData $deferred, + ) { + } +} + class FinishedCollectionCreationData extends Data { /** @@ -492,6 +1349,32 @@ public function __construct( } } +class DataPaginatorCreationData extends Data +{ + /** + * Create a paginated data fixture. + * + * @param Paginator $children + */ + public function __construct( + #[DataCollectionOf(ChildCreationData::class)] + public Paginator $children, + ) { + } +} + +class ScalarPaginatorCreationData extends Data +{ + /** + * Create a scalar paginator fixture. + * + * @param Paginator $ids + */ + public function __construct(public Paginator $ids) + { + } +} + class ScalarIterableCreationData extends Data { /** @var list */ diff --git a/tests/Data/Support/Creation/SourceReaderTest.php b/tests/Data/Support/Creation/SourceReaderTest.php index 75aad6371..5b33d371a 100644 --- a/tests/Data/Support/Creation/SourceReaderTest.php +++ b/tests/Data/Support/Creation/SourceReaderTest.php @@ -6,7 +6,6 @@ use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalized\UnknownProperty; -use Hypervel\Data\Optional; use Hypervel\Data\Support\Creation\SourceReader; use Hypervel\Data\Support\DataProperty; use Hypervel\Tests\TestCase; @@ -20,9 +19,9 @@ public function testReadsArraySourcesWithoutConflatingNullAndMissing(): void { $property = $this->property(); - $this->assertSame('Hello', SourceReader::read(['title' => 'Hello'], 'title', $property)); - $this->assertNull(SourceReader::read(['title' => null], 'title', $property)); - $this->assertSame(UnknownProperty::create(), SourceReader::read([], 'title', $property)); + $this->assertSame('Hello', SourceReader::read(['title' => 'Hello'], ['title'], $property)); + $this->assertNull(SourceReader::read(['title' => null], ['title'], $property)); + $this->assertSame(UnknownProperty::create(), SourceReader::read([], ['title'], $property)); } /** @@ -42,13 +41,69 @@ public function getProperty(string $name, DataProperty $dataProperty): mixed $this->assertSame('Taylor', SourceReader::read( ['people' => [['name' => 'Taylor']]], - 'people.0.name', + ['people', '0', 'name'], $property, )); - $this->assertNull(SourceReader::read($normalized, 'profile.contact.email', $property)); + $this->assertNull(SourceReader::read($normalized, ['profile', 'contact', 'email'], $property)); $this->assertSame( UnknownProperty::create(), - SourceReader::read($normalized, 'profile.contact.phone', $property), + SourceReader::read($normalized, ['profile', 'contact', 'phone'], $property), + ); + } + + /** + * Test mapped path segments are read literally. + */ + public function testReadsSpecialPathSegmentsAsLiteralKeys(): void + { + $property = $this->property(); + $source = [ + 'values' => [ + '*' => 'asterisk', + '{first}' => 'first', + '{last}' => 'last', + ], + ]; + + $this->assertSame('asterisk', SourceReader::read($source, ['values', '*'], $property)); + $this->assertSame('first', SourceReader::read($source, ['values', '{first}'], $property)); + $this->assertSame('last', SourceReader::read($source, ['values', '{last}'], $property)); + } + + /** + * Test nested object reads preserve public and magic null boundaries. + */ + public function testReadsAccessibleObjectPropertiesWithoutExposingOtherState(): void + { + $property = $this->property(); + $object = new class { + public ?string $publicNull = null; + + public string $uninitialized; + + protected ?string $protectedNull = null; + + public function __isset(string $name): bool + { + return $name === 'magicNull'; + } + + public function __get(string $name): mixed + { + return null; + } + }; + $source = ['object' => $object]; + + $this->assertNull(SourceReader::read($source, ['object', 'publicNull'], $property)); + $this->assertNull(SourceReader::read($source, ['object', 'magicNull'], $property)); + $this->assertSame( + UnknownProperty::create(), + SourceReader::read($source, ['object', 'protectedNull'], $property), + ); + $this->assertSame( + UnknownProperty::create(), + SourceReader::read($source, ['object', 'uninitialized'], $property), ); } @@ -58,7 +113,7 @@ public function getProperty(string $name, DataProperty $dataProperty): mixed public function testReadsNormalizedSources(): void { $property = $this->property(); - $normalized = new class ($property) implements Normalized { + $normalized = new class($property) implements Normalized { public function __construct( private readonly DataProperty $expectedProperty, ) { @@ -74,38 +129,8 @@ public function getProperty(string $name, DataProperty $dataProperty): mixed } }; - $this->assertSame('Hello', SourceReader::read($normalized, 'title', $property)); - $this->assertSame(UnknownProperty::create(), SourceReader::read($normalized, 'missing', $property)); - } - - /** - * Test the first source containing a key owns its value. - */ - public function testFirstPresentSourceWinsIncludingNullAndOptional(): void - { - $property = $this->property(); - $optional = Optional::create(); - - $this->assertSame('First', SourceReader::readFromMany( - [[], ['title' => 'First'], ['title' => 'Second']], - 'title', - $property, - )); - $this->assertNull(SourceReader::readFromMany( - [['title' => null], ['title' => 'Second']], - 'title', - $property, - )); - $this->assertSame($optional, SourceReader::readFromMany( - [['title' => $optional], ['title' => 'Second']], - 'title', - $property, - )); - $this->assertSame(UnknownProperty::create(), SourceReader::readFromMany( - [[], []], - 'title', - $property, - )); + $this->assertSame('Hello', SourceReader::read($normalized, ['title'], $property)); + $this->assertSame(UnknownProperty::create(), SourceReader::read($normalized, ['missing'], $property)); } /** From f057da56f480e9435a36e7dcdea97da9010736ab Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:48:29 +0000 Subject: [PATCH 17/35] Complete Data transformation and lazy values Finalize live transformation, output mapping, nested partial composition, constructable persistence views, resource-specific transformation boundaries, and typed iterable handling without cached result state. Add closure and relation-aware automatic lazy values with pruned replay state, repeatable resolution, and explicit persistence failures. Cover partial lifetimes, nested collections, source retention, dates, enums, Arrayable values, and depth limits. --- src/data/src/Attributes/AutoClosureLazy.php | 23 + .../src/Attributes/AutoWhenLoadedLazy.php | 43 ++ src/data/src/Concerns/TransformableData.php | 13 +- src/data/src/Contracts/TransformableData.php | 12 +- .../src/Exceptions/CannotTransformData.php | 22 + src/data/src/Lazy.php | 19 +- src/data/src/Support/Lazy/ClosureLazy.php | 8 + .../Transformation/DataTransformer.php | 156 ++++-- .../Transformation/EmptyDataResolver.php | 6 +- .../Transformation/TransformationContext.php | 11 +- .../TransformationContextFactory.php | 33 +- .../DateTimeInterfaceTransformer.php | 8 +- src/data/src/Transformers/Transformer.php | 1 + tests/Data/LazyTest.php | 66 ++- .../Transformation/DataTransformerTest.php | 503 ++++++++++++++++++ .../TransformationContextFactoryTest.php | 36 ++ .../TransformationContextTest.php | 19 + .../Transformers/ArrayableTransformerTest.php | 32 ++ .../DateTimeInterfaceTransformerTest.php | 110 ++++ .../Data/Transformers/EnumTransformerTest.php | 37 ++ 20 files changed, 1078 insertions(+), 80 deletions(-) create mode 100644 src/data/src/Attributes/AutoClosureLazy.php create mode 100644 src/data/src/Attributes/AutoWhenLoadedLazy.php create mode 100644 src/data/src/Exceptions/CannotTransformData.php create mode 100644 tests/Data/Transformers/ArrayableTransformerTest.php create mode 100644 tests/Data/Transformers/DateTimeInterfaceTransformerTest.php create mode 100644 tests/Data/Transformers/EnumTransformerTest.php diff --git a/src/data/src/Attributes/AutoClosureLazy.php b/src/data/src/Attributes/AutoClosureLazy.php new file mode 100644 index 000000000..27eaa6f26 --- /dev/null +++ b/src/data/src/Attributes/AutoClosureLazy.php @@ -0,0 +1,23 @@ + $castValue($value)); + } +} diff --git a/src/data/src/Attributes/AutoWhenLoadedLazy.php b/src/data/src/Attributes/AutoWhenLoadedLazy.php new file mode 100644 index 000000000..ad1895f69 --- /dev/null +++ b/src/data/src/Attributes/AutoWhenLoadedLazy.php @@ -0,0 +1,43 @@ +forRelation($property); + + return Lazy::when(fn () => $payload->relationLoaded($relation), fn () => $castValue( + $payload->getRelation($relation) + )); + } + + /** + * Get the relation represented by the property. + */ + public function forRelation(DataProperty $property): string + { + return $this->relation ?? $property->name; + } +} diff --git a/src/data/src/Concerns/TransformableData.php b/src/data/src/Concerns/TransformableData.php index fca5bb68b..c5f1390b8 100644 --- a/src/data/src/Concerns/TransformableData.php +++ b/src/data/src/Concerns/TransformableData.php @@ -5,9 +5,6 @@ namespace Hypervel\Data\Concerns; use Hypervel\Container\Container; -use Hypervel\Contracts\Database\Eloquent\CastsAttributes; -use Hypervel\Contracts\Database\Eloquent\CastsInboundAttributes; -use Hypervel\Data\Eloquent\DataEloquentCast; use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Data\Support\Transformation\TransformationContext; use Hypervel\Data\Support\Transformation\TransformationContextFactory; @@ -21,7 +18,7 @@ trait TransformableData * @return array */ public function transform( - null|TransformationContextFactory|TransformationContext $transformationContext = null, + TransformationContextFactory|TransformationContext|null $transformationContext = null, ): array { $transformationContext = match (true) { $transformationContext instanceof TransformationContext => $transformationContext, @@ -71,12 +68,4 @@ public function jsonSerialize(): array { return $this->transform(); } - - /** - * Get the Eloquent caster for the data object. - */ - public static function castUsing(array $arguments): CastsAttributes|CastsInboundAttributes|string - { - return new DataEloquentCast(static::class, $arguments); - } } diff --git a/src/data/src/Contracts/TransformableData.php b/src/data/src/Contracts/TransformableData.php index 3a6e04627..1b2ea59cd 100644 --- a/src/data/src/Contracts/TransformableData.php +++ b/src/data/src/Contracts/TransformableData.php @@ -4,9 +4,6 @@ namespace Hypervel\Data\Contracts; -use Hypervel\Contracts\Database\Eloquent\Castable as EloquentCastable; -use Hypervel\Contracts\Database\Eloquent\CastsAttributes; -use Hypervel\Contracts\Database\Eloquent\CastsInboundAttributes; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Data\Support\Transformation\TransformationContext; @@ -16,13 +13,13 @@ /** * @extends Arrayable */ -interface TransformableData extends JsonSerializable, Jsonable, Arrayable, EloquentCastable +interface TransformableData extends JsonSerializable, Jsonable, Arrayable { /** * Transform the data object to an array. */ public function transform( - null|TransformationContextFactory|TransformationContext $transformationContext = null, + TransformationContextFactory|TransformationContext|null $transformationContext = null, ): array; /** @@ -44,9 +41,4 @@ public function toJson(int $options = 0): string; * Get the data that should be serialized to JSON. */ public function jsonSerialize(): array; - - /** - * Get the Eloquent caster for the data object. - */ - public static function castUsing(array $arguments): CastsAttributes|CastsInboundAttributes|string; } diff --git a/src/data/src/Exceptions/CannotTransformData.php b/src/data/src/Exceptions/CannotTransformData.php new file mode 100644 index 000000000..db8343569 --- /dev/null +++ b/src/data/src/Exceptions/CannotTransformData.php @@ -0,0 +1,22 @@ +className}::\${$property->name}] does not resolve to constructable data. " + . 'Conditional and relational lazy values must be included before persistence; callback and Inertia lazy values cannot be persisted.' + ); + } +} diff --git a/src/data/src/Lazy.php b/src/data/src/Lazy.php index 1557272f9..ad2dd7010 100644 --- a/src/data/src/Lazy.php +++ b/src/data/src/Lazy.php @@ -57,9 +57,12 @@ public static function inertia(Closure $value): InertiaLazy /** * Create an Inertia deferred prop. */ - public static function inertiaDeferred(mixed $value, ?string $group = null): InertiaDeferred - { - return new InertiaDeferred($value, $group); + public static function inertiaDeferred( + mixed $value, + ?string $group = null, + bool $rescue = false, + ): InertiaDeferred { + return new InertiaDeferred($value, $group, $rescue); } /** @@ -111,12 +114,20 @@ public function shouldBeIncluded(): ?bool return null; } + /** + * Determine if resolving this lazy value produces data. + */ + public function resolvesToData(): bool + { + return true; + } + /** * Forward property access to the resolved value. */ public function __get(string $name): mixed { - return $this->resolve()->$name; + return $this->resolve()->{$name}; } /** diff --git a/src/data/src/Support/Lazy/ClosureLazy.php b/src/data/src/Support/Lazy/ClosureLazy.php index e720ad399..d477cc94e 100644 --- a/src/data/src/Support/Lazy/ClosureLazy.php +++ b/src/data/src/Support/Lazy/ClosureLazy.php @@ -24,4 +24,12 @@ public function resolve(): Closure { return $this->value; } + + /** + * Determine if resolving this lazy value produces data. + */ + public function resolvesToData(): bool + { + return false; + } } diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php index cc1e10543..696557efa 100644 --- a/src/data/src/Support/Transformation/DataTransformer.php +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -18,10 +18,13 @@ use Hypervel\Data\Contracts\IncludeableData; use Hypervel\Data\Contracts\TransformableData; use Hypervel\Data\Contracts\WrappableData; +use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; +use Hypervel\Data\Exceptions\CannotTransformData; use Hypervel\Data\Exceptions\MaxTransformationDepthReached; use Hypervel\Data\Lazy; use Hypervel\Data\Optional; +use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\DataClass; use Hypervel\Data\Support\DataClassRepository; use Hypervel\Data\Support\DataConfig; @@ -30,6 +33,7 @@ use Hypervel\Data\Support\Types\Type; use Hypervel\Data\Support\Wrapping\WrapExecutionType; use Hypervel\Data\Transformers\Transformer; +use Hypervel\Support\Collection; class DataTransformer { @@ -62,6 +66,27 @@ public function transform( : $this->transformData($data, $context, $extensions); } + /** + * Transform the root payload for Hypervel's resource response pipeline. + */ + public function transformForResourceResponse( + (BaseData&TransformableData)|(BaseDataCollectable&TransformableData) $data, + TransformationContext $context, + ?Collection $rootItems = null, + ): array { + $extensions = []; + + return $data instanceof BaseDataCollectable + ? $this->transformCollectable( + $data, + $context, + $extensions, + includePaginationData: false, + rootItems: $rootItems, + ) + : $this->transformData($data, $context, $extensions, includeAdditionalData: false); + } + /** * Transform a nested data object within the current root operation. * @@ -71,6 +96,7 @@ protected function transformData( BaseData&TransformableData $data, TransformationContext $context, array &$extensions, + bool $includeAdditionalData = true, ): array { if ($context->maxDepth !== null && $context->depth >= $context->maxDepth) { throw MaxTransformationDepthReached::create($context->maxDepth); @@ -79,22 +105,25 @@ protected function transformData( $dataClass = $this->dataClasses->get($data::class); $values = get_object_vars($data); - if ($dataClass->plainTransform + // The plain path includes computed output, which cannot reconstruct the object. + if (! $context->constructable + && $dataClass->plainTransform && ! $context->hasPartials() && $context->transformers === [] ) { return $this->finalizeTransformation( $data, - $dataClass, $context, $this->transformPlain($data, $dataClass, $values), + $includeAdditionalData, ); } $transformed = []; foreach ($dataClass->properties as $property) { - if ($property->hidden + if (($property->hidden && ! $context->constructable) + || ($context->constructable && $property->computed) || $context->except?->selects($property->name) || ($context->only !== null && $context->only->children !== [] @@ -137,7 +166,12 @@ protected function transformData( $transformed[$name] = $value; } - return $this->finalizeTransformation($data, $dataClass, $context, $transformed); + return $this->finalizeTransformation( + $data, + $context, + $transformed, + $includeAdditionalData, + ); } /** @@ -149,6 +183,8 @@ protected function transformCollectable( BaseDataCollectable&TransformableData $data, TransformationContext $context, array &$extensions, + bool $includePaginationData = true, + ?Collection $rootItems = null, ): array { if ($context->maxDepth !== null && $context->depth >= $context->maxDepth) { throw MaxTransformationDepthReached::create($context->maxDepth); @@ -156,7 +192,7 @@ protected function transformCollectable( $transformed = []; - foreach ($this->collectableItems($data) as $key => $item) { + foreach ($rootItems ?? $this->collectableItems($data) as $key => $item) { if (! $context->transformValues) { if ($context->hasPartials() && $item instanceof IncludeableData) { $item->getPartialsDefinition()->addResolved($context->partialDefinitions); @@ -170,18 +206,72 @@ protected function transformCollectable( $itemContext = $context->withWrapExecutionType( $this->resolveWrapExecutionType($item, $context), ); - $transformed[$key] = $this->transformData( + $transformed[$key] = $this->transformNested( $item, - $this->mergeInstancePartials($item, $itemContext), + $itemContext, $extensions, ); } + if ($includePaginationData + && ($data instanceof PaginatedDataCollection + || $data instanceof CursorPaginatedDataCollection) + && $context->transformValues + ) { + return $this->transformPaginatorCollectable($data, $transformed); + } + return $data instanceof WrappableData && $context->wrapExecutionType->shouldExecute() ? $data->getWrap()->wrap($transformed, $this->config->wrap) : $transformed; } + /** + * Transform a reached data value within the current root operation. + * + * @param array $extensions + * @return array|BaseData|BaseDataCollectable + */ + protected function transformNested( + BaseData|BaseDataCollectable $value, + TransformationContext $context, + array &$extensions, + ): array|BaseData|BaseDataCollectable { + if (! $value instanceof TransformableData) { + return $value; + } + + $context = $this->mergeInstancePartials($value, $context); + + return $value instanceof BaseDataCollectable + ? $this->transformCollectable($value, $context, $extensions) + : $this->transformData($value, $context, $extensions); + } + + /** + * Transform a paginator while retaining its native metadata. + * + * @param array $items + * @return array + */ + protected function transformPaginatorCollectable( + PaginatedDataCollection|CursorPaginatedDataCollection $data, + array $items, + ): array { + $paginator = (clone $data->items())->setCollection(new Collection($items)); + $transformed = $paginator->toArray(); + $wrapKey = $data->getWrap()->getKey($this->config->wrap) ?? 'data'; + + if ($wrapKey === 'data') { + return $transformed; + } + + $items = $transformed['data']; + unset($transformed['data']); + + return [$wrapKey => $items, ...$transformed]; + } + /** * Get collection items without triggering public transformation behavior. * @@ -199,20 +289,21 @@ protected function collectableItems(BaseDataCollectable $data): iterable */ protected function finalizeTransformation( BaseData $data, - DataClass $dataClass, TransformationContext $context, array $transformed, + bool $includeAdditionalData, ): array { - if ($dataClass->wrappable && $context->wrapExecutionType->shouldExecute()) { - /** @var WrappableData $data */ + if ($data instanceof WrappableData && $context->wrapExecutionType->shouldExecute()) { $transformed = $data->getWrap()->wrap($transformed, $this->config->wrap); } - if (! $dataClass->appendable) { + if (! $includeAdditionalData + || $context->constructable + || ! $data instanceof AppendableData + ) { return $transformed; } - /** @var AppendableData $data */ $additional = $data->getAdditionalData(); return $additional === [] @@ -249,6 +340,16 @@ protected function includesLazy( DataProperty $property, TransformationContext $context, ): bool { + if ($context->constructable) { + if (! $lazy->resolvesToData() + || (! $lazy instanceof DefaultLazy && $lazy->shouldBeIncluded() !== true) + ) { + throw CannotTransformData::nonConstructableLazy($property); + } + + return true; + } + if (! $lazy instanceof DefaultLazy) { return $lazy->shouldBeIncluded() === true; } @@ -296,17 +397,9 @@ protected function transformPropertyValue( $this->resolveWrapExecutionType($value, $context), ); - if ($value instanceof BaseData) { - return $this->transformData( - $value, - $this->mergeInstancePartials($value, $nestedContext), - $extensions, - ); - } - - return $this->transformCollectable( + return $this->transformNested( $value, - $this->mergeInstancePartials($value, $nestedContext), + $nestedContext, $extensions, ); } @@ -371,7 +464,7 @@ protected function mergeInstancePartials( BaseData|BaseDataCollectable $value, TransformationContext $context, ): TransformationContext { - if (! $value instanceof IncludeableData) { + if ($context->constructable || ! $value instanceof IncludeableData) { return $context; } @@ -458,7 +551,7 @@ protected function matchesTransformable(string $transformable, mixed $value): bo /** * Resolve one transformer once for the current root operation. * - * @param Transformer|class-string $transformer + * @param class-string|Transformer $transformer * @param array $extensions */ protected function resolveTransformer( @@ -541,17 +634,9 @@ protected function transformIterableItem( $this->resolveWrapExecutionType($value, $context), ); - if ($value instanceof BaseData) { - return $this->transformData( - $value, - $this->mergeInstancePartials($value, $context), - $extensions, - ); - } - - return $this->transformCollectable( + return $this->transformNested( $value, - $this->mergeInstancePartials($value, $context), + $context, $extensions, ); } @@ -644,8 +729,7 @@ protected function filterArray( array $value, ?PartialTree $only, ?PartialTree $except, - ): array - { + ): array { if ($except?->all) { $value = []; } elseif ($except !== null) { diff --git a/src/data/src/Support/Transformation/EmptyDataResolver.php b/src/data/src/Support/Transformation/EmptyDataResolver.php index c3b8679e1..25927f87a 100644 --- a/src/data/src/Support/Transformation/EmptyDataResolver.php +++ b/src/data/src/Support/Transformation/EmptyDataResolver.php @@ -34,8 +34,7 @@ public function execute( string $class, array $extra = [], mixed $defaultReturnValue = null, - ): array - { + ): array { $dataClass = $this->dataClasses->get($class); $payload = []; @@ -79,8 +78,7 @@ protected function getDefaultValue(DataClass $dataClass, DataProperty $property) protected function getValueForProperty( DataProperty $property, mixed $defaultReturnValue = null, - ): mixed - { + ): mixed { $propertyType = $property->type; if ($propertyType->isMixed) { diff --git a/src/data/src/Support/Transformation/TransformationContext.php b/src/data/src/Support/Transformation/TransformationContext.php index e737cb089..5807c7970 100644 --- a/src/data/src/Support/Transformation/TransformationContext.php +++ b/src/data/src/Support/Transformation/TransformationContext.php @@ -13,12 +13,13 @@ /** * Create an immutable transformation context. * - * @param array{include: list, exclude: list, only: list, except: list} $partialDefinitions - * @param array> $transformers + * @param array{include: list, exclude: list, only: list, except: list}|array{} $partialDefinitions + * @param array|Transformer> $transformers */ public function __construct( public bool $transformValues = true, public bool $mapPropertyNames = true, + public bool $constructable = false, public ?PartialTree $include = null, public ?PartialTree $exclude = null, public ?PartialTree $only = null, @@ -65,6 +66,7 @@ public function withMergedPartials(array $partialDefinitions): self return new self( transformValues: $this->transformValues, mapPropertyNames: $this->mapPropertyNames, + constructable: $this->constructable, include: self::mergeTree($this->include, $partialDefinitions['include']), exclude: self::mergeTree($this->exclude, $partialDefinitions['exclude']), only: self::mergeTree($this->only, $partialDefinitions['only']), @@ -85,6 +87,7 @@ public function withWrapExecutionType(WrapExecutionType $wrapExecutionType): sel return new self( transformValues: $this->transformValues, mapPropertyNames: $this->mapPropertyNames, + constructable: $this->constructable, include: $this->include, exclude: $this->exclude, only: $this->only, @@ -128,11 +131,11 @@ public function partialsForNestedProperty(string $property): array public function child( string $property, ?WrapExecutionType $wrapExecutionType = null, - ): self - { + ): self { return new self( transformValues: $this->transformValues, mapPropertyNames: $this->mapPropertyNames, + constructable: $this->constructable, include: $this->include?->child($property), exclude: $this->exclude?->child($property), only: $this->only?->child($property), diff --git a/src/data/src/Support/Transformation/TransformationContextFactory.php b/src/data/src/Support/Transformation/TransformationContextFactory.php index b1616ea3f..270c4a827 100644 --- a/src/data/src/Support/Transformation/TransformationContextFactory.php +++ b/src/data/src/Support/Transformation/TransformationContextFactory.php @@ -22,13 +22,17 @@ class TransformationContextFactory implements Transient protected bool $mapPropertyNames = true; + protected bool $constructable = false; + protected WrapExecutionType $wrapExecutionType = WrapExecutionType::Disabled; - /** @var array> */ + /** @var array|Transformer> */ protected array $transformers = []; protected ?int $maxDepth; + protected readonly ?int $configuredMaxDepth; + protected PartialsDefinition $partialDefinitions; /** @@ -36,7 +40,8 @@ class TransformationContextFactory implements Transient */ public function __construct(DataConfig $config) { - $this->maxDepth = $config->maxTransformationDepth; + $this->configuredMaxDepth = $config->maxTransformationDepth; + $this->maxDepth = $this->configuredMaxDepth; $this->partialDefinitions = new PartialsDefinition; } @@ -48,11 +53,33 @@ public static function create(): static return Container::getInstance()->make(static::class); } + /** + * Create a fresh constructable persistence context factory. + */ + public static function forPersistence(): static + { + $factory = static::create(); + $factory->constructable = true; + + return $factory; + } + /** * Build the context for one root transformation. */ public function get(object $data): TransformationContext { + if ($this->constructable) { + return new TransformationContext( + transformValues: true, + mapPropertyNames: false, + constructable: true, + include: PartialTree::compile(['*']), + wrapExecutionType: WrapExecutionType::Disabled, + maxDepth: $this->configuredMaxDepth, + ); + } + $partials = $this->partialDefinitions->resolve($data); if ($data instanceof IncludeableData) { @@ -153,7 +180,7 @@ public function withWrapping(): static /** * Add a transformer for one declared or runtime type. * - * @param Transformer|class-string $transformer + * @param class-string|Transformer $transformer */ public function withTransformer(string $transformable, Transformer|string $transformer): static { diff --git a/src/data/src/Transformers/DateTimeInterfaceTransformer.php b/src/data/src/Transformers/DateTimeInterfaceTransformer.php index 895598cc2..3296ccb84 100644 --- a/src/data/src/Transformers/DateTimeInterfaceTransformer.php +++ b/src/data/src/Transformers/DateTimeInterfaceTransformer.php @@ -27,11 +27,13 @@ public function __construct( ) { if ($format === null || $setTimeZone === null) { $config = Container::getInstance()->make(DataConfig::class); + + $format ??= $config->dateFormats[0]; + $setTimeZone ??= $config->dateTimezone; } - $this->format = $format ?? $config->dateFormats[0]; - $timeZone = $setTimeZone ?? $config->dateTimezone; - $this->timeZone = $timeZone === null ? null : new DateTimeZone($timeZone); + $this->format = $format; + $this->timeZone = $setTimeZone === null ? null : new DateTimeZone($setTimeZone); } /** diff --git a/src/data/src/Transformers/Transformer.php b/src/data/src/Transformers/Transformer.php index 70a29d8ff..25556315c 100644 --- a/src/data/src/Transformers/Transformer.php +++ b/src/data/src/Transformers/Transformer.php @@ -7,6 +7,7 @@ use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Transformation\TransformationContext; +// REMOVED: SerializeTransformer; native PHP serialization owns object serialization. interface Transformer { /** diff --git a/tests/Data/LazyTest.php b/tests/Data/LazyTest.php index 837b731b3..bb26c660f 100644 --- a/tests/Data/LazyTest.php +++ b/tests/Data/LazyTest.php @@ -11,7 +11,9 @@ use Hypervel\Data\Support\Lazy\DefaultLazy; use Hypervel\Data\Support\Lazy\RelationalLazy; use Hypervel\Database\Eloquent\Model; -use Hypervel\Tests\TestCase; +use Hypervel\Inertia\DeferProp; +use Hypervel\Inertia\OptionalProp; +use Hypervel\Testbench\TestCase; class LazyTest extends TestCase { @@ -24,6 +26,7 @@ public function testItCreatesAndResolvesDefaultLazyValues(): void $this->assertFalse($lazy->isDefaultIncluded()); $this->assertSame($lazy, $lazy->defaultIncluded()); $this->assertTrue($lazy->isDefaultIncluded()); + $this->assertTrue($lazy->resolvesToData()); } public function testItCreatesConditionalLazyValues(): void @@ -49,21 +52,57 @@ public function testItExposesLazyClosuresWithoutInvokingThem(): void $resolved = $lazy->resolve(); $this->assertInstanceOf(ClosureLazy::class, $lazy); + $this->assertFalse($lazy->resolvesToData()); $this->assertInstanceOf(Closure::class, $resolved); $this->assertSame(0, $calls); $this->assertSame('value', $resolved()); $this->assertSame(1, $calls); } + public function testItCreatesInertiaLazyAndDeferredProperties(): void + { + $lazy = Lazy::inertia(static fn (): string => 'lazy'); + $deferred = Lazy::inertiaDeferred('deferred', 'analytics', true); + + $this->assertTrue($lazy->shouldBeIncluded()); + $this->assertFalse($lazy->resolvesToData()); + $this->assertInstanceOf(OptionalProp::class, $lazy->resolve()); + $this->assertSame('lazy', ($lazy->resolve())()); + + $prop = $deferred->resolve(); + + $this->assertTrue($deferred->shouldBeIncluded()); + $this->assertFalse($deferred->resolvesToData()); + $this->assertSame('analytics', $prop->group()); + $this->assertTrue($prop->shouldRescue()); + $this->assertSame('deferred', $prop()); + } + + public function testItPreservesExistingInertiaDeferredProperties(): void + { + $prop = (new DeferProp(static fn (): string => 'value', 'original', true)) + ->merge() + ->once(as: 'users'); + + $resolved = Lazy::inertiaDeferred($prop, 'ignored')->resolve(); + + $this->assertSame($prop, $resolved); + $this->assertSame('original', $resolved->group()); + $this->assertTrue($resolved->shouldRescue()); + $this->assertTrue($resolved->shouldMerge()); + $this->assertTrue($resolved->shouldResolveOnce()); + $this->assertSame('users', $resolved->getKey()); + } + public function testItIncludesRelationshipValuesOnlyWhenTheRelationIsLoaded(): void { - $model = new LazyTestModel(); + $model = new LazyTestModel; $lazy = Lazy::whenLoaded('related', $model, fn () => $model->related); $this->assertInstanceOf(RelationalLazy::class, $lazy); $this->assertFalse($lazy->shouldBeIncluded()); - $related = new LazyTestModel(); + $related = new LazyTestModel; $model->setRelation('related', $related); $this->assertTrue($lazy->shouldBeIncluded()); @@ -72,7 +111,7 @@ public function testItIncludesRelationshipValuesOnlyWhenTheRelationIsLoaded(): v public function testItReturnsNullForALoadedNullRelationship(): void { - $model = new LazyTestModel(); + $model = new LazyTestModel; $model->setRelation('related', null); $lazy = Lazy::whenLoaded('related', $model, fn () => 'unreachable'); @@ -117,6 +156,25 @@ public function testSerializableLazyValuesRetainTheirBehavior(): void $this->assertTrue($restored->isDefaultIncluded()); $this->assertSame('value', $restored->resolve()); } + + public function testSerializableInertiaLazyValuesRetainTheirBehavior(): void + { + $lazyValue = fn () => 'lazy'; + $deferredValue = fn () => 'deferred'; + $lazy = Lazy::inertia($lazyValue)->defaultIncluded(); + $deferred = Lazy::inertiaDeferred($deferredValue, 'analytics', true)->defaultIncluded(); + + $restoredLazy = unserialize(serialize($lazy)); + $restoredDeferred = unserialize(serialize($deferred)); + $deferredProp = $restoredDeferred->resolve(); + + $this->assertTrue($restoredLazy->isDefaultIncluded()); + $this->assertSame('lazy', ($restoredLazy->resolve())()); + $this->assertTrue($restoredDeferred->isDefaultIncluded()); + $this->assertSame('analytics', $deferredProp->group()); + $this->assertTrue($deferredProp->shouldRescue()); + $this->assertSame('deferred', $deferredProp()); + } } class LazyTestModel extends Model diff --git a/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php index 6bc3f38e7..ceacc75dc 100644 --- a/tests/Data/Support/Transformation/DataTransformerTest.php +++ b/tests/Data/Support/Transformation/DataTransformerTest.php @@ -4,23 +4,41 @@ namespace Hypervel\Tests\Data\Support\Transformation\DataTransformerTest; +use ArrayIterator; use BackedEnum; use Closure; use DateTimeImmutable; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Data\Attributes\AutoInertiaDeferred; +use Hypervel\Data\Attributes\AutoInertiaLazy; +use Hypervel\Data\Attributes\AutoLazy; +use Hypervel\Data\Attributes\Computed; use Hypervel\Data\Attributes\DataCollectionOf; +use Hypervel\Data\Attributes\Hidden; use Hypervel\Data\Attributes\MapOutputName; +use Hypervel\Data\Contracts\BaseDataCollectable; use Hypervel\Data\Data; +use Hypervel\Data\DataCollection; use Hypervel\Data\DataServiceProvider; +use Hypervel\Data\Dto; +use Hypervel\Data\Exceptions\CannotTransformData; use Hypervel\Data\Lazy; +use Hypervel\Data\Normalizers\Normalized\Normalized; +use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Transformation\TransformationContext; use Hypervel\Data\Support\Transformation\TransformationContextFactory; use Hypervel\Data\Transformers\Transformer; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Inertia\DeferProp; +use Hypervel\Inertia\OptionalProp; use Hypervel\Testbench\TestCase; +use Traversable; class DataTransformerTest extends TestCase { + // REMOVED: SerializeTransformer tests; native PHP serialization owns object serialization. + /** * Get package providers for the transformation test application. */ @@ -103,6 +121,39 @@ public function testIncludesAndExcludesLazyValues(): void $this->assertArrayNotHasKey('excluded', $transformed); } + /** + * Test automatic lazy values retain their owning transformation semantics. + */ + public function testTransformsAutomaticLazyAndInertiaValues(): void + { + AutoLazyTransformNormalizer::$calls = 0; + $data = AutoLazyTransformData::from([ + 'child' => ['value' => 'nested'], + 'inertia' => 'lazy', + 'deferred' => 'deferred', + ]); + + $withoutChild = $data->toArray(); + + $this->assertSame(0, AutoLazyTransformNormalizer::$calls); + $this->assertArrayNotHasKey('child', $withoutChild); + $this->assertInstanceOf(OptionalProp::class, $withoutChild['inertia']); + $this->assertInstanceOf(DeferProp::class, $withoutChild['deferred']); + $this->assertSame('analytics', $withoutChild['deferred']->group()); + $this->assertTrue($withoutChild['deferred']->shouldRescue()); + + $withChild = $data->include('child')->toArray(); + + $this->assertSame(['value' => 'nested'], $withChild['child']); + $this->assertInstanceOf(OptionalProp::class, $withChild['inertia']); + $this->assertSame('lazy', $withChild['inertia']()); + $this->assertInstanceOf(DeferProp::class, $withChild['deferred']); + $this->assertSame('analytics', $withChild['deferred']->group()); + $this->assertTrue($withChild['deferred']->shouldRescue()); + $this->assertSame('deferred', $withChild['deferred']()); + $this->assertSame(1, AutoLazyTransformNormalizer::$calls); + } + /** * Test only and except filter nested plain arrays without losing either mode. */ @@ -353,6 +404,121 @@ public function testKeepsNestedTypedArrayItemPartialsIsolated(): void ))->include('items')->toArray()['items']); } + /** + * Test non-transformable data values retain their identity. + */ + public function testRetainsNonTransformableNestedAndIterableDataValues(): void + { + $nested = new SimpleDto('nested'); + $iterable = new SimpleDto('iterable'); + $data = new DtoOwnerData($nested, [$iterable]); + + $this->assertSame([ + 'nested' => $nested, + 'items' => [$iterable], + ], $data->toArray()); + $this->assertSame($nested, $data->all()['nested']); + } + + /** + * Test a modular collectable without transformation capability remains unchanged. + */ + public function testRetainsNonTransformableCustomDataCollectables(): void + { + $collectable = new SimpleDtoCollectable([new SimpleDto('value')]); + $data = new DtoCollectableOwnerData($collectable); + + $this->assertSame($collectable, $data->toArray()['items']); + } + + /** + * Test collection, parent, and item partials compose across the complete graph. + */ + public function testComposesPartialsAcrossDataCollectionGraphs(): void + { + $collection = new DataCollection(PartialCollectionItemData::class, [ + new PartialCollectionItemData( + new NestedLazyData( + Lazy::create(static fn (): string => 'A'), + Lazy::create(static fn (): string => 'ignored'), + ), + [ + (new NestedLazyData( + Lazy::create(static fn (): string => 'B1'), + Lazy::create(static fn (): string => 'ignored'), + ))->include('temporary'), + new NestedLazyData( + Lazy::create(static fn (): string => 'B2'), + Lazy::create(static fn (): string => 'ignored'), + ), + ], + ), + new PartialCollectionItemData( + new NestedLazyData( + Lazy::create(static fn (): string => 'C'), + Lazy::create(static fn (): string => 'ignored'), + ), + [ + new NestedLazyData( + Lazy::create(static fn (): string => 'D1'), + Lazy::create(static fn (): string => 'ignored'), + ), + (new NestedLazyData( + Lazy::create(static fn (): string => 'ignored'), + Lazy::create(static fn (): string => 'D2'), + ))->include('permanent'), + ], + ), + ]); + $collection->include('nested.temporary'); + $data = new PartialCollectionOwnerData(Lazy::create(static fn (): DataCollection => $collection)); + + $this->assertSame([ + 'collection' => [ + [ + 'nested' => ['temporary' => 'A'], + 'nestedCollection' => [ + ['temporary' => 'B1'], + [], + ], + ], + [ + 'nested' => ['temporary' => 'C'], + 'nestedCollection' => [ + [], + ['permanent' => 'D2'], + ], + ], + ], + ], $data->include('collection')->toArray()); + } + + /** + * Test all propagates parent partials to a returned lazy data collection. + */ + public function testPropagatesPartialsFromAllToLazyDataCollections(): void + { + $collection = new DataCollection(NestedLazyData::class, [ + new NestedLazyData( + Lazy::create(static fn (): string => 'first'), + Lazy::create(static fn (): string => 'ignored'), + ), + new NestedLazyData( + Lazy::create(static fn (): string => 'second'), + Lazy::create(static fn (): string => 'ignored'), + ), + ]); + $data = new NestedCollectionOwnerData(Lazy::create(static fn (): DataCollection => $collection)); + + $returned = $data->include('collection.temporary')->all()['collection']; + + $this->assertSame($collection, $returned); + $this->assertSame([ + ['temporary' => 'first'], + ['temporary' => 'second'], + ], $returned->toArray()); + } + /** * Test nested transformation stops at the configured depth. */ @@ -364,6 +530,149 @@ public function testThrowsAtMaximumTransformationDepth(): void $data->transform(TransformationContextFactory::create()->maxDepth(1)); } + + /** + * Test persistence transforms the complete constructable graph without changing partial stores. + */ + public function testTransformsCompleteConstructableGraphWithoutChangingPartials(): void + { + $nested = (new ConstructableNestedData( + 'nested', + 'nested-secret', + Lazy::create(static fn (): string => 'nested-default'), + )) + ->include('defaultLazy') + ->excludePermanently('secret'); + $item = (new ConstructableNestedData( + 'item', + 'item-secret', + Lazy::create(static fn (): string => 'item-default'), + )) + ->only('name') + ->includePermanently('defaultLazy'); + $items = (new DataCollection(ConstructableNestedData::class, [$item])) + ->only('name') + ->exceptPermanently('secret'); + $data = (new ConstructableGraphData( + 'root', + 'root-secret', + $nested, + $items, + Lazy::create(static fn (): string => 'root-default'), + Lazy::when(static fn (): bool => true, static fn (): string => 'root-conditional'), + )) + ->only('name') + ->exceptPermanently('secret') + ->additional([ + 'name' => 'response-name', + 'responseOnly' => true, + ]); + + $rootPartials = $data->getPartialsDefinition()->resolve($data); + $nestedPartials = $nested->getPartialsDefinition()->resolve($nested); + $collectionPartials = $items->getPartialsDefinition()->resolve($items); + $itemPartials = $item->getPartialsDefinition()->resolve($item); + + $this->assertSame([ + 'name' => 'root', + 'secret' => 'root-secret', + 'nested' => [ + 'name' => 'nested', + 'secret' => 'nested-secret', + 'defaultLazy' => 'nested-default', + ], + 'items' => [[ + 'name' => 'item', + 'secret' => 'item-secret', + 'defaultLazy' => 'item-default', + ]], + 'defaultLazy' => 'root-default', + 'conditionalLazy' => 'root-conditional', + ], $data->transform(TransformationContextFactory::forPersistence())); + $this->assertSame($rootPartials, $data->getPartialsDefinition()->resolve($data)); + $this->assertSame($nestedPartials, $nested->getPartialsDefinition()->resolve($nested)); + $this->assertSame($collectionPartials, $items->getPartialsDefinition()->resolve($items)); + $this->assertSame($itemPartials, $item->getPartialsDefinition()->resolve($item)); + } + + /** + * Test persistence rejects lazy callback values. + */ + public function testPersistenceRejectsLazyCallbackValues(): void + { + $data = new ConstructableLazyData(Lazy::closure(static fn (): string => 'value')); + + $this->expectException(CannotTransformData::class); + $this->expectExceptionMessage('Lazy property [' . ConstructableLazyData::class . '::$value] does not resolve to constructable data.'); + + $data->transform(TransformationContextFactory::forPersistence()); + } + + /** + * Test persistence rejects an excluded conditional value without resolving it. + */ + public function testPersistenceRejectsExcludedConditionalLazyWithoutResolvingIt(): void + { + $calls = 0; + $data = new ConstructableLazyData(Lazy::when( + static fn (): bool => false, + function () use (&$calls): string { + ++$calls; + + return 'value'; + }, + )); + + try { + $data->transform(TransformationContextFactory::forPersistence()); + $this->fail('Expected an excluded conditional lazy value to be rejected.'); + } catch (CannotTransformData $exception) { + $this->assertStringContainsString('does not resolve to constructable data', $exception->getMessage()); + } + + $this->assertSame(0, $calls); + } + + /** + * Test persistence rejects an unloaded relation without reading it. + */ + public function testPersistenceRejectsUnloadedRelationalLazyWithoutReadingIt(): void + { + $model = new ConstructableLazyModel; + $data = new ConstructableLazyData(Lazy::whenLoaded( + 'related', + $model, + static fn (): string => 'value', + )); + + try { + $data->transform(TransformationContextFactory::forPersistence()); + $this->fail('Expected an unloaded relational lazy value to be rejected.'); + } catch (CannotTransformData $exception) { + $this->assertStringContainsString('does not resolve to constructable data', $exception->getMessage()); + } + + $this->assertSame(0, $model->relationReads); + } + + /** + * Test persistence resolves included conditional and loaded relational values. + */ + public function testPersistenceResolvesIncludedConditionalAndLoadedRelationalValues(): void + { + $model = new ConstructableLazyModel; + $model->setRelation('related', 'loaded'); + $data = new ConstructableLazyPairData( + Lazy::when(static fn (): bool => true, static fn (): string => 'conditional'), + Lazy::whenLoaded('related', $model, static fn (): string => 'relational'), + ); + + $this->assertSame([ + 'conditional' => 'conditional', + 'relational' => 'relational', + ], $data->transform(TransformationContextFactory::forPersistence())); + $this->assertSame(1, $model->relationReads); + } } enum Status: string @@ -378,6 +687,62 @@ public function __construct(public string $value) } } +class SimpleDto extends Dto +{ + public function __construct( + #[MapOutputName('mapped_value')] + public string $value, + ) { + } +} + +class DtoOwnerData extends Data +{ + /** + * @param list $items + */ + public function __construct( + public SimpleDto $nested, + #[DataCollectionOf(SimpleDto::class)] + public array $items, + ) { + } +} + +/** @implements BaseDataCollectable */ +class SimpleDtoCollectable implements BaseDataCollectable +{ + /** + * @param list $items + */ + public function __construct(public array $items) + { + } + + /** + * Get the data class stored by the collection. + */ + public function getDataClass(): string + { + return SimpleDto::class; + } + + /** + * Get an iterator for the data items. + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } +} + +class DtoCollectableOwnerData extends Data +{ + public function __construct(public BaseDataCollectable $items) + { + } +} + class TransformingData extends Data { public function __construct( @@ -402,6 +767,113 @@ public function __construct( } } +class AutoLazyTransformData extends Data +{ + public function __construct( + #[AutoLazy] + public Lazy|AutoLazyTransformChildData $child, + #[AutoInertiaLazy] + public Lazy|string $inertia, + #[AutoInertiaDeferred('analytics', rescue: true)] + public Lazy|string $deferred, + ) { + } +} + +class AutoLazyTransformChildData extends Data +{ + public function __construct( + public string $value, + ) { + } + + public static function normalizers(): array + { + return [AutoLazyTransformNormalizer::class]; + } +} + +class AutoLazyTransformNormalizer implements Normalizer +{ + public static int $calls = 0; + + public function normalize(mixed $value): array|Normalized|null + { + ++self::$calls; + + return null; + } +} + +class ConstructableGraphData extends Data +{ + #[Computed] + public string $summary = 'computed'; + + public string $virtual { + get => 'virtual'; + } + + public function __construct( + #[MapOutputName('display_name')] + public string $name, + #[Hidden] + public string $secret, + public ConstructableNestedData $nested, + #[DataCollectionOf(ConstructableNestedData::class)] + public DataCollection $items, + public Lazy|string $defaultLazy, + public Lazy|string $conditionalLazy, + ) { + } +} + +class ConstructableNestedData extends Data +{ + #[Computed] + public string $summary = 'computed'; + + public function __construct( + #[MapOutputName('display_name')] + public string $name, + #[Hidden] + public string $secret, + public Lazy|string $defaultLazy, + ) { + } +} + +class ConstructableLazyData extends Data +{ + public function __construct(public Lazy|string $value) + { + } +} + +class ConstructableLazyPairData extends Data +{ + public function __construct( + public Lazy|string $conditional, + public Lazy|string $relational, + ) { + } +} + +class ConstructableLazyModel extends Model +{ + public int $relationReads = 0; + + /** + * Get a relationship value from the model. + */ + public function getRelationValue(string $key): mixed + { + ++$this->relationReads; + + return parent::getRelationValue($key); + } +} + class ArrayData extends Data { public function __construct(public array $meta) @@ -483,6 +955,37 @@ public function __construct( } } +class PartialCollectionItemData extends Data +{ + /** + * @param list $nestedCollection + */ + public function __construct( + public NestedLazyData $nested, + #[DataCollectionOf(NestedLazyData::class)] + public array $nestedCollection, + ) { + } +} + +class PartialCollectionOwnerData extends Data +{ + public function __construct( + #[DataCollectionOf(PartialCollectionItemData::class)] + public Lazy|DataCollection $collection, + ) { + } +} + +class NestedCollectionOwnerData extends Data +{ + public function __construct( + #[DataCollectionOf(NestedLazyData::class)] + public Lazy|DataCollection $collection, + ) { + } +} + class NestedData extends Data { public function __construct(public Data $nested) diff --git a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php index 6e38fc99f..191991069 100644 --- a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php +++ b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php @@ -5,9 +5,11 @@ namespace Hypervel\Tests\Data\Support\Transformation; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Data\Data; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Support\Partials\PartialDefinition; use Hypervel\Data\Support\Transformation\TransformationContextFactory; +use Hypervel\Data\Support\Wrapping\WrapExecutionType; use Hypervel\Testbench\TestCase; use stdClass; @@ -78,4 +80,38 @@ public function testCreateReturnsFreshFactories(): void $this->assertSame(1, $first->get(new stdClass)->maxDepth); $this->assertNull($second->get(new stdClass)->maxDepth); } + + public function testPersistenceFactoryDerivesACompleteConstructableView(): void + { + $data = (new PersistenceContextData('Taylor', 'secret'))->only('name'); + + $context = TransformationContextFactory::forPersistence() + ->withoutValueTransformation() + ->withPropertyNameMapping() + ->withWrapping() + ->only('name') + ->maxDepth(5) + ->get($data); + + $this->assertTrue($context->transformValues); + $this->assertFalse($context->mapPropertyNames); + $this->assertTrue($context->constructable); + $this->assertTrue($context->include?->all); + $this->assertNull($context->exclude); + $this->assertNull($context->only); + $this->assertNull($context->except); + $this->assertSame(WrapExecutionType::Disabled, $context->wrapExecutionType); + $this->assertNull($context->maxDepth); + $this->assertFalse($data->getPartialsDefinition()->isEmpty()); + $this->assertSame(['name' => 'Taylor'], $data->toArray()); + } +} + +class PersistenceContextData extends Data +{ + public function __construct( + public string $name, + public string $secret, + ) { + } } diff --git a/tests/Data/Support/Transformation/TransformationContextTest.php b/tests/Data/Support/Transformation/TransformationContextTest.php index da97e5246..f87eec879 100644 --- a/tests/Data/Support/Transformation/TransformationContextTest.php +++ b/tests/Data/Support/Transformation/TransformationContextTest.php @@ -7,6 +7,7 @@ use Hypervel\Data\Support\Partials\PartialDefinition; use Hypervel\Data\Support\Transformation\PartialTree; use Hypervel\Data\Support\Transformation\TransformationContext; +use Hypervel\Data\Support\Wrapping\WrapExecutionType; use Hypervel\Tests\TestCase; class TransformationContextTest extends TestCase @@ -63,4 +64,22 @@ public function testChildClearsRootRelativePartialDefinitions(): void $this->assertSame(3, $child->depth); $this->assertSame(5, $child->maxDepth); } + + public function testConstructableViewSurvivesEveryContextCopy(): void + { + $context = new TransformationContext(constructable: true); + + $merged = $context->withMergedPartials([ + 'include' => [new PartialDefinition('nested')], + 'exclude' => [], + 'only' => [], + 'except' => [], + ]); + $wrapped = $merged->withWrapExecutionType(WrapExecutionType::Enabled); + $child = $wrapped->child('nested'); + + $this->assertTrue($merged->constructable); + $this->assertTrue($wrapped->constructable); + $this->assertTrue($child->constructable); + } } diff --git a/tests/Data/Transformers/ArrayableTransformerTest.php b/tests/Data/Transformers/ArrayableTransformerTest.php new file mode 100644 index 000000000..dd57e40f7 --- /dev/null +++ b/tests/Data/Transformers/ArrayableTransformerTest.php @@ -0,0 +1,32 @@ +transform( + m::mock(DataProperty::class), + $value, + new TransformationContext, + ); + + $this->assertSame(['A', 'B'], $result); + $this->assertSame(['A', 'B'], $value->all()); + } +} diff --git a/tests/Data/Transformers/DateTimeInterfaceTransformerTest.php b/tests/Data/Transformers/DateTimeInterfaceTransformerTest.php new file mode 100644 index 000000000..eab91fcd6 --- /dev/null +++ b/tests/Data/Transformers/DateTimeInterfaceTransformerTest.php @@ -0,0 +1,110 @@ +dates() as $date) { + $this->assertSame('1994-05-19T00:00:00+00:00', $this->transform($transformer, $date)); + } + } + + /** + * Test an explicit format overrides the configured format. + */ + public function testTransformsDatesWithAnAlternativeFormat(): void + { + $transformer = new DateTimeInterfaceTransformer(format: 'd-m-Y'); + + foreach ($this->dates() as $date) { + $this->assertSame('19-05-1994', $this->transform($transformer, $date)); + } + } + + /** + * Test dates are transformed in an alternative timezone without mutation. + */ + public function testChangesTheTimezoneWithoutMutatingTheValue(): void + { + $transformer = new DateTimeInterfaceTransformer(setTimeZone: 'Europe/Brussels'); + + foreach ($this->dates() as $date) { + $this->assertSame('1994-05-19T02:00:00+02:00', $this->transform($transformer, $date)); + $this->assertSame('UTC', $date->getTimezone()->getName()); + } + } + + /** + * Test a leading reset marker is omitted from output formatting. + */ + public function testTransformsDatesWithLeadingResetMarker(): void + { + $transformer = new DateTimeInterfaceTransformer(format: '!Y-m-d'); + $date = Carbon::createFromFormat('!Y-m-d', '1994-05-19', new DateTimeZone('UTC')); + + $this->assertSame('1994-05-19', $this->transform($transformer, $date)); + } + + /** + * Transform one date. + */ + protected function transform( + DateTimeInterfaceTransformer $transformer, + DateTimeInterface $date, + ): string { + return $transformer->transform( + m::mock(DataProperty::class), + $date, + new TransformationContext, + ); + } + + /** + * Create supported mutable and immutable date values. + * + * @return list + */ + protected function dates(): array + { + $timeZone = new DateTimeZone('UTC'); + + return [ + new Carbon('1994-05-19 00:00:00', $timeZone), + new CarbonImmutable('1994-05-19 00:00:00', $timeZone), + new DateTime('1994-05-19 00:00:00', $timeZone), + new DateTimeImmutable('1994-05-19 00:00:00', $timeZone), + new HypervelCarbon('1994-05-19 00:00:00', $timeZone), + new HypervelCarbonImmutable('1994-05-19 00:00:00', $timeZone), + ]; + } +} diff --git a/tests/Data/Transformers/EnumTransformerTest.php b/tests/Data/Transformers/EnumTransformerTest.php new file mode 100644 index 000000000..836ff247d --- /dev/null +++ b/tests/Data/Transformers/EnumTransformerTest.php @@ -0,0 +1,37 @@ +assertSame('ready', $transformer->transform($property, StringStatus::Ready, $context)); + $this->assertSame(2, $transformer->transform($property, IntegerStatus::Ready, $context)); + } +} + +enum StringStatus: string +{ + case Ready = 'ready'; +} + +enum IntegerStatus: int +{ + case Ready = 2; +} From 9b1023e51546040d709b09a81046b03ade277681 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:49:30 +0000 Subject: [PATCH 18/35] Add typed Data collections and adapters Complete keyed, lazy, paginated, and cursor-paginated Data collections with precise collect() contracts, source-shape rebuilding, side-effect-free reads, transient lifetimes, and paginator metadata preservation. Integrate transformable Data types with Eloquent JSON casts and Hypervel JSON resources through package-owned adapters. Add per-instance resource wrapping, constructable persistence views, strict morph envelopes, order-insensitive dirty comparison, and focused runtime and PHPStan coverage while removing the database-owned legacy cast. --- src/data/src/Concerns/BaseData.php | 89 ++- src/data/src/Concerns/BaseDataCollectable.php | 10 - .../src/Concerns/EloquentCastableData.php | 20 + .../src/Concerns/PaginatedDataCollectable.php | 57 ++ src/data/src/Concerns/ResponsableData.php | 130 ++++ src/data/src/Contracts/BaseData.php | 78 ++- src/data/src/Contracts/ResponsableData.php | 11 + .../src/CursorPaginatedDataCollection.php | 76 +++ src/data/src/Data.php | 8 +- src/data/src/DataCollection.php | 43 +- .../src/Eloquent/AbstractDataEloquentCast.php | 199 +++++++ .../Eloquent/DataCollectionEloquentCast.php | 145 +++++ src/data/src/Eloquent/DataEloquentCast.php | 97 +++ .../PaginatedCollectionIsAlwaysWrapped.php | 18 + .../RequestQueryStringPartialsResolver.php | 258 ++++++++ .../Http/Resources/DataCollectionResource.php | 87 +++ src/data/src/Http/Resources/DataResource.php | 71 +++ src/data/src/PaginatedDataCollection.php | 76 +++ src/data/src/Resource.php | 8 +- .../src/Eloquent/Casts/AsDataObject.php | 77 --- .../Json/ProvidesResourceWrapper.php | 13 + .../src/Resources/Json/ResourceResponse.php | 4 + src/pagination/src/AbstractPaginator.php | 7 +- tests/Data/CapabilityTest.php | 51 ++ tests/Data/DataCollectionTest.php | 260 ++++++++ .../DataCollectionEloquentCastTest.php | 557 ++++++++++++++++++ tests/Data/Eloquent/DataEloquentCastTest.php | 531 +++++++++++++++++ ...RequestQueryStringPartialsResolverTest.php | 271 +++++++++ tests/Data/Http/ResourceResponseTest.php | 438 ++++++++++++++ .../Data/Support/Creation/DataCollectTest.php | 372 ++++++++++++ .../Database/DatabaseEloquentJsonCastTest.php | 30 - tests/Http/ResourceResponseTest.php | 66 +++ types/Data/Data.php | 271 +++++++++ types/Pagination/Paginator.php | 15 + 34 files changed, 4282 insertions(+), 162 deletions(-) create mode 100644 src/data/src/Concerns/EloquentCastableData.php create mode 100644 src/data/src/Concerns/PaginatedDataCollectable.php create mode 100644 src/data/src/Concerns/ResponsableData.php create mode 100644 src/data/src/CursorPaginatedDataCollection.php create mode 100644 src/data/src/Eloquent/AbstractDataEloquentCast.php create mode 100644 src/data/src/Eloquent/DataCollectionEloquentCast.php create mode 100644 src/data/src/Eloquent/DataEloquentCast.php create mode 100644 src/data/src/Exceptions/PaginatedCollectionIsAlwaysWrapped.php create mode 100644 src/data/src/Http/RequestQueryStringPartialsResolver.php create mode 100644 src/data/src/Http/Resources/DataCollectionResource.php create mode 100644 src/data/src/Http/Resources/DataResource.php create mode 100644 src/data/src/PaginatedDataCollection.php delete mode 100644 src/database/src/Eloquent/Casts/AsDataObject.php create mode 100644 src/http/src/Resources/Json/ProvidesResourceWrapper.php create mode 100644 tests/Data/CapabilityTest.php create mode 100644 tests/Data/DataCollectionTest.php create mode 100644 tests/Data/Eloquent/DataCollectionEloquentCastTest.php create mode 100644 tests/Data/Eloquent/DataEloquentCastTest.php create mode 100644 tests/Data/Http/RequestQueryStringPartialsResolverTest.php create mode 100644 tests/Data/Http/ResourceResponseTest.php create mode 100644 tests/Data/Support/Creation/DataCollectTest.php create mode 100644 tests/Http/ResourceResponseTest.php create mode 100644 types/Data/Data.php diff --git a/src/data/src/Concerns/BaseData.php b/src/data/src/Concerns/BaseData.php index 03d544b12..440fd64f0 100644 --- a/src/data/src/Concerns/BaseData.php +++ b/src/data/src/Concerns/BaseData.php @@ -6,18 +6,27 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; +use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; +use Hypervel\Data\Contracts\BaseData as BaseDataContract; use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\Creation\CreationContextFactory; +use Hypervel\Data\Support\Creation\DataCreator; +use Hypervel\Data\Support\DataConfig; use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; use Hypervel\Pagination\AbstractCursorPaginator; use Hypervel\Pagination\AbstractPaginator; +use Hypervel\Pagination\CursorPaginator; +use Hypervel\Pagination\LengthAwarePaginator; +use Hypervel\Pagination\Paginator; use Hypervel\Support\Collection; use Hypervel\Support\Enumerable; use Hypervel\Support\LazyCollection; +use Traversable; trait BaseData { @@ -50,16 +59,83 @@ public static function from(mixed ...$payloads): static /** * Collect data objects. * + * Contract-typed sources retain every possible rebuildable runtime shape. + * * @template TKey of array-key * @template TValue + * @template TCollectValue of BaseDataContract + * @template TModelValue of Model * - * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|PaginatorContract $items + * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items + * @param null|'array'|class-string $into + * @return ( + * $into is null + * ? ($items is array + * ? array + * : ($items is PaginatedDataCollection<*, *>|CursorPaginatedDataCollection<*, *>|DataCollection<*, *> + * ? ($items is PaginatedDataCollection<*, *> + * ? PaginatedDataCollection + * : ($items is CursorPaginatedDataCollection<*, *> + * ? CursorPaginatedDataCollection + * : DataCollection)) + * : ($items is AbstractPaginator<*, *> + * ? ($items is LengthAwarePaginator<*, *> + * ? LengthAwarePaginator + * : ($items is Paginator<*, *> + * ? Paginator + * : AbstractPaginator)) + * : ($items is AbstractCursorPaginator<*, *> + * ? ($items is CursorPaginator<*, *> + * ? CursorPaginator + * : AbstractCursorPaginator) + * : ($items is Enumerable<*, *> + * ? ($items is EloquentCollection<*, *> + * ? Collection + * : ($items is LazyCollection<*, *> + * ? LazyCollection + * : ($items is Collection<*, *> + * ? Collection + * : never))) + * : never))))) + * : ($into is 'array' + * ? array + * : ($into is 'Hypervel\Support\Enumerable'|'Hypervel\Database\Eloquent\Collection'|'Hypervel\Support\Collection' + * ? Collection + * : ($into is 'Hypervel\Support\LazyCollection' + * ? LazyCollection + * : ($into is 'Hypervel\Data\PaginatedDataCollection'|'Hypervel\Data\CursorPaginatedDataCollection'|'Hypervel\Data\DataCollection' + * ? ($into is 'Hypervel\Data\PaginatedDataCollection' + * ? PaginatedDataCollection + * : ($into is 'Hypervel\Data\CursorPaginatedDataCollection' + * ? CursorPaginatedDataCollection + * : DataCollection)) + * : ($into is 'Hypervel\Pagination\LengthAwarePaginator'|'Hypervel\Pagination\Paginator'|'Hypervel\Pagination\CursorPaginator'|'Hypervel\Pagination\AbstractPaginator'|'Hypervel\Pagination\AbstractCursorPaginator' + * ? ($into is 'Hypervel\Pagination\LengthAwarePaginator' + * ? LengthAwarePaginator + * : ($into is 'Hypervel\Pagination\Paginator' + * ? Paginator + * : ($into is 'Hypervel\Pagination\CursorPaginator' + * ? CursorPaginator + * : ($into is 'Hypervel\Pagination\AbstractPaginator' + * ? AbstractPaginator + * : AbstractCursorPaginator)))) + * : ($into is 'Hypervel\Contracts\Pagination\LengthAwarePaginator'|'Hypervel\Contracts\Pagination\Paginator'|'Hypervel\Contracts\Pagination\CursorPaginator' + * ? ($into is 'Hypervel\Contracts\Pagination\LengthAwarePaginator' + * ? LengthAwarePaginatorContract + * : ($into is 'Hypervel\Contracts\Pagination\Paginator' + * ? PaginatorContract + * : CursorPaginatorContract)) + * : array|CursorPaginatedDataCollection|DataCollection|PaginatedDataCollection|Enumerable|AbstractCursorPaginator|AbstractPaginator|CursorPaginatorContract|LengthAwarePaginatorContract|PaginatorContract))))))) + * ) */ public static function collect(mixed $items, ?string $into = null): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection { return static::factory()->collect($items, $into); } + // REMOVED: Deprecated collection() and Enumerable forwarding; use collect() and toCollection(). + // REMOVED: Factories cannot inherit mutable in-flight creation contexts; every call starts fresh. + /** * Create a fresh data construction factory. * @@ -67,10 +143,13 @@ public static function collect(mixed $items, ?string $into = null): array|DataCo */ public static function factory(): CreationContextFactory { + $container = Container::getInstance(); + /** @var CreationContextFactory $factory */ - $factory = Container::getInstance()->make( - CreationContextFactory::class, - ['dataClass' => static::class], + $factory = new CreationContextFactory( + $container->make(DataCreator::class), + $container->make(DataConfig::class), + static::class, ); return $factory; @@ -84,6 +163,8 @@ public static function normalizers(): array return []; } + // REMOVED: Configurable pipelines and prepareForPipeline(); use named factories and factory hooks. + /** * Create a data object from the current request. */ diff --git a/src/data/src/Concerns/BaseDataCollectable.php b/src/data/src/Concerns/BaseDataCollectable.php index 5fc0edf9b..3c1034d18 100644 --- a/src/data/src/Concerns/BaseDataCollectable.php +++ b/src/data/src/Concerns/BaseDataCollectable.php @@ -6,7 +6,6 @@ use Generator; use Hypervel\Data\Contracts\BaseData; -use Hypervel\Data\Contracts\IncludeableData; use Hypervel\Data\Support\Partials\PartialsDefinition; /** @@ -32,16 +31,7 @@ public function getDataClass(): string */ public function getIterator(): Generator { - $partialDefinitions = $this->getPartialsDefinition(); - $partials = $partialDefinitions->isEmpty() - ? null - : $partialDefinitions->resolve($this, consumeTemporary: true); - foreach ($this->itemsForIteration() as $key => $item) { - if ($partials !== null && $item instanceof IncludeableData) { - $item->getPartialsDefinition()->addResolved($partials); - } - yield $key => $item; } } diff --git a/src/data/src/Concerns/EloquentCastableData.php b/src/data/src/Concerns/EloquentCastableData.php new file mode 100644 index 000000000..be3c81a74 --- /dev/null +++ b/src/data/src/Concerns/EloquentCastableData.php @@ -0,0 +1,20 @@ + */ + use BaseDataCollectable; + + /** + * @param Closure(TValue, TKey): TValue $through + */ + public function through(Closure $through): static + { + $clone = clone $this; + $paginator = clone $clone->items; + $paginator->setCollection(clone $paginator->getCollection()); + $clone->items = $paginator->through($through); + + return $clone; + } + + /** + * Get the number of data items on the current page. + */ + public function count(): int + { + return $this->items->count(); + } + + /** + * Disable wrapping for the collection. + */ + public function withoutWrapping(): static + { + throw PaginatedCollectionIsAlwaysWrapped::create(); + } + + /** + * Get the underlying items without transforming them. + * + * @return iterable + */ + protected function itemsForIteration(): iterable + { + return $this->items->getCollection(); + } +} diff --git a/src/data/src/Concerns/ResponsableData.php b/src/data/src/Concerns/ResponsableData.php new file mode 100644 index 000000000..a2fb05180 --- /dev/null +++ b/src/data/src/Concerns/ResponsableData.php @@ -0,0 +1,130 @@ +make(RequestQueryStringPartialsResolver::class) + ->resolve($data, $request, TransformationContextFactory::create()); + $context = $contextFactory->get($data); + $transformer = $container->make(DataTransformer::class); + $wrapper = $data->getWrap()->getKey( + $container->make(DataConfig::class)->wrap, + ); + + if ($data instanceof BaseDataCollectable) { + $originalItems = $this->responseItems($data); + $transformed = $transformer->transformForResourceResponse( + $data, + $context, + $originalItems, + ); + + return (new DataCollectionResource( + $data, + $originalItems, + $transformed, + $wrapper, + ))->toResponse($request); + } + + return (new DataResource( + $data, + $transformer->transformForResourceResponse($data, $context), + $wrapper, + ))->toResponse($request); + } + + /** + * Get the JSON serialization options for the resource response. + */ + public static function jsonOptions(): int + { + return 0; + } + + /** + * Customize the outgoing resource response. + */ + public function withResponse(Request $request, JsonResponse $response): void + { + } + + /** + * Get the request properties that may be included. + */ + public static function allowedRequestIncludes(): ?array + { + return []; + } + + /** + * Get the request properties that may be excluded. + */ + public static function allowedRequestExcludes(): ?array + { + return []; + } + + /** + * Get the request properties allowed by an only selection. + */ + public static function allowedRequestOnly(): ?array + { + return []; + } + + /** + * Get the request properties allowed by an except selection. + */ + public static function allowedRequestExcept(): ?array + { + return []; + } + + /** + * Get original collection items without transforming or enumerating them twice. + * + * @return Collection + */ + protected function responseItems(BaseDataCollectable $data): Collection + { + if ($data instanceof DataCollection) { + $items = $data->toCollection(); + + return $items instanceof Collection ? $items : $items->collect(); + } + + if ($data instanceof PaginatedDataCollection || $data instanceof CursorPaginatedDataCollection) { + return $data->items()->getCollection(); + } + + return new Collection(iterator_to_array($data)); + } +} diff --git a/src/data/src/Contracts/BaseData.php b/src/data/src/Contracts/BaseData.php index e159543cf..3a9576ab4 100644 --- a/src/data/src/Contracts/BaseData.php +++ b/src/data/src/Contracts/BaseData.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Container\SelfBuilding; use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract; +use Hypervel\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Hypervel\Contracts\Pagination\Paginator as PaginatorContract; use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; @@ -13,19 +14,17 @@ use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\Creation\CreationContextFactory; use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\Pagination\AbstractCursorPaginator; use Hypervel\Pagination\AbstractPaginator; use Hypervel\Pagination\CursorPaginator; +use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Pagination\Paginator; use Hypervel\Support\Collection; use Hypervel\Support\Enumerable; use Hypervel\Support\LazyCollection; +use Traversable; -/** - * @template TData - * @template TValue of mixed - * @template TKey of array-key - */ interface BaseData extends SelfBuilding { /** @@ -41,9 +40,74 @@ public static function from(mixed ...$payloads): static; /** * Collect data objects. * - * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|PaginatorContract $items + * Contract-typed sources retain every possible rebuildable runtime shape. * - * @return ($into is 'array' ? array : ($into is class-string ? Collection : ($into is class-string ? Collection : ($into is class-string ? LazyCollection : ($into is class-string ? DataCollection : ($into is class-string ? PaginatedDataCollection : ($into is class-string ? CursorPaginatedDataCollection : ($items is EloquentCollection ? Collection : ($items is Collection ? Collection : ($items is LazyCollection ? LazyCollection : ($items is Enumerable ? Enumerable : ($items is array ? array : ($items is AbstractPaginator ? AbstractPaginator : ($items is PaginatorContract ? PaginatorContract : ($items is AbstractCursorPaginator ? AbstractCursorPaginator : ($items is CursorPaginatorContract ? CursorPaginatorContract : ($items is DataCollection ? DataCollection : ($items is CursorPaginator ? CursorPaginatedDataCollection : ($items is Paginator ? PaginatedDataCollection : DataCollection))))))))))))))))))) + * @template TKey of array-key + * @template TValue + * @template TCollectValue of BaseData + * @template TModelValue of Model + * + * @param AbstractCursorPaginator|AbstractPaginator|array|Collection|CursorPaginatedDataCollection|CursorPaginatorContract|DataCollection|EloquentCollection|Enumerable|LazyCollection|LengthAwarePaginatorContract|PaginatedDataCollection|PaginatorContract|Traversable $items + * @param null|'array'|class-string $into + * @return ( + * $into is null + * ? ($items is array + * ? array + * : ($items is PaginatedDataCollection<*, *>|CursorPaginatedDataCollection<*, *>|DataCollection<*, *> + * ? ($items is PaginatedDataCollection<*, *> + * ? PaginatedDataCollection + * : ($items is CursorPaginatedDataCollection<*, *> + * ? CursorPaginatedDataCollection + * : DataCollection)) + * : ($items is AbstractPaginator<*, *> + * ? ($items is LengthAwarePaginator<*, *> + * ? LengthAwarePaginator + * : ($items is Paginator<*, *> + * ? Paginator + * : AbstractPaginator)) + * : ($items is AbstractCursorPaginator<*, *> + * ? ($items is CursorPaginator<*, *> + * ? CursorPaginator + * : AbstractCursorPaginator) + * : ($items is Enumerable<*, *> + * ? ($items is EloquentCollection<*, *> + * ? Collection + * : ($items is LazyCollection<*, *> + * ? LazyCollection + * : ($items is Collection<*, *> + * ? Collection + * : never))) + * : never))))) + * : ($into is 'array' + * ? array + * : ($into is 'Hypervel\Support\Enumerable'|'Hypervel\Database\Eloquent\Collection'|'Hypervel\Support\Collection' + * ? Collection + * : ($into is 'Hypervel\Support\LazyCollection' + * ? LazyCollection + * : ($into is 'Hypervel\Data\PaginatedDataCollection'|'Hypervel\Data\CursorPaginatedDataCollection'|'Hypervel\Data\DataCollection' + * ? ($into is 'Hypervel\Data\PaginatedDataCollection' + * ? PaginatedDataCollection + * : ($into is 'Hypervel\Data\CursorPaginatedDataCollection' + * ? CursorPaginatedDataCollection + * : DataCollection)) + * : ($into is 'Hypervel\Pagination\LengthAwarePaginator'|'Hypervel\Pagination\Paginator'|'Hypervel\Pagination\CursorPaginator'|'Hypervel\Pagination\AbstractPaginator'|'Hypervel\Pagination\AbstractCursorPaginator' + * ? ($into is 'Hypervel\Pagination\LengthAwarePaginator' + * ? LengthAwarePaginator + * : ($into is 'Hypervel\Pagination\Paginator' + * ? Paginator + * : ($into is 'Hypervel\Pagination\CursorPaginator' + * ? CursorPaginator + * : ($into is 'Hypervel\Pagination\AbstractPaginator' + * ? AbstractPaginator + * : AbstractCursorPaginator)))) + * : ($into is 'Hypervel\Contracts\Pagination\LengthAwarePaginator'|'Hypervel\Contracts\Pagination\Paginator'|'Hypervel\Contracts\Pagination\CursorPaginator' + * ? ($into is 'Hypervel\Contracts\Pagination\LengthAwarePaginator' + * ? LengthAwarePaginatorContract + * : ($into is 'Hypervel\Contracts\Pagination\Paginator' + * ? PaginatorContract + * : CursorPaginatorContract)) + * : array|CursorPaginatedDataCollection|DataCollection|PaginatedDataCollection|Enumerable|AbstractCursorPaginator|AbstractPaginator|CursorPaginatorContract|LengthAwarePaginatorContract|PaginatorContract))))))) + * ) */ public static function collect(mixed $items, ?string $into = null): array|DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection|Enumerable|AbstractPaginator|PaginatorContract|AbstractCursorPaginator|CursorPaginatorContract|LazyCollection|Collection; diff --git a/src/data/src/Contracts/ResponsableData.php b/src/data/src/Contracts/ResponsableData.php index e681121d8..f73c2614f 100644 --- a/src/data/src/Contracts/ResponsableData.php +++ b/src/data/src/Contracts/ResponsableData.php @@ -5,6 +5,7 @@ namespace Hypervel\Data\Contracts; use Hypervel\Contracts\Support\Responsable; +use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -15,6 +16,16 @@ interface ResponsableData extends Responsable */ public function toResponse(Request $request): Response; + /** + * Get the JSON serialization options for the resource response. + */ + public static function jsonOptions(): int; + + /** + * Customize the outgoing resource response. + */ + public function withResponse(Request $request, JsonResponse $response): void; + /** * Get the request properties that may be included. */ diff --git a/src/data/src/CursorPaginatedDataCollection.php b/src/data/src/CursorPaginatedDataCollection.php new file mode 100644 index 000000000..0fdb5c873 --- /dev/null +++ b/src/data/src/CursorPaginatedDataCollection.php @@ -0,0 +1,76 @@ + + */ +class CursorPaginatedDataCollection implements BaseDataCollectableContract, TransformableDataContract, IncludeableDataContract, ResponsableDataContract, WrappableDataContract, Countable, Transient +{ + use IncludeableDataConcern; + use ResponsableDataConcern; + use TransformableDataConcern; + + /** @use PaginatedDataCollectableConcern */ + use PaginatedDataCollectableConcern, WrappableDataConcern { + PaginatedDataCollectableConcern::withoutWrapping insteadof WrappableDataConcern; + } + + use Macroable; + + /** @var AbstractCursorPaginator */ + protected AbstractCursorPaginator $items; + + /** + * Create a typed cursor-paginated data collection. + * + * @param class-string $dataClass + * @param AbstractCursorPaginator $items + */ + public function __construct( + public readonly string $dataClass, + AbstractCursorPaginator $items, + ) { + $normalized = $this->dataClass::factory()->collectItems($items->getCollection()); + $this->items = (clone $items)->setCollection( + new Collection($normalized->all()), + ); + $this->wrap = new Wrap(WrapType::Defined, 'data'); + } + + /** + * Get the underlying cursor paginator. + * + * @return AbstractCursorPaginator + */ + public function items(): AbstractCursorPaginator + { + return $this->items; + } + + // Persist page items through DataCollection; an item array cannot reconstruct paginator state. +} diff --git a/src/data/src/Data.php b/src/data/src/Data.php index 0a650b29f..edcf2e1c6 100644 --- a/src/data/src/Data.php +++ b/src/data/src/Data.php @@ -4,10 +4,13 @@ namespace Hypervel\Data; +use Hypervel\Contracts\Database\Eloquent\Castable as EloquentCastable; use Hypervel\Data\Concerns\AppendableData as AppendableDataConcern; use Hypervel\Data\Concerns\BaseData as BaseDataConcern; +use Hypervel\Data\Concerns\EloquentCastableData as EloquentCastableDataConcern; use Hypervel\Data\Concerns\EmptyData as EmptyDataConcern; use Hypervel\Data\Concerns\IncludeableData as IncludeableDataConcern; +use Hypervel\Data\Concerns\ResponsableData as ResponsableDataConcern; use Hypervel\Data\Concerns\TransformableData as TransformableDataConcern; use Hypervel\Data\Concerns\ValidateableData as ValidateableDataConcern; use Hypervel\Data\Concerns\WrappableData as WrappableDataConcern; @@ -15,16 +18,19 @@ use Hypervel\Data\Contracts\BaseData as BaseDataContract; use Hypervel\Data\Contracts\EmptyData as EmptyDataContract; use Hypervel\Data\Contracts\IncludeableData as IncludeableDataContract; +use Hypervel\Data\Contracts\ResponsableData as ResponsableDataContract; use Hypervel\Data\Contracts\TransformableData as TransformableDataContract; use Hypervel\Data\Contracts\ValidateableData as ValidateableDataContract; use Hypervel\Data\Contracts\WrappableData as WrappableDataContract; -abstract class Data implements AppendableDataContract, BaseDataContract, EmptyDataContract, IncludeableDataContract, TransformableDataContract, ValidateableDataContract, WrappableDataContract +abstract class Data implements AppendableDataContract, BaseDataContract, EloquentCastable, EmptyDataContract, IncludeableDataContract, ResponsableDataContract, TransformableDataContract, ValidateableDataContract, WrappableDataContract { use AppendableDataConcern; use BaseDataConcern; + use EloquentCastableDataConcern; use EmptyDataConcern; use IncludeableDataConcern; + use ResponsableDataConcern; use TransformableDataConcern; use ValidateableDataConcern; use WrappableDataConcern; diff --git a/src/data/src/DataCollection.php b/src/data/src/DataCollection.php index 470d178ab..f2ade3815 100644 --- a/src/data/src/DataCollection.php +++ b/src/data/src/DataCollection.php @@ -6,21 +6,24 @@ use ArrayAccess; use Countable; +use Hypervel\Contracts\Container\Transient; +use Hypervel\Contracts\Database\Eloquent\Castable as EloquentCastable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; use Hypervel\Contracts\Database\Eloquent\CastsInboundAttributes; use Hypervel\Data\Concerns\BaseDataCollectable as BaseDataCollectableConcern; use Hypervel\Data\Concerns\IncludeableData as IncludeableDataConcern; +use Hypervel\Data\Concerns\ResponsableData as ResponsableDataConcern; use Hypervel\Data\Concerns\TransformableData as TransformableDataConcern; use Hypervel\Data\Concerns\WrappableData as WrappableDataConcern; use Hypervel\Data\Contracts\BaseData as BaseDataContract; use Hypervel\Data\Contracts\BaseDataCollectable as BaseDataCollectableContract; use Hypervel\Data\Contracts\IncludeableData as IncludeableDataContract; +use Hypervel\Data\Contracts\ResponsableData as ResponsableDataContract; use Hypervel\Data\Contracts\TransformableData as TransformableDataContract; use Hypervel\Data\Contracts\WrappableData as WrappableDataContract; use Hypervel\Data\Eloquent\DataCollectionEloquentCast; use Hypervel\Data\Exceptions\CannotCastData; use Hypervel\Data\Exceptions\InvalidDataCollectionOperation; -use Hypervel\Support\Collection; use Hypervel\Support\Enumerable; use Hypervel\Support\Traits\Macroable; @@ -31,11 +34,13 @@ * @implements ArrayAccess * @implements BaseDataCollectableContract */ -class DataCollection implements BaseDataCollectableContract, TransformableDataContract, IncludeableDataContract, WrappableDataContract, Countable, ArrayAccess +class DataCollection implements BaseDataCollectableContract, TransformableDataContract, IncludeableDataContract, ResponsableDataContract, WrappableDataContract, EloquentCastable, Countable, ArrayAccess, Transient { /** @use BaseDataCollectableConcern */ use BaseDataCollectableConcern; + use IncludeableDataConcern; + use ResponsableDataConcern; use TransformableDataConcern; use WrappableDataConcern; use Macroable; @@ -47,26 +52,17 @@ class DataCollection implements BaseDataCollectableContract, TransformableDataCo * Create a typed data collection. * * @param class-string $dataClass - * @param array|Enumerable|DataCollection|null $items + * @param null|array|DataCollection|Enumerable $items */ public function __construct( public readonly string $dataClass, Enumerable|array|DataCollection|null $items, ) { - if (is_array($items) || $items === null) { - $items = new Collection($items); - } - if ($items instanceof DataCollection) { $items = $items->toCollection(); } - $factory = $this->dataClass::factory(); - $this->items = $items->map( - fn (mixed $item): BaseDataContract => $item instanceof $this->dataClass - ? $item - : $factory->from($item), - ); + $this->items = $this->dataClass::factory()->collectItems($items); } /** @@ -95,8 +91,6 @@ public function count(): int /** * @param TKey $offset - * - * @return bool */ public function offsetExists(mixed $offset): bool { @@ -118,23 +112,12 @@ public function offsetGet(mixed $offset): mixed throw InvalidDataCollectionOperation::create(); } - $data = $this->items->offsetGet($offset); - $partialDefinitions = $this->getPartialsDefinition(); - - if ($data instanceof IncludeableDataContract && ! $partialDefinitions->isEmpty()) { - $data->getPartialsDefinition()->addResolved( - $partialDefinitions->resolve($this, consumeTemporary: true), - ); - } - - return $data; + return $this->items->offsetGet($offset); } /** - * @param TKey|null $offset + * @param null|TKey $offset * @param TValue $value - * - * @return void */ public function offsetSet(mixed $offset, mixed $value): void { @@ -144,15 +127,13 @@ public function offsetSet(mixed $offset, mixed $value): void $value = $value instanceof $this->dataClass ? $value - : $this->dataClass::from($value); + : $this->dataClass::factory()->from($value); $this->items->offsetSet($offset, $value); } /** * @param TKey $offset - * - * @return void */ public function offsetUnset(mixed $offset): void { diff --git a/src/data/src/Eloquent/AbstractDataEloquentCast.php b/src/data/src/Eloquent/AbstractDataEloquentCast.php new file mode 100644 index 000000000..db393bf94 --- /dev/null +++ b/src/data/src/Eloquent/AbstractDataEloquentCast.php @@ -0,0 +1,199 @@ + $dataClass + * @param list $arguments + */ + public function __construct( + protected readonly string $dataClass, + protected readonly array $arguments = [], + ) { + $container = Container::getInstance(); + $this->dataConfig = $container->make(DataConfig::class); + $this->dataClasses = $container->make(DataClassRepository::class); + + if (! $this->dataClasses->get($this->dataClass)->transformable) { + throw CannotCastData::dataClassMustBeTransformable($this->dataClass); + } + } + + /** + * Compare two stored data representations. + */ + public function compare(Model $model, string $key, mixed $firstValue, mixed $secondValue): bool + { + if ($this->isEncrypted() && Crypt::getPreviousKeys() !== []) { + return false; + } + + $firstPayload = $this->decode($model, $key, $firstValue); + $secondPayload = $this->decode($model, $key, $secondValue); + + if ($firstPayload === null || $secondPayload === null) { + return $firstPayload === $secondPayload; + } + + return $this->payloadsAreEquivalent($firstPayload, $secondPayload); + } + + /** + * Determine if the stored value uses an abstract-class envelope. + */ + protected function isAbstractClassCast(): bool + { + $dataClass = $this->dataClasses->get($this->dataClass); + + return $dataClass->isAbstract && ! $dataClass->propertyMorphable; + } + + /** + * Resolve data from a strict abstract-class envelope. + * + * @param array $payload + * @return TData + */ + protected function resolveMorphedData(Model $model, string $key, array $payload): BaseData + { + $alias = $payload['type'] ?? null; + $data = $payload['data'] ?? null; + + if (! is_string($alias) || ! is_array($data)) { + throw CannotCastData::invalidMorphEnvelope($model::class, $key); + } + + $dataClass = $this->dataConfig->getMorphedDataClass($alias); + + if ($dataClass === null) { + throw CannotCastData::unknownMorphAlias($alias, $this->dataClass); + } + + $metadata = $this->dataClasses->get($dataClass); + + if (! is_a($dataClass, $this->dataClass, true) + || $metadata->isAbstract + || ! $metadata->transformable + ) { + throw CannotCastData::invalidMorphClass($dataClass, $this->dataClass); + } + + /** @var TData */ + return $dataClass::from($data); + } + + /** + * Wrap transformed data in its enforced abstract-class envelope. + * + * @param TData $data + * @param array $payload + * @return array{type: string, data: array} + */ + protected function createMorphEnvelope(BaseData&TransformableData $data, array $payload): array + { + $alias = $this->dataConfig->getDataClassAlias($data::class); + + if ($alias === null) { + throw CannotCastData::morphAliasRequired($data::class); + } + + return [ + 'type' => $alias, + 'data' => $payload, + ]; + } + + /** + * Determine if the stored value is encrypted. + */ + protected function isEncrypted(): bool + { + return in_array('encrypted', $this->arguments, true); + } + + /** + * Decode one stored representation through Eloquent's JSON codec. + * + * @return null|array + */ + protected function decode(Model $model, string $key, mixed $value): ?array + { + if (is_string($value) && $this->isEncrypted()) { + $value = Crypt::decryptString($value); + } + + if ($value === null && in_array('default', $this->arguments, true)) { + $value = static::DEFAULT_STORED_VALUE; + } + + if ($value === null) { + return null; + } + + $payload = Json::decode($value); + + if (! is_array($payload)) { + throw CannotCastData::invalidStoredValue($model::class, $key); + } + + return $payload; + } + + /** + * Determine if two decoded payloads contain the same values. + */ + private function payloadsAreEquivalent(array $firstPayload, array $secondPayload): bool + { + if (count($firstPayload) !== count($secondPayload)) { + return false; + } + + // JSON object columns may normalize key order, so array identity is not meaningful here. + foreach ($firstPayload as $key => $value) { + if (! array_key_exists($key, $secondPayload)) { + return false; + } + + $other = $secondPayload[$key]; + + if (is_array($value) && is_array($other)) { + if (! $this->payloadsAreEquivalent($value, $other)) { + return false; + } + + continue; + } + + if ($value !== $other) { + return false; + } + } + + return true; + } +} diff --git a/src/data/src/Eloquent/DataCollectionEloquentCast.php b/src/data/src/Eloquent/DataCollectionEloquentCast.php new file mode 100644 index 000000000..f85e35bd6 --- /dev/null +++ b/src/data/src/Eloquent/DataCollectionEloquentCast.php @@ -0,0 +1,145 @@ + + * + * @extends AbstractDataEloquentCast + * @implements CastsAttributes|TData>|TDataCollection> + */ +class DataCollectionEloquentCast extends AbstractDataEloquentCast implements CastsAttributes +{ + protected const string DEFAULT_STORED_VALUE = '[]'; + + /** + * Create a data collection Eloquent cast. + * + * @param class-string $dataClass + * @param class-string $dataCollectionClass + * @param list $arguments + */ + public function __construct( + string $dataClass, + protected readonly string $dataCollectionClass = DataCollection::class, + array $arguments = [], + ) { + parent::__construct($dataClass, $arguments); + } + + /** + * Transform the stored attribute into a data collection. + * + * @param array $attributes + * @return null|TDataCollection + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?DataCollection + { + $payload = $this->decode($model, $key, $value); + + if ($payload === null) { + return null; + } + + $isAbstractClassCast = $this->isAbstractClassCast(); + + if (! $isAbstractClassCast) { + foreach ($payload as $itemKey => $item) { + if (! is_array($item)) { + throw CannotCastData::invalidStoredCollectionItem($model::class, $key, $itemKey); + } + } + + /** @var TDataCollection */ + return new ($this->dataCollectionClass)($this->dataClass, $payload); + } + + $items = []; + + foreach ($payload as $itemKey => $item) { + if (! is_array($item)) { + throw CannotCastData::invalidStoredCollectionItem($model::class, $key, $itemKey); + } + + $items[$itemKey] = $this->resolveMorphedData($model, $key, $item); + } + + /** @var TDataCollection */ + return new ($this->dataCollectionClass)($this->dataClass, $items); + } + + /** + * Transform a data collection into its stored representation. + * + * @param null|array|TData>|TDataCollection $value + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + if ($value instanceof DataCollection) { + $value = $value->items(); + } + + if (! is_array($value)) { + throw CannotCastData::shouldBeArray($model::class, $key); + } + + $payload = []; + $isAbstractClassCast = $this->isAbstractClassCast(); + + foreach ($value as $itemKey => $item) { + if (is_array($item) && ! $isAbstractClassCast) { + $item = ($this->dataClass)::from($item); + } + + if (! $item instanceof BaseData) { + throw CannotCastData::shouldBeData($model::class, $key); + } + + if (! $item instanceof TransformableData) { + throw CannotCastData::shouldBeTransformableData($model::class, $key); + } + + if (! $item instanceof $this->dataClass) { + throw CannotCastData::shouldBeDataClass($model::class, $key, $this->dataClass); + } + + $itemPayload = $item->transform(TransformationContextFactory::forPersistence()); + $payload[$itemKey] = $isAbstractClassCast + ? $this->createMorphEnvelope($item, $itemPayload) + : $itemPayload; + } + + $encoded = Json::encode($payload); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + if ($this->isEncrypted()) { + /** @var string $encoded */ + return Crypt::encryptString($encoded); + } + + /** @var string */ + return $encoded; + } +} diff --git a/src/data/src/Eloquent/DataEloquentCast.php b/src/data/src/Eloquent/DataEloquentCast.php new file mode 100644 index 000000000..9844fd259 --- /dev/null +++ b/src/data/src/Eloquent/DataEloquentCast.php @@ -0,0 +1,97 @@ + + * @implements CastsAttributes + */ +class DataEloquentCast extends AbstractDataEloquentCast implements CastsAttributes +{ + /** + * Transform the stored attribute into data. + * + * @param array $attributes + * @return null|TData + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?BaseData + { + $payload = $this->decode($model, $key, $value); + + if ($payload === null) { + return null; + } + + if ($this->isAbstractClassCast()) { + return $this->resolveMorphedData($model, $key, $payload); + } + + /** @var TData */ + return ($this->dataClass)::from($payload); + } + + /** + * Transform data into its stored representation. + * + * @param null|array|TData $value + * @param array $attributes + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + $isAbstractClassCast = $this->isAbstractClassCast(); + + if (is_array($value) && ! $isAbstractClassCast) { + $value = ($this->dataClass)::from($value); + } + + if (! $value instanceof BaseData) { + throw CannotCastData::shouldBeData($model::class, $key); + } + + if (! $value instanceof TransformableData) { + throw CannotCastData::shouldBeTransformableData($model::class, $key); + } + + if (! $value instanceof $this->dataClass) { + throw CannotCastData::shouldBeDataClass($model::class, $key, $this->dataClass); + } + + $payload = $value->transform(TransformationContextFactory::forPersistence()); + + if ($isAbstractClassCast) { + $payload = $this->createMorphEnvelope($value, $payload); + } + + $encoded = Json::encode($payload); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + if ($this->isEncrypted()) { + /** @var string $encoded */ + return Crypt::encryptString($encoded); + } + + /** @var string */ + return $encoded; + } +} diff --git a/src/data/src/Exceptions/PaginatedCollectionIsAlwaysWrapped.php b/src/data/src/Exceptions/PaginatedCollectionIsAlwaysWrapped.php new file mode 100644 index 000000000..7eda8819e --- /dev/null +++ b/src/data/src/Exceptions/PaginatedCollectionIsAlwaysWrapped.php @@ -0,0 +1,18 @@ + */ + private const array PARTIAL_TYPES = ['include', 'exclude', 'only', 'except']; + + /** + * Create a request query string partials resolver. + */ + public function __construct( + protected readonly DataClassRepository $dataClasses, + ) { + } + + /** + * Apply allowed request partials to a transformation context factory. + * + * @param (BaseData&ResponsableData)|(BaseDataCollectable&ResponsableData) $data + */ + public function resolve( + (BaseData&ResponsableData)|(BaseDataCollectable&ResponsableData) $data, + Request $request, + TransformationContextFactory $contextFactory, + ): TransformationContextFactory { + $dataClass = $this->dataClasses->get(match (true) { + $data instanceof BaseData => $data::class, + default => $data->getDataClass(), + }); + $allowedPartials = []; + + foreach (self::PARTIAL_TYPES as $type) { + if (! $request->has($type)) { + continue; + } + + $paths = $this->resolvePaths( + $request->input($type), + $type, + $dataClass, + $allowedPartials, + ); + + if ($paths === []) { + continue; + } + + match ($type) { + 'include' => $contextFactory->include(...$paths), + 'exclude' => $contextFactory->exclude(...$paths), + 'only' => $contextFactory->only(...$paths), + 'except' => $contextFactory->except(...$paths), + }; + } + + return $contextFactory; + } + + /** + * Resolve valid property-name paths from one request value. + * + * @param 'except'|'exclude'|'include'|'only' $type + * @param array $allowedPartials + * @param-out array $allowedPartials + * @return list + */ + protected function resolvePaths( + mixed $value, + string $type, + DataClass $dataClass, + array &$allowedPartials, + ): array { + if (! is_string($value) && ! is_array($value)) { + return []; + } + + $values = is_string($value) ? explode(',', $value) : $value; + $paths = []; + + foreach ($values as $path) { + if (! is_string($path)) { + continue; + } + + try { + $partial = PartialTree::compile([$path]); + } catch (CannotPerformPartialOnDataField) { + continue; + } + + if ($partial === null) { + continue; + } + + foreach ($this->validateTree($partial, $type, $dataClass, $allowedPartials) as $validPath) { + $paths[$validPath] = true; + } + } + + return array_keys($paths); + } + + /** + * Validate a compiled partial tree against one data class. + * + * @param 'except'|'exclude'|'include'|'only' $type + * @param array $allowedPartials + * @param-out array $allowedPartials + * @return list + */ + protected function validateTree( + PartialTree $partial, + string $type, + DataClass $dataClass, + array &$allowedPartials, + ): array { + $allowed = $this->allowedPartials($type, $dataClass, $allowedPartials); + + if ($partial->all) { + return $this->allowsAll($allowed) ? ['*'] : []; + } + + $paths = []; + + foreach ($partial->children as $field => $nestedPartial) { + $property = $this->findProperty($field, $dataClass); + + if ($property === null || ! $this->allows($allowed, $property->name)) { + continue; + } + + if ($nestedPartial->selected) { + $paths[$property->name] = true; + } + + if (! $nestedPartial->all && $nestedPartial->children === []) { + continue; + } + + $nestedDataClass = $this->nestedDataClass($property); + + if ($nestedDataClass === null) { + continue; + } + + $nestedPaths = $this->validateTree( + $nestedPartial, + $type, + $nestedDataClass, + $allowedPartials, + ); + + if ($nestedPaths === []) { + $paths[$property->name] = true; + + continue; + } + + foreach ($nestedPaths as $nestedPath) { + $path = $property->name . '.' . $nestedPath; + $paths[$path] = true; + } + } + + return array_keys($paths); + } + + /** + * Find a data property by its PHP or mapped output name. + */ + protected function findProperty( + string $field, + DataClass $dataClass, + ): ?DataProperty { + if (isset($dataClass->properties[$field])) { + return $dataClass->properties[$field]; + } + + $property = $dataClass->outputMappedProperties[$field] ?? null; + + return $property === null ? null : $dataClass->properties[$property]; + } + + /** + * Get the one nested data class represented by a property. + */ + protected function nestedDataClass(DataProperty $property): ?DataClass + { + $type = $property->type->getDataObjectType() + ?? $property->type->getDataCollectableType(); + + return $type === null ? null : $this->dataClasses->get($type->dataClass); + } + + /** + * Resolve one class-owned allowlist once for this response. + * + * @param 'except'|'exclude'|'include'|'only' $type + * @param array $allowedPartials + * @param-out array $allowedPartials + */ + protected function allowedPartials( + string $type, + DataClass $dataClass, + array &$allowedPartials, + ): ?array { + $key = $type . ':' . $dataClass->name; + + if (array_key_exists($key, $allowedPartials)) { + return $allowedPartials[$key]; + } + + if (! $dataClass->responsable) { + return $allowedPartials[$key] = []; + } + + /** @var class-string $class */ + $class = $dataClass->name; + + return $allowedPartials[$key] = match ($type) { + 'include' => $class::allowedRequestIncludes(), + 'exclude' => $class::allowedRequestExcludes(), + 'only' => $class::allowedRequestOnly(), + 'except' => $class::allowedRequestExcept(), + }; + } + + /** + * Determine whether an allowlist permits every property. + */ + protected function allowsAll(?array $allowed): bool + { + return $allowed === null || $allowed === ['*']; + } + + /** + * Determine whether an allowlist permits one property. + */ + protected function allows(?array $allowed, string $property): bool + { + return $this->allowsAll($allowed) || in_array($property, $allowed, true); + } +} diff --git a/src/data/src/Http/Resources/DataCollectionResource.php b/src/data/src/Http/Resources/DataCollectionResource.php new file mode 100644 index 000000000..73cd389ec --- /dev/null +++ b/src/data/src/Http/Resources/DataCollectionResource.php @@ -0,0 +1,87 @@ + $originalItems + * @param array $transformed + */ + public function __construct( + protected readonly BaseDataCollectable $data, + protected readonly Collection $originalItems, + protected readonly array $transformed, + protected readonly ?string $wrapper, + ) { + $resource = match (true) { + $data instanceof PaginatedDataCollection, + $data instanceof CursorPaginatedDataCollection => (clone $data->items()) + ->setCollection(new Collection($transformed)), + default => $transformed, + }; + + parent::__construct($resource); + } + + /** + * Resolve the pre-transformed collection payload. + */ + public function resolve(?Request $request = null): array + { + return $this->transformed; + } + + /** + * Get the per-instance response wrapper. + */ + public function resourceWrapper(): ?string + { + return $this->wrapper; + } + + /** + * Get the JSON serialization options for the resource response. + */ + public function jsonOptions(): int + { + $dataClass = $this->data->getDataClass(); + + return is_a($dataClass, ResponsableData::class, true) + ? $dataClass::jsonOptions() + : 0; + } + + /** + * Customize the outgoing resource response. + */ + public function withResponse(Request $request, JsonResponse $response): void + { + // Collections have no item-owned response hook, so retain the original Data objects. + $response->original = $this->originalItems; + } + + /** + * Disable per-item JSON resource inference for pre-transformed values. + */ + protected function collects(): ?string + { + return null; + } +} diff --git a/src/data/src/Http/Resources/DataResource.php b/src/data/src/Http/Resources/DataResource.php new file mode 100644 index 000000000..60394735c --- /dev/null +++ b/src/data/src/Http/Resources/DataResource.php @@ -0,0 +1,71 @@ + $transformed + */ + public function __construct( + protected readonly BaseData&AppendableData&ResponsableData $data, + protected readonly array $transformed, + protected readonly ?string $wrapper, + ) { + parent::__construct($data); + } + + /** + * Resolve the pre-transformed resource payload. + */ + public function resolve(?Request $request = null): array + { + return $this->transformed; + } + + /** + * Get the resolved top-level response data. + */ + public function with(Request $request): array + { + return $this->data->getAdditionalData(); + } + + /** + * Get the per-instance response wrapper. + */ + public function resourceWrapper(): ?string + { + return $this->wrapper; + } + + /** + * Get the JSON serialization options for the resource response. + */ + public function jsonOptions(): int + { + return $this->data::jsonOptions(); + } + + /** + * Customize the outgoing resource response. + */ + public function withResponse(Request $request, JsonResponse $response): void + { + $this->data->withResponse($request, $response); + } +} diff --git a/src/data/src/PaginatedDataCollection.php b/src/data/src/PaginatedDataCollection.php new file mode 100644 index 000000000..0006c8b9e --- /dev/null +++ b/src/data/src/PaginatedDataCollection.php @@ -0,0 +1,76 @@ + + */ +class PaginatedDataCollection implements BaseDataCollectableContract, TransformableDataContract, IncludeableDataContract, ResponsableDataContract, WrappableDataContract, Countable, Transient +{ + use IncludeableDataConcern; + use ResponsableDataConcern; + use TransformableDataConcern; + + /** @use PaginatedDataCollectableConcern */ + use PaginatedDataCollectableConcern, WrappableDataConcern { + PaginatedDataCollectableConcern::withoutWrapping insteadof WrappableDataConcern; + } + + use Macroable; + + /** @var AbstractPaginator */ + protected AbstractPaginator $items; + + /** + * Create a typed paginated data collection. + * + * @param class-string $dataClass + * @param AbstractPaginator $items + */ + public function __construct( + public readonly string $dataClass, + AbstractPaginator $items, + ) { + $normalized = $this->dataClass::factory()->collectItems($items->getCollection()); + $this->items = (clone $items)->setCollection( + new Collection($normalized->all()), + ); + $this->wrap = new Wrap(WrapType::Defined, 'data'); + } + + /** + * Get the underlying paginator. + * + * @return AbstractPaginator + */ + public function items(): AbstractPaginator + { + return $this->items; + } + + // Persist page items through DataCollection; an item array cannot reconstruct paginator state. +} diff --git a/src/data/src/Resource.php b/src/data/src/Resource.php index 3c1480e27..2ffcbcd4f 100644 --- a/src/data/src/Resource.php +++ b/src/data/src/Resource.php @@ -4,25 +4,31 @@ namespace Hypervel\Data; +use Hypervel\Contracts\Database\Eloquent\Castable as EloquentCastable; use Hypervel\Data\Concerns\AppendableData as AppendableDataConcern; use Hypervel\Data\Concerns\BaseData as BaseDataConcern; +use Hypervel\Data\Concerns\EloquentCastableData as EloquentCastableDataConcern; use Hypervel\Data\Concerns\EmptyData as EmptyDataConcern; use Hypervel\Data\Concerns\IncludeableData as IncludeableDataConcern; +use Hypervel\Data\Concerns\ResponsableData as ResponsableDataConcern; use Hypervel\Data\Concerns\TransformableData as TransformableDataConcern; use Hypervel\Data\Concerns\WrappableData as WrappableDataConcern; use Hypervel\Data\Contracts\AppendableData as AppendableDataContract; use Hypervel\Data\Contracts\BaseData as BaseDataContract; use Hypervel\Data\Contracts\EmptyData as EmptyDataContract; use Hypervel\Data\Contracts\IncludeableData as IncludeableDataContract; +use Hypervel\Data\Contracts\ResponsableData as ResponsableDataContract; use Hypervel\Data\Contracts\TransformableData as TransformableDataContract; use Hypervel\Data\Contracts\WrappableData as WrappableDataContract; -abstract class Resource implements AppendableDataContract, BaseDataContract, EmptyDataContract, IncludeableDataContract, TransformableDataContract, WrappableDataContract +abstract class Resource implements AppendableDataContract, BaseDataContract, EloquentCastable, EmptyDataContract, IncludeableDataContract, ResponsableDataContract, TransformableDataContract, WrappableDataContract { use AppendableDataConcern; use BaseDataConcern; + use EloquentCastableDataConcern; use EmptyDataConcern; use IncludeableDataConcern; + use ResponsableDataConcern; use TransformableDataConcern; use WrappableDataConcern; } diff --git a/src/database/src/Eloquent/Casts/AsDataObject.php b/src/database/src/Eloquent/Casts/AsDataObject.php deleted file mode 100644 index 5a5e263b8..000000000 --- a/src/database/src/Eloquent/Casts/AsDataObject.php +++ /dev/null @@ -1,77 +0,0 @@ -argument, DataObject::class)) { - throw new InvalidArgumentException(sprintf( - 'The given class %s is not a subclass of %s.', - $this->argument, - DataObject::class - )); - } - } - - /** - * Cast the given value. - * - * @param array $attributes - */ - public function get( - Model $model, - string $key, - mixed $value, - array $attributes, - ): ?DataObject { - $data = Json::decode((string) $value); - - if (! is_array($data)) { - return null; - } - - return call_user_func_array( - [$this->argument, 'make'], - [$data, true] - ); - } - - /** - * Prepare the given value for storage. - * - * @param array $attributes - */ - public function set( - Model $model, - string $key, - mixed $value, - array $attributes, - ): array { - $encoded = Json::encode($value); - - if ($encoded === false) { - throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); - } - - return [$key => $encoded]; - } - - /** - * Specify a custom caster class for the data object. - */ - public static function castUsing(string $class): string - { - return sprintf('%s:%s', static::class, $class); - } -} diff --git a/src/http/src/Resources/Json/ProvidesResourceWrapper.php b/src/http/src/Resources/Json/ProvidesResourceWrapper.php new file mode 100644 index 000000000..8acea83f3 --- /dev/null +++ b/src/http/src/Resources/Json/ProvidesResourceWrapper.php @@ -0,0 +1,13 @@ +resource instanceof ProvidesResourceWrapper) { + return $this->resource->resourceWrapper(); + } + /** @var class-string $class */ $class = get_class($this->resource); diff --git a/src/pagination/src/AbstractPaginator.php b/src/pagination/src/AbstractPaginator.php index 1fa1a1a62..744c2e4c1 100644 --- a/src/pagination/src/AbstractPaginator.php +++ b/src/pagination/src/AbstractPaginator.php @@ -638,8 +638,13 @@ public function getCollection(): Collection /** * Set the paginator's underlying collection. * - * @param Collection $collection + * @template TSetKey of array-key + * @template TSetValue + * + * @param Collection $collection * @return $this + * + * @phpstan-this-out static */ public function setCollection(Collection $collection): static { diff --git a/tests/Data/CapabilityTest.php b/tests/Data/CapabilityTest.php new file mode 100644 index 000000000..ba817d70e --- /dev/null +++ b/tests/Data/CapabilityTest.php @@ -0,0 +1,51 @@ +assertTrue(is_a(Data::class, TransformableData::class, true)); + $this->assertTrue(is_a(Data::class, Castable::class, true)); + $this->assertTrue(is_a(Data::class, ResponsableData::class, true)); + $this->assertTrue(is_a(Resource::class, TransformableData::class, true)); + $this->assertTrue(is_a(Resource::class, Castable::class, true)); + $this->assertTrue(is_a(Resource::class, ResponsableData::class, true)); + $this->assertFalse(is_a(Dto::class, TransformableData::class, true)); + $this->assertFalse(is_a(Dto::class, Castable::class, true)); + $this->assertFalse(is_a(Dto::class, ResponsableData::class, true)); + } + + /** + * Test paginator wrappers remain transformable without claiming persistence support. + */ + public function testCollectionCapabilities(): void + { + $this->assertTrue(is_a(DataCollection::class, TransformableData::class, true)); + $this->assertTrue(is_a(DataCollection::class, Castable::class, true)); + $this->assertTrue(is_a(DataCollection::class, ResponsableData::class, true)); + $this->assertTrue(is_a(PaginatedDataCollection::class, TransformableData::class, true)); + $this->assertFalse(is_a(PaginatedDataCollection::class, Castable::class, true)); + $this->assertTrue(is_a(PaginatedDataCollection::class, ResponsableData::class, true)); + $this->assertTrue(is_a(CursorPaginatedDataCollection::class, TransformableData::class, true)); + $this->assertFalse(is_a(CursorPaginatedDataCollection::class, Castable::class, true)); + $this->assertTrue(is_a(CursorPaginatedDataCollection::class, ResponsableData::class, true)); + } +} diff --git a/tests/Data/DataCollectionTest.php b/tests/Data/DataCollectionTest.php new file mode 100644 index 000000000..21763763a --- /dev/null +++ b/tests/Data/DataCollectionTest.php @@ -0,0 +1,260 @@ +assertSame(1, CollectionNormalizerData::$normalizerCalls); + $this->assertSame([1, 2], array_column($normalized->items(), 'id')); + $this->assertSame([3, 4], array_column($named->items(), 'id')); + } + + public function testConstructorDefersLazyItemsAndSharesTheirOperationMemo(): void + { + CollectionNormalizerData::$normalizerCalls = 0; + $evaluated = false; + $source = LazyCollection::make(function () use (&$evaluated): iterable { + $evaluated = true; + + yield 'first' => '1'; + yield 'second' => '2'; + }); + + $collection = new DataCollection(CollectionNormalizerData::class, $source); + + $this->assertFalse($evaluated); + $this->assertSame(0, CollectionNormalizerData::$normalizerCalls); + $this->assertSame(1, $collection->toCollection()->first()->id); + $this->assertTrue($evaluated); + $this->assertSame(1, CollectionNormalizerData::$normalizerCalls); + $this->assertSame(2, $collection->toCollection()->last()->id); + $this->assertSame(1, CollectionNormalizerData::$normalizerCalls); + } + + public function testConstructorAndOffsetSetBypassAnOverriddenPublicFromMethod(): void + { + $collection = new DataCollection(CollectionOverriddenFromData::class, [ + 'first' => ['id' => '1'], + ]); + + $collection['second'] = ['id' => '2']; + + $this->assertSame(1, $collection['first']->id); + $this->assertSame(2, $collection['second']->id); + } + + public function testKeyedAndIteratorReadsDoNotConsumeOrPropagatePartials(): void + { + $first = new CollectionPartialData(1, 'first'); + $second = new CollectionPartialData(2, 'second'); + $collection = new DataCollection(CollectionPartialData::class, [$first, $second]); + $collection->include('name'); + + $this->assertSame($first, $collection[0]); + + foreach ($collection as $item) { + $this->assertTrue(in_array($item, [$first, $second], true)); + } + + $this->assertTrue($first->getPartialsDefinition()->isEmpty()); + $this->assertTrue($second->getPartialsDefinition()->isEmpty()); + $this->assertFalse($collection->getPartialsDefinition()->isEmpty()); + } + + public function testPaginatedCollectionOwnsItsPaginatorAndThroughReturnsAnIndependentClone(): void + { + $source = new Paginator( + [['id' => '1', 'name' => 'first']], + 15, + 2, + ['path' => '/items'], + ); + $collection = new PaginatedDataCollection(CollectionPartialData::class, $source); + $mapped = $collection->through( + static fn (CollectionPartialData $data): CollectionPartialData => new CollectionPartialData( + $data->id + 10, + strtoupper($data->name), + ), + ); + + $this->assertSame([['id' => '1', 'name' => 'first']], $source->items()); + $this->assertNotSame($source, $collection->items()); + $this->assertNotSame($collection->items(), $mapped->items()); + $this->assertSame(1, $collection->items()->items()[0]->id); + $this->assertSame(11, $mapped->items()->items()[0]->id); + $this->assertSame(2, $mapped->items()->currentPage()); + $this->assertSame('/items', $mapped->items()->path()); + $this->assertSame([['id' => 1, 'name' => 'first']], $collection->toArray()['data']); + $this->assertSame(2, $collection->toArray()['current_page']); + $this->assertSame('/items', $collection->toArray()['path']); + $this->assertCount(1, $collection); + + $this->expectException(PaginatedCollectionIsAlwaysWrapped::class); + + $collection->withoutWrapping(); + } + + public function testCursorPaginatedCollectionOwnsAndTransformsItsPaginator(): void + { + $source = new CursorPaginator( + [['id' => '1', 'name' => 'first']], + 15, + null, + ['path' => '/items'], + ); + $collection = new CursorPaginatedDataCollection(CollectionPartialData::class, $source); + + $this->assertSame([['id' => '1', 'name' => 'first']], $source->items()); + $this->assertNotSame($source, $collection->items()); + $this->assertSame(1, $collection->items()->items()[0]->id); + $this->assertSame('/items', $collection->items()->path()); + $this->assertSame([['id' => 1, 'name' => 'first']], $collection->toArray()['data']); + $this->assertSame('/items', $collection->toArray()['path']); + $this->assertSame(15, $collection->toArray()['per_page']); + $this->assertSame([1], array_column(iterator_to_array($collection), 'id')); + } + + public function testNonTransformableDtoItemsRemainRawAcrossCollectionShapes(): void + { + $collection = new DataCollection(CollectionDto::class, [['id' => 1]]); + $dto = $collection[0]; + + $this->assertSame([$dto], $collection->toArray()); + $this->assertSame([$dto], $collection->all()); + $this->assertSame('[{"id":1}]', $collection->toJson()); + $this->assertSame([], (new DataCollection(CollectionDto::class, []))->toArray()); + + $paginated = new PaginatedDataCollection( + CollectionDto::class, + new Paginator([['id' => 2]], 15, 2, ['path' => '/paginated']), + ); + $paginatedDto = $paginated->items()->items()[0]; + $paginatedOutput = $paginated->toArray(); + + $this->assertSame($paginatedDto, $paginatedOutput['data'][0]); + $this->assertSame(2, $paginatedOutput['current_page']); + $this->assertSame('/paginated', $paginatedOutput['path']); + + $cursorPaginated = new CursorPaginatedDataCollection( + CollectionDto::class, + new CursorPaginator([['id' => 3]], 15, null, ['path' => '/cursor']), + ); + $cursorDto = $cursorPaginated->items()->items()[0]; + $cursorOutput = $cursorPaginated->toArray(); + + $this->assertSame($cursorDto, $cursorOutput['data'][0]); + $this->assertSame('/cursor', $cursorOutput['path']); + $this->assertSame(15, $cursorOutput['per_page']); + } +} + +class CollectionDto extends Dto +{ + public function __construct(public int $id) + { + } +} + +class CollectionNormalizerData extends Data +{ + public static int $normalizerCalls = 0; + + public function __construct(public int $id) + { + } + + /** + * Get class-owned normalizers. + */ + public static function normalizers(): array + { + ++self::$normalizerCalls; + + return [CollectionStringNormalizer::class]; + } +} + +class CollectionStringNormalizer implements Normalizer +{ + /** + * Normalize a scalar identifier. + */ + public function normalize(mixed $value): array|Normalized|null + { + return is_string($value) ? ['id' => $value] : null; + } +} + +class CollectionNamedFactoryData extends Data +{ + public function __construct(public int $id) + { + } + + /** + * Create data from a scalar identifier. + */ + public static function fromString(string $id): static + { + return new static((int) $id); + } +} + +class CollectionOverriddenFromData extends Data +{ + public function __construct(public int $id) + { + } + + /** + * Fail when collection internals reenter the public entry point. + */ + public static function from(mixed ...$payloads): static + { + throw new RuntimeException('Collection internals must not call public from().'); + } +} + +class CollectionPartialData extends Data +{ + public function __construct( + public int $id, + public string $name, + ) { + } +} diff --git a/tests/Data/Eloquent/DataCollectionEloquentCastTest.php b/tests/Data/Eloquent/DataCollectionEloquentCastTest.php new file mode 100644 index 000000000..fa7df08b3 --- /dev/null +++ b/tests/Data/Eloquent/DataCollectionEloquentCastTest.php @@ -0,0 +1,557 @@ +make('config'); + $config->set('app.cipher', 'AES-256-CBC'); + $config->set('app.key', 'base64:' . base64_encode(str_repeat('a', 32))); + $config->set('app.previous_keys', []); + } + + public function testCollectionCastRoundTripsCollectionsArraysKeysAndNull(): void + { + $model = new CollectionCastModel; + $model->items = new DataCollection(CollectionItemData::class, [ + 'first' => new CollectionItemData('Taylor'), + 'second' => new CollectionItemData('Abigail'), + ]); + + $this->assertSame([ + 'first' => ['name' => 'Taylor'], + 'second' => ['name' => 'Abigail'], + ], Json::decode($model->getAttributes()['items'])); + + $model = new CollectionCastModel; + $model->items = [ + ['name' => 'Taylor'], + ['name' => 'Abigail'], + ]; + + $this->assertSame([ + ['name' => 'Taylor'], + ['name' => 'Abigail'], + ], Json::decode($model->getAttributes()['items'])); + + $model = new CollectionCastModel; + $model->setRawAttributes([ + 'items' => '{"first":{"name":"Taylor"},"second":{"name":"Abigail"}}', + ]); + + $this->assertInstanceOf(DataCollection::class, $model->items); + $this->assertSame(['first', 'second'], array_keys($model->items->items())); + $this->assertEquals(new CollectionItemData('Taylor'), $model->items['first']); + $this->assertEquals(new CollectionItemData('Abigail'), $model->items['second']); + + $model = new CollectionCastModel; + $model->items = null; + + $this->assertNull($model->getAttributes()['items']); + $this->assertNull($model->items); + } + + public function testCollectionCastReturnsTheDeclaredCollectionSubclass(): void + { + $model = new CollectionCastModel; + $model->setRawAttributes(['custom_items' => '[{"name":"Taylor"}]']); + + $this->assertInstanceOf(CustomDataCollection::class, $model->custom_items); + $this->assertEquals(new CollectionItemData('Taylor'), $model->custom_items[0]); + } + + public function testStoredCollectionsUseOneInternalRootItemOperation(): void + { + CollectionInternalOperationData::$normalizerCalls = 0; + $caster = new DataCollectionEloquentCast(CollectionInternalOperationData::class); + $items = $caster->get( + new CollectionCastModel, + 'items', + '[{"name":"Taylor"},{"name":"Abigail"}]', + [], + ); + + $this->assertSame(1, CollectionInternalOperationData::$normalizerCalls); + $this->assertSame(['Taylor', 'Abigail'], array_column($items->items(), 'name')); + } + + public function testCollectionCastPersistsCompleteItemsWithoutMutatingPartials(): void + { + $first = (new CollectionGraphItemData( + name: 'Taylor', + secret: 'private', + lazy: Lazy::create(static fn (): string => 'resolved'), + ))->only('name'); + $second = (new CollectionGraphItemData( + name: 'Abigail', + secret: 'private-two', + lazy: Lazy::create(static fn (): string => 'resolved-two'), + ))->except('secret'); + $collection = (new DataCollection(CollectionGraphItemData::class, [$first, $second])) + ->only('name'); + + $collectionPartials = $collection->getPartialsDefinition()->resolve($collection); + $firstPartials = $first->getPartialsDefinition()->resolve($first); + $secondPartials = $second->getPartialsDefinition()->resolve($second); + + $model = new CollectionCastModel; + $model->graph_items = $collection; + + $this->assertSame([ + [ + 'name' => 'Taylor', + 'secret' => 'private', + 'lazy' => 'resolved', + ], + [ + 'name' => 'Abigail', + 'secret' => 'private-two', + 'lazy' => 'resolved-two', + ], + ], Json::decode($model->getAttributes()['graph_items'])); + $this->assertSame($collectionPartials, $collection->getPartialsDefinition()->resolve($collection)); + $this->assertSame($firstPartials, $first->getPartialsDefinition()->resolve($first)); + $this->assertSame($secondPartials, $second->getPartialsDefinition()->resolve($second)); + } + + public function testCollectionDefaultUsesItsLateBoundEmptyListRepresentation(): void + { + $decoded = null; + $model = new CollectionCastModel; + $model->setRawAttributes(['default_items' => null]); + + try { + Json::decodeUsing(function (mixed $value) use (&$decoded): array { + $decoded = $value; + + return []; + }); + + $this->assertInstanceOf(DataCollection::class, $model->default_items); + $this->assertSame([], $model->default_items->items()); + $this->assertSame('[]', $decoded); + } finally { + Json::flushState(); + } + } + + public function testCollectionCastUsesTheConfiguredEloquentJsonCodec(): void + { + $caster = new DataCollectionEloquentCast(CollectionItemData::class); + $model = new CollectionCastModel; + + try { + Json::decodeUsing(static fn (): array => [['name' => 'decoded']]); + Json::encodeUsing(static fn (): string => 'encoded'); + + $decoded = $caster->get($model, 'items', 'ignored', []); + + $this->assertEquals(new CollectionItemData('decoded'), $decoded[0]); + $this->assertSame( + 'encoded', + $caster->set($model, 'items', [new CollectionItemData('value')], []), + ); + } finally { + Json::flushState(); + } + } + + public function testCollectionCastRejectsAnEncoderFalseResult(): void + { + $caster = new DataCollectionEloquentCast(CollectionItemData::class); + + try { + Json::encodeUsing(static fn (): false => false); + + $this->assertThrows( + fn () => $caster->set( + new CollectionCastModel, + 'items', + [new CollectionItemData('value')], + [], + ), + JsonEncodingException::class, + 'Unable to encode attribute [items] for model [' . CollectionCastModel::class . ']', + ); + } finally { + Json::flushState(); + } + } + + public function testPropertyMorphableCollectionUsesOrdinaryItemPayloads(): void + { + $caster = new DataCollectionEloquentCast(CollectionPropertyMorphData::class); + $model = new CollectionCastModel; + $encoded = $caster->set($model, 'property_morph_items', [ + new CollectionPropertyMorphFoo('first'), + new CollectionPropertyMorphBar('second'), + ], []); + + $this->assertEquals([ + ['name' => 'first', 'variant' => 'foo'], + ['name' => 'second', 'variant' => 'bar'], + ], Json::decode($encoded)); + + $decoded = $caster->get($model, 'property_morph_items', $encoded, []); + + $this->assertInstanceOf(CollectionPropertyMorphFoo::class, $decoded[0]); + $this->assertInstanceOf(CollectionPropertyMorphBar::class, $decoded[1]); + } + + public function testAbstractCollectionRoundTripsEnforcedAliasesAndEncryption(): void + { + $this->app->make(DataConfig::class)->enforceMorphMap([ + 'first' => CollectionAbstractFirst::class, + 'second' => CollectionAbstractSecond::class, + ]); + + $items = [ + new CollectionAbstractFirst('one'), + new CollectionAbstractSecond('two'), + ]; + $model = new CollectionCastModel; + $model->abstract_items = $items; + + $this->assertSame([ + ['type' => 'first', 'data' => ['name' => 'one']], + ['type' => 'second', 'data' => ['name' => 'two']], + ], Json::decode($model->getAttributes()['abstract_items'])); + + $model = new CollectionCastModel; + $model->setRawAttributes(['abstract_items' => Json::encode([ + ['type' => 'first', 'data' => ['name' => 'one']], + ['type' => 'second', 'data' => ['name' => 'two']], + ])]); + + $this->assertInstanceOf(CollectionAbstractFirst::class, $model->abstract_items[0]); + $this->assertInstanceOf(CollectionAbstractSecond::class, $model->abstract_items[1]); + + $model = new CollectionCastModel; + $model->encrypted_items = [new CollectionItemData('concrete')]; + $model->encrypted_abstract_items = $items; + $encryptedConcrete = $model->getAttributes()['encrypted_items']; + $encrypted = $model->getAttributes()['encrypted_abstract_items']; + + $this->assertSame( + [['name' => 'concrete']], + Json::decode(Crypt::decryptString($encryptedConcrete)), + ); + $this->assertNotSame('[', $encrypted[0]); + $this->assertCount(2, Json::decode(Crypt::decryptString($encrypted))); + + $model = new CollectionCastModel; + $model->setRawAttributes([ + 'encrypted_items' => $encryptedConcrete, + 'encrypted_abstract_items' => $encrypted, + ]); + + $this->assertEquals(new CollectionItemData('concrete'), $model->encrypted_items[0]); + $this->assertInstanceOf(CollectionAbstractFirst::class, $model->encrypted_abstract_items[0]); + $this->assertInstanceOf(CollectionAbstractSecond::class, $model->encrypted_abstract_items[1]); + } + + public function testAbstractCollectionRejectsMissingAndUnknownAliases(): void + { + $caster = new DataCollectionEloquentCast(CollectionAbstractData::class); + $model = new CollectionCastModel; + + $this->assertThrows( + fn () => $caster->set( + $model, + 'abstract_items', + [new CollectionAbstractFirst('value')], + [], + ), + CannotCastData::class, + 'should have an enforced morph alias', + ); + $this->assertThrows( + fn () => $caster->get( + $model, + 'abstract_items', + '[{"type":"missing","data":{"name":"value"}}]', + [], + ), + CannotCastData::class, + 'is not registered', + ); + $this->assertThrows( + fn () => $caster->get( + $model, + 'abstract_items', + json_encode([ + ['type' => CollectionAbstractFirst::class, 'data' => ['name' => 'value']], + ], JSON_THROW_ON_ERROR), + [], + ), + CannotCastData::class, + 'is not registered', + ); + } + + public function testCollectionCastRejectsInvalidAssignedAndStoredItems(): void + { + $caster = new DataCollectionEloquentCast(CollectionItemData::class); + $model = new CollectionCastModel; + + foreach ([new stdClass, [new stdClass], [new CollectionDto('value')], [new CollectionOtherData('value')]] as $value) { + $this->assertThrows( + fn () => $caster->set($model, 'items', $value, []), + CannotCastData::class, + ); + } + + $this->assertThrows( + fn () => $caster->get($model, 'items', '"value"', []), + CannotCastData::class, + ); + $this->assertThrows( + fn () => $caster->get($model, 'items', '["value"]', []), + CannotCastData::class, + 'Item `0`', + ); + $this->assertThrows( + fn () => $caster->get($model, 'items', '{invalid', []), + JsonException::class, + ); + } + + public function testCollectionCastRejectsMissingAndNonTransformableItemClasses(): void + { + $this->assertThrows( + fn () => DataCollection::castUsing([]), + CannotCastData::class, + 'type of Data should be provided', + ); + $this->assertThrows( + fn () => new DataCollectionEloquentCast(CollectionDto::class), + CannotCastData::class, + 'should implement TransformableData', + ); + } + + public function testCollectionDirtyComparisonUsesSharedPayloadSemantics(): void + { + $model = new CollectionCastModel; + $model->setRawAttributes([ + 'items' => '[{"first":"one","second":"two"}]', + ], true); + $model->setRawAttributes([ + 'items' => '[{"second":"two","first":"one"}]', + ]); + + $this->assertFalse($model->isDirty('items')); + + $model->setRawAttributes([ + 'items' => '[{"first":"two","second":"one"}]', + ]); + + $this->assertTrue($model->isDirty('items')); + } + + public function testEncryptedCollectionDirtyComparisonHonorsPreviousKeys(): void + { + $first = Crypt::encryptString('[{"name":"Taylor"}]'); + $second = Crypt::encryptString('[{"name":"Taylor"}]'); + $model = new CollectionCastModel; + $model->setRawAttributes(['encrypted_items' => $first], true); + $model->setRawAttributes(['encrypted_items' => $second]); + + $this->assertFalse($model->isDirty('encrypted_items')); + + try { + Crypt::previousKeys([random_bytes(32)]); + + $this->assertTrue($model->isDirty('encrypted_items')); + } finally { + Crypt::previousKeys([]); + } + } +} + +class CollectionCastModel extends Model +{ + /** + * Get the model's casts. + */ + protected function casts(): array + { + return [ + 'items' => DataCollection::class . ':' . CollectionItemData::class, + 'default_items' => DataCollection::class . ':' . CollectionItemData::class . ',default', + 'custom_items' => CustomDataCollection::class . ':' . CollectionItemData::class, + 'graph_items' => DataCollection::class . ':' . CollectionGraphItemData::class, + 'abstract_items' => DataCollection::class . ':' . CollectionAbstractData::class, + 'encrypted_items' => DataCollection::class . ':' . CollectionItemData::class . ',encrypted', + 'encrypted_abstract_items' => DataCollection::class . ':' . CollectionAbstractData::class . ',encrypted', + 'property_morph_items' => DataCollection::class . ':' . CollectionPropertyMorphData::class, + ]; + } +} + +class CollectionItemData extends Data +{ + public function __construct(public string $name) + { + } +} + +class CollectionInternalOperationData extends Data +{ + public static int $normalizerCalls = 0; + + public function __construct(public string $name) + { + } + + /** + * Get class-owned normalizers. + */ + public static function normalizers(): array + { + ++self::$normalizerCalls; + + return []; + } + + /** + * Fail when collection construction reenters the public entry point. + */ + public static function from(mixed ...$payloads): static + { + throw new RuntimeException('Stored collection reads must use the internal item operation.'); + } +} + +class CollectionGraphItemData extends Data +{ + #[Computed] + public string $summary = 'computed'; + + public function __construct( + #[MapOutputName('wire_name')] + public string $name, + #[Hidden] + public string $secret, + public Lazy $lazy, + ) { + } + + /** + * Get response-only additional data. + */ + public function with(): array + { + return ['response_only' => true]; + } +} + +/** + * @extends DataCollection + */ +class CustomDataCollection extends DataCollection +{ +} + +abstract class CollectionAbstractData extends Data +{ + public function __construct(public string $name) + { + } +} + +class CollectionAbstractFirst extends CollectionAbstractData +{ +} + +class CollectionAbstractSecond extends CollectionAbstractData +{ +} + +class CollectionOtherData extends Data +{ + public function __construct(public string $name) + { + } +} + +class CollectionDto extends Dto +{ + public function __construct(public string $name) + { + } +} + +abstract class CollectionPropertyMorphData extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public string $variant, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + 'foo' => CollectionPropertyMorphFoo::class, + 'bar' => CollectionPropertyMorphBar::class, + default => null, + }; + } +} + +class CollectionPropertyMorphFoo extends CollectionPropertyMorphData +{ + public function __construct(public string $name) + { + parent::__construct('foo'); + } +} + +class CollectionPropertyMorphBar extends CollectionPropertyMorphData +{ + public function __construct(public string $name) + { + parent::__construct('bar'); + } +} diff --git a/tests/Data/Eloquent/DataEloquentCastTest.php b/tests/Data/Eloquent/DataEloquentCastTest.php new file mode 100644 index 000000000..21f26b2c9 --- /dev/null +++ b/tests/Data/Eloquent/DataEloquentCastTest.php @@ -0,0 +1,531 @@ +make('config'); + $config->set('app.cipher', 'AES-256-CBC'); + $config->set('app.key', 'base64:' . base64_encode(str_repeat('a', 32))); + $config->set('app.previous_keys', []); + } + + public function testDataCastRoundTripsObjectsArraysNullAndDefaults(): void + { + $model = new DataCastModel; + $model->data = new StoredSimpleData('Taylor'); + + $this->assertSame(['name' => 'Taylor'], Json::decode($model->getAttributes()['data'])); + + $model = new DataCastModel; + $model->data = ['name' => 'Abigail']; + + $this->assertSame(['name' => 'Abigail'], Json::decode($model->getAttributes()['data'])); + + $model = new DataCastModel; + $model->setRawAttributes(['data' => '{"name":"Dayle"}']); + + $this->assertEquals(new StoredSimpleData('Dayle'), $model->data); + + $model = new DataCastModel; + $model->data = null; + + $this->assertNull($model->getAttributes()['data']); + $this->assertNull($model->data); + + $model = new DataCastModel; + $model->setRawAttributes([ + 'default_data' => null, + 'empty_default_data' => null, + ]); + + $this->assertEquals(new StoredDefaultData, $model->default_data); + $this->assertEquals(new StoredEmptyData, $model->empty_default_data); + + foreach (['{}', '[]'] as $stored) { + $model = new DataCastModel; + $model->setRawAttributes(['empty_default_data' => $stored]); + + $this->assertEquals(new StoredEmptyData, $model->empty_default_data); + } + } + + public function testDataCastPersistsTheCompleteConstructableViewWithoutMutatingPartials(): void + { + $nested = (new StoredNestedData('nested'))->only('value'); + $item = (new StoredNestedData('item'))->except('value'); + $items = (new DataCollection(StoredNestedData::class, [$item]))->only('value'); + $data = (new StoredGraphData( + name: 'Taylor', + secret: 'private', + nested: $nested, + items: $items, + lazy: Lazy::create(static fn (): string => 'resolved'), + ))->exclude('name')->additional(['response_only' => true]); + + $rootPartials = $data->getPartialsDefinition()->resolve($data); + $nestedPartials = $nested->getPartialsDefinition()->resolve($nested); + $collectionPartials = $items->getPartialsDefinition()->resolve($items); + $itemPartials = $item->getPartialsDefinition()->resolve($item); + + $model = new DataCastModel; + $model->graph_data = $data; + + $this->assertSame([ + 'name' => 'Taylor', + 'secret' => 'private', + 'nested' => ['value' => 'nested'], + 'items' => [['value' => 'item']], + 'lazy' => 'resolved', + ], Json::decode($model->getAttributes()['graph_data'])); + $this->assertSame($rootPartials, $data->getPartialsDefinition()->resolve($data)); + $this->assertSame($nestedPartials, $nested->getPartialsDefinition()->resolve($nested)); + $this->assertSame($collectionPartials, $items->getPartialsDefinition()->resolve($items)); + $this->assertSame($itemPartials, $item->getPartialsDefinition()->resolve($item)); + } + + public function testDataCastUsesTheConfiguredEloquentJsonCodec(): void + { + $caster = new DataEloquentCast(StoredSimpleData::class); + $model = new DataCastModel; + + try { + Json::decodeUsing(static fn (): array => ['name' => 'decoded']); + Json::encodeUsing(static fn (): string => 'encoded'); + + $this->assertEquals( + new StoredSimpleData('decoded'), + $caster->get($model, 'data', 'ignored', []), + ); + $this->assertSame( + 'encoded', + $caster->set($model, 'data', new StoredSimpleData('value'), []), + ); + } finally { + Json::flushState(); + } + } + + public function testDataCastRejectsAnEncoderFalseResult(): void + { + $caster = new DataEloquentCast(StoredSimpleData::class); + + try { + Json::encodeUsing(static fn (): false => false); + + $this->assertThrows( + fn () => $caster->set( + new DataCastModel, + 'data', + new StoredSimpleData('value'), + [], + ), + JsonEncodingException::class, + 'Unable to encode attribute [data] for model [' . DataCastModel::class . ']', + ); + } finally { + Json::flushState(); + } + } + + public function testPropertyMorphableAbstractDataUsesItsOrdinaryPayload(): void + { + $caster = new DataEloquentCast(StoredPropertyMorphData::class); + $model = new DataCastModel; + $encoded = $caster->set($model, 'property_morph_data', new StoredPropertyMorphFoo('value'), []); + + $this->assertEquals([ + 'variant' => 'foo', + 'name' => 'value', + ], Json::decode($encoded)); + + $decoded = $caster->get($model, 'property_morph_data', $encoded, []); + + $this->assertInstanceOf(StoredPropertyMorphFoo::class, $decoded); + $this->assertSame('value', $decoded->name); + } + + public function testAbstractDataRequiresAndRoundTripsAnEnforcedAlias(): void + { + $this->app->make(DataConfig::class)->enforceMorphMap([ + 'first' => StoredAbstractFirst::class, + ]); + + $caster = new DataEloquentCast(StoredAbstractData::class); + $model = new DataCastModel; + $encoded = $caster->set($model, 'abstract_data', new StoredAbstractFirst('value'), []); + + $this->assertSame([ + 'type' => 'first', + 'data' => ['name' => 'value'], + ], Json::decode($encoded)); + $this->assertEquals( + new StoredAbstractFirst('value'), + $caster->get($model, 'abstract_data', $encoded, []), + ); + } + + public function testEncryptedConcreteAndAbstractDataRoundTrip(): void + { + $this->app->make(DataConfig::class)->enforceMorphMap([ + 'first' => StoredAbstractFirst::class, + ]); + + $model = new DataCastModel; + $model->encrypted_data = new StoredSimpleData('concrete'); + $model->encrypted_abstract_data = new StoredAbstractFirst('abstract'); + + $encryptedConcrete = $model->getAttributes()['encrypted_data']; + $encryptedAbstract = $model->getAttributes()['encrypted_abstract_data']; + + $this->assertSame( + ['name' => 'concrete'], + Json::decode(Crypt::decryptString($encryptedConcrete)), + ); + $this->assertSame([ + 'type' => 'first', + 'data' => ['name' => 'abstract'], + ], Json::decode(Crypt::decryptString($encryptedAbstract))); + + $model = new DataCastModel; + $model->setRawAttributes([ + 'encrypted_data' => $encryptedConcrete, + 'encrypted_abstract_data' => $encryptedAbstract, + ]); + + $this->assertEquals(new StoredSimpleData('concrete'), $model->encrypted_data); + $this->assertEquals(new StoredAbstractFirst('abstract'), $model->encrypted_abstract_data); + } + + public function testAbstractDataRejectsValuesWithoutAnEnforcedAlias(): void + { + $caster = new DataEloquentCast(StoredAbstractData::class); + + $this->assertThrows( + fn () => $caster->set( + new DataCastModel, + 'abstract_data', + new StoredAbstractFirst('value'), + [], + ), + CannotCastData::class, + 'should have an enforced morph alias', + ); + } + + public function testAbstractDataRejectsUnknownFqcnAndInvalidMorphClasses(): void + { + $config = $this->app->make(DataConfig::class); + $config->enforceMorphMap([ + 'unrelated' => StoredUnrelatedData::class, + 'dto' => StoredDto::class, + ]); + + $caster = new DataEloquentCast(StoredAbstractData::class); + $model = new DataCastModel; + + foreach ([ + ['missing', CannotCastData::class], + [StoredAbstractFirst::class, CannotCastData::class], + ['unrelated', CannotCastData::class], + ['dto', CannotCastData::class], + ] as [$alias, $exception]) { + $this->assertThrows( + fn () => $caster->get( + $model, + 'abstract_data', + json_encode(['type' => $alias, 'data' => ['name' => 'value']], JSON_THROW_ON_ERROR), + [], + ), + $exception, + ); + } + } + + public function testDataCastRejectsInvalidAssignedValues(): void + { + $caster = new DataEloquentCast(StoredSimpleData::class); + $model = new DataCastModel; + + foreach ([new stdClass, new StoredDto('value'), new StoredUnrelatedData('value')] as $value) { + $this->assertThrows( + fn () => $caster->set($model, 'data', $value, []), + CannotCastData::class, + ); + } + } + + public function testDataCastRejectsANonTransformableTargetClass(): void + { + $this->assertThrows( + fn () => new DataEloquentCast(StoredDto::class), + CannotCastData::class, + 'should implement TransformableData', + ); + } + + public function testDataCastRejectsMalformedAndScalarStoredJson(): void + { + $caster = new DataEloquentCast(StoredSimpleData::class); + $model = new DataCastModel; + + $this->assertThrows( + fn () => $caster->get($model, 'data', '{invalid', []), + JsonException::class, + ); + $this->assertThrows( + fn () => $caster->get($model, 'data', '"value"', []), + CannotCastData::class, + ); + } + + public function testDirtyComparisonIgnoresJsonObjectKeyOrderRecursively(): void + { + $this->assertDirtyComparison( + ['first' => 'one', 'second' => 'two'], + ['second' => 'two', 'first' => 'one'], + false, + ); + $this->assertDirtyComparison( + ['meta' => ['first' => 'one', 'second' => 'two']], + ['meta' => ['second' => 'two', 'first' => 'one']], + false, + ); + $this->assertDirtyComparison( + [2 => 'two', 1 => 'one'], + [1 => 'one', 2 => 'two'], + false, + ); + } + + public function testDirtyComparisonPreservesListOrderAndStrictLeafTypes(): void + { + $this->assertDirtyComparison(['items' => ['one', 'two']], ['items' => ['two', 'one']], true); + $this->assertDirtyComparison(['value' => 1], ['value' => '1'], true); + $this->assertDirtyComparison(['value' => 'one'], ['value' => 'two'], true); + } + + public function testDirtyComparisonHandlesNullAndDefaultValues(): void + { + $model = new DataCastModel; + $model->setRawAttributes(['data' => null], true); + $model->setRawAttributes(['data' => '{}']); + + $this->assertTrue($model->isDirty('data')); + + $model = new DataCastModel; + $model->setRawAttributes(['empty_default_data' => null], true); + $model->setRawAttributes(['empty_default_data' => '{}']); + + $this->assertFalse($model->isDirty('empty_default_data')); + } + + public function testEncryptedDirtyComparisonHonorsPreviousKeys(): void + { + $first = Crypt::encryptString('{"name":"Taylor"}'); + $second = Crypt::encryptString('{"name":"Taylor"}'); + $model = new DataCastModel; + $model->setRawAttributes(['encrypted_data' => $first], true); + $model->setRawAttributes(['encrypted_data' => $second]); + + $this->assertFalse($model->isDirty('encrypted_data')); + + try { + Crypt::previousKeys([random_bytes(32)]); + + $this->assertTrue($model->isDirty('encrypted_data')); + } finally { + Crypt::previousKeys([]); + } + } + + /** + * Assert dirty comparison through Eloquent's real class-cast caller. + */ + private function assertDirtyComparison(array $original, array $current, bool $dirty): void + { + $model = new DataCastModel; + $model->setRawAttributes([ + 'pair_data' => json_encode($original, JSON_THROW_ON_ERROR), + ], true); + $model->setRawAttributes([ + 'pair_data' => json_encode($current, JSON_THROW_ON_ERROR), + ]); + + $this->assertSame($dirty, $model->isDirty('pair_data')); + } +} + +class DataCastModel extends Model +{ + /** + * Get the model's casts. + */ + protected function casts(): array + { + return [ + 'data' => StoredSimpleData::class, + 'default_data' => StoredDefaultData::class . ':default', + 'empty_default_data' => StoredEmptyData::class . ':default', + 'graph_data' => StoredGraphData::class, + 'pair_data' => StoredPairData::class, + 'abstract_data' => StoredAbstractData::class, + 'encrypted_data' => StoredSimpleData::class . ':encrypted', + 'encrypted_abstract_data' => StoredAbstractData::class . ':encrypted', + 'property_morph_data' => StoredPropertyMorphData::class, + ]; + } +} + +class StoredSimpleData extends Data +{ + public function __construct(public string $name) + { + } +} + +class StoredDefaultData extends Data +{ + public function __construct(public string $name = 'default') + { + } +} + +class StoredEmptyData extends Data +{ +} + +class StoredNestedData extends Data +{ + public function __construct(public string $value) + { + } +} + +class StoredGraphData extends Data +{ + #[Computed] + public string $summary = 'computed'; + + public function __construct( + #[MapOutputName('wire_name')] + public string $name, + #[Hidden] + public string $secret, + public StoredNestedData $nested, + #[DataCollectionOf(StoredNestedData::class)] + public DataCollection $items, + public Lazy $lazy, + ) { + } + + /** + * Get response-only additional data. + */ + public function with(): array + { + return ['class_response_only' => true]; + } +} + +class StoredPairData extends Data +{ + public function __construct( + public mixed $first = null, + public mixed $second = null, + public mixed $meta = null, + public mixed $items = null, + public mixed $value = null, + ) { + } +} + +abstract class StoredAbstractData extends Data +{ + public function __construct(public string $name) + { + } +} + +class StoredAbstractFirst extends StoredAbstractData +{ +} + +class StoredUnrelatedData extends Data +{ + public function __construct(public string $name) + { + } +} + +class StoredDto extends Dto +{ + public function __construct(public string $name) + { + } +} + +abstract class StoredPropertyMorphData extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public string $variant, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant'] ?? null) { + 'foo' => StoredPropertyMorphFoo::class, + default => null, + }; + } +} + +class StoredPropertyMorphFoo extends StoredPropertyMorphData +{ + public function __construct(public string $name) + { + parent::__construct('foo'); + } +} diff --git a/tests/Data/Http/RequestQueryStringPartialsResolverTest.php b/tests/Data/Http/RequestQueryStringPartialsResolverTest.php new file mode 100644 index 000000000..30a6147dd --- /dev/null +++ b/tests/Data/Http/RequestQueryStringPartialsResolverTest.php @@ -0,0 +1,271 @@ +makeData()->toResponse($this->request([ + 'include' => 'secret,nested.secret', + ])); + + $this->assertSame([ + 'id' => 1, + 'display_name' => 'Taylor', + 'secret' => 'root-secret', + 'default_secret' => 'root-default-secret', + 'nested' => [ + 'id' => 2, + 'display_name' => 'Abigail', + 'secret' => 'nested-secret', + 'default_secret' => 'nested-default-secret', + ], + ], $response->getData(true)); + } + + public function testOnlyAcceptsMappedOutputNamesAndNestedPaths(): void + { + $response = $this->makeData()->toResponse($this->request([ + 'only' => 'display_name,nested.display_name', + ])); + + $this->assertSame([ + 'display_name' => 'Taylor', + 'nested' => ['display_name' => 'Abigail'], + ], $response->getData(true)); + } + + public function testExcludeAcceptsMappedOutputNamesAndNestedPaths(): void + { + $response = $this->makeData()->toResponse($this->request([ + 'exclude' => 'default_secret,nested.default_secret', + ])); + + $this->assertSame([ + 'id' => 1, + 'display_name' => 'Taylor', + 'nested' => [ + 'id' => 2, + 'display_name' => 'Abigail', + ], + ], $response->getData(true)); + } + + public function testExceptAcceptsMappedOutputNamesAndNestedPaths(): void + { + $response = $this->makeData()->toResponse($this->request([ + 'except' => 'display_name,nested.display_name', + ])); + + $this->assertSame([ + 'id' => 1, + 'default_secret' => 'root-default-secret', + 'nested' => [ + 'id' => 2, + 'default_secret' => 'nested-default-secret', + ], + ], $response->getData(true)); + } + + public function testDisallowedAndMalformedPathsAreIgnored(): void + { + $response = $this->makeData()->toResponse($this->request([ + 'only' => ['secret', 'unknown', 123], + 'include' => new stdClass, + ])); + + $this->assertSame([ + 'id' => 1, + 'display_name' => 'Taylor', + 'default_secret' => 'root-default-secret', + 'nested' => [ + 'id' => 2, + 'display_name' => 'Abigail', + 'default_secret' => 'nested-default-secret', + ], + ], $response->getData(true)); + } + + public function testInvalidNestedChildFallsBackToTheAllowedParent(): void + { + $response = $this->makeData()->toResponse($this->request([ + 'only' => 'nested.unknown', + ])); + + $this->assertSame([ + 'nested' => [ + 'id' => 2, + 'display_name' => 'Abigail', + 'default_secret' => 'nested-default-secret', + ], + ], $response->getData(true)); + } + + public function testNullAllowlistPermitsWildcardSelection(): void + { + $data = new UnrestrictedRequestPartialData( + 1, + Lazy::create(static fn (): string => 'secret'), + ); + + $this->assertSame([ + 'id' => 1, + 'secret' => 'secret', + ], $data->toResponse($this->request(['include' => '*']))->getData(true)); + } + + /** + * Create the nested data graph used by resolver tests. + */ + private function makeData(): RequestPartialData + { + return new RequestPartialData( + 1, + 'Taylor', + Lazy::create(static fn (): string => 'root-secret'), + Lazy::create(static fn (): string => 'root-default-secret')->defaultIncluded(), + new RequestPartialNestedData( + 2, + 'Abigail', + Lazy::create(static fn (): string => 'nested-secret'), + Lazy::create(static fn (): string => 'nested-default-secret')->defaultIncluded(), + ), + ); + } + + /** + * Create a request with query parameters. + */ + private function request(array $query): Request + { + return Request::create('/', 'GET', $query); + } +} + +class RequestPartialData extends Data +{ + public function __construct( + public int $id, + #[MapOutputName('display_name')] + public string $name, + public Lazy|string $secret, + #[MapOutputName('default_secret')] + public Lazy|string $defaultSecret, + public RequestPartialNestedData $nested, + ) { + } + + /** + * Get the request properties that may be included. + */ + public static function allowedRequestIncludes(): ?array + { + return ['secret', 'nested']; + } + + /** + * Get the request properties that may be excluded. + */ + public static function allowedRequestExcludes(): ?array + { + return ['defaultSecret', 'nested']; + } + + /** + * Get the request properties allowed by an only selection. + */ + public static function allowedRequestOnly(): ?array + { + return ['id', 'name', 'nested']; + } + + /** + * Get the request properties allowed by an except selection. + */ + public static function allowedRequestExcept(): ?array + { + return ['name', 'nested']; + } +} + +class RequestPartialNestedData extends Data +{ + public function __construct( + public int $id, + #[MapOutputName('display_name')] + public string $name, + public Lazy|string $secret, + #[MapOutputName('default_secret')] + public Lazy|string $defaultSecret, + ) { + } + + /** + * Get the request properties that may be included. + */ + public static function allowedRequestIncludes(): ?array + { + return ['secret']; + } + + /** + * Get the request properties that may be excluded. + */ + public static function allowedRequestExcludes(): ?array + { + return ['defaultSecret']; + } + + /** + * Get the request properties allowed by an only selection. + */ + public static function allowedRequestOnly(): ?array + { + return ['name']; + } + + /** + * Get the request properties allowed by an except selection. + */ + public static function allowedRequestExcept(): ?array + { + return ['name']; + } +} + +class UnrestrictedRequestPartialData extends Data +{ + public function __construct( + public int $id, + public Lazy|string $secret, + ) { + } + + /** + * Get the request properties that may be included. + */ + public static function allowedRequestIncludes(): ?array + { + return null; + } +} diff --git a/tests/Data/Http/ResourceResponseTest.php b/tests/Data/Http/ResourceResponseTest.php new file mode 100644 index 000000000..6b0cbad1e --- /dev/null +++ b/tests/Data/Http/ResourceResponseTest.php @@ -0,0 +1,438 @@ +toResponse(Request::create('/')); + $resourceResponse = $resource->toResponse(Request::create('/')); + + $this->assertSame(['id' => 1, 'name' => 'Taylor'], $dataResponse->getData(true)); + $this->assertSame(['id' => 2, 'name' => 'Abigail'], $resourceResponse->getData(true)); + $this->assertSame($data, $dataResponse->getOriginalContent()); + $this->assertSame($resource, $resourceResponse->getOriginalContent()); + } + + public function testAdditionalDataUsesTheLaravelFallbackWrapper(): void + { + $data = (new ResponseData(1, 'Taylor')) + ->withoutWrapping() + ->additional(['meta' => ['source' => 'test']]); + + $this->assertSame([ + 'data' => ['id' => 1, 'name' => 'Taylor'], + 'meta' => ['source' => 'test'], + ], $data->toResponse(Request::create('/'))->getData(true)); + } + + public function testExplicitWrappingAndResponseCollisionsFollowResourceResponseOnce(): void + { + $wrapped = (new ResponseData(1, 'Taylor'))->wrap('payload'); + $collision = (new CollisionResponseData(['value' => 'body'])) + ->wrap('data') + ->additional(['additional' => true]); + + $this->assertSame([ + 'payload' => ['id' => 1, 'name' => 'Taylor'], + ], $wrapped->toResponse(Request::create('/'))->getData(true)); + $this->assertSame([ + 'data' => ['value' => 'body'], + 'with' => true, + 'additional' => true, + ], $collision->toResponse(Request::create('/'))->getData(true)); + } + + public function testJsonOptionsAndResponseHookAreDelegatedWithoutChangingTheOriginal(): void + { + $data = new HookedResponseData('https://hypervel.org/data'); + $response = $data->toResponse(Request::create('/', 'POST')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('https://hypervel.org/data', $response->getContent()); + $this->assertSame('applied', $response->headers->get('X-Data-Hook')); + $this->assertSame($data, $response->getOriginalContent()); + } + + public function testAllowedRequestIncludesAreAppliedToResponseTransformation(): void + { + $excluded = new LazyResponseData(1, Lazy::create(static fn (): string => 'secret')); + $included = new LazyResponseData(1, Lazy::create(static fn (): string => 'secret')); + + $this->assertSame( + ['id' => 1], + $excluded->toResponse(Request::create('/'))->getData(true), + ); + $this->assertSame( + ['id' => 1, 'secret' => 'secret'], + $included->toResponse(Request::create('/?include=secret'))->getData(true), + ); + } + + public function testDtoCollectionsRemainResponseCapableAndDenyRequestPartials(): void + { + $collection = new DataCollection(ResponseDto::class, [ + ['id' => 1, 'secret' => 'visible'], + ]); + $items = $collection->toCollection(); + $response = $collection->toResponse(Request::create('/?only=id')); + + $this->assertSame([ + ['id' => 1, 'secret' => 'visible'], + ], $response->getData(true)); + $this->assertSame($items, $response->getOriginalContent()); + $this->assertSame($collection[0], $response->getOriginalContent()[0]); + } + + public function testNonResponsableTransformableItemsDenyRequestPartials(): void + { + $item = new ModularResponseData( + 1, + Lazy::create(static fn (): string => 'secret'), + ); + $collection = new DataCollection(ModularResponseData::class, [$item]); + + $this->assertSame([ + ['id' => 1], + ], $collection->toResponse(Request::create('/?include=secret'))->getData(true)); + } + + public function testLazyCollectionResponseMaterializesTheSourceOnce(): void + { + $iterations = 0; + $source = LazyCollection::make(function () use (&$iterations): iterable { + ++$iterations; + + yield 'first' => ['id' => 1, 'name' => 'Taylor']; + yield 'second' => ['id' => 2, 'name' => 'Abigail']; + }); + $collection = new DataCollection(ResponseData::class, $source); + $response = $collection->toResponse(Request::create('/')); + $original = $response->getOriginalContent(); + + $this->assertSame(1, $iterations); + $this->assertSame([ + 'first' => ['id' => 1, 'name' => 'Taylor'], + 'second' => ['id' => 2, 'name' => 'Abigail'], + ], $response->getData(true)); + $this->assertInstanceOf(Collection::class, $original); + $this->assertInstanceOf(ResponseData::class, $original['first']); + $this->assertSame('Abigail', $original['second']->name); + } + + public function testPaginatorResponsesPreserveMetadataAndOriginalDtoItems(): void + { + $paginated = new PaginatedDataCollection( + ResponseDto::class, + new Paginator( + [['id' => 1, 'secret' => 'first']], + 15, + 2, + ['path' => '/items'], + ), + ); + $cursorPaginated = new CursorPaginatedDataCollection( + ResponseDto::class, + new CursorPaginator( + [['id' => 2, 'secret' => 'second']], + 15, + null, + ['path' => '/cursor-items'], + ), + ); + + $paginatedResponse = $paginated->toResponse(Request::create('/items?only=id')); + $cursorResponse = $cursorPaginated->toResponse(Request::create('/cursor-items?only=id')); + $paginatedBody = $paginatedResponse->getData(true); + $cursorBody = $cursorResponse->getData(true); + + $this->assertSame([ + ['id' => 1, 'secret' => 'first'], + ], $paginatedBody['data']); + $this->assertSame(2, $paginatedBody['meta']['current_page']); + $this->assertSame('/items', $paginatedBody['meta']['path']); + $this->assertSame([ + ['id' => 2, 'secret' => 'second'], + ], $cursorBody['data']); + $this->assertSame('/cursor-items', $cursorBody['meta']['path']); + $this->assertSame(15, $cursorBody['meta']['per_page']); + $this->assertSame( + $paginated->items()->getCollection()[0], + $paginatedResponse->getOriginalContent()[0], + ); + $this->assertSame( + $cursorPaginated->items()->getCollection()[0], + $cursorResponse->getOriginalContent()[0], + ); + } + + public function testCollectionJsonOptionsUseTheDeclaredItemClassWithoutInstantiation(): void + { + $collection = new DataCollection(AbstractJsonOptionsData::class, []); + $resource = new DataCollectionResource( + $collection, + new Collection, + [], + null, + ); + $dtoCollection = new DataCollection(ConstructorRequiredDto::class, []); + $dtoResource = new DataCollectionResource( + $dtoCollection, + new Collection, + [], + null, + ); + + $this->assertSame(JSON_UNESCAPED_SLASHES, $resource->jsonOptions()); + $this->assertSame(0, $dtoResource->jsonOptions()); + } + + public function testDataResourceResolveBypassesTheGenericConditionalFilter(): void + { + $resource = new FilterSpyDataResource( + new ResponseData(1, 'Taylor'), + ['id' => 1, 'name' => 'Taylor'], + null, + ); + + $this->assertSame( + ['id' => 1, 'name' => 'Taylor'], + $resource->resolve(), + ); + $this->assertFalse(FilterSpyDataResource::$filterCalled); + } + + public function testConcurrentResponsesKeepWrappingAndAdditionalDataIsolated(): void + { + [$first, $second] = parallel([ + function (): array { + $data = (new ResponseData(1, 'Taylor')) + ->wrap('first') + ->additional(['source' => 'first']); + + usleep(5000); + + return $data->toResponse(Request::create('/'))->getData(true); + }, + function (): array { + $data = (new ResponseData(2, 'Abigail')) + ->wrap('second') + ->additional(['source' => 'second']); + + usleep(1000); + + return $data->toResponse(Request::create('/'))->getData(true); + }, + ]); + + $this->assertSame([ + 'first' => ['id' => 1, 'name' => 'Taylor'], + 'source' => 'first', + ], $first); + $this->assertSame([ + 'second' => ['id' => 2, 'name' => 'Abigail'], + 'source' => 'second', + ], $second); + } +} + +class GlobalWrappingResourceResponseTest extends ResourceResponseTestCase +{ + /** + * Define the test environment. + */ + protected function defineEnvironment(Application $app): void + { + $app->make('config')->set('data.wrap', 'global'); + } + + public function testGlobalWrappingCanBeOverriddenPerResponse(): void + { + $global = new ResponseData(1, 'Taylor'); + $explicit = (new ResponseData(2, 'Abigail'))->wrap('payload'); + $unwrapped = (new ResponseData(3, 'Jess'))->withoutWrapping(); + + $this->assertSame([ + 'global' => ['id' => 1, 'name' => 'Taylor'], + ], $global->toResponse(Request::create('/'))->getData(true)); + $this->assertSame([ + 'payload' => ['id' => 2, 'name' => 'Abigail'], + ], $explicit->toResponse(Request::create('/'))->getData(true)); + $this->assertSame( + ['id' => 3, 'name' => 'Jess'], + $unwrapped->toResponse(Request::create('/'))->getData(true), + ); + } +} + +class ResponseData extends Data +{ + public function __construct( + public int $id, + public string $name, + ) { + } +} + +class ResponseResource extends Resource +{ + public function __construct( + public int $id, + public string $name, + ) { + } +} + +class CollisionResponseData extends Data +{ + public function __construct(public array $data) + { + } + + /** + * Get top-level response data. + */ + public function with(): array + { + return ['with' => true]; + } +} + +class HookedResponseData extends Data +{ + public function __construct(public string $url) + { + } + + /** + * Get the JSON serialization options for the resource response. + */ + public static function jsonOptions(): int + { + return JSON_UNESCAPED_SLASHES; + } + + /** + * Customize the outgoing resource response. + */ + public function withResponse(Request $request, JsonResponse $response): void + { + $response->headers->set('X-Data-Hook', 'applied'); + } +} + +class LazyResponseData extends Data +{ + public function __construct( + public int $id, + public Lazy|string $secret, + ) { + } + + /** + * Get the request properties that may be included. + */ + public static function allowedRequestIncludes(): ?array + { + return ['secret']; + } +} + +class ResponseDto extends Dto +{ + public function __construct( + public int $id, + public string $secret, + ) { + } +} + +class ConstructorRequiredDto extends Dto +{ + public function __construct(public string $value) + { + } +} + +class ModularResponseData implements BaseDataContract, IncludeableDataContract, TransformableDataContract +{ + use BaseDataConcern; + use IncludeableDataConcern; + use TransformableDataConcern; + + public function __construct( + public int $id, + public Lazy|string $secret, + ) { + } +} + +abstract class AbstractJsonOptionsData extends Data +{ + /** + * Get the JSON serialization options for the resource response. + */ + public static function jsonOptions(): int + { + return JSON_UNESCAPED_SLASHES; + } +} + +class FilterSpyDataResource extends DataResource +{ + public static bool $filterCalled = false; + + /** + * Mark use of the generic resource filter. + */ + protected function filter(array $data): array + { + self::$filterCalled = true; + + return parent::filter($data); + } +} diff --git a/tests/Data/Support/Creation/DataCollectTest.php b/tests/Data/Support/Creation/DataCollectTest.php new file mode 100644 index 000000000..3552abe6b --- /dev/null +++ b/tests/Data/Support/Creation/DataCollectTest.php @@ -0,0 +1,372 @@ + ['id' => '1'], + 'second' => new RootCollectData(2), + ]; + + $array = RootCollectData::collect($source); + $collection = RootCollectData::collect($source, DataCollection::class); + + $this->assertSame(['first', 'second'], array_keys($array)); + $this->assertSame(1, $array['first']->id); + $this->assertSame($source['second'], $array['second']); + $this->assertInstanceOf(DataCollection::class, $collection); + $this->assertSame(['first', 'second'], array_keys($collection->items())); + } + + public function testCollectPreservesOrdinaryCollectionShapeAndDowngradesEloquentCollections(): void + { + $collection = RootCollectData::collect(new RootCollectSourceCollection([ + ['id' => '1'], + ])); + $eloquent = RootCollectData::collect(new EloquentCollection([ + ['id' => '2'], + ])); + + $this->assertInstanceOf(RootCollectSourceCollection::class, $collection); + $this->assertSame(1, $collection->first()->id); + $this->assertInstanceOf(Collection::class, $eloquent); + $this->assertNotInstanceOf(EloquentCollection::class, $eloquent); + $this->assertSame(2, $eloquent->first()->id); + } + + public function testCollectClonesPaginatorMetadataWithoutMutatingTheCaller(): void + { + $source = new Paginator( + ['first' => ['id' => '1']], + 15, + 2, + ['path' => '/items', 'fragment' => 'results'], + ); + + $result = RootCollectData::collect($source); + + $this->assertInstanceOf(Paginator::class, $result); + $this->assertNotSame($source, $result); + $this->assertSame(['id' => '1'], $source->items()['first']); + $this->assertSame(1, $result->items()['first']->id); + $this->assertSame(2, $result->currentPage()); + $this->assertSame('/items', $result->path()); + $this->assertSame('results', $result->fragment()); + } + + public function testCollectPreservesLazyTraversalWithoutValidation(): void + { + $evaluated = false; + $source = LazyCollection::make(function () use (&$evaluated): iterable { + $evaluated = true; + + yield 'first' => ['id' => '1']; + }); + + $result = RootCollectData::collect($source); + + $this->assertInstanceOf(LazyCollection::class, $result); + $this->assertFalse($evaluated); + $this->assertSame(1, $result->first()->id); + $this->assertTrue($evaluated); + } + + public function testCollectRunsOneRootValidationLifecycle(): void + { + $prepareCalls = 0; + $beforeValidationCalls = 0; + $withValidatorCalls = 0; + $afterValidationCalls = 0; + + $result = RootCollectData::factory() + ->alwaysValidate() + ->prepareData(function (array $payload) use (&$prepareCalls): array { + ++$prepareCalls; + + return $payload; + }) + ->beforeValidation(function (array $payload) use (&$beforeValidationCalls): array { + ++$beforeValidationCalls; + + return $payload; + }) + ->withValidator(function () use (&$withValidatorCalls): void { + ++$withValidatorCalls; + }) + ->afterValidation(function (array $payload) use (&$afterValidationCalls): array { + ++$afterValidationCalls; + + return $payload; + }) + ->collect([ + ['id' => '1'], + ['id' => '2'], + ]); + + $this->assertSame(2, $prepareCalls); + $this->assertSame(1, $beforeValidationCalls); + $this->assertSame(1, $withValidatorCalls); + $this->assertSame(1, $afterValidationCalls); + $this->assertSame([1, 2], array_column($result, 'id')); + } + + public function testCollectMethodsReceiveTheNormalizedSourceShape(): void + { + $result = NamedRootCollectData::collect(new Collection([ + ['id' => '1'], + ])); + $array = NamedRootCollectData::collect(new Collection([ + ['id' => '2'], + ]), 'array'); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertSame(11, $result->first()->id); + $this->assertIsArray($array); + $this->assertSame(2, $array[0]->id); + } + + public function testExactEloquentCollectMethodDoesNotMatchDowngradedSource(): void + { + $result = EloquentNamedRootCollectData::collect(new EloquentCollection([ + ['id' => '1'], + ])); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertNotInstanceOf(EloquentCollection::class, $result); + $this->assertSame(1, $result->first()->id); + } + + public function testCollectBuildsExplicitPaginatorWrappersWithoutMutatingTheSource(): void + { + $paginator = new Paginator( + [['id' => '1']], + 15, + 2, + ['path' => '/items'], + ); + $cursorPaginator = new CursorPaginator( + [['id' => '2']], + 15, + null, + ['path' => '/cursor-items'], + ); + + $paginated = RootCollectData::collect($paginator, PaginatedDataCollection::class); + $cursorPaginated = RootCollectData::collect( + $cursorPaginator, + CursorPaginatedDataCollection::class, + ); + + $this->assertInstanceOf(PaginatedDataCollection::class, $paginated); + $this->assertInstanceOf(CursorPaginatedDataCollection::class, $cursorPaginated); + $this->assertSame(['id' => '1'], $paginator->items()[0]); + $this->assertSame(['id' => '2'], $cursorPaginator->items()[0]); + $this->assertSame(1, $paginated->items()->items()[0]->id); + $this->assertSame(2, $cursorPaginated->items()->items()[0]->id); + $this->assertSame(2, $paginated->items()->currentPage()); + $this->assertSame('/cursor-items', $cursorPaginated->items()->path()); + } + + public function testContractOnlyPaginatorCanTargetANonPaginatorWithoutCollectMethodDispatch(): void + { + $paginator = m::mock(PaginatorContract::class); + $paginator->shouldReceive('items')->once()->andReturn([ + ['id' => '1'], + ]); + + $result = NamedRootCollectData::collect($paginator, 'array'); + + $this->assertIsArray($result); + $this->assertSame(1, $result[0]->id); + } + + public function testTraversableCanTargetANonSourceShapedCollectionWithoutCollectMethodDispatch(): void + { + $result = NamedRootCollectData::collect( + new ArrayIterator([['id' => '1']]), + 'array', + ); + + $this->assertIsArray($result); + $this->assertSame(1, $result[0]->id); + } + + public function testCollectBatchesExplicitModelRelationsBeforeItemNormalization(): void + { + $first = (new RootCollectModel)->setRawAttributes(['id' => 1]); + $second = (new RootCollectModel)->setRawAttributes(['id' => 2]); + $models = new RootCollectModelCollection([$first, $second]); + + $result = RootCollectModelData::collect($models, DataCollection::class); + + $this->assertSame(['profile'], $models->loadedRelations); + $this->assertSame(1, $models->loadMissingCount); + $this->assertSame(0, $first->loadMissingCount); + $this->assertSame(0, $second->loadMissingCount); + $this->assertSame(1, $result[0]->profile->modelId); + $this->assertSame(2, $result[1]->profile->modelId); + } + + public function testNestedEloquentCollectionsBatchRelationsBeforeChildNormalization(): void + { + $child = (new RootCollectModel)->setRawAttributes(['id' => 3]); + $children = new RootCollectModelCollection([$child]); + $parent = new RootCollectParentModel; + $parent->setRelation('children', $children); + + $data = RootCollectParentData::from($parent); + + $this->assertSame(['profile'], $children->loadedRelations); + $this->assertSame(1, $children->loadMissingCount); + $this->assertSame(0, $child->loadMissingCount); + $this->assertSame(3, $data->children->first()->profile->modelId); + } +} + +class RootCollectModelData extends Data +{ + public function __construct( + public int $id, + #[LoadRelation] + public object $profile, + ) { + } +} + +class RootCollectParentData extends Data +{ + public function __construct( + #[DataCollectionOf(RootCollectModelData::class)] + public Collection $children, + ) { + } +} + +class RootCollectModel extends Model +{ + public int $loadMissingCount = 0; + + /** + * Determine if the fixture profile relation exists. + */ + public function isRelation(string $key): bool + { + return $key === 'profile'; + } + + /** + * Fail when collection creation falls back to per-model relation loading. + */ + public function loadMissing(array|string $relations): static + { + ++$this->loadMissingCount; + + return parent::loadMissing($relations); + } +} + +class RootCollectParentModel extends Model +{ +} + +/** @extends EloquentCollection */ +class RootCollectModelCollection extends EloquentCollection +{ + public int $loadMissingCount = 0; + + /** @var list */ + public array $loadedRelations = []; + + /** + * Load fixture relations for every model in one collection operation. + */ + public function loadMissing(array|string $relations): static + { + ++$this->loadMissingCount; + $this->loadedRelations = is_array($relations) ? $relations : [$relations]; + + foreach ($this as $model) { + $profile = new stdClass; + $profile->modelId = $model->getAttribute('id'); + $model->setRelation('profile', $profile); + } + + return $this; + } +} + +class RootCollectData extends Data +{ + public function __construct(public int $id) + { + } +} + +class NamedRootCollectData extends RootCollectData +{ + /** + * Customize collection construction. + * + * @param Collection $items + * @return Collection + */ + public static function collectCollection(Collection $items): Collection + { + return $items->map( + static fn (self $item): self => new self($item->id + 10), + ); + } +} + +class EloquentNamedRootCollectData extends RootCollectData +{ + /** + * Customize Eloquent collection construction. + * + * @param EloquentCollection $items + * @return Collection + */ + public static function collectEloquent(EloquentCollection $items): Collection + { + return $items->map( + static fn (self $item): self => new self($item->id + 10), + ); + } +} + +class RootCollectSourceCollection extends Collection +{ +} diff --git a/tests/Database/DatabaseEloquentJsonCastTest.php b/tests/Database/DatabaseEloquentJsonCastTest.php index fecb031f7..9e54921ab 100644 --- a/tests/Database/DatabaseEloquentJsonCastTest.php +++ b/tests/Database/DatabaseEloquentJsonCastTest.php @@ -7,7 +7,6 @@ use Hypervel\Contracts\Encryption\Encrypter; use Hypervel\Database\Eloquent\Casts\AsArrayObject; use Hypervel\Database\Eloquent\Casts\AsCollection; -use Hypervel\Database\Eloquent\Casts\AsDataObject; use Hypervel\Database\Eloquent\Casts\AsEncryptedArrayObject; use Hypervel\Database\Eloquent\Casts\AsEncryptedCollection; use Hypervel\Database\Eloquent\Casts\AsEnumArrayObject; @@ -16,7 +15,6 @@ use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; -use Hypervel\Support\DataObject; use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Fluent; use Hypervel\Testbench\TestCase; @@ -82,7 +80,6 @@ public function testEveryFirstPartyJsonClassCastRejectsEncoderFalseWithModelCont 'enum_array_object' => [AsEnumArrayObject::castUsing([JsonCastStatus::class]), [JsonCastStatus::Ready]], 'enum_collection' => [AsEnumCollection::castUsing([JsonCastStatus::class]), [JsonCastStatus::Ready]], 'fluent' => [AsFluent::castUsing([]), new Fluent(['value' => true])], - 'data_object' => [new AsDataObject(JsonCastData::class), new JsonCastData('value')], ]; try { @@ -111,7 +108,6 @@ public function testJsonClassCastReadersRejectSuccessfullyDecodedWrongShapes(): $this->assertNull(AsEncryptedArrayObject::castUsing([])->get($model, 'value', null, ['value' => $encryptedNull])); $this->assertNull(AsEncryptedCollection::castUsing([])->get($model, 'value', null, ['value' => $encryptedNull])); - $this->assertNull((new AsDataObject(JsonCastData::class))->get($model, 'value', 'null', ['value' => 'null'])); $this->assertNull(AsFluent::castUsing([])->get($model, 'value', 'null', ['value' => 'null'])); } @@ -129,25 +125,6 @@ public function testFluentCastAcceptsAnObjectFromACustomDecoder(): void } } - public function testDataObjectCastAcceptsEmptyMapsAndUsesTheCustomCodec(): void - { - $caster = new AsDataObject(JsonCastData::class); - $model = new JsonCastModel; - - $this->assertInstanceOf(JsonCastData::class, $caster->get($model, 'value', '{}', ['value' => '{}'])); - $this->assertInstanceOf(JsonCastData::class, $caster->get($model, 'value', '[]', ['value' => '[]'])); - - try { - Json::decodeUsing(static fn (): array => ['name' => 'decoded']); - Json::encodeUsing(static fn (): string => 'encoded'); - - $this->assertSame('decoded', $caster->get($model, 'value', 'ignored', ['value' => 'ignored'])->name); - $this->assertSame(['value' => 'encoded'], $caster->set($model, 'value', new JsonCastData('value'), [])); - } finally { - Json::flushState(); - } - } - public function testJsonPathAssignmentRejectsEncoderFalseBeforeStorageOrEncryption(): void { $model = new JsonCastModel; @@ -196,13 +173,6 @@ class JsonCastModel extends Model ]; } -class JsonCastData extends DataObject -{ - public function __construct(public readonly string $name = 'default') - { - } -} - enum JsonCastStatus: string { case Ready = 'ready'; diff --git a/tests/Http/ResourceResponseTest.php b/tests/Http/ResourceResponseTest.php new file mode 100644 index 000000000..2d91b78d1 --- /dev/null +++ b/tests/Http/ResourceResponseTest.php @@ -0,0 +1,66 @@ + 'Taylor'])) + ->toResponse(Request::create('/')); + + $this->assertSame([ + 'legacy' => ['name' => 'Taylor'], + ], $response->getData(true)); + } + + public function testInstanceWrapperOverridesStaticWrapper(): void + { + $response = (new InstanceWrappedResource(['name' => 'Taylor'], 'payload')) + ->toResponse(Request::create('/')); + + $this->assertSame([ + 'payload' => ['name' => 'Taylor'], + ], $response->getData(true)); + } + + public function testNullInstanceWrapperIsAuthoritativeWhenForcedWrappingIsEnabled(): void + { + $response = (new ForceWrappedResource(['name' => 'Taylor'], null)) + ->toResponse(Request::create('/')); + + $this->assertSame(['name' => 'Taylor'], $response->getData(true)); + } +} + +class LegacyWrappedResource extends JsonResource +{ + public static ?string $wrap = 'legacy'; +} + +class InstanceWrappedResource extends JsonResource implements ProvidesResourceWrapper +{ + public static ?string $wrap = 'legacy'; + + public function __construct(mixed $resource, protected readonly ?string $instanceWrapper) + { + parent::__construct($resource); + } + + public function resourceWrapper(): ?string + { + return $this->instanceWrapper; + } +} + +class ForceWrappedResource extends InstanceWrappedResource +{ + public static bool $forceWrapping = true; +} diff --git a/types/Data/Data.php b/types/Data/Data.php new file mode 100644 index 000000000..c7f167eef --- /dev/null +++ b/types/Data/Data.php @@ -0,0 +1,271 @@ + */ + use WithData; + + protected string $dataClass = DataTypeUserData::class; +} + +/** @return array */ +function dataTypeStringKeyedRows(): array +{ + return ['first' => ['id' => 1]]; +} + +/** @return class-string */ +function dataTypeDynamicCollectionTarget(): string +{ + return Collection::class; +} + +/** @return PaginatorContract */ +function dataTypePaginatorContract(): PaginatorContract +{ + return new Paginator([['id' => 1]], 10, 1); +} + +/** @return CursorPaginatorContract */ +function dataTypeCursorPaginatorContract(): CursorPaginatorContract +{ + return new CursorPaginator([['id' => 1]], 10); +} + +/** @return LengthAwarePaginatorContract */ +function dataTypeLengthAwarePaginatorContract(): LengthAwarePaginatorContract +{ + return new LengthAwarePaginator([['id' => 1]], 1, 10); +} + +assertType(DataTypeUserData::class, DataTypeUserData::from(['id' => 1])); +assertType(DataTypeUserData::class . '|null', DataTypeUserData::optional(null)); +assertType('Hypervel\Data\Support\Creation\CreationContextFactory', DataTypeUserData::factory()); +assertType(DataTypeUserData::class, (new DataTypeUserModel)->getData()); + +$array = DataTypeUserData::collect(dataTypeStringKeyedRows()); +$collection = DataTypeUserData::collect(new Collection(dataTypeStringKeyedRows())); +$lazyCollection = DataTypeUserData::collect(new LazyCollection(dataTypeStringKeyedRows())); +$eloquentCollection = DataTypeUserData::collect(new EloquentCollection([new DataTypeUserModel])); +$dataCollection = DataTypeUserData::collect(dataTypeStringKeyedRows(), DataCollection::class); +$directDataCollection = new DataCollection(DataTypeUserData::class, dataTypeStringKeyedRows()); +$sourceDataCollection = DataTypeUserData::collect($directDataCollection); +$factoryDataCollection = DataTypeUserData::factory()->collect(dataTypeStringKeyedRows(), DataCollection::class); + +/** @var class-string $baseDataClass */ +$baseDataClass = DataTypeUserData::class; +$baseDataCollection = $baseDataClass::collect(dataTypeStringKeyedRows(), DataCollection::class); + +$explicitArray = DataTypeUserData::collect(dataTypeStringKeyedRows(), 'array'); +$explicitEnumerable = DataTypeUserData::collect(dataTypeStringKeyedRows(), Enumerable::class); +$explicitEloquentCollection = DataTypeUserData::collect(dataTypeStringKeyedRows(), EloquentCollection::class); +$explicitCollection = DataTypeUserData::collect(dataTypeStringKeyedRows(), Collection::class); +$explicitLazyCollection = DataTypeUserData::collect(dataTypeStringKeyedRows(), LazyCollection::class); +$explicitDataCollection = DataTypeUserData::collect(dataTypeStringKeyedRows(), DataCollection::class); + +/** @var ArrayIterator $iterator */ +$iterator = new ArrayIterator(dataTypeStringKeyedRows()); +$iteratorCollection = DataTypeUserData::collect($iterator, Collection::class); +$dynamicTarget = DataTypeUserData::collect(dataTypeStringKeyedRows(), dataTypeDynamicCollectionTarget()); + +assertType('array', $array); +assertType('Hypervel\Support\Collection', $collection); +assertType('Hypervel\Support\LazyCollection', $lazyCollection); +assertType('Hypervel\Support\Collection', $eloquentCollection); +assertType('Hypervel\Data\DataCollection', $dataCollection); +assertType('array', $dataCollection->items()); +assertType(DataTypeUserData::class, $dataCollection['first']); +assertType('Hypervel\Data\DataCollection', $directDataCollection); +assertType('Hypervel\Data\DataCollection', $sourceDataCollection); +assertType('Hypervel\Data\DataCollection', $factoryDataCollection); +assertType('Hypervel\Data\DataCollection', $baseDataCollection); +assertType('array', $explicitArray); +assertType('Hypervel\Support\Collection', $explicitEnumerable); +assertType('Hypervel\Support\Collection', $explicitEloquentCollection); +assertType('Hypervel\Support\Collection', $explicitCollection); +assertType('Hypervel\Support\LazyCollection', $explicitLazyCollection); +assertType('Hypervel\Data\DataCollection', $explicitDataCollection); +assertType('Hypervel\Support\Collection', $iteratorCollection); +assertType('array|Hypervel\Contracts\Pagination\CursorPaginator|Hypervel\Contracts\Pagination\Paginator|Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator|Hypervel\Pagination\AbstractPaginator|Hypervel\Support\Enumerable', $dynamicTarget); + +/** @var Paginator $paginator */ +$paginator = new Paginator([['id' => 1]], 10, 1); +/** @var CursorPaginator $cursorPaginator */ +$cursorPaginator = new CursorPaginator([['id' => 1]], 10); +/** @var LengthAwarePaginator $lengthAwarePaginator */ +$lengthAwarePaginator = new LengthAwarePaginator([['id' => 1]], 1, 10); +/** @var AbstractPaginator $abstractPaginator */ +$abstractPaginator = $paginator; +/** @var AbstractCursorPaginator $abstractCursorPaginator */ +$abstractCursorPaginator = $cursorPaginator; + +$paginated = DataTypeUserData::collect($paginator); +$paginatedData = DataTypeUserData::collect($paginator, PaginatedDataCollection::class); +$cursorPaginated = DataTypeUserData::collect($cursorPaginator); +$cursorPaginatedData = DataTypeUserData::collect($cursorPaginator, CursorPaginatedDataCollection::class); +$lengthAwarePaginated = DataTypeUserData::collect($lengthAwarePaginator); +$abstractPaginated = DataTypeUserData::collect($abstractPaginator); +$abstractCursorPaginated = DataTypeUserData::collect($abstractCursorPaginator); +$contractPaginated = DataTypeUserData::collect(dataTypePaginatorContract()); +$contractCursorPaginated = DataTypeUserData::collect(dataTypeCursorPaginatorContract()); +$contractLengthAwarePaginated = DataTypeUserData::collect(dataTypeLengthAwarePaginatorContract()); +$explicitPaginator = DataTypeUserData::collect($paginator, Paginator::class); +$explicitCursorPaginator = DataTypeUserData::collect($cursorPaginator, CursorPaginator::class); +$explicitLengthAwarePaginator = DataTypeUserData::collect($lengthAwarePaginator, LengthAwarePaginator::class); +$explicitAbstractPaginator = DataTypeUserData::collect($paginator, AbstractPaginator::class); +$explicitAbstractCursorPaginator = DataTypeUserData::collect($cursorPaginator, AbstractCursorPaginator::class); +$explicitPaginatorContract = DataTypeUserData::collect($paginator, PaginatorContract::class); +$explicitCursorPaginatorContract = DataTypeUserData::collect($cursorPaginator, CursorPaginatorContract::class); +$explicitLengthAwareContract = DataTypeUserData::collect($lengthAwarePaginator, LengthAwarePaginatorContract::class); +$explicitPaginatedData = DataTypeUserData::collect($paginator, PaginatedDataCollection::class); +$explicitCursorPaginatedData = DataTypeUserData::collect($cursorPaginator, CursorPaginatedDataCollection::class); +$sourcePaginatedData = DataTypeUserData::collect($paginatedData); +$sourceCursorPaginatedData = DataTypeUserData::collect($cursorPaginatedData); + +/** @var Paginator $stringKeyedPaginator */ +$stringKeyedPaginator = new Paginator(dataTypeStringKeyedRows(), 10, 1); +/** @var CursorPaginator $stringKeyedCursorPaginator */ +$stringKeyedCursorPaginator = new CursorPaginator(dataTypeStringKeyedRows(), 10); +$directPaginatedData = new PaginatedDataCollection(DataTypeUserData::class, $stringKeyedPaginator); +$directCursorPaginatedData = new CursorPaginatedDataCollection(DataTypeUserData::class, $stringKeyedCursorPaginator); + +assertType('Hypervel\Pagination\Paginator', $paginated); +assertType('Hypervel\Data\PaginatedDataCollection', $paginatedData); +assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginated); +assertType('Hypervel\Data\CursorPaginatedDataCollection', $cursorPaginatedData); +assertType('Hypervel\Pagination\LengthAwarePaginator', $lengthAwarePaginated); +assertType('Hypervel\Pagination\AbstractPaginator', $abstractPaginated); +assertType('Hypervel\Pagination\AbstractCursorPaginator', $abstractCursorPaginated); +assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator|Hypervel\Pagination\AbstractPaginator|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractPaginated); +assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator|Hypervel\Pagination\AbstractPaginator|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractCursorPaginated); +assertType('Hypervel\Data\CursorPaginatedDataCollection|Hypervel\Data\DataCollection|Hypervel\Data\PaginatedDataCollection|Hypervel\Pagination\AbstractCursorPaginator|Hypervel\Pagination\AbstractPaginator|Hypervel\Support\Collection|Hypervel\Support\LazyCollection', $contractLengthAwarePaginated); +assertType('Hypervel\Pagination\Paginator', $explicitPaginator); +assertType('Hypervel\Pagination\CursorPaginator', $explicitCursorPaginator); +assertType('Hypervel\Pagination\LengthAwarePaginator', $explicitLengthAwarePaginator); +assertType('Hypervel\Pagination\AbstractPaginator', $explicitAbstractPaginator); +assertType('Hypervel\Pagination\AbstractCursorPaginator', $explicitAbstractCursorPaginator); +assertType('Hypervel\Contracts\Pagination\Paginator', $explicitPaginatorContract); +assertType('Hypervel\Contracts\Pagination\CursorPaginator', $explicitCursorPaginatorContract); +assertType('Hypervel\Contracts\Pagination\LengthAwarePaginator', $explicitLengthAwareContract); +assertType('Hypervel\Data\PaginatedDataCollection', $explicitPaginatedData); +assertType('Hypervel\Data\CursorPaginatedDataCollection', $explicitCursorPaginatedData); +assertType('Hypervel\Data\PaginatedDataCollection', $sourcePaginatedData); +assertType('Hypervel\Data\CursorPaginatedDataCollection', $sourceCursorPaginatedData); +assertType('Hypervel\Data\PaginatedDataCollection', $directPaginatedData); +assertType('Hypervel\Data\CursorPaginatedDataCollection', $directCursorPaginatedData); + +function dataTypeAcceptsTransformable(TransformableData $data): void +{ +} + +function dataTypeAcceptsValidateable(ValidateableData $data): void +{ +} + +function dataTypeAcceptsResponsable(ResponsableData $data): void +{ +} + +function dataTypeAcceptsCastable(Castable $data): void +{ +} + +dataTypeAcceptsTransformable(new DataTypeUserData(1)); +dataTypeAcceptsTransformable(new DataTypeUserResource(1)); +dataTypeAcceptsValidateable(new DataTypeUserData(1)); +dataTypeAcceptsValidateable(new DataTypeUserDto(1)); +dataTypeAcceptsResponsable(new DataTypeUserData(1)); +dataTypeAcceptsResponsable(new DataTypeUserResource(1)); +dataTypeAcceptsCastable(new DataTypeUserData(1)); +dataTypeAcceptsCastable(new DataTypeUserResource(1)); + +/** @extends Request */ +class DataTypeUserRequest extends Request +{ + protected Method $method = Method::GET; + + public function resolveEndpoint(): string + { + return '/user'; + } + + /** @param Response $response */ + public function createDtoFromResponse(Response $response): DataTypeUserData + { + return DataTypeUserData::from($response->json()); + } +} + +/** @extends Connector */ +class DataTypeConnector extends Connector +{ + public function resolveBaseUrl(): string + { + return 'https://example.com'; + } +} + +$response = (new DataTypeConnector)->send(new DataTypeUserRequest); + +assertType('Hypervel\Saloon\Http\Response', $response); +assertType(DataTypeUserData::class, $response->dto()); + +/** @var ArrayIterator $unsupportedIterator */ +$unsupportedIterator = new ArrayIterator(dataTypeStringKeyedRows()); + +assertType('never', DataTypeUserData::collect($unsupportedIterator)); diff --git a/types/Pagination/Paginator.php b/types/Pagination/Paginator.php index 156bbde89..a6a63dd89 100644 --- a/types/Pagination/Paginator.php +++ b/types/Pagination/Paginator.php @@ -5,6 +5,7 @@ use Hypervel\Pagination\CursorPaginator; use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Pagination\Paginator; +use Hypervel\Support\Collection; use function PHPStan\Testing\assertType; @@ -27,6 +28,13 @@ assertType('Post', $post); } +$paginatorWithReplacedCollection = clone $paginator; +$paginatorWithReplacedCollection->setCollection(new Collection([ + 'first' => ['id' => 1], +])); + +assertType('Hypervel\Pagination\Paginator', $paginatorWithReplacedCollection); + /** @var LengthAwarePaginator $lengthAwarePaginator */ $lengthAwarePaginator = new LengthAwarePaginator($items, 1, 1); @@ -58,6 +66,13 @@ assertType('Post', $post); } +$cursorPaginatorWithReplacedCollection = clone $cursorPaginator; +$cursorPaginatorWithReplacedCollection->setCollection(new Collection([ + 'first' => ['id' => 1], +])); + +assertType('Hypervel\Pagination\CursorPaginator', $cursorPaginatorWithReplacedCollection); + $throughPaginator = clone $cursorPaginator; $throughPaginator->through(function ($post, $key): array { assertType('int', $key); From 93e3c129a15e8fe4e6273f5aaa35c8bc61b453d4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:49:44 +0000 Subject: [PATCH 19/35] Move FormRequest Data casts into the package Add explicit AsData and AsDataCollection adapters that reuse the package creation engine through Foundation's generic Castable contract. Remove Foundation's special-case DataObject branch and legacy array and collection casts. Keep the general request-casting extension unchanged and cover single objects, typed collections, arrays, and explicit target containers. --- src/data/src/Http/Casts/AsData.php | 61 ++++++ src/data/src/Http/Casts/AsDataCollection.php | 64 ++++++ .../src/Http/Casts/AsDataObjectArray.php | 55 ----- .../src/Http/Casts/AsDataObjectCollection.php | 55 ----- src/foundation/src/Http/Traits/HasCasts.php | 52 ----- tests/Data/Http/Casts/FormRequestCastTest.php | 207 ++++++++++++++++++ tests/Foundation/Http/CustomCastingTest.php | 135 ------------ 7 files changed, 332 insertions(+), 297 deletions(-) create mode 100644 src/data/src/Http/Casts/AsData.php create mode 100644 src/data/src/Http/Casts/AsDataCollection.php delete mode 100644 src/foundation/src/Http/Casts/AsDataObjectArray.php delete mode 100644 src/foundation/src/Http/Casts/AsDataObjectCollection.php create mode 100644 tests/Data/Http/Casts/FormRequestCastTest.php diff --git a/src/data/src/Http/Casts/AsData.php b/src/data/src/Http/Casts/AsData.php new file mode 100644 index 000000000..1b7bb6782 --- /dev/null +++ b/src/data/src/Http/Casts/AsData.php @@ -0,0 +1,61 @@ + $dataClass + */ + public function __construct(protected readonly string $dataClass) + { + if (! is_a($dataClass, BaseData::class, true)) { + throw new InvalidArgumentException( + "Data cast target `{$dataClass}` should implement `" . BaseData::class . '`', + ); + } + } + + /** + * Get the cast declaration for a data class. + * + * @param class-string $dataClass + */ + public static function of(string $dataClass): string + { + return static::class . ':' . $dataClass; + } + + /** + * Get the caster for a data class. + */ + public static function castUsing(array $arguments = []): CastInputs + { + $dataClass = $arguments[0] ?? throw new InvalidArgumentException( + 'A data class is required for the FormRequest data cast.', + ); + + return new static($dataClass); + } + + /** + * Transform an input value into data. + */ + public function get(string $key, mixed $value, array $inputs): ?BaseData + { + if (! array_key_exists($key, $inputs) || $value === null) { + return null; + } + + return ($this->dataClass)::from($value); + } +} diff --git a/src/data/src/Http/Casts/AsDataCollection.php b/src/data/src/Http/Casts/AsDataCollection.php new file mode 100644 index 000000000..71bdd840f --- /dev/null +++ b/src/data/src/Http/Casts/AsDataCollection.php @@ -0,0 +1,64 @@ + $dataClass + */ + public function __construct( + protected readonly string $dataClass, + protected readonly string $into = DataCollection::class, + ) { + if (! is_a($dataClass, BaseData::class, true)) { + throw new InvalidArgumentException( + "Data collection cast target `{$dataClass}` should implement `" . BaseData::class . '`', + ); + } + } + + /** + * Get the cast declaration for a data collection. + * + * @param class-string $dataClass + */ + public static function of(string $dataClass, string $into = DataCollection::class): string + { + return static::class . ':' . $dataClass . ',' . $into; + } + + /** + * Get the caster for a data collection. + */ + public static function castUsing(array $arguments = []): CastInputs + { + $dataClass = $arguments[0] ?? throw new InvalidArgumentException( + 'A data class is required for the FormRequest data collection cast.', + ); + + return new static($dataClass, $arguments[1] ?? DataCollection::class); + } + + /** + * Transform an input value into a data collection. + */ + public function get(string $key, mixed $value, array $inputs): mixed + { + if (! array_key_exists($key, $inputs) || $value === null) { + return null; + } + + return ($this->dataClass)::collect($value, $this->into); + } +} diff --git a/src/foundation/src/Http/Casts/AsDataObjectArray.php b/src/foundation/src/Http/Casts/AsDataObjectArray.php deleted file mode 100644 index c4400f6c7..000000000 --- a/src/foundation/src/Http/Casts/AsDataObjectArray.php +++ /dev/null @@ -1,55 +0,0 @@ -arguments[0]; - - // Check if the class has make static method (provided by DataObject) - if (! method_exists($dataClass, 'make')) { - throw new RuntimeException( - "Class {$dataClass} must implement static make(array \$data) method" - ); - } - - return new ArrayObject( - array_map(fn ($item) => $dataClass::make($item), $value) - ); - } - }; - } -} diff --git a/src/foundation/src/Http/Casts/AsDataObjectCollection.php b/src/foundation/src/Http/Casts/AsDataObjectCollection.php deleted file mode 100644 index b08a9c59e..000000000 --- a/src/foundation/src/Http/Casts/AsDataObjectCollection.php +++ /dev/null @@ -1,55 +0,0 @@ -arguments[0]; - - // Check if the class has make static method (provided by DataObject) - if (! method_exists($dataClass, 'make')) { - throw new RuntimeException( - "Class {$dataClass} must implement static make(array \$data) method" - ); - } - - return new Collection( - array_map(fn ($item) => $dataClass::make($item), $value) - ); - } - }; - } -} diff --git a/src/foundation/src/Http/Traits/HasCasts.php b/src/foundation/src/Http/Traits/HasCasts.php index 5949cde81..ba3e4becf 100644 --- a/src/foundation/src/Http/Traits/HasCasts.php +++ b/src/foundation/src/Http/Traits/HasCasts.php @@ -12,10 +12,8 @@ use Hypervel\Foundation\Http\Contracts\Castable; use Hypervel\Foundation\Http\Contracts\CastInputs; use Hypervel\Support\Collection; -use Hypervel\Support\DataObject; use Hypervel\Support\Facades\Date; use Hypervel\Support\Json; -use RuntimeException; use UnitEnum; trait HasCasts @@ -159,11 +157,6 @@ protected function castInput(string $key, mixed $value, bool $validate = true): return $this->getEnumCastableInputValue($key, $value); } - // Handle DataObject casts - if ($this->isDataObjectCastable($key)) { - return $this->getDataObjectCastableInputValue($key, $value); - } - // Handle custom class casts if ($this->isClassCastable($key)) { return $this->getClassCastableInputValue($key, $value, $validate); @@ -213,31 +206,6 @@ protected function getEnumCastableInputValue(string $key, mixed $value): mixed return $this->getEnumCaseFromValue($castType, $value); } - /** - * Cast the given input to a DataObject. - */ - public function getDataObjectCastableInputValue(string $key, mixed $value): mixed - { - if (is_null($value)) { - return null; - } - - $castType = $this->getCasts()[$key]; - - if (! is_array($value)) { - throw new InvalidCastException($this, $key, $castType); - } - - // Check if the class has make static method (provided by DataObject) - if (! method_exists($castType, 'make')) { - throw new RuntimeException( - "Class {$castType} must implement static make(array \$data) method" - ); - } - - return $castType::make($value); - } - /** * Get an enum case instance from a given class and value. */ @@ -328,26 +296,6 @@ protected function isEnumCastable(string $key): bool return enum_exists($castType); } - /** - * Determine if the given key is cast using a DataObject. - */ - public function isDataObjectCastable(string $key): bool - { - $casts = $this->getCasts(); - - if (! array_key_exists($key, $casts)) { - return false; - } - - $castType = $casts[$key]; - - if (in_array($castType, static::$primitiveCastTypes)) { - return false; - } - - return is_subclass_of($castType, DataObject::class); - } - /** * Resolve the custom caster class for a given key. */ diff --git a/tests/Data/Http/Casts/FormRequestCastTest.php b/tests/Data/Http/Casts/FormRequestCastTest.php new file mode 100644 index 000000000..51a8503f0 --- /dev/null +++ b/tests/Data/Http/Casts/FormRequestCastTest.php @@ -0,0 +1,207 @@ + ['name' => 'Taylor'], + 'dto' => ['name' => 'Abigail'], + ]); + $request->setContainer($this->app); + $request->validateResolved(); + + $contact = $request->casted('contact'); + $dto = $request->casted('dto'); + + $this->assertInstanceOf(RequestContactData::class, $contact); + $this->assertSame('Taylor', $contact->name); + $this->assertInstanceOf(RequestContactDto::class, $dto); + $this->assertSame('Abigail', $dto->name); + } + + public function testAsDataCollectionSupportsTheDefaultAndExplicitTargets(): void + { + $request = DataCollectionCastingRequest::create('/', 'POST', [ + 'default_contacts' => [ + 'primary' => ['name' => 'Taylor'], + 'secondary' => ['name' => 'Abigail'], + ], + 'array_contacts' => [ + 'primary' => ['name' => 'Taylor'], + ], + 'collection_contacts' => [ + 'secondary' => ['name' => 'Abigail'], + ], + ]); + $request->setContainer($this->app); + + $default = $request->casted('default_contacts', false); + $array = $request->casted('array_contacts', false); + $collection = $request->casted('collection_contacts', false); + + $this->assertInstanceOf(DataCollection::class, $default); + $this->assertSame(['primary', 'secondary'], array_keys($default->items())); + $this->assertEquals(new RequestContactData('Taylor'), $default['primary']); + $this->assertIsArray($array); + $this->assertSame(['primary'], array_keys($array)); + $this->assertEquals(new RequestContactData('Taylor'), $array['primary']); + $this->assertInstanceOf(Collection::class, $collection); + $this->assertEquals(new RequestContactData('Abigail'), $collection['secondary']); + } + + public function testDataCastsReturnNullForMissingOrNullInputs(): void + { + $request = NullableDataCastingRequest::create('/', 'POST', [ + 'contact' => null, + 'contacts' => null, + ]); + $request->setContainer($this->app); + + $this->assertNull($request->casted('contact', false)); + $this->assertNull($request->casted('contacts', false)); + $this->assertNull($request->casted('missing_contact', false)); + $this->assertNull($request->casted('missing_contacts', false)); + } + + public function testCastDeclarationsUseTheGenericFoundationCasterSurface(): void + { + $this->assertSame( + AsData::class . ':' . RequestContactData::class, + AsData::of(RequestContactData::class), + ); + $this->assertSame( + AsDataCollection::class . ':' . RequestContactData::class . ',' . DataCollection::class, + AsDataCollection::of(RequestContactData::class), + ); + $this->assertSame( + AsDataCollection::class . ':' . RequestContactData::class . ',array', + AsDataCollection::of(RequestContactData::class, 'array'), + ); + } + + public function testDataCastsRejectMissingAndInvalidTargetClasses(): void + { + foreach ([ + static fn () => AsData::castUsing(), + static fn () => AsDataCollection::castUsing(), + static fn () => AsData::castUsing([stdClass::class]), + static fn () => AsDataCollection::castUsing([stdClass::class]), + ] as $declaration) { + $this->assertThrows($declaration, InvalidArgumentException::class); + } + } +} + +class DataCastingRequest extends FormRequest +{ + /** + * Get the request casts. + */ + protected function casts(): array + { + return [ + 'contact' => AsData::of(RequestContactData::class), + 'dto' => AsData::of(RequestContactDto::class), + ]; + } + + /** + * Get the validation rules for the request. + */ + public function rules(): array + { + return [ + 'contact' => ['required', 'array'], + 'contact.name' => ['required', 'string'], + 'dto' => ['required', 'array'], + 'dto.name' => ['required', 'string'], + ]; + } +} + +class DataCollectionCastingRequest extends FormRequest +{ + /** + * Get the request casts. + */ + protected function casts(): array + { + return [ + 'default_contacts' => AsDataCollection::of(RequestContactData::class), + 'array_contacts' => AsDataCollection::of(RequestContactData::class, 'array'), + 'collection_contacts' => AsDataCollection::of(RequestContactData::class, Collection::class), + ]; + } + + /** + * Get the validation rules for the request. + */ + public function rules(): array + { + return []; + } +} + +class NullableDataCastingRequest extends FormRequest +{ + /** + * Get the request casts. + */ + protected function casts(): array + { + return [ + 'contact' => AsData::of(RequestContactData::class), + 'contacts' => AsDataCollection::of(RequestContactData::class), + 'missing_contact' => AsData::of(RequestContactData::class), + 'missing_contacts' => AsDataCollection::of(RequestContactData::class), + ]; + } + + /** + * Get the validation rules for the request. + */ + public function rules(): array + { + return []; + } +} + +class RequestContactData extends Data +{ + public function __construct(public string $name) + { + } +} + +class RequestContactDto extends Dto +{ + public function __construct(public string $name) + { + } +} diff --git a/tests/Foundation/Http/CustomCastingTest.php b/tests/Foundation/Http/CustomCastingTest.php index ffaeccd09..63c15d23a 100644 --- a/tests/Foundation/Http/CustomCastingTest.php +++ b/tests/Foundation/Http/CustomCastingTest.php @@ -6,8 +6,6 @@ use ArrayObject; use Carbon\CarbonInterface; -use Hypervel\Foundation\Http\Casts\AsDataObjectArray; -use Hypervel\Foundation\Http\Casts\AsDataObjectCollection; use Hypervel\Foundation\Http\Casts\AsEnumArrayObject; use Hypervel\Foundation\Http\Casts\AsEnumCollection; use Hypervel\Foundation\Http\Contracts\CastInputs; @@ -16,7 +14,6 @@ use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; -use Hypervel\Support\DataObject; use Hypervel\Support\Facades\Date; use Hypervel\Support\Json; use Hypervel\Testbench\TestCase; @@ -374,71 +371,6 @@ public function testDatetimeCastingPreservesAppTimezone(): void date_default_timezone_set($originalTimezone); } } - - /** - * Test DataObject casting with DataObject. - */ - public function testDataObjectCasting() - { - $request = DataObjectCastingRequest::create('/', 'POST', [ - 'contact' => ['name' => 'Jane', 'email' => 'jane@example.com'], - ]); - $request->setContainer($this->app); - $request->validateResolved(); - - $contact = $request->casted('contact'); - $this->assertInstanceOf(Contact::class, $contact); - $this->assertSame('Jane', $contact->name); - $this->assertSame('jane@example.com', $contact->email); - } - - /** - * Test AsDataObjectArray casting with DataObject. - */ - public function testAsArrayObjectCasting() - { - $request = DataObjectArrayCastingRequest::create('/', 'POST', [ - 'contacts' => [ - ['name' => 'John', 'email' => 'john@example.com'], - ['name' => 'Jane', 'email' => 'jane@example.com'], - ], - ]); - $request->setContainer($this->app); - - $contacts = $request->casted('contacts', false); - $this->assertInstanceOf(ArrayObject::class, $contacts); - $this->assertCount(2, $contacts); - $this->assertInstanceOf(Contact::class, $contacts[0]); - $this->assertSame('John', $contacts[0]->name); - $this->assertSame('john@example.com', $contacts[0]->email); - } - - /** - * Test AsCollection casting with DataObject. - */ - public function testAsCollectionCasting() - { - $request = DataObjectCollectionCastingRequest::create('/', 'POST', [ - 'products' => [ - ['sku' => 'ABC123', 'name' => 'Product A', 'price' => 100], - ['sku' => 'DEF456', 'name' => 'Product B', 'price' => 200], - ['sku' => 'GHI789', 'name' => 'Product C', 'price' => 150], - ], - ]); - $request->setContainer($this->app); - - $products = $request->casted('products', false); - $this->assertInstanceOf(Collection::class, $products); - $this->assertCount(3, $products); - $this->assertInstanceOf(Product::class, $products->first()); - - // Test Collection methods - $expensiveProducts = $products->filter(fn ($p) => $p->price > 100); - $this->assertCount(2, $expensiveProducts); - - $skus = $products->pluck('sku')->all(); - $this->assertSame(['ABC123', 'DEF456', 'GHI789'], $skus); - } } // Test Request Classes @@ -571,54 +503,6 @@ public function rules(): array } } -class DataObjectCastingRequest extends FormRequest -{ - protected array $casts = [ - 'contact' => Contact::class, - ]; - - public function rules(): array - { - return [ - 'contact' => 'array', - ]; - } -} - -class DataObjectArrayCastingRequest extends FormRequest -{ - protected function casts(): array - { - return [ - 'contacts' => AsDataObjectArray::of(Contact::class), - ]; - } - - public function rules(): array - { - return [ - 'contacts' => 'array', - ]; - } -} - -class DataObjectCollectionCastingRequest extends FormRequest -{ - protected function casts(): array - { - return [ - 'products' => AsDataObjectCollection::of(Product::class), - ]; - } - - public function rules(): array - { - return [ - 'products' => 'array', - ]; - } -} - // Test Enums and Classes enum UserStatus: string @@ -649,22 +533,3 @@ public function get(string $key, mixed $value, array $inputs): Money return Money::fromCents((int) $value); } } - -class Contact extends DataObject -{ - public function __construct( - public readonly string $name, - public readonly string $email - ) { - } -} - -class Product extends DataObject -{ - public function __construct( - public readonly string $sku, - public readonly string $name, - public readonly int $price - ) { - } -} From ad02debab70a26e2645081591716db7cf0536a51 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:49:57 +0000 Subject: [PATCH 20/35] Add optional Inertia lazy values Adapt Data lazy values to Hypervel Inertia's optional and deferred props, including group and rescue options and exact preservation of existing deferred prop state. Keep the integration behind explicit factories so ordinary Data loading and transformation do not resolve Inertia classes, with focused initial, partial, deferred, serialized, and concurrent-request coverage. --- .../src/Attributes/AutoInertiaDeferred.php | 32 ++++ src/data/src/Attributes/AutoInertiaLazy.php | 23 +++ src/data/src/Support/Lazy/InertiaDeferred.php | 84 ++++++++++ src/data/src/Support/Lazy/InertiaLazy.php | 65 ++++++++ tests/Data/Inertia/InertiaIntegrationTest.php | 145 ++++++++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 src/data/src/Attributes/AutoInertiaDeferred.php create mode 100644 src/data/src/Attributes/AutoInertiaLazy.php create mode 100644 src/data/src/Support/Lazy/InertiaDeferred.php create mode 100644 src/data/src/Support/Lazy/InertiaLazy.php create mode 100644 tests/Data/Inertia/InertiaIntegrationTest.php diff --git a/src/data/src/Attributes/AutoInertiaDeferred.php b/src/data/src/Attributes/AutoInertiaDeferred.php new file mode 100644 index 000000000..a741d7344 --- /dev/null +++ b/src/data/src/Attributes/AutoInertiaDeferred.php @@ -0,0 +1,32 @@ + $castValue($value), $this->group, $this->rescue); + } +} diff --git a/src/data/src/Attributes/AutoInertiaLazy.php b/src/data/src/Attributes/AutoInertiaLazy.php new file mode 100644 index 000000000..4ec25bc0e --- /dev/null +++ b/src/data/src/Attributes/AutoInertiaLazy.php @@ -0,0 +1,23 @@ + $castValue($value)); + } +} diff --git a/src/data/src/Support/Lazy/InertiaDeferred.php b/src/data/src/Support/Lazy/InertiaDeferred.php new file mode 100644 index 000000000..767ac8620 --- /dev/null +++ b/src/data/src/Support/Lazy/InertiaDeferred.php @@ -0,0 +1,84 @@ +value = match (true) { + $value instanceof DeferProp => $value, + is_callable($value) => Closure::fromCallable($value), + default => fn (): mixed => $value, + }; + } + + /** + * Resolve the Inertia property. + */ + public function resolve(): DeferProp + { + return $this->value instanceof DeferProp + ? $this->value + : new DeferProp($this->value, $this->group, $this->rescue); + } + + /** + * Determine if the Inertia property is intrinsically included. + */ + public function shouldBeIncluded(): bool + { + return true; + } + + /** + * Determine if resolving this lazy value produces data. + */ + public function resolvesToData(): bool + { + return false; + } + + /** + * Get the serializable lazy state. + */ + public function __serialize(): array + { + return [ + 'value' => $this->value instanceof Closure + ? new SerializableClosure($this->value) + : $this->value, + 'group' => $this->group, + 'rescue' => $this->rescue, + 'defaultIncluded' => $this->defaultIncluded, + ]; + } + + /** + * Restore serialized lazy state. + */ + public function __unserialize(array $data): void + { + $this->value = $data['value'] instanceof SerializableClosure + ? $data['value']->getClosure() + : $data['value']; + $this->group = $data['group']; + $this->rescue = $data['rescue']; + $this->defaultIncluded = $data['defaultIncluded']; + } +} diff --git a/src/data/src/Support/Lazy/InertiaLazy.php b/src/data/src/Support/Lazy/InertiaLazy.php new file mode 100644 index 000000000..b808c4ae4 --- /dev/null +++ b/src/data/src/Support/Lazy/InertiaLazy.php @@ -0,0 +1,65 @@ +value); + } + + /** + * Determine if the Inertia property is intrinsically included. + */ + public function shouldBeIncluded(): bool + { + return true; + } + + /** + * Determine if resolving this lazy value produces data. + */ + public function resolvesToData(): bool + { + return false; + } + + /** + * Get the serializable lazy state. + */ + public function __serialize(): array + { + return [ + 'value' => new SerializableClosure($this->value), + 'defaultIncluded' => $this->defaultIncluded, + ]; + } + + /** + * Restore serialized lazy state. + */ + public function __unserialize(array $data): void + { + $this->value = $data['value']->getClosure(); + $this->defaultIncluded = $data['defaultIncluded']; + } +} diff --git a/tests/Data/Inertia/InertiaIntegrationTest.php b/tests/Data/Inertia/InertiaIntegrationTest.php new file mode 100644 index 000000000..d2296ae39 --- /dev/null +++ b/tests/Data/Inertia/InertiaIntegrationTest.php @@ -0,0 +1,145 @@ + '1', + 'optional' => ['id' => '2'], + 'deferred' => ['id' => '3'], + ]); + + $initial = $this->makePage($this->makeInertiaRequest(), $data); + + $this->assertSame(['id' => 1], $initial['props']); + $this->assertSame(['analytics' => ['deferred']], $initial['deferredProps']); + + $partial = $this->makePage( + $this->makeInertiaRequest('optional,deferred'), + $data, + ); + + $this->assertSame([ + 'optional' => ['id' => 2], + 'deferred' => ['id' => 3], + ], $partial['props']); + $this->assertArrayNotHasKey('deferredProps', $partial); + } + + public function testAutomaticInertiaPropertiesAreIsolatedBetweenCoroutines(): void + { + [$first, $second] = parallel([ + function (): int { + $data = InertiaPageData::from([ + 'id' => '1', + 'optional' => ['id' => '11'], + 'deferred' => ['id' => '12'], + ]); + + usleep(5000); + + return $this->makePage( + $this->makeInertiaRequest('optional'), + $data, + )['props']['optional']['id']; + }, + function (): int { + $data = InertiaPageData::from([ + 'id' => '2', + 'optional' => ['id' => '21'], + 'deferred' => ['id' => '22'], + ]); + + usleep(1000); + + return $this->makePage( + $this->makeInertiaRequest('optional'), + $data, + )['props']['optional']['id']; + }, + ]); + + $this->assertSame(11, $first); + $this->assertSame(21, $second); + } + + /** + * Resolve Data through an Inertia response. + * + * @return array + */ + protected function makePage(Request $request, InertiaPageData $data): array + { + $response = Inertia::render('TestComponent', $data)->toResponse($request); + + $this->assertInstanceOf(JsonResponse::class, $response); + + return $response->getData(true); + } + + /** + * Create an Inertia request, optionally selecting partial props. + */ + protected function makeInertiaRequest(?string $only = null): Request + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + + if ($only !== null) { + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => $only]); + } + + return $request; + } +} + +class InertiaPageData extends Data +{ + public function __construct( + public int $id, + #[AutoInertiaLazy] + public Lazy|InertiaChildData $optional, + #[AutoInertiaDeferred('analytics', rescue: true)] + public Lazy|InertiaChildData $deferred, + ) { + } +} + +class InertiaChildData extends Data +{ + public function __construct( + public int $id, + ) { + } +} From b543d6c0bd3a4d1be61b2a4f125466dbc7f61a2f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:50:08 +0000 Subject: [PATCH 21/35] Add the WithData source concern Provide the familiar generic WithData trait for models, requests, and ordinary source objects that declare their associated Data class through a property or method. Preserve property-before-method precedence, return precise static-analysis types, and fail clearly for missing or invalid declarations. Cover request validation and non-request source behavior through the shared construction engine. --- .../src/Exceptions/CannotFindDataClass.php | 14 + src/data/src/WithData.php | 35 +++ tests/Data/WithDataTest.php | 239 ++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 src/data/src/WithData.php create mode 100644 tests/Data/WithDataTest.php diff --git a/src/data/src/Exceptions/CannotFindDataClass.php b/src/data/src/Exceptions/CannotFindDataClass.php index b9141cd56..05b9df18e 100644 --- a/src/data/src/Exceptions/CannotFindDataClass.php +++ b/src/data/src/Exceptions/CannotFindDataClass.php @@ -20,6 +20,20 @@ public static function forClass(string $class): self return new self("Class [{$class}] must implement [" . BaseData::class . '].'); } + /** + * Create an exception for a source without a valid data class. + */ + public static function forSource(string $source, mixed $dataClass): self + { + if ($dataClass === null) { + return new self("Class [{$source}] must declare a [\$dataClass] property or a [dataClass()] method to use [getData()]."); + } + + $declared = is_string($dataClass) ? $dataClass : get_debug_type($dataClass); + + return new self("Class [{$source}] declared data class [{$declared}], which must implement [" . BaseData::class . '].'); + } + /** * Create an exception for a declaration without a data class. */ diff --git a/src/data/src/WithData.php b/src/data/src/WithData.php new file mode 100644 index 000000000..005a1281e --- /dev/null +++ b/src/data/src/WithData.php @@ -0,0 +1,35 @@ + $this->dataClass, + method_exists($this, 'dataClass') => $this->dataClass(), + default => null, + }; + + if (! is_string($dataClass) || ! is_a($dataClass, BaseData::class, true)) { + throw CannotFindDataClass::forSource(static::class, $dataClass); + } + + /** @var class-string $dataClass */ + return $dataClass::from($this); + } +} diff --git a/tests/Data/WithDataTest.php b/tests/Data/WithDataTest.php new file mode 100644 index 000000000..f4c258e34 --- /dev/null +++ b/tests/Data/WithDataTest.php @@ -0,0 +1,239 @@ +setRawAttributes(['name' => 'Taylor']); + + $data = $model->getData(); + + $this->assertInstanceOf(WithDataNameData::class, $data); + $this->assertSame('Taylor', $data->name); + } + + public function testArrayableCanDeclareItsDataClassWithAMethod(): void + { + $data = (new WithDataArrayableSource('Taylor'))->getData(); + + $this->assertInstanceOf(WithDataNameData::class, $data); + $this->assertSame('Taylor', $data->name); + } + + public function testPropertyDeclarationTakesPrecedenceOverMethodDeclaration(): void + { + $data = (new WithDataPrecedenceSource)->getData(); + + $this->assertInstanceOf(WithDataPropertyData::class, $data); + } + + public function testMissingDataClassDeclarationFailsClearly(): void + { + $this->expectException(CannotFindDataClass::class); + $this->expectExceptionMessage( + 'Class [' . WithDataMissingSource::class . '] must declare a [$dataClass] property or a [dataClass()] method to use [getData()].', + ); + + (new WithDataMissingSource)->getData(); + } + + public function testInvalidDataClassDeclarationFailsClearly(): void + { + $this->expectException(CannotFindDataClass::class); + $this->expectExceptionMessage( + 'Class [' . WithDataInvalidSource::class . '] declared data class [array], which must implement [Hypervel\Data\Contracts\BaseData].', + ); + + (new WithDataInvalidSource)->getData(); + } + + public function testFormRequestUsesTheAssociatedDataClassValidation(): void + { + $request = WithDataFormRequestSource::create('/', 'POST', ['name' => 'invalid']); + $request->setContainer($this->app); + + try { + $request->getData(); + $this->fail('Expected the associated data class validation to fail.'); + } catch (ValidationException $exception) { + $this->assertSame(['Data validation ran.'], $exception->errors()['name']); + } + } + + public function testModelSourceDoesNotValidateUnderOnlyRequests(): void + { + $model = new WithDataValidatedModelSource; + $model->setRawAttributes(['name' => 'invalid']); + + $data = $model->getData(); + + $this->assertInstanceOf(WithDataValidatedNameData::class, $data); + $this->assertSame('invalid', $data->name); + } +} + +class WithDataNameData extends Data +{ + public function __construct(public string $name) + { + } +} + +class WithDataPropertyData extends Data +{ +} + +class WithDataMethodData extends Data +{ +} + +class WithDataValidatedNameData extends Data +{ + public function __construct(public string $name) + { + } + + /** + * Get the validation rules. + */ + public static function rules(): array + { + return ['name' => ['in:data']]; + } + + /** + * Get the validation messages. + */ + public static function messages(): array + { + return ['name.in' => 'Data validation ran.']; + } +} + +class WithDataModelSource extends Model +{ + /** @use WithData */ + use WithData; + + protected string $dataClass = WithDataNameData::class; +} + +class WithDataValidatedModelSource extends Model +{ + /** @use WithData */ + use WithData; + + protected string $dataClass = WithDataValidatedNameData::class; +} + +class WithDataArrayableSource implements Arrayable +{ + /** @use WithData */ + use WithData; + + public function __construct(public string $name) + { + } + + /** + * Convert the source to an array. + */ + public function toArray(): array + { + return ['name' => $this->name]; + } + + /** + * Get the associated data class. + */ + protected function dataClass(): string + { + return WithDataNameData::class; + } +} + +class WithDataPrecedenceSource implements Arrayable +{ + /** @use WithData */ + use WithData; + + protected string $dataClass = WithDataPropertyData::class; + + /** + * Convert the source to an array. + */ + public function toArray(): array + { + return []; + } + + /** + * Get the fallback data class. + */ + protected function dataClass(): string + { + return WithDataMethodData::class; + } +} + +class WithDataMissingSource +{ + /** @use WithData */ + use WithData; +} + +class WithDataInvalidSource +{ + /** @use WithData */ + use WithData; + + protected array $dataClass = []; +} + +class WithDataFormRequestSource extends FormRequest +{ + /** @use WithData */ + use WithData; + + protected string $dataClass = WithDataValidatedNameData::class; + + /** + * Get the FormRequest validation rules. + */ + public function rules(): array + { + return ['name' => ['in:request']]; + } + + /** + * Get the FormRequest validation messages. + */ + public function messages(): array + { + return ['name.in' => 'FormRequest validation ran.']; + } +} From d585bdb1b700d48fefe69c1ba2e0e4264e16f159 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:50:24 +0000 Subject: [PATCH 22/35] Finish Data package tooling and boot integration Register typed Data configuration at worker boot, add the Laravel-style make:data generator and application stub override, and install one stateless Symfony VarDumper caster for transformable Data values. Extend optional-package test cleanup to the four independent Data macro registries. Cover command naming and overwrite behavior, provider idempotence, custom dump casters, logical dump output, and cleanup without forcing optional package loading. --- src/data/src/Console/DataMakeCommand.php | 64 +++++++ src/data/src/DataServiceProvider.php | 14 ++ .../Support/VarDumper/DataVarDumperCaster.php | 26 +++ src/data/stubs/data.stub | 18 ++ .../src/PHPUnit/AfterEachTestSubscriber.php | 13 +- tests/Data/Console/DataMakeCommandTest.php | 140 +++++++++++++++ tests/Data/DataServiceProviderTest.php | 3 + .../VarDumper/DataVarDumperCasterTest.php | 168 ++++++++++++++++++ .../PHPUnit/AfterEachTestSubscriberTest.php | 39 ++++ 9 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/data/src/Console/DataMakeCommand.php create mode 100644 src/data/src/Support/VarDumper/DataVarDumperCaster.php create mode 100644 src/data/stubs/data.stub create mode 100644 tests/Data/Console/DataMakeCommandTest.php create mode 100644 tests/Data/Support/VarDumper/DataVarDumperCasterTest.php diff --git a/src/data/src/Console/DataMakeCommand.php b/src/data/src/Console/DataMakeCommand.php new file mode 100644 index 000000000..038d03f5f --- /dev/null +++ b/src/data/src/Console/DataMakeCommand.php @@ -0,0 +1,64 @@ +resolveStubPath('/stubs/data.stub'); + } + + /** + * Resolve the fully-qualified path to the stub. + */ + protected function resolveStubPath(string $stub): string + { + return file_exists($customPath = $this->hypervel->basePath(trim($stub, '/'))) + ? $customPath + : dirname(__DIR__, 2) . $stub; + } + + /** + * Get the default namespace for the class. + */ + protected function getDefaultNamespace(string $rootNamespace): string + { + return $rootNamespace . '\Data'; + } + + /** + * Get the console command options. + */ + protected function getOptions(): array + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'Create the Data class even if it already exists'], + ]; + } +} diff --git a/src/data/src/DataServiceProvider.php b/src/data/src/DataServiceProvider.php index 05f3ea4c3..cea56ef25 100644 --- a/src/data/src/DataServiceProvider.php +++ b/src/data/src/DataServiceProvider.php @@ -6,8 +6,12 @@ use Hypervel\Contracts\Config\Repository; use Hypervel\Contracts\Container\Container; +use Hypervel\Data\Console\DataMakeCommand; +use Hypervel\Data\Contracts\TransformableData; use Hypervel\Data\Support\DataConfig; +use Hypervel\Data\Support\VarDumper\DataVarDumperCaster; use Hypervel\Support\ServiceProvider; +use Symfony\Component\VarDumper\Cloner\AbstractCloner; class DataServiceProvider extends ServiceProvider { @@ -27,6 +31,9 @@ public function register(): void $container->make(Repository::class), ), ); + + // REMOVED: Livewire/Wireable integration has no Hypervel equivalent. + // REMOVED: TypeScript integration belongs to a general reflection transformer package. } /** @@ -34,9 +41,16 @@ public function register(): void */ public function boot(): void { + // Build the typed configuration once during worker boot. $this->app->make(DataConfig::class); + AbstractCloner::$defaultCasters[TransformableData::class] + ??= [DataVarDumperCaster::class, 'cast']; + if ($this->app->runningInConsole()) { + // REMOVED: data:cache-structures; worker memory is the metadata cache boundary. + $this->commands([DataMakeCommand::class]); + $this->publishes([ dirname(__DIR__) . '/config/data.php' => config_path('data.php'), ], 'data-config'); diff --git a/src/data/src/Support/VarDumper/DataVarDumperCaster.php b/src/data/src/Support/VarDumper/DataVarDumperCaster.php new file mode 100644 index 000000000..85ef8e1f0 --- /dev/null +++ b/src/data/src/Support/VarDumper/DataVarDumperCaster.php @@ -0,0 +1,26 @@ + $data->all()] + : $data->all(); + } +} diff --git a/src/data/stubs/data.stub b/src/data/stubs/data.stub new file mode 100644 index 000000000..c6f93c74d --- /dev/null +++ b/src/data/stubs/data.stub @@ -0,0 +1,18 @@ +flushDataState(); $this->flushFortifyState(); $this->flushHorizonState(); $this->flushImageState(); @@ -330,6 +330,17 @@ protected function flushFrameworkState(): void $this->flushWayfinderState(); } + /** + * Flush Data state. + */ + protected function flushDataState(): void + { + $this->callIfExists(\Hypervel\Data\CursorPaginatedDataCollection::class, 'flushMacros'); + $this->callIfExists(\Hypervel\Data\DataCollection::class, 'flushMacros'); + $this->callIfExists(\Hypervel\Data\Lazy::class, 'flushMacros'); + $this->callIfExists(\Hypervel\Data\PaginatedDataCollection::class, 'flushMacros'); + } + /** * Flush Fortify state. */ diff --git a/tests/Data/Console/DataMakeCommandTest.php b/tests/Data/Console/DataMakeCommandTest.php new file mode 100644 index 000000000..2ebb0f8aa --- /dev/null +++ b/tests/Data/Console/DataMakeCommandTest.php @@ -0,0 +1,140 @@ + + */ + protected array $generatedFiles = []; + + protected function getPackageProviders(ApplicationContract $app): array + { + return [DataServiceProvider::class]; + } + + protected function tearDown(): void + { + $files = new Filesystem; + + foreach ($this->generatedFiles as $generatedFile) { + $files->delete($generatedFile); + } + + parent::tearDown(); + } + + public function testDataIsGeneratedWithoutAnImplicitSuffix(): void + { + $this->artisan('make:data', [ + 'name' => 'User', + '--no-interaction' => true, + ])->assertSuccessful(); + + $path = app_path('Data/User.php'); + $contents = $this->generatedFile($path); + + $this->assertStringContainsString('namespace App\Data;', $contents); + $this->assertStringContainsString('use Hypervel\Data\Data;', $contents); + $this->assertStringContainsString('class User extends Data', $contents); + $this->assertStringContainsString('declare(strict_types=1);', $contents); + } + + public function testNestedAndQualifiedNamesUseTheirDeclaredNamespaces(): void + { + $this->artisan('make:data', [ + 'name' => 'Billing/InvoiceData', + '--no-interaction' => true, + ])->assertSuccessful(); + $this->artisan('make:data', [ + 'name' => 'App\Domain\ReportData', + '--no-interaction' => true, + ])->assertSuccessful(); + + $nested = $this->generatedFile(app_path('Data/Billing/InvoiceData.php')); + $qualified = $this->generatedFile(app_path('Domain/ReportData.php')); + + $this->assertStringContainsString('namespace App\Data\Billing;', $nested); + $this->assertStringContainsString('namespace App\Domain;', $qualified); + } + + public function testExistingDataRequiresForceToBeReplaced(): void + { + $arguments = [ + 'name' => 'ExistingData', + '--no-interaction' => true, + ]; + $path = app_path('Data/ExistingData.php'); + + $this->artisan('make:data', $arguments)->assertSuccessful(); + $this->generatedFiles[] = $path; + (new Filesystem)->put($path, 'sentinel'); + + $this->artisan('make:data', $arguments) + ->expectsOutputToContain('Data already exists.'); + $this->assertSame('sentinel', (new Filesystem)->get($path)); + + $this->artisan('make:data', [ + ...$arguments, + '--force' => true, + ])->assertSuccessful(); + + $contents = $this->generatedFile($path); + + $this->assertStringNotContainsString('sentinel', $contents); + $this->assertStringContainsString('class ExistingData extends Data', $contents); + } + + public function testApplicationStubOverridesThePackageStub(): void + { + $stubPath = base_path('stubs/data.stub'); + $files = new Filesystem; + $files->ensureDirectoryExists(dirname($stubPath)); + $files->put($stubPath, <<<'PHP' +generatedFiles[] = $stubPath; + + $this->artisan('make:data', [ + 'name' => 'PublishedData', + '--no-interaction' => true, + ])->assertSuccessful(); + + $contents = $this->generatedFile(app_path('Data/PublishedData.php')); + + $this->assertStringContainsString("public const string SOURCE = 'published';", $contents); + } + + /** + * Read and validate a generated PHP file. + */ + protected function generatedFile(string $path): string + { + $this->generatedFiles[] = $path; + + $process = new Process([PHP_BINARY, '-l', $path]); + $process->mustRun(); + + return (new Filesystem)->get($path); + } +} diff --git a/tests/Data/DataServiceProviderTest.php b/tests/Data/DataServiceProviderTest.php index d0fc69431..23b2706c3 100644 --- a/tests/Data/DataServiceProviderTest.php +++ b/tests/Data/DataServiceProviderTest.php @@ -11,6 +11,9 @@ class DataServiceProviderTest extends TestCase { + // REMOVED: Structure-cache command tests; worker memory is the metadata cache boundary. + // REMOVED: Livewire/Wireable and TypeScript integration tests; Hypervel has no matching Data integration. + protected function getPackageProviders(Application $app): array { return [DataServiceProvider::class]; diff --git a/tests/Data/Support/VarDumper/DataVarDumperCasterTest.php b/tests/Data/Support/VarDumper/DataVarDumperCasterTest.php new file mode 100644 index 000000000..54565a566 --- /dev/null +++ b/tests/Data/Support/VarDumper/DataVarDumperCasterTest.php @@ -0,0 +1,168 @@ + 'secret'), + ); + $resource = new DumpResource('Abigail'); + + $data->name = 'Jess'; + + $this->assertSame( + ['display_name' => 'Jess'], + DataVarDumperCaster::cast($data, ['internal' => true], new Stub, false), + ); + $this->assertSame( + ['display_name' => 'Abigail'], + DataVarDumperCaster::cast($resource, [], new Stub, false), + ); + } + + public function testCasterUsesOneItemsEnvelopeForEveryCollectionShape(): void + { + $item = new DumpData('Taylor', Optional::create(), 'visible'); + $collection = new DataCollection(DumpData::class, [$item]); + $paginated = new PaginatedDataCollection( + DumpData::class, + new Paginator([$item], 15, 1), + ); + $cursorPaginated = new CursorPaginatedDataCollection( + DumpData::class, + new CursorPaginator([$item], 15), + ); + + foreach ([$collection, $paginated, $cursorPaginated] as $data) { + $this->assertSame( + ['items' => [$item]], + DataVarDumperCaster::cast($data, [], new Stub, false), + ); + } + } + + public function testSymfonyAppliesTheRegisteredInterfaceCaster(): void + { + $data = new DumpData( + 'Taylor', + Optional::create(), + Lazy::create(static fn (): string => 'secret'), + ); + $output = $this->dump($data); + + $this->assertSame( + [DataVarDumperCaster::class, 'cast'], + AbstractCloner::$defaultCasters[TransformableData::class], + ); + $this->assertStringContainsString('display_name', $output); + $this->assertStringContainsString('Taylor', $output); + $this->assertStringNotContainsString('_additional', $output); + $this->assertStringNotContainsString('partialDefinitions', $output); + $this->assertStringNotContainsString('secret', $output); + } + + public function testProviderPreservesAnExistingApplicationCaster(): void + { + $hadCaster = array_key_exists( + TransformableData::class, + AbstractCloner::$defaultCasters, + ); + $previousCaster = AbstractCloner::$defaultCasters[TransformableData::class] ?? null; + $caster = static fn (): array => ['custom' => true]; + + try { + AbstractCloner::$defaultCasters[TransformableData::class] = $caster; + + (new DataServiceProvider($this->app))->boot(); + + $this->assertSame( + $caster, + AbstractCloner::$defaultCasters[TransformableData::class], + ); + } finally { + if ($hadCaster) { + AbstractCloner::$defaultCasters[TransformableData::class] = $previousCaster; + } else { + unset(AbstractCloner::$defaultCasters[TransformableData::class]); + } + } + } + + public function testOrdinaryObjectsKeepSymfonyDefaultDumping(): void + { + $object = new stdClass; + $object->name = 'Taylor'; + + $output = $this->dump($object); + + $this->assertStringContainsString('name', $output); + $this->assertStringContainsString('Taylor', $output); + } + + /** + * Dump a value through Symfony's configured cloner. + */ + private function dump(mixed $value): string + { + $dumper = new CliDumper; + $dumper->setColors(false); + + return $dumper->dump((new VarCloner)->cloneVar($value), true); + } +} + +class DumpData extends Data +{ + public function __construct( + #[MapOutputName('display_name')] + public string $name, + public string|Optional $missing, + public Lazy|string $secret, + ) { + } +} + +class DumpResource extends Resource +{ + public function __construct( + #[MapOutputName('display_name')] + public string $name, + ) { + } +} diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index a4a756058..96f5fe589 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -8,6 +8,10 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Pool\ConnectionInterface; +use Hypervel\Data\CursorPaginatedDataCollection; +use Hypervel\Data\DataCollection; +use Hypervel\Data\Lazy; +use Hypervel\Data\PaginatedDataCollection; use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Factories\Factory as EloquentFactory; use Hypervel\Database\PdoConnection; @@ -306,6 +310,41 @@ public function flushFrameworkStateForTest(): void } } + public function testDataCleanupFlushesEveryMacroableRegistry(): void + { + $classes = [ + CursorPaginatedDataCollection::class, + DataCollection::class, + Lazy::class, + PaginatedDataCollection::class, + ]; + $macro = 'dataCleanupProbe'; + + foreach ($classes as $class) { + $class::macro($macro, static fn (): string => 'macro'); + $this->assertTrue($class::hasMacro($macro)); + } + + $subscriber = new class extends AfterEachTestSubscriber { + public function flushDataStateForTest(): void + { + $this->flushDataState(); + } + }; + + try { + $subscriber->flushDataStateForTest(); + + foreach ($classes as $class) { + $this->assertFalse($class::hasMacro($macro)); + } + } finally { + foreach ($classes as $class) { + $class::flushMacros(); + } + } + } + public function testFrameworkCleanupFlushesSaloonStaticState(): void { $macro = 'saloonCleanupProbe'; From 73efd30349a19339724633da261560f63e5f15c4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:50:31 +0000 Subject: [PATCH 23/35] Verify Saloon Data interoperability Cover request- and connector-produced Data objects, DTO priority, static-analysis inference, and attachment of Saloon responses through the existing WithResponse contract. Keep hypervel/data independent from Saloon runtime code while proving generated SDK DTOs can use the first-party Data APIs directly. --- tests/Data/Saloon/SaloonIntegrationTest.php | 145 ++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/Data/Saloon/SaloonIntegrationTest.php diff --git a/tests/Data/Saloon/SaloonIntegrationTest.php b/tests/Data/Saloon/SaloonIntegrationTest.php new file mode 100644 index 000000000..cabcd4c7e --- /dev/null +++ b/tests/Data/Saloon/SaloonIntegrationTest.php @@ -0,0 +1,145 @@ +response( + connector: new DataConnector, + request: new DataRequest, + ); + $data = $response->dto(); + + $this->assertInstanceOf(SaloonUserData::class, $data); + $this->assertSame(7, $data->id); + $this->assertSame('Taylor', $data->name); + $this->assertSame('request', $data->source); + $this->assertSame($response, $data->getResponse()); + } + + public function testConnectorDataIsUsedWhenTheRequestReturnsNothing(): void + { + $response = $this->response( + connector: new DataConnector, + request: new PlainDataRequest, + ); + $data = $response->dto(); + + $this->assertInstanceOf(SaloonUserData::class, $data); + $this->assertSame('connector', $data->source); + $this->assertSame($response, $data->getResponse()); + } + + /** + * Create a Saloon response for a data operation. + */ + protected function response(Connector $connector, Request $request): Response + { + $pendingRequest = new PendingRequest( + $connector, + $request, + m::mock(CacheFactory::class), + m::mock(RateLimiter::class), + ); + $psrRequest = new PsrRequest($request->method()->value, 'https://api.example.com/users/7'); + + return Response::fromResponse( + new HttpResponse(new PsrResponse( + 200, + ['Content-Type' => 'application/json'], + '{"id":"7","name":"Taylor"}', + )), + $pendingRequest, + $psrRequest, + ); + } +} + +/** @extends Connector */ +class DataConnector extends Connector +{ + public function resolveBaseUrl(): string + { + return 'https://api.example.com'; + } + + public function createDtoFromResponse(Response $response): SaloonUserData + { + return SaloonUserData::from([ + ...$response->json(), + 'source' => 'connector', + ]); + } +} + +/** @extends Request */ +class DataRequest extends Request +{ + protected Method $method = Method::GET; + + public function resolveEndpoint(): string + { + return '/users/7'; + } + + public function createDtoFromResponse(Response $response): SaloonUserData + { + return SaloonUserData::from([ + ...$response->json(), + 'source' => 'request', + ]); + } +} + +/** @extends Request */ +class PlainDataRequest extends Request +{ + protected Method $method = Method::GET; + + public function resolveEndpoint(): string + { + return '/users/7'; + } +} + +class SaloonUserData extends Data implements WithResponse +{ + use HasResponse; + + public function __construct( + public int $id, + public string $name, + public string $source, + ) { + } +} From 2454bbabbfb65620d700e68fb6854b7884691eef Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:50:40 +0000 Subject: [PATCH 24/35] Remove the superseded Support DataObject Delete the old monolithic DataObject and its API-specific test suite now that construction, validation, transformation, persistence, request casting, and response behavior are owned by hypervel/data. Avoid compatibility aliases, global casting switches, serialized output caches, and duplicate cleanup paths in the greenfield 0.4 codebase. --- src/support/src/DataObject.php | 637 -------------------- tests/Support/DataObjectTest.php | 986 ------------------------------- 2 files changed, 1623 deletions(-) delete mode 100644 src/support/src/DataObject.php delete mode 100644 tests/Support/DataObjectTest.php diff --git a/src/support/src/DataObject.php b/src/support/src/DataObject.php deleted file mode 100644 index 22e730914..000000000 --- a/src/support/src/DataObject.php +++ /dev/null @@ -1,637 +0,0 @@ - [ReflectionParameter]). - */ - public static array $reflectionParametersCache = []; - - /** - * Property map cache (class name => [snake_case key => camelCase property]). - */ - public static array $propertyMapCache = []; - - /** - * Reversed property map cache (class name => [camelCase key => snake_case property]). - */ - public static array $reversedPropertyMapCache = []; - - /** - * Flag to indicate if auto-casting is enabled. - */ - protected static bool $autoCasting = true; - - /** - * Cache for dependencies map (class name => dependencies array). - */ - protected static array $dependenciesMapCache = []; - - /** - * The date format for DateTime properties. - */ - protected static string $dateFormat = self::DEFAULT_DATE_FORMAT; - - /** - * Cache for the array representation of the object. - */ - protected array $arrayCache = []; - - /** - * Create an instance of the class using the provided data array. - */ - public static function make(array $data, bool $autoResolve = false): static - { - $properties = static::getReversedPropertyMap(); - if ($autoResolve) { - $data = static::getConvertedData($data); - } - - $constructorArgs = []; - foreach (static::getReflectionParameters() as $parameter) { - $paramName = $parameter->getName(); - $dataKey = $properties[$paramName]; - $dataValue = null; - - // check if the data key exists in the array - // and convert the value to the correct type automatically - if (array_key_exists($dataKey, $data)) { - $dataValue = $data[$dataKey]; - if (static::$autoCasting) { - $dataValue = static::convertValueToType($dataValue, $parameter); - } - // use the default value if available - } elseif ($parameter->isDefaultValueAvailable()) { - $dataValue = $parameter->getDefaultValue(); - } else { - $dataValue = static::getDefaultValueForType($parameter); - } - - $constructorArgs[$paramName] = $dataValue; - } - - return new static(...$constructorArgs); - } - - /** - * Create an instance of the class using the provided data array. - * This is an alias of the `make` method. - */ - public static function from(array $data, bool $autoResolve = false): static - { - return static::make($data, $autoResolve); - } - - /** - * Get the customized dependencies map. - * - * @return array - */ - protected static function getCustomizedDependencies(): array - { - $dependencies = []; - $dateTargets = [ - DateTimeInterface::class, - CarbonInterface::class, - DateTime::class, - DateTimeImmutable::class, - Carbon::class, - CarbonImmutable::class, - BaseCarbon::class, - BaseCarbonImmutable::class, - ]; - - foreach ($dateTargets as $target) { - $dependencies[$target] = static fn (mixed $value): ?DateTimeInterface => $value === [] ? null : static::asDateTime($value, $target); - } - - return $dependencies; - } - - /** - * Get the serialization handlers for specific dependency types. - * - * @return array - */ - protected static function getSerializers(): array - { - return [ - DateTimeInterface::class => static fn (DateTimeInterface $value): string => $value->format('c'), - ]; - } - - /** - * Convert a value to the declared date target. - * - * @param BaseCarbon::class|BaseCarbonImmutable::class|Carbon::class|CarbonImmutable::class|CarbonInterface::class|DateTime::class|DateTimeImmutable::class|DateTimeInterface::class $target - */ - protected static function asDateTime(mixed $value, string $target): DateTimeInterface - { - if ($value instanceof DateTimeInterface) { - $date = Date::instance($value); - } elseif (is_numeric($value)) { - $date = Date::createFromTimestamp( - $value, - date_default_timezone_get() - ); - } elseif (static::isStandardDateFormat($value)) { - $date = Date::parse($value)->startOfDay(); - } else { - try { - $date = Date::createFromFormat(static::$dateFormat, $value); - // @phpstan-ignore catch.neverThrown (the Date facade's magic dispatch hides Carbon's @throws from analysis) - } catch (InvalidFormatException) { - $date = null; - } - - $date ??= Date::parse($value); - } - - return match ($target) { - DateTimeInterface::class, CarbonInterface::class => $date, - DateTime::class => DateTime::createFromInterface($date), - DateTimeImmutable::class => DateTimeImmutable::createFromInterface($date), - // instance() clones same-mutability subclasses, so cross the mutability - // boundary first to honor the exact target while retaining Carbon settings. - Carbon::class => Carbon::instance($date->toImmutable()), - CarbonImmutable::class => CarbonImmutable::instance($date->toMutable()), - BaseCarbon::class => BaseCarbon::instance($date->toImmutable()), - BaseCarbonImmutable::class => BaseCarbonImmutable::instance($date->toMutable()), - }; - } - - /** - * Determine if the given value is a standard date format. - */ - protected static function isStandardDateFormat(mixed $value): bool - { - return (bool) preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', (string) $value); - } - - /** - * Get the converted data array with dependencies resolved. - */ - protected static function getConvertedData(array $data): array - { - if (! $dependencies = static::getDependenciesData()) { - return $data; - } - - return static::replaceDependenciesData( - $dependencies, - $data - ); - } - - /** - * Get the dependencies map for the current class. - * - * @return array - */ - protected static function getDependenciesData(): array - { - if (array_key_exists(static::class, static::$dependenciesMapCache)) { - return static::$dependenciesMapCache[static::class]; - } - - return static::$dependenciesMapCache[static::class] = static::resolveDependenciesMap(static::class); - } - - protected static function getDependencyFromUnionType(ReflectionUnionType $type): ?ReflectionNamedType - { - foreach ($type->getTypes() as $namedType) { - if (! $namedType instanceof ReflectionNamedType) { - continue; - } - - $className = $namedType->getName(); - if ( - is_subclass_of($className, DataObject::class) - || is_a($className, DateTimeInterface::class, true) - ) { - return $namedType; - } - } - - return null; - } - - /** - * Check if the union type allows null. - */ - protected static function hasNullableUnionType(ReflectionUnionType $type): bool - { - foreach ($type->getTypes() as $namedType) { - if ($namedType->allowsNull()) { - return true; - } - } - - return false; - } - - /** - * Recursively resolve the dependencies map for the given class. - * - * @param array $visited - * @return array - */ - protected static function resolveDependenciesMap(string $class, array &$visited = []): array - { - if (isset($visited[$class])) { - return []; - } - - $visited[$class] = true; - $reflection = new ReflectionClass($class); - $properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); - $customizedDependencies = $class::getCustomizedDependencies(); - - $result = []; - foreach ($properties as $property) { - if ($property->isStatic()) { - continue; - } - $propertyType = $property->getType(); - - if (! $propertyType instanceof ReflectionNamedType && ! $propertyType instanceof ReflectionUnionType) { - continue; - } - - $allowsNull = $propertyType->allowsNull(); - if ($propertyType instanceof ReflectionUnionType) { - $allowsNull = static::hasNullableUnionType($propertyType); - $propertyType = static::getDependencyFromUnionType($propertyType); - - if ($propertyType === null) { - continue; - } - } - - $typeName = $propertyType->getName(); - $dataKey = $class::isAutoCasting() - ? $class::convertPropertyToDataKey($property->getName()) - : $property->getName(); - - if (is_subclass_of($typeName, DataObject::class)) { - $result[$dataKey] = [ - 'handler' => fn ($value) => $value instanceof $typeName ? $value : $typeName::make($value), - 'nullable' => $allowsNull, - 'children' => static::resolveDependenciesMap($typeName, $visited), - ]; - continue; - } - if (enum_exists($typeName) && is_subclass_of($typeName, BackedEnum::class, true)) { - $result[$dataKey] = [ - 'handler' => fn ($value) => $value instanceof $typeName ? $value : $typeName::from($value), - 'nullable' => $allowsNull, - 'children' => [], - ]; - continue; - } - if ($resolver = $customizedDependencies[$typeName] ?? null) { - $result[$dataKey] = [ - 'handler' => $resolver, - 'nullable' => $allowsNull, - 'children' => [], - ]; - continue; - } - } - - unset($visited[$class]); - - return $result; - } - - /** - * Recursively replace dependencies data in the given data array. - */ - protected static function replaceDependenciesData(array $dependencies, array $data): array - { - foreach ($dependencies as $key => $dependency) { - if (! array_key_exists($key, $data)) { - continue; - } - - $handler = $dependency['handler']; - $children = $dependency['children'] ?? []; - $nullable = $dependency['nullable'] ?? false; - $matched = $data[$key]; - - if ($nullable && $matched === null) { - continue; - } - if (! is_array($matched)) { - $data[$key] = $handler($matched === null ? [] : $matched); - continue; - } - - if ($children) { - $data[$key] = static::replaceDependenciesData($children, $matched); - } - - $data[$key] = $handler($data[$key]); - } - - return $data; - } - - /** - * Enable or disable auto-casting of data values. - * - * Boot-only. The auto-casting flag persists in a static property for the - * worker lifetime and affects every subsequent data object hydration. - */ - public static function enableAutoCasting(): void - { - static::$autoCasting = true; - } - - /** - * Enable or disable auto-casting of data values. - */ - public static function isAutoCasting(): bool - { - return static::$autoCasting; - } - - /** - * Disable auto-casting of data values. - * - * Boot-only. The auto-casting flag persists in a static property for the - * worker lifetime and affects every subsequent data object hydration. - */ - public static function disableAutoCasting(): void - { - static::$autoCasting = false; - } - - /** - * Convert the property name to the data key format. - * It converts camelCase to snake_case by default. - */ - public static function convertPropertyToDataKey(string $input): string - { - return Str::snake($input); - } - - /** - * Convert the data key to the property name format. - * It converts snake_case to camelCase by default. - */ - public static function convertDataKeyToProperty(string $input): string - { - return Str::camel($input); - } - - /** - * Get the reflection parameters for the constructor. - * - * @return ReflectionParameter[] - */ - protected static function getReflectionParameters(): array - { - if (! is_null($parameters = static::$reflectionParametersCache[static::class] ?? null)) { - return $parameters; - } - - $reflection = new ReflectionClass(static::class); - $constructor = $reflection->getConstructor(); - $parameters = $constructor ? $constructor->getParameters() : []; - - return static::$reflectionParametersCache[static::class] = $parameters; - } - - /** - * Convert the value to the correct type based on the parameter type. - */ - protected static function convertValueToType(mixed $value, ReflectionParameter $parameter): mixed - { - if (! $type = $parameter->getType()) { - return $value; - } - if ($type->allowsNull() && is_null($value)) { - return null; - } - - if ($type instanceof ReflectionNamedType) { - return match ($type->getName()) { - 'int' => (int) $value, - 'float' => (float) $value, - 'string' => (string) $value, - 'bool' => (bool) $value, - 'array' => is_array($value) ? $value : [$value], - default => $value, - }; - } - - return $value; - } - - /** - * Get default value for the parameter type. - */ - protected static function getDefaultValueForType(ReflectionParameter $parameter): mixed - { - $type = $parameter->getType(); - if (! $type || $type->allowsNull()) { - return null; - } - - throw new RuntimeException( - "Missing required property `{$parameter->name}` in `" . static::class . '`' - ); - } - - /** - * Get property map (snake_case key => camelCase property). - * - * @return array - */ - protected static function getPropertyMap(): array - { - if (array_key_exists(static::class, static::$propertyMapCache)) { - return static::$propertyMapCache[static::class]; - } - - $reflection = new ReflectionClass(static::class); - $properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); - $map = []; - - foreach ($properties as $property) { - if ($property->isStatic()) { - continue; - } - $propName = $property->getName(); - $snakeKey = static::convertPropertyToDataKey($propName); - $map[$snakeKey] = $propName; - } - - return static::$propertyMapCache[static::class] = $map; - } - - /** - * Get reversed property map (camelCase key => snake_case property). - * - * @return array - */ - protected static function getReversedPropertyMap(): array - { - if (array_key_exists(static::class, static::$reversedPropertyMapCache)) { - return static::$reversedPropertyMapCache[static::class]; - } - - return static::$reversedPropertyMapCache[static::class] = array_flip( - static::getPropertyMap() - ); - } - - /** - * Update the object properties with the provided data array. - */ - public function update(array $data): static - { - $properties = static::getPropertyMap(); - foreach ($data as $key => $value) { - $this->{$properties[$key]} = $value; - } - - $this->refresh(); - - return $this; - } - - /** - * Check if the offset exists. - */ - public function offsetExists(mixed $offset): bool - { - return array_key_exists($offset, static::getPropertyMap()); - } - - /** - * Get the value at the specified offset. - */ - public function offsetGet(mixed $offset): mixed - { - if (array_key_exists($offset, $this->toArray())) { - return $this->toArray()[$offset]; - } - - throw new OutOfBoundsException("Undefined offset: {$offset}"); - } - - /** - * Set the value at the specified offset. - */ - public function offsetSet(mixed $offset, mixed $value): void - { - throw new LogicException('Data object may not be mutated using array access.'); - } - - /** - * Unset the value at the specified offset. - */ - public function offsetUnset(mixed $offset): void - { - throw new LogicException('Data object may not be mutated using array access.'); - } - - /** - * Convert the object to an array representation. - */ - public function toArray(): array - { - if ($this->arrayCache) { - return $this->arrayCache; - } - - $result = []; - $map = static::getPropertyMap(); - - $serializers = static::getSerializers(); - foreach ($map as $snakeKey => $propName) { - $value = $this->{$propName}; - // recursively convert nested objects to arrays - if ($value instanceof self) { - $value = $value->toArray(); - } elseif ( - $value instanceof DateTimeInterface - && $serializer = $serializers[DateTimeInterface::class] ?? null - ) { - $value = $serializer($value); - } elseif ( - is_object($value) - && $serializer = $serializers[$value::class] ?? null - ) { - $value = $serializer($value); - } elseif (is_object($value) && method_exists($value, 'toArray')) { - $value = $value->toArray(); - } - $result[$snakeKey] = $value; - } - - return $this->arrayCache = $result; - } - - /** - * JSON serialize the object. - */ - public function jsonSerialize(): array - { - return $this->toArray(); - } - - /** - * Return a refreshed instance of the object with cleared cache. - */ - public function refresh(): static - { - $this->arrayCache = []; - - return $this; - } - - /** - * Flush all static state. - */ - public static function flushState(): void - { - static::$reflectionParametersCache = []; - static::$propertyMapCache = []; - static::$reversedPropertyMapCache = []; - static::$autoCasting = true; - static::$dependenciesMapCache = []; - static::$dateFormat = self::DEFAULT_DATE_FORMAT; - } -} diff --git a/tests/Support/DataObjectTest.php b/tests/Support/DataObjectTest.php deleted file mode 100644 index ca97cf586..000000000 --- a/tests/Support/DataObjectTest.php +++ /dev/null @@ -1,986 +0,0 @@ - 'test', - 'int_value' => '42', // String that should be converted to int - 'float_value' => '3.14', // String that should be converted to float - 'bool_value' => 1, // Int that should be converted to bool - 'array_value' => ['item1', 'item2'], - 'object_value' => new stdClass, - ]; - - $object = TestDataObject::make($data); - - $this->assertInstanceOf(TestDataObject::class, $object); - $this->assertSame('test', $object->stringValue); - $this->assertSame(42, $object->intValue); - $this->assertSame(3.14, $object->floatValue); - $this->assertTrue($object->boolValue); - $this->assertSame(['item1', 'item2'], $object->arrayValue); - $this->assertInstanceOf(stdClass::class, $object->objectValue); - $this->assertSame('default value', $object->withDefaultValue); - $this->assertNull($object->nullableValue); - } - - /** - * Test mutating a data object and refreshing data. - */ - public function testMutationAndRefreshData(): void - { - $data = [ - 'string_value' => 'test', - 'int_value' => '42', // String that should be converted to int - 'float_value' => '3.14', // String that should be converted to float - 'bool_value' => 1, // Int that should be converted to bool - 'array_value' => ['item1', 'item2'], - 'object_value' => new stdClass, - ]; - - $object = TestDataObject::make($data); - $object->stringValue = 'test_changed'; - $object->intValue = 100; - $object->floatValue = 6.28; - $object->boolValue = false; - $object->arrayValue = ['item3', 'item4']; - - $object->refresh(); - - $this->assertInstanceOf(TestDataObject::class, $object); - $this->assertSame('test_changed', $object->stringValue); - $this->assertSame(100, $object->intValue); - $this->assertSame(6.28, $object->floatValue); - $this->assertFalse($object->boolValue); - $this->assertSame(['item3', 'item4'], $object->arrayValue); - $this->assertInstanceOf(stdClass::class, $object->objectValue); - $this->assertSame('default value', $object->withDefaultValue); - $this->assertNull($object->nullableValue); - } - - /** - * Test mutating a data object and refreshing data. - */ - public function testUpdate(): void - { - $data = [ - 'string_value' => 'test', - 'int_value' => '42', // String that should be converted to int - 'float_value' => '3.14', // String that should be converted to float - 'bool_value' => 1, // Int that should be converted to bool - 'array_value' => ['item1', 'item2'], - 'object_value' => new stdClass, - ]; - - $object = TestDataObject::make($data); - $object->update([ - 'string_value' => 'test_changed', - 'int_value' => 100, - 'float_value' => 6.28, - 'bool_value' => false, - 'array_value' => ['item3', 'item4'], - ]); - - $this->assertInstanceOf(TestDataObject::class, $object); - $this->assertSame('test_changed', $object->stringValue); - $this->assertSame(100, $object->intValue); - $this->assertSame(6.28, $object->floatValue); - $this->assertFalse($object->boolValue); - $this->assertSame(['item3', 'item4'], $object->arrayValue); - $this->assertInstanceOf(stdClass::class, $object->objectValue); - $this->assertSame('default value', $object->withDefaultValue); - $this->assertNull($object->nullableValue); - } - - /** - * Test creating a data object with massing data. - */ - public function testMakeWithMissingConfig(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Missing required property `stringValue` in `Hypervel\Tests\Support\TestDataObject`'); - - TestDataObject::make([]); - } - - /** - * Test overriding the default conversion functions. - */ - public function testOverrideConvertFunctions(): void - { - $data = [ - 'snakeCaseParam' => 'foo', - 'multiWordParameterName' => 'bar', - ]; - - $object = TestOverrideDataObject::make($data); - - $this->assertSame('foo', $object->snake_case_param); - $this->assertSame('bar', $object->multi_word_parameter_name); - } - - /** - * Test type conversion for different parameter types. - */ - public function testTypeConversion(): void - { - $data = [ - 'string_value' => 123, // Int that should be converted to string - 'int_value' => '42.99', // String that should be converted to int (truncated) - 'float_value' => '3.14', // String that should be converted to float - 'bool_value' => '0', // String that should be converted to bool - 'array_value' => 'single item', // String that should be wrapped in array - ]; - - $object = TestDataObject::make($data); - - $this->assertSame('123', $object->stringValue); - $this->assertSame(42, $object->intValue); - $this->assertSame(3.14, $object->floatValue); - $this->assertFalse($object->boolValue); - $this->assertSame(['single item'], $object->arrayValue); - } - - /** - * Test ArrayAccess implementation - offsetExists and offsetGet. - */ - public function testArrayAccess(): void - { - $object = TestDataObject::make( - array_merge($this->getData(), [ - 'nullable_value' => null, - ]) - ); - - // Test offsetExists - $this->assertTrue(isset($object['string_value'])); - $this->assertTrue(isset($object['int_value'])); - $this->assertFalse(isset($object['non_existent'])); - $this->assertTrue(isset($object['nullable_value'])); - - // Test offsetGet - $this->assertSame('test', $object['string_value']); - $this->assertSame(42, $object['int_value']); - $this->assertNull($object['nullable_value']); - - // Test accessing properties that don't exist - $this->expectException(OutOfBoundsException::class); - $object['non_existent']; - } - - /** - * Test the immutability of DataObject - offsetSet and offsetUnset. - */ - public function testImmutability(): void - { - $object = TestDataObject::make($this->getData()); - - // Test offsetSet - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Data object may not be mutated using array access.'); - $object['string_value'] = 'changed'; - } - - /** - * Test offsetUnset throws exception. - */ - public function testOffsetUnset(): void - { - $object = TestDataObject::make($this->getData()); - - // Test offsetUnset - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Data object may not be mutated using array access.'); - unset($object['string_value']); - } - - /** - * Test toArray method. - */ - public function testToArray(): void - { - $object = TestDataObject::make($this->getData()); - $array = $object->toArray(); - - $this->assertIsArray($array); - $this->assertSame('test', $array['string_value']); - $this->assertSame(42, $array['int_value']); - $this->assertSame(['item1', 'item2'], $array['array_value']); - } - - /** - * Test JsonSerialize implementation. - */ - public function testJsonSerialize(): void - { - $object = TestDataObject::make($this->getData()); - $json = json_encode($object); - $decoded = json_decode($json, true); - - $this->assertIsString($json); - $this->assertIsArray($decoded); - $this->assertSame('test', $decoded['string_value']); - $this->assertSame(42, $decoded['int_value']); - $this->assertSame(['item1', 'item2'], $decoded['array_value']); - } - - /** - * Test nested DataObject serialization. - */ - public function testNestedObjectSerialization(): void - { - $nestedObject = TestDataObject::make( - array_merge($this->getData(), [ - 'string_value' => 'nested', - ]) - ); - - $object = TestDataObject::make( - array_merge($this->getData(), [ - 'string_value' => 'parent', - 'object_value' => $nestedObject, - ]) - ); - - // Test toArray with nested objects - $array = $object->toArray(); - $this->assertIsArray($array); - $this->assertIsArray($array['object_value']); - $this->assertSame('nested', $array['object_value']['string_value']); - - // Test JSON serialization with nested objects - $json = json_encode($object); - $decoded = json_decode($json, true); - $this->assertIsArray($decoded['object_value']); - $this->assertSame('nested', $decoded['object_value']['string_value']); - } - - /** - * Test autoResolve = false (default behavior). - */ - public function testMakeWithoutAutoResolve(): void - { - $data = [ - 'name' => 'John Doe', - 'address' => [ - 'street' => '123 Main St', - 'city' => 'New York', - 'zipCode' => '10001', - ], - 'gender' => TestGenderEnum::Male, - 'created_at' => '2023-01-01 12:00:00', - ]; - - $user = TestUserDataObject::make($data, false); - - $this->assertSame('John Doe', $user->name); - $this->assertIsArray($user->address); - $this->assertSame(['street' => '123 Main St', 'city' => 'New York', 'zipCode' => '10001'], $user->address); - $this->assertIsString($user->createdAt); - $this->assertSame('2023-01-01 12:00:00', $user->createdAt); - $this->assertSame(TestGenderEnum::Male, $user->gender); - } - - /** - * Test autoResolve = true with nested DataObject conversion. - */ - public function testMakeWithAutoResolveDataObject(): void - { - $data = [ - 'name' => 'John Doe', - 'address' => [ - 'street' => '123 Main St', - 'city' => 'New York', - 'zip_code' => '10001', - ], - 'gender' => 'male', - 'created_at' => '2023-01-01 12:00:00', - ]; - - $user = TestUserDataObject::make($data, true); - - $this->assertSame('John Doe', $user->name); - $this->assertInstanceOf(TestAddressDataObject::class, $user->address); - $this->assertSame('123 Main St', $user->address->street); - $this->assertSame('New York', $user->address->city); - $this->assertSame('10001', $user->address->zipCode); - $this->assertInstanceOf(DateTime::class, $user->createdAt); - $this->assertSame('2023-01-01 12:00:00', $user->createdAt->format('Y-m-d H:i:s')); - $this->assertSame(TestGenderEnum::Male, $user->gender); - } - - #[DataProvider('dateInputProvider')] - public function testAutoResolveHydratesEveryDeclaredDateTarget( - mixed $input, - DateTimeImmutable $expected - ): void { - $object = DateTargetDataObject::make(array_fill_keys([ - 'date_time_interface', - 'carbon_interface', - 'date_time', - 'date_time_immutable', - 'carbon', - 'carbon_immutable', - 'base_carbon', - 'base_carbon_immutable', - ], $input), true); - - $expectedClasses = [ - 'dateTimeInterface' => CarbonImmutable::class, - 'carbonInterface' => CarbonImmutable::class, - 'dateTime' => DateTime::class, - 'dateTimeImmutable' => DateTimeImmutable::class, - 'carbon' => Carbon::class, - 'carbonImmutable' => CarbonImmutable::class, - 'baseCarbon' => BaseCarbon::class, - 'baseCarbonImmutable' => BaseCarbonImmutable::class, - ]; - - foreach ($expectedClasses as $property => $expectedClass) { - $date = $object->{$property}; - - $this->assertSame($expectedClass, $date::class); - $this->assertSame( - $expected->format('Y-m-d H:i:s.u e'), - $date->format('Y-m-d H:i:s.u e') - ); - - if ($input instanceof CarbonInterface && $date instanceof CarbonInterface) { - $this->assertSame($input->locale(), $date->locale()); - } - } - } - - public static function dateInputProvider(): array - { - $defaultTimezone = new DateTimeZone(date_default_timezone_get()); - $auckland = new DateTimeZone('Pacific/Auckland'); - $instant = new DateTimeImmutable('2026-07-22 12:34:56.123456', $auckland); - $epoch = (new DateTimeImmutable('@0'))->setTimezone($defaultTimezone); - - return [ - 'database format' => [ - '2026-07-22 12:34:56', - new DateTimeImmutable('2026-07-22 12:34:56', $defaultTimezone), - ], - 'standard date' => [ - '2026-07-22', - new DateTimeImmutable('2026-07-22 00:00:00', $defaultTimezone), - ], - 'integer epoch' => [0, $epoch], - 'string epoch' => ['0', $epoch], - 'native mutable' => [DateTime::createFromInterface($instant), $instant], - 'native immutable' => [$instant, $instant], - 'base mutable Carbon' => [BaseCarbon::instance($instant)->locale('fr'), $instant], - 'base immutable Carbon' => [BaseCarbonImmutable::instance($instant)->locale('fr'), $instant], - 'Hypervel mutable Carbon' => [Carbon::instance($instant)->locale('fr'), $instant], - 'Hypervel immutable Carbon' => [CarbonImmutable::instance($instant)->locale('fr'), $instant], - ]; - } - - public function testAutoResolveSupportsFormatModifiersAndTrailingData(): void - { - $keys = [ - 'date_time_interface', - 'carbon_interface', - 'date_time', - 'date_time_immutable', - 'carbon', - 'carbon_immutable', - 'base_carbon', - 'base_carbon_immutable', - ]; - - $this->setDataObjectStaticProperty('dateFormat', '!Y-d-m \Y'); - $object = DateTargetDataObject::make(array_fill_keys($keys, '2017-05-11 Y'), true); - - $this->assertSame(CarbonImmutable::class, $object->carbonInterface::class); - $this->assertSame('2017-11-05 00:00:00.000000', $object->carbonInterface->format('Y-m-d H:i:s.u')); - - $this->setDataObjectStaticProperty('dateFormat', '!Y-m-d+'); - $object = DateTargetDataObject::make(array_fill_keys($keys, '2020-09-11 trailing data'), true); - - $this->assertSame(CarbonImmutable::class, $object->carbonInterface::class); - $this->assertSame('2020-09-11 00:00:00.000000', $object->carbonInterface->format('Y-m-d H:i:s.u')); - } - - public function testInterfaceDateTargetsFollowConfiguredFactoryWithoutChangingConcreteTargets(): void - { - Date::use(Carbon::class); - - $object = DateTargetDataObject::make(array_fill_keys([ - 'date_time_interface', - 'carbon_interface', - 'date_time', - 'date_time_immutable', - 'carbon', - 'carbon_immutable', - 'base_carbon', - 'base_carbon_immutable', - ], '2026-07-22 12:34:56'), true); - - $this->assertSame(Carbon::class, $object->dateTimeInterface::class); - $this->assertSame(Carbon::class, $object->carbonInterface::class); - $this->assertSame(DateTime::class, $object->dateTime::class); - $this->assertSame(DateTimeImmutable::class, $object->dateTimeImmutable::class); - $this->assertSame(Carbon::class, $object->carbon::class); - $this->assertSame(CarbonImmutable::class, $object->carbonImmutable::class); - $this->assertSame(BaseCarbon::class, $object->baseCarbon::class); - $this->assertSame(BaseCarbonImmutable::class, $object->baseCarbonImmutable::class); - } - - public function testConcreteDateTargetsRemainExactWithConfiguredCarbonSubclasses(): void - { - foreach ([ - DataObjectMutableCarbonSubclass::class, - DataObjectImmutableCarbonSubclass::class, - ] as $dateClass) { - Date::use($dateClass); - - $object = DateTargetDataObject::make(array_fill_keys([ - 'date_time_interface', - 'carbon_interface', - 'date_time', - 'date_time_immutable', - 'carbon', - 'carbon_immutable', - 'base_carbon', - 'base_carbon_immutable', - ], '2026-07-22 12:34:56'), true); - - $this->assertSame($dateClass, $object->dateTimeInterface::class); - $this->assertSame($dateClass, $object->carbonInterface::class); - $this->assertSame(Carbon::class, $object->carbon::class); - $this->assertSame(CarbonImmutable::class, $object->carbonImmutable::class); - $this->assertSame(BaseCarbon::class, $object->baseCarbon::class); - $this->assertSame(BaseCarbonImmutable::class, $object->baseCarbonImmutable::class); - } - } - - public function testExplicitNullForRequiredDateReachesConstructorAsNull(): void - { - $data = array_fill_keys([ - 'date_time_interface', - 'carbon_interface', - 'date_time', - 'date_time_immutable', - 'carbon', - 'carbon_immutable', - 'base_carbon', - 'base_carbon_immutable', - ], '2026-07-22 12:34:56'); - $data['date_time_interface'] = null; - - try { - DateTargetDataObject::make($data, true); - - $this->fail('Expected the required date constructor argument to reject null.'); - } catch (TypeError $exception) { - $this->assertStringContainsString('$dateTimeInterface', $exception->getMessage()); - $this->assertStringContainsString('null given', $exception->getMessage()); - } - } - - public function testEveryDeclaredDateTargetSerializesThroughDateTimeInterface(): void - { - $input = new DateTimeImmutable( - '2026-07-22 12:34:56', - new DateTimeZone('Pacific/Auckland') - ); - $object = DateTargetDataObject::make(array_fill_keys([ - 'date_time_interface', - 'carbon_interface', - 'date_time', - 'date_time_immutable', - 'carbon', - 'carbon_immutable', - 'base_carbon', - 'base_carbon_immutable', - ], $input), true); - $array = $object->toArray(); - - foreach ($array as $value) { - $this->assertSame('2026-07-22T12:34:56+12:00', $value); - } - - $this->assertSame($array, json_decode(json_encode($object), true)); - } - - public function testCustomNonDateSerializerRemainsAvailableWithDateSerialization(): void - { - $object = CustomSerializerDataObject::make([ - 'date' => CarbonImmutable::parse('2026-07-22 12:34:56', 'UTC'), - 'value' => new ResolverValue('custom'), - ]); - - $this->assertSame([ - 'date' => '2026-07-22T12:34:56+00:00', - 'value' => 'serialized:custom', - ], $object->toArray()); - } - - /** - * Test autoResolve with deep nesting. - */ - public function testMakeWithAutoResolveDeepNesting(): void - { - $data = [ - 'name' => 'Company Inc', - 'employee' => [ - 'name' => 'Jane Smith', - 'address' => [ - 'street' => '456 Oak Ave', - 'city' => 'Boston', - 'zip_code' => '02101', - ], - 'gender' => 'male', - 'created_at' => '2023-06-15 09:30:00', - ], - ]; - - $company = TestCompanyDataObject::make($data, true); - - $this->assertSame('Company Inc', $company->name); - $this->assertInstanceOf(TestUserDataObject::class, $company->employee); - $this->assertSame('Jane Smith', $company->employee->name); - $this->assertInstanceOf(TestAddressDataObject::class, $company->employee->address); - $this->assertSame('456 Oak Ave', $company->employee->address->street); - $this->assertSame('Boston', $company->employee->address->city); - $this->assertInstanceOf(DateTime::class, $company->employee->createdAt); - $this->assertSame(TestGenderEnum::Male, $company->employee->gender); - } - - /** - * Test autoResolve with null values. - */ - public function testMakeWithAutoResolveNullValues(): void - { - $data = [ - 'name' => 'John Doe', - 'address' => null, - 'created_at' => null, - 'gender' => 'male', - ]; - - $user = TestUserDataObject::make($data, true); - - $this->assertSame('John Doe', $user->name); - $this->assertNull($user->address); - $this->assertNull($user->createdAt); - $this->assertSame(TestGenderEnum::Male, $user->gender); - } - - public function testEmptyDataObjectCanBeCreatedAndSerialized(): void - { - $object = EmptyDataObject::make([], true); - - $this->assertSame([], $object->toArray()); - } - - public function testAutoResolveSkipsUntypedAndIntersectionProperties(): void - { - $untyped = UntypedDataObject::make([], true); - $intersectionValue = new IntersectionValue; - $intersection = IntersectionDataObject::make(['value' => $intersectionValue], true); - - $this->assertSame('default', $untyped->value); - $this->assertSame($intersectionValue, $intersection->value); - } - - public function testAutoResolveSkipsUnsupportedScalarUnions(): void - { - $object = ScalarUnionDataObject::make(['value' => 'value'], true); - - $this->assertSame('value', $object->value); - } - - public function testAutoResolveSkipsIntersectionMembersInDnfUnions(): void - { - $address = new TestAddressDataObject('123 Main St', 'New York', '10001'); - $object = DnfUnionDataObject::make(['value' => $address], true); - - $this->assertSame($address, $object->value); - } - - public function testAutoResolvePreservesMissingDefaultsAndExplicitNulls(): void - { - $defaulted = DefaultedDependencyDataObject::make([], true); - $explicitNull = DefaultedDependencyDataObject::make(['gender' => null], true); - - $this->assertSame(TestGenderEnum::Female, $defaulted->gender); - $this->assertNull($explicitNull->gender); - } - - public function testAutoResolvePreservesExistingNestedDataObject(): void - { - $address = new TestAddressDataObject('123 Main St', 'New York', '10001'); - - $user = TestUserDataObject::make([ - 'name' => 'John Doe', - 'gender' => TestGenderEnum::Male, - 'address' => $address, - 'created_at' => null, - ], true); - - $this->assertSame($address, $user->address); - } - - public function testNestedDependenciesUseEachOwningClassKeyConvention(): void - { - $object = OwnerKeyDataObject::make([ - 'owner_child' => [ - 'child_value' => 'nested', - ], - ], true); - - $this->assertSame('nested', $object->child->value); - } - - public function testNestedDependenciesUseTheNestedClassResolver(): void - { - $object = RootResolverDataObject::make([ - 'child' => [ - 'value' => 'resolved', - ], - ], true); - - $this->assertSame('child:resolved', $object->child->value->value); - } - - public function testEmptyDependencyMapsAreCached(): void - { - DependencylessDataObject::$dependencyLookups = 0; - - DependencylessDataObject::make([], true); - DependencylessDataObject::make([], true); - - $this->assertSame(1, DependencylessDataObject::$dependencyLookups); - } - - public function testFlushStateRestoresStaticDefaults(): void - { - TestDataObject::make($this->getData()); - TestDataObject::disableAutoCasting(); - $this->setDataObjectStaticProperty('dateFormat', 'Y/m/d'); - - $this->assertNotSame([], $this->getDataObjectStaticProperty('reflectionParametersCache')); - $this->assertFalse(TestDataObject::isAutoCasting()); - - TestDataObject::flushState(); - - $this->assertSame([], $this->getDataObjectStaticProperty('reflectionParametersCache')); - $this->assertSame([], $this->getDataObjectStaticProperty('propertyMapCache')); - $this->assertSame([], $this->getDataObjectStaticProperty('reversedPropertyMapCache')); - $this->assertSame([], $this->getDataObjectStaticProperty('dependenciesMapCache')); - $this->assertTrue(TestDataObject::isAutoCasting()); - $this->assertSame('Y-m-d H:i:s', $this->getDataObjectStaticProperty('dateFormat')); - } - - protected function getData(): array - { - return [ - 'string_value' => 'test', - 'int_value' => 42, - 'float_value' => 3.14, - 'bool_value' => true, - 'array_value' => ['item1', 'item2'], - 'object_value' => new stdClass, - ]; - } - - private function getDataObjectStaticProperty(string $name): mixed - { - return (new ReflectionClass(DataObject::class))->getStaticPropertyValue($name); - } - - private function setDataObjectStaticProperty(string $name, mixed $value): void - { - (new ReflectionClass(DataObject::class))->setStaticPropertyValue($name, $value); - } -} - -/** - * Concrete implementation of DataObject for testing. - */ -class TestDataObject extends DataObject -{ - public function __construct( - public string $stringValue, - public int $intValue, - public float $floatValue, - public bool $boolValue, - public array $arrayValue, - public ?object $objectValue, - public string $withDefaultValue = 'default value', - public ?string $nullableValue = null - ) { - } -} - -/** - * Concrete implementation of DataObject for testing. - */ -class TestOverrideDataObject extends DataObject -{ - public function __construct( - public string $snake_case_param, - public string $multi_word_parameter_name, - ) { - } - - /** - * Convert the property name to the data key format. - * It converts camelCase to snake_case by default. - */ - public static function convertPropertyToDataKey(string $input): string - { - return Str::camel($input); - } - - /** - * Convert the data key to the property name format. - * It converts snake_case to camelCase by default. - */ - public static function convertDataKeyToProperty(string $input): string - { - return Str::snake($input); - } -} - -/** - * Test DataObject for address. - */ -class TestAddressDataObject extends DataObject -{ - public function __construct( - public string $street, - public string $city, - public string $zipCode, - ) { - } -} - -/** - * Test DataObject for user with nested address and DateTime. - */ -class TestUserDataObject extends DataObject -{ - public function __construct( - public string $name, - public TestGenderEnum $gender, - public TestAddressDataObject|array|null $address, - public DateTime|string|null $createdAt, - ) { - } -} - -/** - * Test DataObject for company with nested user. - */ -class TestCompanyDataObject extends DataObject -{ - public function __construct( - public string $name, - public TestUserDataObject|array $employee, - ) { - } -} - -class DateTargetDataObject extends DataObject -{ - public function __construct( - public DateTimeInterface $dateTimeInterface, - public CarbonInterface $carbonInterface, - public DateTime $dateTime, - public DateTimeImmutable $dateTimeImmutable, - public Carbon $carbon, - public CarbonImmutable $carbonImmutable, - public BaseCarbon $baseCarbon, - public BaseCarbonImmutable $baseCarbonImmutable, - ) { - } -} - -class DataObjectMutableCarbonSubclass extends Carbon -{ -} - -class DataObjectImmutableCarbonSubclass extends CarbonImmutable -{ -} - -enum TestGenderEnum: string -{ - case Male = 'male'; - case Female = 'female'; -} - -class EmptyDataObject extends DataObject -{ -} - -class UntypedDataObject extends DataObject -{ - public $value = 'default'; -} - -interface FirstIntersectionType -{ -} - -interface SecondIntersectionType -{ -} - -class IntersectionValue implements FirstIntersectionType, SecondIntersectionType -{ -} - -class IntersectionDataObject extends DataObject -{ - public function __construct(public FirstIntersectionType&SecondIntersectionType $value) - { - } -} - -class DnfUnionDataObject extends DataObject -{ - public function __construct(public (FirstIntersectionType&SecondIntersectionType)|TestAddressDataObject $value) - { - } -} - -class ScalarUnionDataObject extends DataObject -{ - public function __construct(public int|string $value) - { - } -} - -class DefaultedDependencyDataObject extends DataObject -{ - public function __construct(public ?TestGenderEnum $gender = TestGenderEnum::Female) - { - } -} - -class OwnerKeyDataObject extends DataObject -{ - public function __construct(public ChildKeyDataObject $child) - { - } - - public static function convertPropertyToDataKey(string $input): string - { - return 'owner_' . $input; - } -} - -class ChildKeyDataObject extends DataObject -{ - public function __construct(public string $value) - { - } - - public static function convertPropertyToDataKey(string $input): string - { - return 'child_' . $input; - } -} - -class RootResolverDataObject extends DataObject -{ - public function __construct(public ChildResolverDataObject $child) - { - } - - protected static function getCustomizedDependencies(): array - { - return [ - ResolverValue::class => fn (string $value) => new ResolverValue('root:' . $value), - ]; - } -} - -class ChildResolverDataObject extends DataObject -{ - public function __construct(public ResolverValue $value) - { - } - - protected static function getCustomizedDependencies(): array - { - return [ - ResolverValue::class => fn (string $value) => new ResolverValue('child:' . $value), - ]; - } -} - -class ResolverValue -{ - public function __construct(public string $value) - { - } -} - -class CustomSerializerDataObject extends DataObject -{ - public function __construct( - public CarbonInterface $date, - public ResolverValue $value, - ) { - } - - protected static function getSerializers(): array - { - return parent::getSerializers() + [ - ResolverValue::class => static fn (ResolverValue $value): string => 'serialized:' . $value->value, - ]; - } -} - -class DependencylessDataObject extends DataObject -{ - public static int $dependencyLookups = 0; - - public function __construct(public string $value = 'default') - { - } - - protected static function getCustomizedDependencies(): array - { - ++static::$dependencyLookups; - - return parent::getCustomizedDependencies(); - } -} From b98eed452520732707af29a06403ab8ec59f8432 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:50:47 +0000 Subject: [PATCH 25/35] Expand the Data performance harness Retain reproducible warmup, sampling, percentile, throughput, memory, and environment reporting for flat and nested construction, large collections, validation graphs, named factories, metadata reuse, transformation, and Eloquent relation loading. Include exact-array success and miss measurements plus native/manual baselines so future optimizations must show a real same-machine benefit without hard-coded timing gates. --- tests/Benchmarks/Data/README.md | 6 +- tests/Benchmarks/Data/benchmark.php | 791 ++++++++++++++++++++++++++-- 2 files changed, 745 insertions(+), 52 deletions(-) diff --git a/tests/Benchmarks/Data/README.md b/tests/Benchmarks/Data/README.md index a302dc0c6..8009f7e84 100644 --- a/tests/Benchmarks/Data/README.md +++ b/tests/Benchmarks/Data/README.md @@ -1,6 +1,6 @@ # Data Benchmark -This developer-only harness measures data-object construction against native constructors and explicit array mapping. It is not registered as an Artisan command and is not part of the PHPUnit suite. +This developer-only harness measures Data construction against native constructors and explicit array mapping, plus collection, validation, named-factory, transformation, metadata, and Eloquent relation paths. It is not registered as an Artisan command and is not part of the PHPUnit suite. Run it from the components repository root: @@ -8,7 +8,7 @@ Run it from the components repository root: php tests/Benchmarks/Data/benchmark.php ``` -The harness warms each scenario, records repeated samples, and reports operations per second, median and p95 nanoseconds per operation, and peak allocated memory. Its heading records the commit, PHP version, operating system, loaded extensions, OPcache/JIT state, and workload size. +The harness warms each scenario, records repeated samples, and reports operations per second, median and p95 nanoseconds per operation, database queries per operation, and peak allocated memory. Its heading records the commit, PHP version, operating system, loaded extensions, OPcache/JIT state, and workload size. Expensive 1,000- and 5,000-item scenarios scale their operation counts from the requested baseline. Raw reports are opt-in and should be written outside the repository so local measurements cannot be committed accidentally: @@ -21,6 +21,6 @@ php tests/Benchmarks/Data/benchmark.php \ --csv=/tmp/hypervel-data-benchmark.csv ``` -The native constructor is the language floor. The manual mapper represents purpose-built SDK code with no reflection. Warm scenarios measure normal worker-lifetime operation after metadata has been retained, while cold scenarios include metadata analysis. +The native constructor is the language floor. The manual mapper represents purpose-built SDK code with no reflection. Warm scenarios measure normal worker-lifetime operation after metadata has been retained, while the first-use and metadata-analysis rows expose cold work separately. Eloquent scenarios use a disposable SQLite database and show the difference between preloaded relations and one batched LoadRelation query. Compare ratios on the same machine and commit rather than treating one run as a release claim. Re-run the harness after changes to metadata, creation, validation, transformation, or collection internals, and inspect both throughput and memory before retaining a specialized path. diff --git a/tests/Benchmarks/Data/benchmark.php b/tests/Benchmarks/Data/benchmark.php index 5e91411f3..193161123 100644 --- a/tests/Benchmarks/Data/benchmark.php +++ b/tests/Benchmarks/Data/benchmark.php @@ -3,42 +3,307 @@ declare(strict_types=1); -use Hypervel\Support\DataObject; +use Hypervel\Container\Attributes\Config; +use Hypervel\Contracts\Config\Repository; +use Hypervel\Data\Attributes\AutoLazy; +use Hypervel\Data\Attributes\LoadRelation; +use Hypervel\Data\Attributes\MapInputName; +use Hypervel\Data\Attributes\PropertyForMorph; +use Hypervel\Data\Attributes\Validation\Email; +use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Casts\Cast; +use Hypervel\Data\Contracts\PropertyMorphableData; +use Hypervel\Data\Data; +use Hypervel\Data\DataCollection; +use Hypervel\Data\DataServiceProvider; +use Hypervel\Data\Lazy; +use Hypervel\Data\Support\Creation\ConstructionState; +use Hypervel\Data\Support\Creation\CreationContext; +use Hypervel\Data\Support\DataClassRepository; +use Hypervel\Data\Support\DataProperty; +use Hypervel\Data\Support\Factories\DataClassFactory; +use Hypervel\Database\Connection; +use Hypervel\Database\DatabaseManager; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\HasOne; +use Hypervel\Database\Events\QueryExecuted; +use Hypervel\Database\Schema\Blueprint; +use Hypervel\Support\ClassMetadataCache; +use Hypervel\Support\LazyCollection; +use Hypervel\Testbench\Bootstrapper; +use Hypervel\Testbench\Foundation\Application as TestbenchApplication; + +use function Hypervel\Coroutine\run; require dirname(__DIR__, 3) . '/tests/bootstrap.php'; -class LegacyDataBenchmarkAddress extends DataObject +Bootstrapper::bootstrap(); + +class DataBenchmarkAddress extends Data { public function __construct( + #[MapInputName('line_one')] public string $lineOne, public string $city, + #[MapInputName('country_code')] public string $countryCode, ) { } } -class LegacyDataBenchmarkUser extends DataObject +class DataBenchmarkUser extends Data +{ + public function __construct( + public int $id, + public string $name, + public string $email, + public bool $active, + public ?DataBenchmarkAddress $address, + ) { + } +} + +class DataBenchmarkColdData extends Data +{ + public function __construct( + public int $id, + public string $name, + public bool $active, + ) { + } +} + +class DataBenchmarkLeaf extends Data +{ + public function __construct( + public int $id, + public string $code, + public string $label, + public bool $enabled, + ) { + } +} + +class DataBenchmarkLevelThree extends Data +{ + public function __construct( + public DataBenchmarkLeaf $child, + public string $alpha, + public string $beta, + public string $gamma, + public string $delta, + ) { + } +} + +class DataBenchmarkLevelTwo extends Data +{ + public function __construct( + public DataBenchmarkLevelThree $child, + public int $one, + public int $two, + public int $three, + public int $four, + ) { + } +} + +class DataBenchmarkRoot extends Data +{ + public function __construct( + public DataBenchmarkLevelTwo $child, + public float $amount, + public string $status, + public bool $active, + public ?string $note, + ) { + } +} + +class DataBenchmarkValidatedItem extends Data { public function __construct( public int $id, public string $name, + #[Email] public string $email, public bool $active, - public ?LegacyDataBenchmarkAddress $address, + ) { + } +} + +class DataBenchmarkLazyItem extends Data +{ + public function __construct( + public int $id, + #[AutoLazy] + public DataBenchmarkAddress|Lazy $address, + ) { + } +} + +class DataBenchmarkPrefixCast implements Cast +{ + /** + * Prefix one benchmark value. + */ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): string { + return 'cast:' . $value; + } +} + +abstract class DataBenchmarkShape extends Data implements PropertyMorphableData +{ + public function __construct( + #[PropertyForMorph] + public string $type, + ) { + } + + /** + * Resolve the concrete benchmark shape. + */ + public static function morph(array $properties): ?string + { + return ($properties['type'] ?? null) === 'circle' + ? DataBenchmarkCircle::class + : null; + } +} + +class DataBenchmarkCircle extends DataBenchmarkShape +{ + public function __construct( + string $type, + public float $radius, + ) { + parent::__construct($type); + } +} + +class DataBenchmarkExtensionData extends Data +{ + public function __construct( + #[Config('app.name')] + public string $applicationName, + #[MapInputName('external_id')] + public int $id, + #[WithCast(DataBenchmarkPrefixCast::class)] + public string $label, + public DataBenchmarkShape $shape, + ) { + } +} + +class DataBenchmarkNamedFactoryDependency +{ + public function __construct(public readonly string $suffix = 'dependency') + { + } +} + +class DataBenchmarkDirectFactoryData extends Data +{ + public function __construct(public int $id) + { + } + + /** + * Create benchmark data from one identifier. + */ + public static function fromIdentifier(int $identifier): self + { + return new self($identifier); + } +} + +class DataBenchmarkContainerFactoryData extends Data +{ + public function __construct(public string $value) + { + } + + /** + * Create benchmark data through the container invocation path. + */ + public static function fromIdentifier( + int $identifier, + DataBenchmarkNamedFactoryDependency $dependency, + CreationContext $context, + ): self { + return new self($identifier . ':' . $dependency->suffix . ':' . $context->dataClass); + } +} + +class DataBenchmarkProfile extends Model +{ + protected ?string $table = 'data_benchmark_profiles'; + + public bool $timestamps = false; +} + +class DataBenchmarkUserModel extends Model +{ + protected ?string $table = 'data_benchmark_users'; + + public bool $timestamps = false; + + /** + * Get the benchmark user's profile. + */ + public function profile(): HasOne + { + return $this->hasOne(DataBenchmarkProfile::class, 'user_id'); + } +} + +class DataBenchmarkProfileData extends Data +{ + public function __construct( + public int $userId, + public string $bio, + ) { + } +} + +class DataBenchmarkModelData extends Data +{ + public function __construct( + public int $id, + public string $name, + #[LoadRelation] + public DataBenchmarkProfileData $profile, ) { } } class DataBenchmark { + private int $queryCount = 0; + /** * Create a new data benchmark. + * + * @param EloquentCollection $loadedModels */ public function __construct( + private readonly DataClassFactory $dataClassFactory, + private readonly DataClassRepository $dataClasses, + private readonly Connection $connection, + private readonly EloquentCollection $loadedModels, private readonly int $operations, private readonly int $samples, private readonly int $warmup, ) { + $this->connection->listen(function (QueryExecuted $event): void { + ++$this->queryCount; + }); } /** @@ -49,8 +314,6 @@ public function __construct( public function execute(): array { $environment = $this->environment(); - $results = []; - $flatPayload = [ 'id' => 1001, 'name' => 'Taylor Otwell', @@ -66,24 +329,219 @@ public function execute(): array 'country_code' => 'US', ], ]; + $deepPayload = [ + 'child' => [ + 'child' => [ + 'child' => [ + 'id' => 1001, + 'code' => 'sdk-1001', + 'label' => 'Benchmark leaf', + 'enabled' => true, + ], + 'alpha' => 'a', + 'beta' => 'b', + 'gamma' => 'c', + 'delta' => 'd', + ], + 'one' => 1, + 'two' => 2, + 'three' => 3, + 'four' => 4, + ], + 'amount' => 125.50, + 'status' => 'active', + 'active' => true, + 'note' => null, + ]; + $collectionRows = $this->userRows(1_000); + $validationRows = $this->userRows(5_000); + $lazyRows = array_map( + static fn (array $row): array => [ + 'id' => $row['id'], + 'address' => [ + 'line_one' => $row['id'] . ' Framework Way', + 'city' => 'Little Rock', + 'country_code' => 'US', + ], + ], + $collectionRows, + ); + $factoryIdentifiers = range(1, 1_000); + $simpleData = DataBenchmarkUser::from($flatPayload); + $nestedData = DataBenchmarkUser::from($nestedPayload); + $lazyTransformData = DataBenchmarkLazyItem::from($lazyRows[0]) + ->includePermanently('address') + ->onlyPermanently('id', 'address.lineOne'); + + $results = [ + $this->benchmarkOnce( + 'data-from-cold-first-use', + fn (): int => DataBenchmarkColdData::from([ + 'id' => 1, + 'name' => 'Cold', + 'active' => true, + ])->id, + ), + ]; + + $standardOperations = $this->operations; + $nestedOperations = $this->scaledOperations(10); + $collectionOperations = $this->scaledOperations(100); + $validationOperations = $this->scaledOperations(1_000); + $standardWarmup = $this->warmup; + $nestedWarmup = $this->scaledWarmup(10); + $collectionWarmup = $this->scaledWarmup(100); + $validationWarmup = $this->scaledWarmup(1_000); $scenarios = [ - 'native-constructor' => fn (): int => (new LegacyDataBenchmarkUser( - $flatPayload['id'], - $flatPayload['name'], - $flatPayload['email'], - $flatPayload['active'], - null, - ))->id, - 'manual-flat-mapper' => fn (): int => $this->mapUser($flatPayload)->id, - 'data-object-flat-warm' => fn (): int => LegacyDataBenchmarkUser::make($flatPayload)->id, - 'data-object-flat-cold' => function () use ($flatPayload): int { - DataObject::flushState(); - - return LegacyDataBenchmarkUser::make($flatPayload)->id; - }, - 'manual-nested-mapper' => fn (): int => $this->mapUser($nestedPayload)->address?->countryCode === 'US' ? 1 : 0, - 'data-object-nested-warm' => fn (): int => LegacyDataBenchmarkUser::make($nestedPayload, true)->address?->countryCode === 'US' ? 1 : 0, + 'native-constructor' => [ + $standardOperations, + $standardWarmup, + fn (): int => (new DataBenchmarkUser( + $flatPayload['id'], + $flatPayload['name'], + $flatPayload['email'], + $flatPayload['active'], + null, + ))->id, + ], + 'manual-flat-mapper' => [ + $standardOperations, + $standardWarmup, + fn (): int => $this->mapUser($flatPayload)->id, + ], + 'data-from-flat-warm' => [ + $standardOperations, + $standardWarmup, + fn (): int => DataBenchmarkUser::from($flatPayload)->id, + ], + 'manual-nested-mapper' => [ + $standardOperations, + $standardWarmup, + fn (): int => $this->mapUser($nestedPayload)->address?->countryCode === 'US' ? 1 : 0, + ], + 'data-from-nested-warm' => [ + $standardOperations, + $standardWarmup, + fn (): int => DataBenchmarkUser::from($nestedPayload)->address?->countryCode === 'US' ? 1 : 0, + ], + 'data-from-deep-wide' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => DataBenchmarkRoot::from($deepPayload)->child->child->child->id, + ], + 'collect-1000-eager' => [ + $collectionOperations, + $collectionWarmup, + fn (): int => DataBenchmarkUser::collect($collectionRows, DataCollection::class)->count(), + ], + 'collect-1000-lazy-traversal' => [ + $collectionOperations, + $collectionWarmup, + function () use ($collectionRows): int { + $items = DataBenchmarkUser::collect(LazyCollection::make( + static fn (): iterable => yield from $collectionRows, + )); + $checksum = 0; + + foreach ($items as $item) { + $checksum += $item->id; + } + + return $checksum; + }, + ], + 'collect-1000-auto-lazy' => [ + $collectionOperations, + $collectionWarmup, + fn (): int => DataBenchmarkLazyItem::collect($lazyRows, DataCollection::class)->count(), + ], + 'validate-5000-nested' => [ + $validationOperations, + $validationWarmup, + fn (): int => DataBenchmarkValidatedItem::factory() + ->alwaysValidate() + ->collect($validationRows, DataCollection::class) + ->count(), + ], + 'factory-direct' => [ + $standardOperations, + $standardWarmup, + fn (): int => DataBenchmarkDirectFactoryData::from(1001)->id, + ], + 'factory-container' => [ + $standardOperations, + $standardWarmup, + fn (): int => strlen(DataBenchmarkContainerFactoryData::from(1001)->value), + ], + 'factory-direct-collection' => [ + $collectionOperations, + $collectionWarmup, + fn (): int => DataBenchmarkDirectFactoryData::collect( + $factoryIdentifiers, + DataCollection::class, + )->count(), + ], + 'factory-container-collection' => [ + $collectionOperations, + $collectionWarmup, + fn (): int => DataBenchmarkContainerFactoryData::collect( + $factoryIdentifiers, + DataCollection::class, + )->count(), + ], + 'mapped-cast-morph-injection' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => DataBenchmarkExtensionData::from([ + 'external_id' => 1001, + 'label' => 'benchmark', + 'shape' => ['type' => 'circle', 'radius' => 2.5], + ])->id, + ], + 'transform-simple' => [ + $standardOperations, + $standardWarmup, + fn (): int => $simpleData->toArray()['id'], + ], + 'transform-nested' => [ + $standardOperations, + $standardWarmup, + fn (): int => $nestedData->toArray()['address']['countryCode'] === 'US' ? 1 : 0, + ], + 'transform-lazy-partial' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => $lazyTransformData->toArray()['id'], + ], + 'metadata-analysis' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => count($this->dataClassFactory + ->build(ClassMetadataCache::reflectClass(DataBenchmarkRoot::class)) + ->properties), + ], + 'metadata-repository-hit' => [ + $standardOperations, + $standardWarmup, + fn (): int => count($this->dataClasses->get(DataBenchmarkRoot::class)->properties), + ], + 'eloquent-loaded-relations' => [ + $collectionOperations, + $collectionWarmup, + fn (): int => DataBenchmarkModelData::collect( + $this->loadedModels, + DataCollection::class, + )->count(), + ], + 'eloquent-load-missing' => [ + $collectionOperations, + $collectionWarmup, + fn (): int => DataBenchmarkModelData::collect( + $this->freshUnloadedModels(), + DataCollection::class, + )->count(), + ], ]; printf("Hypervel data benchmark\n"); @@ -93,26 +551,23 @@ public function execute(): array } printf( - "\n%-28s %14s %14s %14s %14s\n", + "\n%-34s %10s %14s %14s %14s %12s %14s\n", 'scenario', + 'operations', 'operations/s', 'p50 ns/op', 'p95 ns/op', - 'peak memory', + 'queries/op', + 'peak delta', ); - foreach ($scenarios as $name => $scenario) { - $result = $this->benchmark($name, $scenario); + foreach ($scenarios as $name => [$operations, $warmup, $operation]) { + $result = $this->benchmark($name, $operation, $operations, $warmup); $results[] = $result; + } - printf( - "%-28s %14.0f %14.2f %14.2f %14d\n", - $result['scenario'], - $result['operations_per_second'], - $result['p50_nanoseconds'], - $result['p95_nanoseconds'], - $result['peak_memory_bytes'], - ); + foreach ($results as $result) { + $this->printResult($result); } return compact('environment', 'results'); @@ -124,28 +579,34 @@ public function execute(): array * @param Closure(): int $operation * @return array */ - private function benchmark(string $name, Closure $operation): array - { + private function benchmark( + string $name, + Closure $operation, + int $operations, + int $warmup, + ): array { $checksum = 0; - for ($iteration = 0; $iteration < $this->warmup; ++$iteration) { + for ($iteration = 0; $iteration < $warmup; ++$iteration) { $checksum += $operation(); } $nanosecondsPerOperation = []; $peakMemory = 0; + $queryCount = $this->queryCount; for ($sample = 0; $sample < $this->samples; ++$sample) { memory_reset_peak_usage(); + $baselineMemory = memory_get_usage(true); $startedAt = hrtime(true); - for ($operationIndex = 0; $operationIndex < $this->operations; ++$operationIndex) { + for ($operationIndex = 0; $operationIndex < $operations; ++$operationIndex) { $checksum += $operation(); } $elapsedNanoseconds = hrtime(true) - $startedAt; - $nanosecondsPerOperation[] = $elapsedNanoseconds / $this->operations; - $peakMemory = max($peakMemory, memory_get_peak_usage(true)); + $nanosecondsPerOperation[] = $elapsedNanoseconds / $operations; + $peakMemory = max($peakMemory, memory_get_peak_usage(true) - $baselineMemory); } if ($checksum === 0) { @@ -157,29 +618,80 @@ private function benchmark(string $name, Closure $operation): array return [ 'scenario' => $name, - 'operations' => $this->operations, + 'operations' => $operations, 'samples' => $this->samples, 'operations_per_second' => 1_000_000_000 / $median, 'p50_nanoseconds' => $median, 'p95_nanoseconds' => $this->percentile($nanosecondsPerOperation, 0.95), + 'queries_per_operation' => ($this->queryCount - $queryCount) / ($operations * $this->samples), 'peak_memory_bytes' => $peakMemory, ]; } + /** + * Benchmark one worker-first-use operation. + * + * @param Closure(): int $operation + * @return array + */ + private function benchmarkOnce(string $name, Closure $operation): array + { + memory_reset_peak_usage(); + $baselineMemory = memory_get_usage(true); + $queryCount = $this->queryCount; + $startedAt = hrtime(true); + $checksum = $operation(); + $elapsedNanoseconds = hrtime(true) - $startedAt; + + if ($checksum === 0) { + throw new LogicException("Benchmark scenario [{$name}] produced an empty checksum."); + } + + return [ + 'scenario' => $name, + 'operations' => 1, + 'samples' => 1, + 'operations_per_second' => 1_000_000_000 / $elapsedNanoseconds, + 'p50_nanoseconds' => $elapsedNanoseconds, + 'p95_nanoseconds' => $elapsedNanoseconds, + 'queries_per_operation' => $this->queryCount - $queryCount, + 'peak_memory_bytes' => memory_get_peak_usage(true) - $baselineMemory, + ]; + } + + /** + * Print one benchmark result. + * + * @param array $result + */ + private function printResult(array $result): void + { + printf( + "%-34s %10d %14.0f %14.2f %14.2f %12.3f %14d\n", + $result['scenario'], + $result['operations'], + $result['operations_per_second'], + $result['p50_nanoseconds'], + $result['p95_nanoseconds'], + $result['queries_per_operation'], + $result['peak_memory_bytes'], + ); + } + /** * Map one representative SDK payload without reflection. */ - private function mapUser(array $payload): LegacyDataBenchmarkUser + private function mapUser(array $payload): DataBenchmarkUser { $address = $payload['address'] === null ? null - : new LegacyDataBenchmarkAddress( + : new DataBenchmarkAddress( $payload['address']['line_one'], $payload['address']['city'], $payload['address']['country_code'], ); - return new LegacyDataBenchmarkUser( + return new DataBenchmarkUser( $payload['id'], $payload['name'], $payload['email'], @@ -188,6 +700,59 @@ private function mapUser(array $payload): LegacyDataBenchmarkUser ); } + /** + * Build representative keyed API rows. + * + * @return list + */ + private function userRows(int $count): array + { + $rows = []; + + for ($index = 1; $index <= $count; ++$index) { + $rows[] = [ + 'id' => $index, + 'name' => 'User ' . $index, + 'email' => 'user' . $index . '@example.com', + 'active' => true, + 'address' => null, + ]; + } + + return $rows; + } + + /** + * Create fresh unloaded model instances for one relation-loading operation. + * + * @return EloquentCollection + */ + private function freshUnloadedModels(): EloquentCollection + { + return new EloquentCollection($this->loadedModels->map( + static fn (DataBenchmarkUserModel $model): DataBenchmarkUserModel => $model->newFromBuilder( + $model->getAttributes(), + $model->getConnectionName(), + ), + )); + } + + /** + * Scale expensive scenarios while retaining at least one measured operation. + */ + private function scaledOperations(int $divisor): int + { + return max(1, intdiv($this->operations, $divisor)); + } + + /** + * Scale warmup work consistently with the measured scenario. + */ + private function scaledWarmup(int $divisor): int + { + return $this->warmup === 0 ? 0 : max(1, intdiv($this->warmup, $divisor)); + } + /** * Return the nearest-rank percentile from sorted samples. * @@ -220,6 +785,9 @@ private function environment(): array 'operations_per_sample' => $this->operations, 'samples' => $this->samples, 'warmup_operations' => $this->warmup, + 'collection_items' => 1_000, + 'validation_items' => 5_000, + 'database_driver' => $this->connection->getDriverName(), ]; } } @@ -247,14 +815,90 @@ function main(): int $operations = parseIntegerOption($options, 'operations', 20_000, 1, 1_000_000); $samples = parseIntegerOption($options, 'samples', 7, 1, 100); $warmup = parseIntegerOption($options, 'warmup', 1_000, 0, 100_000); - $report = (new DataBenchmark($operations, $samples, $warmup))->execute(); + $databasePath = tempnam(sys_get_temp_dir(), 'hypervel-data-benchmark-'); - if (array_key_exists('json', $options)) { - writeJsonReport($options['json'], $report); + if ($databasePath === false) { + throw new RuntimeException('Unable to create the benchmark database.'); } - if (array_key_exists('csv', $options)) { - writeCsvReport($options['csv'], $report['results']); + $application = null; + try { + $application = TestbenchApplication::create( + options: ['load_environment_variables' => false], + ); + $application->register(DataServiceProvider::class); + + $config = $application->make(Repository::class); + $config->set('database.default', 'sqlite'); + $config->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'url' => null, + 'database' => $databasePath, + 'prefix' => '', + 'prefix_indexes' => null, + 'foreign_key_constraints' => true, + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + 'pragmas' => [], + ]); + + $connection = $application->make(DatabaseManager::class)->connection(); + + if (! $connection instanceof Connection) { + throw new RuntimeException('The Data benchmark requires a Hypervel database connection.'); + } + + $report = null; + $executionException = null; + + $completed = run(function () use ( + $application, + $connection, + $operations, + $samples, + $warmup, + &$executionException, + &$report, + ): void { + try { + $loadedModels = prepareDataBenchmarkDatabase($connection); + $report = (new DataBenchmark( + $application->make(DataClassFactory::class), + $application->make(DataClassRepository::class), + $connection, + $loadedModels, + $operations, + $samples, + $warmup, + ))->execute(); + } catch (Throwable $throwable) { + $executionException = $throwable; + } + }); + + if ($executionException !== null) { + throw $executionException; + } + + if (! $completed || $report === null) { + throw new RuntimeException('The benchmark coroutine did not complete.'); + } + + if (array_key_exists('json', $options)) { + writeJsonReport($options['json'], $report); + } + + if (array_key_exists('csv', $options)) { + writeCsvReport($options['csv'], $report['results']); + } + } finally { + $application?->terminate(); + + if (is_file($databasePath)) { + unlink($databasePath); + } } } catch (Throwable $throwable) { fwrite(STDERR, sprintf("Benchmark failed: %s: %s\n", $throwable::class, $throwable->getMessage())); @@ -265,6 +909,55 @@ function main(): int return 0; } +/** + * Build the benchmark schema and return models with their relation preloaded. + * + * @return EloquentCollection + */ +function prepareDataBenchmarkDatabase(Connection $connection): EloquentCollection +{ + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists('data_benchmark_profiles'); + $schema->dropIfExists('data_benchmark_users'); + $schema->create('data_benchmark_users', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->string('name'); + }); + $schema->create('data_benchmark_profiles', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->integer('user_id')->index(); + $table->string('bio'); + }); + + $users = []; + $profiles = []; + + for ($identifier = 1; $identifier <= 1_000; ++$identifier) { + $users[] = [ + 'id' => $identifier, + 'name' => 'User ' . $identifier, + ]; + $profiles[] = [ + 'id' => $identifier, + 'user_id' => $identifier, + 'bio' => 'Profile ' . $identifier, + ]; + } + + foreach (array_chunk($users, 200) as $chunk) { + $connection->table('data_benchmark_users')->insert($chunk); + } + + foreach (array_chunk($profiles, 200) as $chunk) { + $connection->table('data_benchmark_profiles')->insert($chunk); + } + + return DataBenchmarkUserModel::query() + ->with('profile') + ->orderBy('id') + ->get(); +} + /** * Parse and validate one integer option. * From c7a663f3239fe1a9f56f43e94c97ce836ede9937 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:51:03 +0000 Subject: [PATCH 26/35] Describe the Data component contract Link the canonical Data Objects guide, retain upstream attribution, and record only the lasting public differences developers need to know when moving from Laravel Data. Document Hypervel's fixed factory contexts, null and Optional semantics, first-source precedence, contextual constructor values, normalized collect factories, and wildcard-aware validation behavior without duplicating the full guide. --- src/data/README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/data/README.md b/src/data/README.md index 6c347b27d..2efdd6efb 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -6,9 +6,17 @@ Documentation: https://hypervel.org/docs/data-objects Hypervel Data keeps the familiar `spatie/laravel-data` vocabulary with fixed, coroutine-safe internals for long-lived workers. Metadata is analyzed once per used class and retained in worker memory; there is no discovery or deploy cache command. -`Data`, `Dto`, and `Resource` validate request input by default. Each `factory()` call starts a fresh operation, omitted nullable properties become `null`, and a declared `Optional` union always preserves absence. +`Data`, `Dto`, and `Resource` use `OnlyRequests` validation by default, and Hypervel retains the class-level `withValidator()` hook. `validate()` disables named factories and returns validated input, while `validateAndCreate()` may use a direct-returning factory that owns its validation. Each `factory()` call starts a fresh operation. -Constructor injection uses Hypervel contextual attributes. Their resolved value always wins over payload input, including `null`; use a named factory or creation hook when payload values should take precedence. Hypervel's compiled wildcard validation is used for uniform nested collections, with concrete indexed rules for dynamic shapes. +Omitted nullable properties become `null`; use `Optional` to preserve absence and `#[Present]` when a nullable key must be supplied. A Model attribute containing `null` remains an explicit value, even for a non-nullable property with a default. With multiple payloads, the first source containing a property's input key wins, including when the value is `null`. + +Input and output mapping collisions are rejected when metadata is built. Hypervel's compiled wildcard validation is used for uniform nested collections, with concrete indexed rules for dynamic shapes. + +Constructor injection uses Hypervel contextual attributes, including property extraction through `CurrentUser` and `RouteParameter`. Their resolved value always wins over payload input, including `null`; use a named factory or creation hook when payload values should take precedence. + +Data-specific `From*` aliases, optional-value factory switches, `SerializeTransformer`, and `UnserializeCast` are not included. Use Hypervel contextual attributes, declared `Optional` unions, native PHP serialization, or an explicit custom cast or transformer. + +Named `collect*` methods receive the normalized container of created data objects rather than the raw source. An exact Eloquent collection parameter therefore does not match an Eloquent source after it has been normalized to a base collection. Deprecated collection forwarding, Livewire integration, and TypeScript generation are not included. Use `toCollection()` for collection operations; TypeScript generation belongs in a general transformer package. From 577f9ebbdf9999cffc2f81e3a19ee92a1c0ffb1c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:51:17 +0000 Subject: [PATCH 27/35] Document first-party Data objects Replace the legacy DataObject guide with the complete hypervel/data API: construction, validation, mapping, casts, factories, lazy values, partials, collections, resources, Eloquent persistence, Inertia, VarDumper, and extension contracts. Update API client, Saloon, Eloquent, Validation, and Laravel-porting guidance to use the new package and its owning framework APIs. Keep examples Laravel-shaped while calling out the few deliberate Hypervel behavior differences. --- src/docs/api-client.md | 12 +- src/docs/data-objects.md | 779 ++++++++++++++++++------------- src/docs/eloquent-mutators.md | 30 +- src/docs/porting-from-laravel.md | 9 + src/docs/saloon.md | 13 +- src/docs/validation.md | 26 +- 6 files changed, 506 insertions(+), 363 deletions(-) diff --git a/src/docs/api-client.md b/src/docs/api-client.md index af0721b70..347086ec0 100644 --- a/src/docs/api-client.md +++ b/src/docs/api-client.md @@ -95,11 +95,11 @@ API clients are regular classes, so their dependencies may be injected through t declare(strict_types=1); -namespace App\DataObjects; +namespace App\Data; -use Hypervel\Support\DataObject; +use Hypervel\Data\Dto; -class GitHubConfig extends DataObject +class GitHubConfig extends Dto { public function __construct( public readonly string $baseUrl, @@ -118,7 +118,7 @@ declare(strict_types=1); namespace App\ApiClients; -use App\DataObjects\GitHubConfig; +use App\Data\GitHubConfig; use Hypervel\ApiClient\ApiClient; use Hypervel\ApiClient\PendingRequest; @@ -171,10 +171,10 @@ If your client requires configuration that cannot be resolved automatically, you ```php use App\ApiClients\GitHubClient; -use App\DataObjects\GitHubConfig; +use App\Data\GitHubConfig; $this->app->singleton(GitHubClient::class, function () { - return new GitHubClient(GitHubConfig::make( + return new GitHubClient(GitHubConfig::from( config()->array('services.github') )); }); diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index b65aa581f..f8afc4db9 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -1,53 +1,69 @@ # Data Objects - [Introduction](#introduction) +- [Choosing a Base Class](#choosing-a-base-class) - [Creating Data Objects](#creating-data-objects) - [Creating Instances](#creating-instances) + - [Associating a Data Class](#associating-a-data-class) + - [Defaults, Null, and Optional Values](#defaults-null-and-optional-values) + - [Named Factories](#named-factories) - [Property Name Conversion](#property-name-conversion) - [Type Conversion](#type-conversion) - [Date and Time Values](#date-and-time-values) - [Nested Data Objects](#nested-data-objects) - [Backed Enums](#backed-enums) -- [Array Access](#array-access) -- [Serialization](#serialization) - - [Converting to Arrays](#converting-to-arrays) - - [JSON Serialization](#json-serialization) - - [Custom Serializers](#custom-serializers) -- [Updating Data Objects](#updating-data-objects) -- [Customizing Data Objects](#customizing-data-objects) - - [Custom Property Conversion](#custom-property-conversion) - - [Custom Dependency Resolution](#custom-dependency-resolution) - - [Auto-Casting](#auto-casting) - - [Flushing State](#flushing-state) +- [Casts and Transformers](#casts-and-transformers) +- [Validation](#validation) + - [Validation Attributes](#validation-attributes) + - [Manual Rules and Hooks](#manual-rules-and-hooks) + - [Creation Factories](#creation-factories) +- [Transformation](#transformation) + - [Lazy Properties](#lazy-properties) + - [Partial Trees](#partial-trees) + - [Hidden, Computed, and Appended Values](#hidden-computed-and-appended-values) +- [Collections](#collections) +- [HTTP Resources](#http-resources) - [Form Request Casting](#form-request-casting) - [Eloquent Casting](#eloquent-casting) -- [Validation and Exceptions](#validation-and-exceptions) +- [Contextual Constructor Values](#contextual-constructor-values) +- [Inertia](#inertia) +- [Saloon](#saloon) +- [Generating Data Classes](#generating-data-classes) +- [Worker Lifetime](#worker-lifetime) ## Introduction -Hypervel data objects provide a small, typed wrapper around array data. They are useful when you want to pass structured data through your application without repeatedly reading from untyped arrays. +Hypervel Data turns untyped input into typed PHP objects and can validate, transform, collect, return, and persist those objects. Its public API follows the familiar `spatie/laravel-data` vocabulary while its metadata and execution paths are designed for Hypervel's long-lived workers. -Data objects support constructor-promoted properties, automatic scalar casting, conversion between `snake_case` array keys and `camelCase` property names, nested object resolution, array access for reads, and array / JSON serialization. +Metadata is analyzed once for each used data class and retained for the worker lifetime. Request values, validation state, partial selections, lazy evaluation, and factory hooks stay within the current operation or object instance. -> [!NOTE] -> Data objects are not fully immutable by default. Array access is read-only, but public properties may still be assigned unless you declare them as `readonly`. + +## Choosing a Base Class + +The package provides three base classes that share one construction engine: + +- `Data` supports construction, validation, transformation, HTTP responses, collections, and Eloquent casting. +- `Dto` supports construction and validation without transformation or response behavior. Use it for commands, service boundaries, and domain input. +- `Resource` supports construction, transformation, HTTP responses, collections, and Eloquent casting without the public validation helpers. + +Choose the smallest capability set that matches the object's role. Nested or collected `Dto` values remain objects when a surrounding `Data` object is transformed because `Dto` deliberately has no transformation contract. ## Creating Data Objects -To create a data object, extend the `Hypervel\Support\DataObject` class and define the values your object accepts on its constructor: +Extend one of the base classes and define the object's public properties: ```php ### Creating Instances -You may create data object instances using the `make` method. The `from` method is also available as an alias: +Create an object with `from`: ```php -$user = UserData::make([ - 'name' => 'Taylor Otwell', - 'age' => '39', - 'email' => 'taylor@example.com', -]); - $user = UserData::from([ 'name' => 'Taylor Otwell', 'age' => '39', @@ -100,114 +110,181 @@ $user = UserData::from([ ]); ``` -When auto-casting is enabled, scalar constructor arguments are cast to the declared type: +`from` accepts arrays, JSON strings, `Arrayable` objects, initialized public properties from ordinary objects, Eloquent models, and requests. Existing instances of the requested data type pass through unchanged. + +You may pass multiple payloads. For each property, the first payload containing its input key wins, including when that value is `null`: ```php -$user->age; +$user = UserData::from($routeValues, $requestValues, $defaults); +``` -// 39 +Use `optional` when the whole object may be absent. It returns `null` when no payload is supplied or every supplied payload is `null`: + +```php +$user = UserData::optional($payload); ``` -Missing values use their constructor defaults. Passing `null` explicitly is distinct from omitting a nullable value. + +### Associating a Data Class + +A model, request, or other source object may use the `WithData` trait to expose its associated data object: + +```php +use App\Data\UserData; +use Hypervel\Data\WithData; +use Hypervel\Database\Eloquent\Model; + +class User extends Model +{ + /** @use WithData */ + use WithData; + + protected string $dataClass = UserData::class; +} + +$data = $user->getData(); +``` + +You may instead return the class from a `dataClass()` method. The `$dataClass` property takes precedence when both are declared. + +When a FormRequest uses `WithData`, `getData()` runs the associated data class's authorization and validation rules. It does not reuse the FormRequest's rules. Pass `$request->validated()` directly to `UserData::from()` when you want to construct from the FormRequest's validated result instead. + + +### Defaults, Null, and Optional Values + +Missing properties resolve in this order: a declared constructor default, an `Optional` union, then `null` for a nullable type. Any other missing property fails validation or construction. + +```php +use Hypervel\Data\Optional; + +class PatchUserData extends Data +{ + public function __construct( + public string|Optional $name, + public ?string $phone, + public string $locale = 'en', + ) { + } +} +``` + +For this object, an omitted `name` becomes `Optional::create()`, an omitted `phone` becomes `null`, and an omitted `locale` uses `en`. Explicit `null` is a supplied value and is accepted only when the declared type allows it. Use `#[Present]` when a nullable input key must still be supplied. + + +### Named Factories + +Public static methods beginning with `from` may provide source-specific construction. Type the parameters so Hypervel can choose the first compatible method in declaration order: + +```php +use Hypervel\Database\Eloquent\Model; + +class UserData extends Data +{ + public function __construct( + public int $id, + public string $name, + ) { + } + + public static function fromModel(Model $user): self + { + return new self($user->getKey(), $user->getAttribute('name')); + } +} +``` + +Named methods may receive container-resolved dependencies and a `CreationContext`. A method that returns the target object owns that node completely; inferred validation, casts, and creation hooks do not run again for it. Methods returning another normalizable value continue through the ordinary engine without being matched a second time. + +Public static `collect*` methods provide the same escape hatch for a complete normalized collection. Their parameter receives the container of already-created data objects, not the raw source values. ### Property Name Conversion -Data objects convert between `snake_case` array keys and `camelCase` properties: +Input and output names are unchanged by default. Use mapping attributes when the wire format differs from the PHP property name: ```php 'Desk', 'unit_price' => '199.99', - 'is_available' => 1, ]); $product->productName; // Desk +``` -$product['unit_price']; +`MapName` applies the same name in both directions. `MapInputName` and `MapOutputName` keep the directions independent. Class-level mappers such as `SnakeCaseMapper`, `CamelCaseMapper`, and `KebabCaseMapper` provide a convention for every property, while a property attribute overrides the class mapper. -// 199.99 -``` +Mapped input paths may use dot notation. When both the mapped input path and PHP property name are present, the mapped input wins. Hypervel rejects two properties that claim the same effective input path or output key when metadata is built instead of silently overwriting a value. ## Type Conversion -Data objects automatically cast values for constructor parameters typed as `string`, `int`, `float`, `bool`, or `array`: +`from` casts supported scalar values, backed enums, dates, nested data objects, and typed iterables to their declared PHP types. Existing values that already satisfy the type retain their identity. ```php - 123, - 'integer_value' => '42', - 'float_value' => '3.14', - 'boolean_value' => 1, - 'array_value' => 'single item', +$product = ProductData::from([ + 'stock' => '42', + 'price' => '19.95', + 'active' => 'true', ]); ``` +Ambiguous unions of data classes or typed data containers are not guessed. Use a cast, morph discriminator, or typed named factory to select the intended type. + ### Date and Time Values -When the second argument passed to `make` is `true`, data objects will resolve supported object dependencies. The built-in date resolver supports `DateTimeInterface`, `Carbon\CarbonInterface`, native `DateTime` and `DateTimeImmutable`, `Hypervel\Support\Carbon` and `Hypervel\Support\CarbonImmutable`, and Carbon's base mutable and immutable classes. +Date interfaces use Hypervel's configured Date factory. A property that declares a concrete date class receives that exact class. Input is parsed with `data.date_format`, which accepts one format or an ordered list of formats. The `data.date_timezone` setting converts parsed and transformed dates to a target timezone. For a property with a different source timezone, set `timeZone` on `DateTimeInterfaceCast`; its `setTimeZone` argument overrides the target timezone for that property. -Interface-typed properties use Hypervel's configured date factory and therefore receive an exact `Hypervel\Support\CarbonImmutable` instance by default. A concrete property type always receives that exact concrete class, regardless of the configured factory. This allows a data object to request mutable or immutable behavior explicitly while keeping interfaces application-configurable: +Dates are transformed using the configured output format unless a property transformer overrides it: ```php 'Conference', - 'starts_at' => '2026-04-30 09:00:00', -], autoResolve: true); + 'startsAt' => '2026-04-30 09:00:00', +]); ``` -By default, database-style date strings are parsed using the `Y-m-d H:i:s` format. You may customize the format by defining a static `$dateFormat` property on your data object: +For one property, select a different parser with `WithCast`: ```php -class EventData extends DataObject -{ - protected static string $dateFormat = 'Y-m-d H:i:s.u'; +use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Casts\DateTimeInterfaceCast; +class EventData extends Data +{ public function __construct( + #[WithCast(DateTimeInterfaceCast::class, format: 'Y-m-d')] public DateTimeInterface $startsAt, ) { } @@ -241,18 +320,18 @@ class EventData extends DataObject ### Nested Data Objects -Nested data objects are resolved when `autoResolve` is enabled: +Nested data objects are created recursively: ```php 'Taylor Otwell', 'address' => [ 'street' => '123 Main Street', 'city' => 'Chicago', - 'postal_code' => '60601', + 'postalCode' => '60601', ], -], autoResolve: true); +]); $user->address->street; // 123 Main Street ``` -Nested resolution works recursively for nested data object properties. If you use a union type for an auto-resolved dependency, the union should include a data object or date / time type. +Nested construction works through the complete graph. Existing `AddressData` instances pass through unchanged. + +For a typed collection, use `DataCollectionOf` or a supported PHPDoc item annotation: + +```php +use Hypervel\Data\Attributes\DataCollectionOf; +use Hypervel\Data\DataCollection; + +class TeamData extends Data +{ + public function __construct( + #[DataCollectionOf(UserData::class)] + public DataCollection $members, + ) { + } +} +``` -You may also pass an existing nested data object instance. Auto-resolution preserves that instance instead of rebuilding it. +The same typed item conversion works for arrays, ordinary collections, lazy collections, and supported paginator types. `DataCollectionOf` is preferred for generated classes because it is explicit and requires no PHPDoc parsing. ### Backed Enums -Backed enums are resolved automatically when `autoResolve` is enabled: +Backed enums are resolved from their backing values: ```php 'ORD-1000', 'status' => 'paid', -], autoResolve: true); +]); $order->status === OrderStatus::Paid; // true ``` - -## Array Access + +## Casts and Transformers -Data objects implement PHP's `ArrayAccess` interface. Array access uses the serialized array keys, so `camelCase` properties are read using their `snake_case` key: +Casts convert input into PHP values. Transformers convert PHP values into output. Attach them to a property with `WithCast`, `WithTransformer`, or `WithCastAndTransformer`: ```php -$product = ProductData::make([ - 'product_name' => 'Desk', - 'unit_price' => '199.99', - 'is_available' => true, -]); - -$product['product_name']; +use Hypervel\Data\Attributes\WithCastAndTransformer; -// Desk +class InvoiceData extends Data +{ + public function __construct( + #[WithCastAndTransformer(MoneyCast::class)] + public Money $total, + ) { + } +} ``` -Array access is read-only: - -```php -$product['product_name'] = 'Chair'; - -// LogicException - -unset($product['product_name']); +A cast implements `Hypervel\Data\Casts\Cast`; a transformer implements `Hypervel\Data\Transformers\Transformer`. Return `Uncastable::create()` from a cast when the next applicable candidate should be tried. Returning `null` means the cast produced a real null value. -// LogicException -``` +Use `Castable` when a value class owns its input conversion, `IterableItemCast` when a cast also applies to typed iterable items, or `factory()->withCast()` for one operation. Application-wide replacement casts and transformers belong in `config/data.php`; built-in date, enum, iterable, and `Arrayable` handling does not need to be configured. - -## Serialization +Custom normalizers adapt whole source objects before properties are selected. Declare class-owned normalizers with `normalizers()` or add them to one factory with `withNormalizers()`. Prefer a typed named factory when only one source type needs special handling. - -### Converting to Arrays + +## Validation -The `toArray` method converts a data object to an array using the configured data keys: +`Data`, `Dto`, and `Resource` validate request input during construction by default. Arrays, models, JSON, and other non-request sources skip validation under the shipped `OnlyRequests` strategy, so trusted internal construction keeps the lean path. `Data` and `Dto` also expose `validateAndCreate` to validate any array-like payload explicitly: ```php -$user = UserData::make([ - 'name' => 'Taylor Otwell', - 'address' => [ - 'street' => '123 Main Street', - 'city' => 'Chicago', - 'postal_code' => '60601', - ], -], autoResolve: true); - -$user->toArray(); - -// [ -// 'name' => 'Taylor Otwell', -// 'address' => [ -// 'street' => '123 Main Street', -// 'city' => 'Chicago', -// 'postal_code' => '60601', -// ], -// ] +$user = UserData::validateAndCreate($payload); ``` -Nested data objects are recursively converted to arrays. Objects with a `toArray` method are also converted using that method. - - -### JSON Serialization - -Data objects implement `JsonSerializable`, so they may be encoded directly: +Use `validate` when only the validated payload is needed, or `getValidationRules` to inspect the compiled rules: ```php -return response()->json($user); +$validated = UserData::validate($payload); +$rules = UserData::getValidationRules($payload); ``` - -### Custom Serializers - -You may customize how object values are serialized by overriding the `getSerializers` method. Serializer keys are class names, and serializers are applied to object values during `toArray` and JSON serialization: +Hypervel infers presence, nullable, scalar, enum, date, nested data, and typed collection rules from PHP declarations. One Validator handles the complete nested graph. Uniform collections use wildcard rules and Hypervel's compiled validation plans; dynamic shapes use exact indexed rules. -```php - +### Validation Attributes -namespace App\DataObjects; +Validation attributes mirror Hypervel's validation rules: -use Hypervel\Support\DataObject; +```php +use Hypervel\Data\Attributes\Validation\Email; +use Hypervel\Data\Attributes\Validation\Max; +use Hypervel\Data\Attributes\Validation\Required; -class Money +class UserData extends Data { public function __construct( - public int $amount, - public string $currency, + #[Required, Max(100)] + public string $name, + #[Required, Email] + public string $email, ) { } } +``` -class ProductPriceData extends DataObject -{ - public function __construct( - public string $name, - public Money $price, - ) { - } +Database-aware `Exists` and `Unique` attributes support the familiar fluent constraints. References to another field or an external value use the package's typed validation reference objects rather than interpolated strings. - protected static function getSerializers(): array - { - return array_merge(parent::getSerializers(), [ - Money::class => fn (Money $money) => [ - 'amount' => $money->amount, - 'currency' => $money->currency, - ], - ]); - } + +### Manual Rules and Hooks + +Define `rules`, `messages`, and `attributes` on the data class for rules that cannot be inferred: + +```php +use Hypervel\Data\Support\Validation\ValidationContext; +use Hypervel\Validation\Validator; + +public static function rules(ValidationContext $context): array +{ + return [ + 'email' => ['required', 'email:rfc'], + ]; } ``` -All `DateTimeInterface` and Carbon instances created by the built-in resolver are serialized as ISO 8601 strings. +A class rule replaces inferred rules for that property. Add `#[MergeValidationRules]` to merge instead. Property keys use PHP property names; Hypervel translates them to the input paths selected for the current payload. - -## Updating Data Objects +Use `withValidator(Validator $validator)` and `after(): array` like a FormRequest. Authorization, messages, translated attribute names, error bags, redirects, stop-on-first-failure, Precognition, and `#[FailOnUnknownFields]` use the corresponding Hypervel request-validation behavior. A declared class method overrides the matching Foundation attribute when both are present. -The `update` method updates properties using serialized array keys and clears the cached array representation: + +### Creation Factories + +`factory()` returns a fresh fluent factory for one operation: ```php -$product = ProductData::make([ - 'product_name' => 'Desk', - 'unit_price' => '199.99', - 'is_available' => true, -]); +$user = UserData::factory() + ->alwaysValidate() + ->prepareData(fn (array $data): array => [ + ...$data, + 'source' => 'import', + ]) + ->withValidator(fn (Validator $validator) => $validator->after($check)) + ->from($payload); +``` -$product->toArray(); +Factories may change the validation strategy, enable or disable name mapping and named factories, ignore selected named methods, add casts or normalizers, and register the ordered `prepareData`, `beforeValidation`, `beforeRules`, `afterRules`, `withValidator`, `afterValidation`, `beforeCreation`, and `afterCreation` hooks. -$product->update([ - 'product_name' => 'Chair', -]); +For creation, `prepareData`, `beforeCreation`, and `afterCreation` run even when validation is skipped. `beforeValidation`, `beforeRules`, and `afterRules` run only when the operation validates or returns rules; `withValidator` and `afterValidation` run only when validation executes. Call `alwaysValidate()` when these validation hooks must apply to an array, model, JSON value, or another non-request source. -$product->productName; +Each call to `factory()` starts a new operation. Do not store or reuse a factory across requests. Hooks receive the current operation's values and are never cached in worker metadata. -// Chair -``` + +## Transformation -If you assign to a public property directly after calling `toArray`, call `refresh` before serializing the object again: +`Data` and `Resource` transform their current property values. There is no serialized result cache, so later public-property assignments are visible immediately: ```php +$product = ProductData::from($payload); $product->productName = 'Table'; -$product->refresh(); +$array = $product->toArray(); +$json = $product->toJson(); ``` - -## Customizing Data Objects - - -### Custom Property Conversion +`toArray()` recursively transforms nested transformable data, typed iterable items, dates, enums, and `Arrayable` values. `all()` returns visible values without transforming nested values. `transform()` accepts a `TransformationContext` or `TransformationContextFactory` for advanced one-operation control. -You may customize how data object property names are converted to and from array keys by overriding the `convertPropertyToDataKey` and `convertDataKeyToProperty` methods: - -```php - +### Lazy Properties -namespace App\DataObjects; +A `Lazy` property is omitted until it is included: -use Hypervel\Support\DataObject; -use Hypervel\Support\Str; +```php +use Hypervel\Data\Lazy; -class ExternalUserData extends DataObject +class UserData extends Data { public function __construct( - public string $first_name, - public string $last_name, + public string $name, + public Lazy|ProfileData $profile, ) { } +} - public static function convertPropertyToDataKey(string $input): string - { - return Str::camel($input); - } +$user = new UserData( + 'Taylor Otwell', + Lazy::create(fn () => ProfileData::from($profile)), +); - public static function convertDataKeyToProperty(string $input): string - { - return Str::snake($input); - } -} +return $user->include('profile')->toArray(); ``` -```php -$user = ExternalUserData::make([ - 'firstName' => 'Taylor', - 'lastName' => 'Otwell', -]); -``` +`Lazy::when` and `Lazy::whenLoaded` add conditional and relation-aware values. `Lazy::closure` returns the closure itself for consumers that understand callback values. Add `#[AutoLazy]`, `#[AutoClosureLazy]`, or `#[AutoWhenLoadedLazy]` to let `from()` wrap supplied values automatically. - -### Custom Dependency Resolution +Automatic lazy values defer their nested construction work when validation does not require it. A custom `AutoLazy::build()` implementation receives the original raw source aligned with the property that won. Values changed by validation hooks receive the hook's final payload instead. A named factory returning another normalizable value makes that return the aligned source. `AutoWhenLoadedLazy` requires a Model source; a hook-selected morph with no Model fails clearly rather than retaining stale source state. -You may customize how object dependencies are resolved when `autoResolve` is enabled by overriding the `getCustomizedDependencies` method: + +### Partial Trees + +Use `include`, `exclude`, `only`, and `except` to select nested output paths: ```php -include('profile.avatar') + ->only('name', 'profile.*') + ->except('profile.internalNotes') + ->toArray(); +``` -declare(strict_types=1); +The ordinary methods apply to the next transformation. Their `Permanently` variants apply to every transformation of that object, and the `When` variants accept a boolean or closure condition. A terminal `*` selects the complete subtree. Invalid partial paths fail instead of being silently ignored. -namespace App\DataObjects; +Selections owned by nested objects and collection items are composed with selections from their parent. Temporary selections are consumed only when that object is actually reached; collection reads and iteration do not consume them. -use Hypervel\Support\DataObject; + +### Hidden, Computed, and Appended Values -class Money -{ - public function __construct( - public int $amount, - public string $currency, - ) { - } -} +`#[Hidden]` omits a declared property from ordinary output. `#[Computed]` marks an output-only property whose value is set by the class; caller input for it is rejected. PHP 8.4 virtual properties are treated as output-only in the same way. -class OrderData extends DataObject -{ - public function __construct( - public string $number, - public Money $total, - ) { - } +Return response-only values from `with()` or add them to one object with `additional()`: - protected static function getCustomizedDependencies(): array - { - return array_merge(parent::getCustomizedDependencies(), [ - Money::class => fn (array|Money $value) => $value instanceof Money - ? $value - : new Money($value['amount'], $value['currency']), - ]); - } +```php +public function with(): array +{ + return ['links' => ['self' => route('users.show', $this->id)]]; } -``` -```php -$order = OrderData::make([ - 'number' => 'ORD-1000', - 'total' => [ - 'amount' => 2999, - 'currency' => 'USD', - ], -], autoResolve: true); +return $user->additional(['meta' => ['version' => 1]]); ``` -Always merge with `parent::getCustomizedDependencies()` so the built-in date and time resolvers remain available. +These values participate in HTTP resource responses, not Eloquent persistence. Dumps show the current logical `all()` view, so hidden, excluded lazy, and `Optional` values do not expose package internals. - -### Auto-Casting + +## Collections -Auto-casting is enabled by default. You may disable it if you want constructor values to be passed through without scalar type conversion: +Use `collect()` to create several objects while preserving supported source shapes and keys: ```php -DataObject::disableAutoCasting(); +$users = UserData::collect($rows); +$users = UserData::collect($rows, DataCollection::class); +$users = UserData::collect($rows, Collection::class); +$users = UserData::collect($rows, 'array'); +``` -DataObject::isAutoCasting(); +The `$into` argument accepts `null`, `'array'`, or a class-string. Narrow values read from configuration to `class-string` before passing them. With `null`, arrays remain arrays, ordinary collections remain collections, lazy collections remain lazy when validation does not require materialization, and Hypervel paginators are cloned with their metadata intact. Eloquent sources become base support collections because data objects are not Eloquent models. -// false +`DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` provide typed items, keyed access, transformation, and response behavior. Use `toCollection()` for map, filter, reduce, and other collection operations. Paginator wrappers are not Eloquent-castable because their metadata cannot be reconstructed from a JSON item array; persist their items through `DataCollection`. -DataObject::enableAutoCasting(); -``` +When a source remains lazy, every traversal creates its items again and `count()` is also a traversal. If the items are needed more than once, materialize them once with `$collection->toCollection()->collect()` and reuse that eager collection. -> [!WARNING] -> Auto-casting is controlled by a static flag that persists for the worker lifetime. Configure it during application boot or in tests, not per request. +All eager items in one root `collect()` call share one construction operation and, when selected, one Validator. Eloquent collections batch explicitly requested `#[LoadRelation]` paths before item construction. - -### Flushing State + +## HTTP Resources -The `flushState` method clears data object caches and resets auto-casting and the date format to their defaults: +Return `Data`, `Resource`, or their collection wrappers directly from a controller: ```php -DataObject::flushState(); +return UserData::from($user); + +return UserData::collect($users, DataCollection::class); ``` -This method is useful in tests or when changing data object configuration during bootstrapping. +Responses use Hypervel's JSON resource and paginator machinery, including native links and metadata. Use `wrap()` or `withoutWrapping()` on one object or collection. The global `data.wrap` setting supplies the package default without mutating `JsonResource::$wrap` during a request. + +Override static `jsonOptions()` or `withResponse(Request $request, JsonResponse $response)` for Laravel-style response customization. Query-string `include`, `exclude`, `only`, and `except` selections are disabled unless the data class allows them through `allowedRequestIncludes()`, `allowedRequestExcludes()`, `allowedRequestOnly()`, or `allowedRequestExcept()`. ## Form Request Casting -Form requests may cast validated input into data objects. To cast a single nested array, use the data object class name as the cast target: +Use the package-owned casts to convert FormRequest input after validation: ```php AddressData::class, + 'address' => AsData::of(AddressData::class), + 'contacts' => AsDataCollection::of(ContactData::class), ]; } } ``` -To cast an array of data objects, use `AsDataObjectArray`. The cast returns an `ArrayObject` containing data object instances: - -```php -use App\DataObjects\ContactData; -use Hypervel\Foundation\Http\Casts\AsDataObjectArray; - -protected function casts(): array -{ - return [ - 'contacts' => AsDataObjectArray::of(ContactData::class), - ]; -} -``` - -To cast into a collection, use `AsDataObjectCollection`: +`AsDataCollection::of()` returns a `DataCollection` by default and accepts the same explicit targets as `collect()`, including `'array'` and `Hypervel\Support\Collection::class`: ```php -use App\DataObjects\ProductData; -use Hypervel\Foundation\Http\Casts\AsDataObjectCollection; - protected function casts(): array { return [ - 'products' => AsDataObjectCollection::of(ProductData::class), + 'contacts' => AsDataCollection::of(ContactData::class, 'array'), ]; } ``` @@ -674,7 +709,7 @@ For more information on request input casting, see the [validation documentation ## Eloquent Casting -The `AsDataObject` cast converts JSON columns into data object instances: +`Data`, `Resource`, and `DataCollection` implement Eloquent's `Castable` contract. Use the data class directly in a model's cast declaration: ```php AsDataObject::castUsing(UserProfileData::class), + 'profile' => UserProfileData::class, + 'members' => DataCollection::class . ':' . MemberData::class, ]; } } ``` -When an Eloquent model retrieves the value, the cast decodes the JSON value and creates the data object with `autoResolve` enabled. When the model is saved, the data object is encoded back to JSON. +Eloquent stores a complete constructable view using PHP property names. Hidden declared values are included; computed, virtual, appended, and response-only values are omitted. Instance partials are ignored without being consumed. Output transformers still run, so a one-way transformer needs a matching input cast or `WithCastAndTransformer` for a round trip. -```php -$user = User::create([ - 'profile' => [ - 'first_name' => 'Taylor', - 'last_name' => 'Otwell', - ], -]); +Conditional and relation lazy values must already be included when the model is saved. Persistence never loads a relation. Closure and Inertia lazy values cannot be stored because they do not resolve to constructable data. + +Both casts support `encrypted` and `default` arguments. Abstract data classes use an enforced alias map unless they select a concrete subtype through `PropertyMorphableData::morph()`: -$user->profile->firstName; +```php +use Hypervel\Data\Support\DataConfig; -// Taylor +public function boot(DataConfig $data): void +{ + $data->enforceMorphMap([ + 'card' => CardPaymentData::class, + 'bank' => BankPaymentData::class, + ]); +} ``` +Morph maps are boot-time configuration. Unknown aliases and payload-provided class names are rejected. + For more information on Eloquent casts, see the [Eloquent mutators and casts documentation](/docs/{{version}}/eloquent-mutators#data-object-casting). - -## Validation and Exceptions + +## Contextual Constructor Values + +Hypervel contextual attributes may supply constructor values from framework services without Data-specific injection aliases: + +```php +use Hypervel\Container\Attributes\CurrentUser; +use Hypervel\Container\Attributes\RouteParameter; + +class UpdatePostData extends Data +{ + public function __construct( + public string $title, + #[CurrentUser(property: 'id')] + public int $userId, + #[RouteParameter('post', 'id')] + public int $postId, + ) { + } +} +``` + +Contextual values are resolved only after validation succeeds and always win over caller input and creation hooks, including when the resolved value is `null`. Promoted contextual properties are known but discarded from strict input validation. A distinct-name, non-promoted contextual parameter is constructor-only. Use a named factory or creation hook without the contextual attribute when payload input should win. + +`CurrentUser` and `RouteParameter` accept an optional `property` path and use `data_get()` semantics. Accessors and Eloquent relations may run while traversing that path. `RequestAttribute` selects an exact request-attributes key; `Config`, `Context`, `Give`, and custom contextual attributes work through the same constructor boundary. -Data objects do not replace request validation. Validate external input before creating a data object, then use the data object to work with typed values in the rest of your application. + +## Inertia -If a required constructor argument is missing and no default value is available, Hypervel will throw a `RuntimeException`: +When `hypervel/inertia` is installed, Data lazy values can produce Inertia props: ```php -try { - $user = UserData::make([ - 'age' => 39, - 'email' => 'taylor@example.com', - ]); -} catch (RuntimeException $e) { - $e->getMessage(); +public function __construct( + public Lazy|ProfileData $profile, + public Lazy|ActivityData $activity, +) { +} + +$data = new DashboardData( + Lazy::inertia(fn () => ProfileData::from($profile)), + Lazy::inertiaDeferred(fn () => ActivityData::from($activity), group: 'activity'), +); +``` + +`#[AutoInertiaLazy]` and `#[AutoInertiaDeferred]` provide automatic variants. Existing `DeferProp` instances retain their complete merge, caching, grouping, and rescue state. Ordinary Data creation and transformation do not resolve Inertia classes when the integration is unused. + + +## Saloon + +Hypervel Saloon may return a Data object directly from `createDtoFromResponse`: + +```php +use Hypervel\Data\Data; +use Hypervel\Saloon\Contracts\DataObjects\WithResponse; +use Hypervel\Saloon\Traits\Responses\HasResponse; + +final class GitHubUserData extends Data implements WithResponse +{ + use HasResponse; + + public function __construct( + public int $id, + public string $login, + ) { + } +} - // Missing required property `name` in `App\DataObjects\UserData` +public function createDtoFromResponse(Response $response): GitHubUserData +{ + return GitHubUserData::from($response->json()); } ``` + +Saloon attaches its response through the existing `WithResponse` contract. `hypervel/data` has no Saloon dependency. + + +## Generating Data Classes + +Generate a class with the `make:data` command: + +```shell +php bin/hypervel.php make:data UserData +``` + +The class is placed under your application's `Data` namespace, normally `App\Data`. The command does not append a suffix, so supply the complete class name you want. Like Hypervel's other generators, it honors an application stub override and supports `--force`. + + +## Worker Lifetime + +Data metadata and typed configuration are retained for the worker lifetime. Metadata contains immutable class recipes, not requests, validators, models, resolved extensions, or factory hooks. There is no discovery or generated metadata cache command. + +Register `Lazy`, `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` macros during provider boot. Each class owns a worker-lifetime macro registry; do not register request-specific callbacks or values. Configure morph aliases during boot for the same reason. + +VarDumper displays the current logical `all()` view for transformable data and an `items` envelope for data collections. It hides construction metadata, partial trees, and operation state without adding runtime work outside an explicit dump. + +Data objects do not implement `ArrayAccess`. Read public properties or call `toArray()`. Data collections retain keyed access and enumeration. diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index 4dd1e66d0..5ee35a681 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -218,7 +218,8 @@ The `casts` method should return an array where the key is the name of the attri - `AsArrayObject::class` - `AsBinary::uuid()` - `AsCollection::class` -- `AsDataObject::castUsing(...)` +- `UserProfileData::class` +- `DataCollection::class . ':' . MemberData::class` - `AsEncryptedArrayObject::class` - `AsEncryptedCollection::class` - `AsEnumArrayObject::of(...)` @@ -331,11 +332,10 @@ class User extends Model #### Data Object Casting -You may use the `Hypervel\Database\Eloquent\Casts\AsDataObject` cast class to cast a JSON column to a data object: +Classes extending `Hypervel\Data\Data` or `Hypervel\Data\Resource` are directly castable. Use the class name to cast a JSON column: ```php -use App\DataObjects\UserProfile; -use Hypervel\Database\Eloquent\Casts\AsDataObject; +use App\Data\UserProfileData; /** * Get the attributes that should be cast. @@ -345,12 +345,30 @@ use Hypervel\Database\Eloquent\Casts\AsDataObject; protected function casts(): array { return [ - 'profile' => AsDataObject::castUsing(UserProfile::class), + 'profile' => UserProfileData::class, ]; } ``` -The target class should extend `Hypervel\Support\DataObject`. +Use `DataCollection` with the item class as a cast argument for a JSON array of data objects: + +```php +use App\Data\MemberData; +use Hypervel\Data\DataCollection; + +protected function casts(): array +{ + return [ + 'members' => DataCollection::class . ':' . MemberData::class, + ]; +} +``` + +Both casts use Hypervel's configured Eloquent JSON codec and support `encrypted` and `default` arguments. They store a complete constructable view: PHP property names are used, hidden declared properties are retained, computed and appended output is omitted, and object partials are ignored without being consumed. Conditional and relation lazy values must already be included when saved; persistence never loads a relation. Closure and Inertia lazy values cannot be persisted. + +`Dto` is not Eloquent-castable because it deliberately has no transformation contract. Paginated Data wrappers are also not castable because a JSON item array cannot reconstruct paginator metadata; persist their items through `DataCollection`. + +For the complete mapping, lazy-value, abstract morph, and encrypted-cast behavior, see the [Data Objects documentation](/docs/{{version}}/data-objects#eloquent-casting). ### Array and JSON Casting diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index a60e5463f..0d4664e3e 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -24,6 +24,8 @@ - [Other API Differences](#other-api-differences) - [HTTP Client and Concurrency](#http-client-and-concurrency) - [Scout](#scout) + - [JSON Schema](#json-schema) + - [Data Objects](#data-objects) - [Rate Limiting](#rate-limiting) - [Pagination](#pagination) - [Dates](#dates) @@ -497,6 +499,13 @@ Hypervel compiles integer and float values passed to Scout's Algolia `where`, `w When porting schemas that place sibling assertions beside a local `$ref` or use nullable composition, make overlapping assertions identical. Hypervel rejects conflicts instead of silently replacing referenced constraints. See the [JSON Schema documentation](/docs/{{version}}/json-schema#reconstructing-schemas). + +### Data Objects + +When porting `spatie/laravel-data`, replace its namespace with `Hypervel\Data` and review the [Data Objects documentation](/docs/{{version}}/data-objects). Hypervel retains the familiar `Data`, `Dto`, `Resource`, `Optional`, mapping, casting, validation, lazy-value, collection, resource, and Eloquent APIs while adapting their internals to long-lived workers. + +Model attributes containing `null` remain explicit values, including for non-nullable properties with defaults. When several payloads are supplied to `from()`, the first payload containing a property's input key wins, including when its value is `null`. + ### Rate Limiting diff --git a/src/docs/saloon.md b/src/docs/saloon.md index 9d35dcfa9..2f31417de 100644 --- a/src/docs/saloon.md +++ b/src/docs/saloon.md @@ -958,9 +958,9 @@ A request or connector may convert responses into any value using `createDtoFrom ```php use Hypervel\Saloon\Http\Response; use Hypervel\Saloon\Http\Request; -use Hypervel\Support\DataObject; +use Hypervel\Data\Data; -class GitHubUserData extends DataObject +class GitHubUserData extends Data { public function __construct( public readonly int $id, @@ -976,10 +976,7 @@ class GetUser extends Request public function createDtoFromResponse(Response $response): GitHubUserData { - return new GitHubUserData( - id: (int) $response->json('id'), - login: (string) $response->json('login'), - ); + return GitHubUserData::from($response->json()); } } ``` @@ -997,9 +994,9 @@ Saloon's `HasResponse` trait implements this contract for you: ```php use Hypervel\Saloon\Contracts\DataObjects\WithResponse; use Hypervel\Saloon\Traits\Responses\HasResponse; -use Hypervel\Support\DataObject; +use Hypervel\Data\Data; -class GitHubUserData extends DataObject implements WithResponse +class GitHubUserData extends Data implements WithResponse { use HasResponse; diff --git a/src/docs/validation.md b/src/docs/validation.md index 9334c42c9..9558a0437 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -803,16 +803,16 @@ protected array $casts = [ ]; ``` -Inputs may also be cast to `Hypervel\Support\DataObject` classes: +Inputs may also be cast to classes extending `Hypervel\Data\Data`, `Dto`, or `Resource` using the package's explicit FormRequest casts: ```php PostMetadata::class, + 'metadata' => AsData::of(PostMetadata::class), ]; ``` -Hypervel also provides cast helpers for arrays and collections of enums or data objects: +Hypervel also provides cast helpers for collections of data objects and arrays or collections of enums: ```php -use App\DataObjects\Contact; +use App\Data\ContactData; use App\Enums\PostStatus; -use Hypervel\Foundation\Http\Casts\AsDataObjectArray; -use Hypervel\Foundation\Http\Casts\AsDataObjectCollection; +use Hypervel\Data\Http\Casts\AsDataCollection; use Hypervel\Foundation\Http\Casts\AsEnumArrayObject; use Hypervel\Foundation\Http\Casts\AsEnumCollection; protected array $casts = [ 'status_history' => AsEnumArrayObject::of(PostStatus::class), 'statuses' => AsEnumCollection::of(PostStatus::class), - 'contact_list' => AsDataObjectArray::of(Contact::class), - 'contacts' => AsDataObjectCollection::of(Contact::class), + 'contact_list' => AsDataCollection::of(ContactData::class, 'array'), + 'contacts' => AsDataCollection::of(ContactData::class), ]; ``` -The `AsEnumArrayObject` and `AsDataObjectArray` helpers return an `ArrayObject`. The `AsEnumCollection` and `AsDataObjectCollection` helpers return a `Hypervel\Support\Collection`. +`AsEnumArrayObject` returns an `ArrayObject`, while `AsEnumCollection` returns a `Hypervel\Support\Collection`. `AsDataCollection` returns a `Hypervel\Data\DataCollection` by default and accepts the same explicit targets as `Data::collect()`, including `'array'` and `Hypervel\Support\Collection::class`. For more complex casting logic, you may create a custom cast class that implements the `CastInputs` interface: From 91f795500ebc7f9fcf865d49f2f01a44814c7fbd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:51:29 +0000 Subject: [PATCH 28/35] Finalize the Hypervel Data implementation plan Bring the active design document in line with the signed-off implementation after the full second-opinion and code-review loops. Record the final construction, validation, collection, lazy, persistence, resource, metadata, typing, performance, and framework-integration invariants while removing superseded proposals and implementation history that no longer guides the code. --- .../2026-08-30-0349-hypervel-data-package.md | 137 +++++++++++------- 1 file changed, 85 insertions(+), 52 deletions(-) diff --git a/docs/plans/2026-08-30-0349-hypervel-data-package.md b/docs/plans/2026-08-30-0349-hypervel-data-package.md index c92a0cda5..d2ba8fe3e 100644 --- a/docs/plans/2026-08-30-0349-hypervel-data-package.md +++ b/docs/plans/2026-08-30-0349-hypervel-data-package.md @@ -147,7 +147,7 @@ $users = UserData::collect($rows, DataCollection::class); Do not add `make()` as a synonym. `from()` is the established data-package API; constructors remain available when no normalization or casting is wanted. -All three base classes retain `optional()`, `from()`, `collect()`, `factory()`, and `normalizers()`. `Data` and `Dto` expose `validate()`, `validateAndCreate()`, and `getValidationRules()`; `Data` and `Resource` expose transformation, resource, wrapping, append, partial, and `empty()` behavior. `WithData` lets a Model, Request, or ordinary source class name its associated data class and expose `getData()` without coupling that source to construction internals. `Lazy` retains Hypervel's `Macroable` behavior so applications can define boot-time lazy strategies. +All three base classes retain `optional()`, `from()`, `collect()`, `factory()`, and `normalizers()`. `Data` and `Dto` expose `validate()`, `validateAndCreate()`, and `getValidationRules()`; `Data` and `Resource` expose transformation, resource, wrapping, append, partial, and `empty()` behavior. The stateless root `Hypervel\Data\WithData` trait lets a Model, Request, or ordinary source class name its associated data class and expose `getData()` without coupling that source to construction internals. It uses `@template TData of BaseData`, a native `BaseData` return, upstream's property-before-method precedence, and `CannotFindDataClass::forSource()` for a missing or invalid declaration; the consuming class's `@use WithData` is the same trusted generic promise as Laravel's factory traits, not a reason for runtime enforcement or metadata. `Lazy` retains Hypervel's `Macroable` behavior so applications can define boot-time lazy strategies. `validate()` enables validation, forces named creation methods off, and exits before casting/construction, returning the Validator's validated payload like `Request::validate()`. `getValidationRules(array $payload)` likewise forces named methods off and exits after rule generation; retain its upstream array-only signature so rule introspection never authorizes or normalizes a Request merely for API symmetry. `validateAndCreate()` enables the ordinary complete flow, including named methods. A named method that returns the target object owns its validation and finishes creation after root Request authorization, so `validate($payload)` may reject input that `validateAndCreate($payload)` accepts through such a method. Document that distinction instead of transforming a finished object back into an input payload. All three entry points use the same Fill/compiler/Validator engine and context toggles rather than parallel validation semantics. @@ -165,6 +165,8 @@ Presence has one Laravel-familiar wire-to-PHP resolution order: declared default There is no `auto_optional`, `auto_null`, or strict-mode compatibility setting. Nullable omission resolves to `null`; declare an `Optional` union when application code must observe that the wire key was absent. +Apply this order identically to every source. In particular, a Model attribute containing `null` is present: it never activates a non-nullable property's declared default, and the ordinary cast/construction path rejects it. A nullable property receives `null` even when its declared default is non-null. This deliberately differs from Spatie's model normalizer, which converts null on a non-nullable property to absence, and must be recorded in the package differences documentation. + ### Mapping - No input or output mapper is active by default. @@ -172,8 +174,8 @@ There is no `auto_optional`, `auto_null`, or strict-mode compatibility setting. - Class attributes set defaults; property attributes override them; package config may set global input/output defaults. - Input and output mapping remain independent. - On input, the mapped wire key wins when both it and the PHP property name exist. The actual chosen wire path is retained in construction state so validation errors use the key the caller sent; when the value is absent, the mapped key is the canonical path for a presence error. -- `MapInputName` supports upstream dot notation such as `artists.0.name`; the compiled input path distinguishes a missing segment from an explicitly null value. -- Nested collection rules use the same mapping tree as construction. Mapping is compiled into metadata rather than recomputed for every value. +- `MapInputName` supports upstream dot notation such as `artists.0.name`; its string key remains the validation/wire identity while one immutable segment list compiled into property metadata owns all source reads and construction-state access. Segments are literal: a whole `*`, `{first}`, or `{last}` is an ordinary key rather than `data_get()` grammar. Array reads distinguish a missing segment from explicit null; nested object reads preserve initialized public null and magic access without exposing inaccessible or uninitialized properties. +- Nested collection rules use the same mapping tree as construction. Mapping is never split, joined, or otherwise recomputed on the ordinary value path. - Reject identical effective input paths and identical effective output keys when class metadata is built. Prefix overlap such as `artist` and `artist.name` remains valid. Every public data property participates in input ownership through its PHP name and any distinct mapped path, including inherited, computed, hidden, and contextual promoted properties; hidden properties alone are excluded from output ownership because they never emit a value. PHP array-key normalization defines integer/string key equivalence without package-owned normalization. This deliberately prevents input fan-out whose validation and unknown-field behavior would depend on source type, computed-property collisions, and silent output overwrite. - Throw `InvalidDataDeclaration` for a collision. Its factory receives the target data class, effective path/key, and both `DataProperty` definitions so the message names each declaring class and property. Input messages direct callers to assign unique paths or use a distinctly named computed property for a derived value. Do not retain mapper-provenance metadata merely for diagnostics. @@ -293,7 +295,7 @@ Use Hypervel Validation directly; do not port Spatie's rule-inferrer registry. - Construct from the Validator's validated/exclusion-filtered payload, not the original request array. Laravel's wildcard expansion can place concrete leaves after exact rules, so mixed wildcard/exact graphs can make `Validator::validated()` rebuild a source list in rule order and turn it into a non-list JSON object. After restoring deliberate unvalidated values, recursively restore surviving keys to the pre-validation payload's insertion order without reindexing gaps; retain filtered values exactly and append any validator-produced keys absent from the source in their existing order. Do this once at the Data validation payload boundary before `afterValidation` hooks, not in Validation, the compiler, collection construction, or `CompiledValidation`. Honor the application-wide `includeUnvalidatedArrayKeys()`/`excludeUnvalidatedArrayKeys()` setting rather than overriding Laravel's factory behavior. - Preserve filled values only for properties explicitly marked `WithoutValidation` and observed finished Data values. A finished value owns its complete mapped path: inferred rules, validation attributes, and explicit class rules do not run for that path or descendants. Record declared `WithoutValidation` paths even when the wildcard template item omits the property, plus exact observed finished-value paths. After `validated()`, copy only existing values at those paths from the complete pre-validation payload. Traverse `ValidationPath::rawSegments()` directly: `null` expands over actual collection keys, while string/int segments remain literal, including a collection key named `*`; use `array_key_exists()` throughout so present null is restored. Do not retain first-item values, materialize concrete paths, or merge arbitrary unvalidated input back into construction. - Resolve constructor defaults, `Optional`, and nullable omission only after validation. -- Preserve Laravel error bags, messages, translated property names, redirects, stop-on-first-failure, and Precognition filtering/authorization behavior where their owning Hypervel APIs support them. Data uses `#[FailOnUnknownFields]` per class only; it does not inherit FormRequest's process-global `failOnUnknownFields()` toggle or add another global/config setting. +- Preserve Laravel error bags, messages, translated property names, redirects, stop-on-first-failure, and Precognition filtering/authorization behavior where their owning Hypervel APIs support them. Precognition narrows the Validator's already-prepared graph through `retainRules(array $attributes)`, preserving the original wildcard identity and declarations without weakening `setRules()` replacement. Retention applies only to the graph prepared for the current data; `setData()` rebuilds the complete original graph because concrete indices from the previous data cannot define a sound selection on new data. Data uses `#[FailOnUnknownFields]` per class only; it does not inherit FormRequest's process-global `failOnUnknownFields()` toggle or add another global/config setting. - Compile nested rules/messages/attribute labels across the whole tree, but invoke request authorization and `withValidator()` only for the root object, matching the documented upstream behavior. - Resolve parameters declared by validation lifecycle methods through the container once per root validation. This cost exists only when a class declares such a method. - When `#[FailOnUnknownFields]` enables rejection, call `UnknownFields::validate(Validator $validator, array $input, ?array $unfilteredRules = null, array $additionalFields = [], array $allowedSubtrees = [])`. The Validation-owned helper derives exact known paths from the Validator's already-expanded effective rules and adds confirmation fields. A path with an `array` rule and no descendant rule is an opaque allowed subtree. Data also passes mapped unstructured `mixed` and non-enum/non-date object paths, structured/mixed `WithoutValidation` paths, structured/mixed contextual promoted paths, and observed finished Data paths through `$allowedSubtrees`; scalar `WithoutValidation` and scalar contextual promoted paths use `$additionalFields`. Ordinary nested Data and typed Data collections remain structured and never become opaque merely because they are nested. Echoed contextual input is therefore known but discarded, and the server-resolved value still wins. An allowed subtree matches the path itself and dot-separated descendants, so a declared `array $meta` accepts `meta.foo` without weakening a structured `items.*.id` schema. @@ -333,21 +335,34 @@ If an attribute maps to a Laravel rule that Hypervel Validation is unintentional - `Data::toArray()`, `all()`, `transform()`, `toJson()`, and `jsonSerialize()` always reflect current property values; there is no result cache or refresh protocol. - The ordinary transform loop reads precompiled property metadata and writes mapped output directly. - Allocate a full `TransformationContext` only for lazy values, partials, a custom transformer, wrapping/additional resource data, or a configured maximum depth. A simple object does not build include/exclude trees. `PartialsDefinition::isEmpty()` is the required nested-node guard that preserves this invariant: an object with no instance definitions reuses its narrowed child context without resolving definitions or compiling trees. +- `TransformationContextFactory::forPersistence()` is the one named constructable-view selector used by Eloquent casts. Its immutable context carries one `constructable` flag through every child/copy operation and derives the complete invariant in `get()`: transform values, use PHP property names, include every default-lazy value and hidden declared property, omit computed/virtual and appended response-only values, ignore root and nested instance partial stores without consuming or adding to them, disable wrapping, and retain the configured maximum depth. Constructable transformation bypasses `plainTransform` because that shortcut intentionally emits computed output in ordinary views. Do not expose independent switches whose partial use could create an unreadable stored representation. +- A non-default `Lazy` value is constructable only when `resolvesToData()` is true and its intrinsic condition includes it. `ClosureLazy` and the Inertia lazy variants return false because they resolve to consumer callbacks/prop wrappers rather than data. An excluded conditional value or unloaded relation throws `CannotTransformData` before resolution; an included conditional or already-loaded relation resolves normally, and persistence never triggers a relation load. - Port `Hidden`, `Computed`, `Lazy`, `AutoLazy`, `AutoClosureLazy`, `AutoWhenLoadedLazy`, include/exclude/only/except, conditional inclusion, appended values, and maximum-depth protection. A supplied value for a `#[Computed]` or PHP 8.4 virtual property throws an actionable declaration/input exception; there is no compatibility switch that silently ignores it. +- Automatic lazy casting reuses the fixed creation engine without retaining the mutable root operation. Each owning structure node stores one compiler-inert `autoLazy` map keyed only by its AutoLazy properties. An entry contains the raw source and, only when structural Fill work was deferred, an `AutoLazyReplayMode::Normal` or `Hook` enum case. Enum cases are worker singletons; do not add strings, a value object, a parallel map, or a DataClass feature bit. Replay is required only for an unambiguous nested Data object, an unambiguous Data iterable, or a Data/non-Data paginator or cursor-paginator. Scalars and non-paginator typed iterables need the pruned payload for casting but no replay entry because their Fill body reduces to the raw write and item casting already belongs to `castProperty()`. A missing native default follows the same predicate after materialization. +- Resolve AutoLazy provenance without widening ordinary resolved-property tuples or adding an output parameter. Extract the mapped-key/PHP-name match for one normalized source and reuse it from pure `resolveProperty()` and an AutoLazy-only raw-source lookup. A supplied property records the exact raw payload aligned with the normalized source that won; absence records the first raw payload under Hypervel's first-source precedence, or `[]` for zero payloads. A named factory returning another normalizable value makes that return the sole aligned source before this lookup. `prepareData` changes construction values without changing raw provenance, matching upstream. An absent or supplied `AutoWhenLoadedLazy` records the first raw Model source and fails with actionable `CannotCreateData` when none exists. Before ordinary absence handling, wrap supplied values and freshly materialized native defaults unless they are `null`, `Optional`, or already `Lazy`; a missing nullable `AutoWhenLoadedLazy` still receives its relation wrapper before nullable fallback. Plain validating AutoLazy properties complete Fill before rule compilation. When rules are not compiled, or the property is non-validating, defer only the structural Fill work identified above so an excluded value performs no nested normalization, `LoadRelation` query, Eloquent relation batching, or paginator work. `AutoWhenLoadedLazy` is model-owned and always non-validating; it reads the live relation only when resolution finds it loaded. +- Validation-hook reconciliation owns changed provenance explicitly. An unchanged property retains its source; `reconcileProperty()` replaces the owning node's entry with the final hook payload before either its scalar or nested path returns. A hook-selected morph clears the owning node's map in `resetNodeStructure()` and records the final hook payload for the new class. A newly hook-selected morph target with `AutoWhenLoadedLazy` therefore fails the model-only boundary rather than retaining a second hidden Model reference from the replaced source. Document this boundary with the custom AutoLazy payload contract. +- A deferred cast captures the raw property value and one pruned `ConstructionState` baseline. Preserve the original traversal path, a payload skeleton ending in the complete post-validation owning-node sibling payload, and both the template and exact item-override structure spines ending in the selected property's untouched subtree. Drop ancestor/sibling payload and structure, ancestor paginator sources, `unknownInput`, the root extension memo, Validator, Request, Container, and resolved extension objects. After reading the selected property's recipe, remove the owning node's complete `autoLazy` map from the pruned copy; descendant maps remain until their own wrappers consume them. Preserving the path keeps every class, mapping, item override, and nested paginator source under its existing ownership rule without a merge, relocation, source handoff, or per-slot classification. Each nested wrapper prunes again from its resolution clone, so retained state does not compound by depth. +- The baseline is shallow-cloned for every non-memoized Lazy resolution before replay mutates payload or structure, and each resolution uses a fresh extension memo plus the worker-shared `DataCreator`. Paginator rebuilding clones its retained source before replacing items, so repeated resolution never mutates a baseline object. Do not add a parallel provenance map, snapshot merge, generic live-object transport, another creation context type, or a separate retained Model slot. AutoLazy on every item in a collected class necessarily makes that collection's source overrides dense because each raw source differs; benchmark this supported shape rather than obscuring it with another representation. +- Extract the current body of one `fillResolvedProperties()` iteration into `fillResolvedProperty()` because the eager loop and deferred cast are real callers for Data objects, Data collections, non-Data typed iterables, paginator retention, and batched Eloquent relation loading. The deferred entry point exposes no validation/rule arguments and invokes the helper with literal disabled values; replay never compiles rules or participates in collection-uniformity decisions. - Compile each partial mode into one immutable tree. Every node retains an exact-endpoint bit, a propagating fully-selected-subtree bit for terminal `*`, and named children, so exact selection cannot be confused with a traversal prefix. `include` retains and traverses either form; `except`/`exclude` remove only exact endpoints or fully selected subtrees; `only` treats an empty child map as unrestricted and otherwise retains named children, matching upstream `only('*')`, `only('*', 'nested.a')`, and `only('nested.*')`. A pure fully-selected node reuses itself while descending. `PartialTree::merge()` composes two selections by unioning endpoints, subtree selection, and children; it is not lifetime provenance and does not require reverse path generation. Propagating `include('*')` deliberately fixes upstream's order-dependent loss of explicit nested includes. Invalid partial paths throw; there is no ignore-invalid-partials compatibility flag. - Before internally transforming a reached nested Data node, resolve its non-empty instance partial store with temporary consumption and merge those node-relative selections into the narrowed parent context through `TransformationContext::withMergedPartials()`. Do this at the nested-property and typed-iterable-item call sites, outside `transformData()`, so the root store is not resolved twice. Item contexts remain local to `transformIterableItem()`; never hoist an item merge into the shared iterable context, because each item may own different partials. A repeated object consumes a temporary at its first reached occurrence while permanent definitions apply every time. During the collection slice, delete the two currently unreachable non-`BaseData` public-transform branches and route collection containers and items through one internal loop that retains the root transformer extension cache and applies the same per-node rule. - `all()` preserves raw nested Data and collection identity. When child partials apply, retain the four resolved definition lists beside the compiled trees and add their decoupled remainders to the returned nested object's existing partial store. Temporary and permanent definitions keep their own lifetimes; conditions are evaluated once against the object that declared them and propagate as unconditional resolved definitions. Terminal selections do not propagate, while `nested.*` and a bare `*` propagate the fully selected subtree. These lists are root-relative and exist only for shallow outward propagation: `TransformationContext::child()` clears them, and propagation reads the parent before returning without recursion. Consult definitions only when partials exist; ordinary `all()` and every `toArray()` avoid this work. Do not add lifetime flags or a reverse operation to `PartialTree`. +- Transform reached `BaseData` and `BaseDataCollectable` values only when they also implement `TransformableData`; otherwise retain them unchanged, matching Laravel collection and Spatie behavior for values without a transformation capability. Perform this capability check before resolving instance partials so an unchanged `Dto` or custom modular data value never consumes temporary state. Use one internal nested-value dispatch for properties, typed iterable items, and collection items rather than repeating capability and object/collectable branches. - Maximum depth defaults to `null`, matching upstream and avoiding an invented limit. When configured, reaching it throws `MaxTransformationDepthReached`; no silent empty-array mode is added. - Cyclic object graphs are unsupported. Do not impose identity-set work on every ordinary nested SDK transform: the realistic recursive `Lazy`/relationship case creates fresh Data instances and would not be stopped by identity anyway. Applications with recursive includes configure `max_transformation_depth`; the default remains `null` for upstream familiarity and unbounded legitimate trees. - `Data` is not `ArrayAccess`. Data collections retain collection-style keyed access and enumeration. - Use `Hypervel\Support\Json` for general JSON normalization and encoding so Data inherits Hypervel's nesting and exception contract. Eloquent casts use the distinct `Hypervel\Database\Eloquent\Casts\Json` codec so application custom encoders/decoders remain authoritative. - Typed iterable properties recursively cast and transform their declared item type, including custom `IterableItemCast`, Data, enum, and date items. This behavior is enabled from the start; do not port Spatie's compatibility feature flag. Untyped arrays are not recursively guessed into arbitrary object types. -- PHP serialization includes declared data properties and stable per-instance transformation state while excluding request/validator/operation objects. Use `laravel/serializable-closure`, already standard in Hypervel, for package-owned lazy and conditional-partial closures so queued data retains upstream behavior; unsupported captured values fail normally rather than being silently discarded. +- PHP serialization includes declared data properties, stable per-instance transformation state, and the detached immutable creation options required by an unresolved AutoLazy value while excluding request, validator, mutable root-state, and resolved-extension objects. Use `laravel/serializable-closure`, already standard in Hypervel, for package-owned lazy and conditional-partial closures so queued data retains upstream behavior; unsupported captured values fail normally rather than being silently discarded. ### Collections - Port the non-deprecated `DataCollection` API, paginator/cursor-paginator wrappers, `collect()`, collection annotations, and `DataCollectionOf`. +- Keep `BaseData` non-generic and declare method-level collection templates on `collect()`. `TKey` binds only through the complete input union. Ordinary covariant sources share unbounded `TValue`; invariant package collections use `TCollectValue of BaseData`, and invariant Eloquent collections use `TModelValue of Model`, preserving their required bounds without weakening ordinary inputs. Type `$into` as `class-string|'array'|null`: `null` follows source shape, `'array'` is the one non-class target, and config/dynamic callers must narrow supported class names to `class-string`. Group the conditional return by existing source/target families so PHPStan does not expand one deeply nested chain. Source order remains load-bearing and mirrors `rebuildRoot()`: array; paginated/cursor/data package wrappers; concrete-before-abstract offset paginators; concrete-before-abstract cursor paginators; then Eloquent, lazy, and ordinary collections under an `Enumerable<*, *>` family test. Every generic shape test uses a star projection. Generic `Enumerable`, arbitrary `Traversable`, and paginator-contract-only terminals return `never` because no rebuildable source can reach them; the contracts remain in the input union for explicit targets. An interface-only paginator source cannot exclude earlier concrete and collection-family tests because a contract-typed value may itself be a rebuildable collection shape. No single family type covers every successful outcome, so the normalized union is the accurate result; concrete Hypervel paginator sources stay exact, while contract-typed callers narrow when they need shape-specific methods. Target groups exhaustively use exact FQCN literals for `Enumerable`, all three package wrappers, Eloquent/ordinary/lazy collections, all three concrete paginators, both abstract paginator bases, and all three paginator contracts; literals are mutually exclusive, so inner target order is for readability. Pin every literal in the PHPStan fixture because a stale FQCN would otherwise degrade silently to the broad union. Arbitrary `Traversable` is accepted only with an explicit rebuildable target. An explicit paginator target requires a matching paginator source. Custom subclass and nonliteral `class-string` targets retain runtime support but receive the complete supported union; user `collect*` factories remain a typed dynamic escape hatch. Do not add raw generic conditions, a PHPStan extension, or runtime typing machinery. +- Carry keyed item types through `DataCreator::collect()`, both `collectItems()` methods, the eager/lazy collection helpers, and all three package wrapper constructors. `BaseData::factory(): CreationContextFactory` is the value-type link for direct, paginated, and cursor-paginated wrappers. Keep `ConstructionState`, `fillNode()`, the instantiator, and `DataCollectableFactory::forTarget()` non-generic: narrow eager/lazy `TData` once at their owning helper boundaries against the engine's named-factory, morph-subtype, instantiation, and `afterCreation` guards, with a short coupling comment at each site; narrow polymorphic named-factory/target exits locally to the native supported union. Do not add conditional types below the public surfaces, generic parameters used only for PHPStan, a dynamic return extension, or runtime machinery. - Keep `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` as distinct public types. They encode different item/container return contracts and paginator operations for PHPStan and callers; one union-backed class would replace those guarantees with runtime `instanceof` branches and methods that are invalid for some instances. Share concerns, item construction, transformation, and private response adapters so three familiar public names do not become three implementations. Spatie performance issue #434 concerns recursive item conversion, not the number of wrapper classes; the fixed engine and collection benchmarks address that actual cost. +- Keep transformation and Eloquent persistence as separate capabilities. `DataCollection` implements Eloquent `Castable` directly; paginated and cursor-paginated wrappers remain transformable but are not castable because a JSON item array cannot reconstruct paginator state. Preserve a concise source comment where upstream's misleading `castUsing()` method would sit, and direct callers to persist page items through `DataCollection`. +- All three package-owned collection wrappers implement `Transient` because each owns caller-supplied values and freshness is intrinsic to its whole hierarchy. Keep `BaseDataCollectable` lifetime-neutral because external implementations own their own container classification. - One eager root `collect()` operation owns one `ConstructionState`, one Fill pass over all items, one Validator, and one extension/normalizer memo. Root `prepareData` remains per item; root `beforeValidation` and `afterValidation` receive and return the complete keyed collection payload once. `ValidationStrategy::OnlyRequests` checks the collection source object, not nested items: an array/Collection root does not begin request validation merely because an item is a Request, while `alwaysValidate()` is the explicit untrusted-collection path. Preserve `LazyCollection` laziness when validation and rule introspection are both disabled; either rule-producing mode materializes it once. - `DataCollectableFactory` is the single owner of safe item extraction, root source-shaped rebuilding, explicit/inferred `$into` targets, paginator cloning, and declared-property reconstruction for Data and non-Data typed iterables. `DataCreator` retains Fill, reconciliation, casting, and instantiation; delete its duplicate eager iterable rebuilder. Cache each Data class's resolved custom normalizer list in the existing operation memo under a class-keyed entry; do not add another cache object or threaded parameter. - Root `collect()` preserves input keys and the original ordinary collection/paginator shape where it can be rebuilt safely. It explicitly downgrades an Eloquent source to base `Hypervel\Support\Collection` for empty and non-empty Data results; an explicit Eloquent Data target does the same. Property casting is instead declared-shaped: arrays and `iterable` become keyed arrays, declared ordinary collection classes are rebuilt as declared, and unsupported custom `Traversable` containers fail with `CannotCreateDataCollectable`. A declared Eloquent property is valid only when its complete item type guarantees `Model`; otherwise metadata rejects the invalid Eloquent generic. Batch `loadMissing()` for metadata-declared `LoadRelation` paths before collected Eloquent models are normalized. @@ -405,11 +420,11 @@ protected function casts(): array } ``` -- `Data` and `Resource` implement Eloquent `Castable` through their shared transformable contract and return a package-owned `DataEloquentCast`; `Dto` is not persistable because it has no transformation contract. -- `DataCollection` returns a package-owned `DataCollectionEloquentCast` and remains the value returned by collection casts. Keep upstream `encrypted` and `default` cast arguments. -- Use Hypervel's Eloquent JSON codec so custom encoders/decoders apply. Persist the complete representation through a fresh transformation context rather than mutating instance partials with `include('*')`. -- Abstract classes that self-discriminate through `PropertyMorphableData::morph()` persist their ordinary full representation. Other abstract-class values use `{type, data}` envelopes whose `type` is a required alias from the familiar boot-only `DataConfig::enforceMorphMap()` registry. Reads reject unknown aliases and require the result to be a concrete, transformable `BaseData` subtype of the declared abstract class before construction; payload-provided FQCN fallbacks are not accepted. Encrypt abstract collections as well as concrete ones. -- Dirty comparison decodes both stored values and compares their arrays; encrypted casts return unequal while previous encryption keys are configured, matching the framework's rotation behavior. +- `TransformableData` describes transformation only. `Data` and `Resource` implement Eloquent `Castable` directly through a shared `EloquentCastableData` concern and return a package-owned `DataEloquentCast`; `Dto` is not persistable because it does not implement Eloquent `Castable`. +- `DataCollection` implements Eloquent `Castable` directly, returns a package-owned `DataCollectionEloquentCast`, and remains the value returned by collection casts. Paginated wrappers are deliberately not Eloquent-castable; persist their page items as a `DataCollection`. Keep upstream `encrypted` and `default` cast arguments. Concrete and property-morphable stored collections validate their decoded item shapes and pass the complete payload to the collection constructor, so its one internal root item operation owns normalization and any globally configured `Always` validation produces indexed collection paths. Strict abstract envelopes resolve their enforced alias per item before construction because the single-class collection operation cannot represent heterogeneous target classes; do not add a polymorphic batch path without measured evidence that it earns the machinery. +- Use Hypervel's Eloquent JSON codec so custom encoders/decoders apply. Both casters persist the complete constructable representation through `TransformationContextFactory::forPersistence()`, never through a mutable option chain or by mutating instance partials with `include('*')`. Stored objects use PHP property names, include hidden declared state, omit computed/virtual and `with()`/`additional()` output, ignore partials without consuming them, and never retain a paginator wrapper. Output transformers still apply; an arbitrary one-way transformer therefore requires a matching input cast or `WithCastAndTransformer` for an Eloquent round trip. +- Abstract classes that self-discriminate through `PropertyMorphableData::morph()` persist their ordinary constructable representation. Other abstract-class values use `{type, data}` envelopes whose `type` is a required alias from the familiar boot-only `DataConfig::enforceMorphMap()` registry. Reads reject unknown aliases and require the result to be a concrete, transformable `BaseData` subtype of the declared abstract class before construction; payload-provided FQCN fallbacks are not accepted. Encrypt abstract collections as well as concrete ones. +- Share Data/DataCollection persistence primitives through an internal generic `AbstractDataEloquentCast`: container-resolved config/repository setup, custom-codec decode, `encrypted`/`default` handling, abstract-class detection, dirty comparison, and recursive payload equality. The base reads its late-bound `DEFAULT_STORED_VALUE` (`{}` for Data, `[]` for DataCollection) through `static::`; each concrete caster retains its precise `CastsAttributes` generic contract and distinct `get()`/`set()` shape. Dirty comparison handles a reachable null original before comparing non-null decoded payloads recursively by count and key with strict leaves. This ignores JSON-object key reordering performed by JSON/JSONB stores, preserves list position because positions remain keys, performs no sort/allocation or Data reconstruction, and fixes nested and numeric-key object order without weakening scalar types. Encrypted casts return unequal while previous encryption keys are configured, matching the framework's rotation behavior. - Remove `Hypervel\Database\Eloquent\Casts\AsDataObject`; Database must not depend on an optional data package or a Support implementation. ### HTTP resources and wrapping @@ -427,20 +442,28 @@ interface ProvidesResourceWrapper } ``` -`ResourceResponse::wrapper()` uses the `instanceof ProvidesResourceWrapper` check as the switch: when implemented, the returned value is authoritative, including `null` meaning deliberately unwrapped; otherwise it retains `JsonResource::$wrap`. Data's single and collection adapters redeclare `public static ?string $wrap = null` so the existing force-wrapping test cannot mistake inherited `'data'` for an active default. Package adapters keep the actual selection in per-instance state and never mutate the static during a request. +`ResourceResponse::wrapper()` uses the `instanceof ProvidesResourceWrapper` check as the switch: when implemented, the returned value is authoritative, including `null` meaning deliberately unwrapped; otherwise it retains `JsonResource::$wrap`. Data's single and collection adapters redeclare `public static ?string $wrap = null` so the existing force-wrapping test cannot mistake inherited `'data'` for an active default. The responsable concern resolves `WrappableData::getWrap()` against `DataConfig::wrap` once and passes the resulting `?string` to the adapter, which keeps that selection in per-instance state and never mutates the static during a request. + +Transform each response once before constructing its adapter. `DataTransformer::transformForResourceResponse()` reuses the ordinary root operation and extension memo but passes two explicit root-only choices: `transformData(..., includeAdditionalData: false)` leaves root `with()`/`additional()` to `ResourceResponse`, while `transformCollectable(..., includePaginationData: false)` returns transformed page items directly and leaves links/meta to `PaginatedResourceResponse`. It also accepts an optional already-materialized root `Collection` used only by the direct collection call. Both protected booleans default to `true` and nested values retain the ordinary item source, so nested behavior and every non-response caller remain unchanged. Keep wrapping disabled at this call site; per-instance HTTP wrapping belongs exclusively to `ResourceResponse`. Do not put these choices in `TransformationContext`: collection items reuse their parent context without `child()`, so a context flag would also suppress nested behavior. + +`DataResource` accepts exactly `BaseData&AppendableData&ResponsableData`, keeps the original Data object in inherited `JsonResource::$resource`, and stores the transformed payload separately for its `resolve()` result. It delegates JSON options and `withResponse()` to the Data object, while its JsonResource `with(Request): array` adapter returns the object's resolved Spatie-style `with()` plus fluent `additional()` state; do not change the data method's signature. The original object therefore remains `JsonResponse::$original`, and `ResourceResponse::calculateStatus()` naturally returns 200 rather than guessing 201 from the request method. + +`DataCollectionResource` accepts `BaseDataCollectable` and stores one retained original item `Collection` and the transformed item array separately. For `DataCollection`, reuse its eager base `Collection`; materialize a deferred `LazyCollection` once through `collect()`. Paginator wrappers are already eager and supply `items()->getCollection()`. Pass that same retained `Collection` to the response transform so a lazy generator, item construction, I/O, temporary partials, and response-original identity are consumed exactly once; after materialization, never count or enumerate through the package wrapper again. Pass ordinary transformed arrays to `ResourceCollection`; for paginator wrappers, clone the real paginator, replace only the clone's collection with transformed items, and pass that clone so native links, cursors, paths, queries, fragments, and metadata remain authoritative. This is required for correctness as well as speed: leaving Data items in the paginator would make `paginationInformation()->toArray()` transform them a second time after temporary partials were consumed. Override the existing protected `collects(): ?string` extension point to return `null`, explicitly disabling per-item JsonResource inference and its repeated class-name checks. Override `withResponse()` with a short WHY comment to restore the retained original item collection after the generic response assigned transformed items; Data collections deliberately have no Data-owned response hook to delegate. -`DataResource` delegates transformation, includes/excludes, JSON options, and `withResponse()` to the data object/context. Its JsonResource `with(Request): array` adapter calls the data object's familiar Spatie-style `with(): array` plus `additional()` state; do not change the data method's signature. Override `resolve()` in the Data-owned single and collection adapters to return the already-transformed array directly: Data transformation has removed `Optional` and never emits HTTP `MergeValue`/`MissingValue`, so calling `ConditionallyLoadsAttributes::filter()` would add a redundant recursive pass and reallocation. Keep the rest of Hypervel's `ResourceCollection`, `ResourceResponse`, `PaginatedResourceResponse`, and cursor pagination machinery. The adapter's underlying resource remains the Data object, so `ResourceResponse::calculateStatus()` naturally returns 200; no request-method guess changes it to 201. +Declare `public static function jsonOptions(): int` and `public function withResponse(Request $request, JsonResponse $response): void` on `ResponsableData`, with `0` and a no-op as the shared concern defaults. The single adapter delegates JSON options to the concrete Data class and `withResponse()` to the object. Item-class response hooks have one capability rule across collection JSON options and request partials: when the declared item class implements `ResponsableData`, evaluate its current static hook; otherwise use JSON options `0` and deny every request partial with `[]`. The adapter uses `is_a()` directly, while the resolver uses the already-loaded `DataClass::$responsable` bit; do not add a shared helper or another contract merely to unify the expression. This keeps constructor-required, abstract, empty, and heterogeneous morph collections deterministic without reflection or first-item inference, preserves response-capable `DataCollection` values, and fixes upstream's inconsistent unrestricted `null` fallback for a non-responsable item class. The adapters themselves retain Laravel's instance `JsonResource::jsonOptions()` surface. -`Data` and `Resource` retain Spatie's `with(): array`, `additional()`, `wrap()`, `withoutWrapping()`, and request-query partial allowlists (`allowedRequestIncludes`, `allowedRequestExcludes`, `allowedRequestOnly`, `allowedRequestExcept`), and add Laravel resource hooks `withResponse(Request, JsonResponse): void` and `jsonOptions(): int`. Query-requested partials are intersected with allowlists evaluated for the current response; their results are never cached in metadata. +Both Data-owned adapters override `resolve()` to return the already-transformed array directly. Data transformation has removed `Optional` and never emits HTTP `MergeValue`/`MissingValue`, so calling `ConditionallyLoadsAttributes::filter()` would add a redundant recursive pass and reallocation. Keep the rest of Hypervel's `ResourceCollection`, `ResourceResponse`, `PaginatedResourceResponse`, and cursor pagination machinery unchanged. -Add regression tests proving unchanged behavior for ordinary JsonResource subclasses and forced wrapping, plus forced-interleaving tests showing two data responses can use different wrappers/additional data without leakage. A spy adapter subclass overrides `filter()` and proves Data adapter `resolve()` never invokes the generic sentinel filter; do not use a meaningless PHP array-identity assertion. +`Data` and `Resource` retain Spatie's `with(): array`, `additional()`, `wrap()`, `withoutWrapping()`, and request-query partial allowlists (`allowedRequestIncludes`, `allowedRequestExcludes`, `allowedRequestOnly`, `allowedRequestExcept`), and add Laravel resource hooks `withResponse(Request, JsonResponse): void` and static `jsonOptions(): int`. Query-requested partials are intersected with item-class allowlists evaluated for the current response under the capability rule above; their results are never cached in metadata. + +Add regression tests proving unchanged behavior for ordinary JsonResource subclasses and forced wrapping, plus forced-interleaving tests showing two data responses can use different wrappers/additional data without leakage. Cover unwrapped responses without metadata, Laravel's fallback `data` envelope when an otherwise-unwrapped response has top-level metadata, explicit/global wrappers with metadata outside them, response-key collisions following `ResourceResponse` exactly once, original single/collection items, static JSON options for ordinary, constructor-required, abstract, empty, and heterogeneous morph collections, and temporary partials being consumed only once for paginated output. Cover ordinary, empty, paginated, and cursor-paginated `DataCollection` responses, including an `include` request that cannot call a missing item hook. A custom transformable/includeable `BaseData` item without `ResponsableData` must prove that the same request include is denied rather than treated as unrestricted. A counting lazy generator must run once and expose the same constructed item instances through response original and body transformation. A spy adapter subclass overrides `filter()` and proves Data adapter `resolve()` never invokes the generic sentinel filter; do not use a meaningless PHP array-identity assertion. ### Inertia Add `hypervel/inertia` under Composer `suggest`, not `require`. -- Port `Lazy::inertia()`, `Lazy::inertiaDeferred()`, `AutoInertiaLazy`, and `AutoInertiaDeferred` against Hypervel's `OptionalProp` and `DeferProp` APIs. -- Preserve deferred group and rescue options supported by Hypervel. +- Port `Lazy::inertia()`, `Lazy::inertiaDeferred()`, `AutoInertiaLazy`, and `AutoInertiaDeferred` against Hypervel's `OptionalProp` and `DeferProp` APIs. Both adapters extend `Lazy` directly, are always intrinsically included, and return `resolvesToData() === false`; do not retain an always-true conditional closure or add compatibility for prop types Hypervel does not expose. +- `Lazy::inertiaDeferred(mixed $value, ?string $group = null, bool $rescue = false)` and `AutoInertiaDeferred(?string $group = null, bool $rescue = false)` preserve Hypervel's group and rescue options when constructing a prop from a closure/plain value. When the wrapped value is already a `DeferProp`, return that exact instance so merge, deep-merge, match, append/prepend, once/fresh/TTL/key, group, and rescue state remain authoritative; wrapper arguments do not rewrite an existing prop. - Keep Inertia references behind the explicit Inertia factories/adapters so loading Data and running ordinary creation/transformation never resolves an Inertia class. Because Inertia is always installed in the monorepo, verify this ownership by source/dependency audit rather than a doctored-autoloader subprocess test. - Data remains `Arrayable`; Inertia-specific Lazy variants resolve to existing prop wrapper objects during transformation, which `PropsResolver` handles recursively. Do not implement `ProvidesInertiaProperties`, because `ResponseFactory::render()` intentionally checks `Arrayable` first. - Register no integration when Inertia is absent and impose no class-resolution work on ordinary transforms. @@ -478,7 +501,7 @@ class DataVarDumperCaster public static function cast( TransformableData $data, array $properties, - Stub $stub, + \Symfony\Component\VarDumper\Cloner\Stub $stub, bool $isNested, ): array { return $data instanceof BaseDataCollectable @@ -514,27 +537,30 @@ Each root construction owns the v5-shaped pair of operation objects. `Constructi 7. On a successful precognitive request with `Precognition-Validate-Only`, Foundation's registered after-validation hook aborts with 204 and unwinds the operation before absence resolution, casts, contextual resolution, or construction. Do not add a separate `isPrecognitive()` return branch: an ordinary full-form precognitive request must construct the promised `static` instance, after which the precognition dispatcher owns its 204 response. Validation-only APIs that do not promise an object disable creation through their context instead. 8. Resolve true absence in one order: declared constructor default, then `Optional`, then `null` for a nullable type; otherwise retain absence for a clear missing-value error. 9. Cast scalar leaves recursively using the selected class metadata; nested Data objects are not constructed during this pass. -10. Instantiate objects bottom up through one shared primitive. For each node, run `beforeCreation` over final casted payload values, then resolve contextual parameters into their slots immediately before the constructor so contextual injection always wins; immediately after construction, run `afterCreation`. Ordinary nodes use direct construction, while a node with contextual parameters uses Container `buildWith()` so the target class remains on the contextual build stack. Public `build()`/`buildWith()` are raw-construction APIs and bypass the Data class's `SelfBuilding` factory; only Container resolution dispatches that factory. Existing target instances and direct-returning named factories finish before this primitive. If ordinary construction reaches a private or protected constructor, throw `CannotCreateData` before PHP's access error: report the reflected visibility and that no matching named factory returned an instance, then direct the caller to return the target object from a matching public static `from*` method or make the constructor public. This is a payload-dependent creation failure, not invalid metadata. Keep the guard in the shared primitive so any measured direct-array specialization inherits it; do not catch `Error`, analyze factory return paths, or duplicate constructor visibility as a metadata flag. Omit absent defaulted constructor arguments so PHP supplies their declared defaults, and never resolve contextual values for a graph rejected by validation. +10. Instantiate objects bottom up through one shared primitive. For each node, run `beforeCreation` over final casted payload values, then resolve contextual parameters into their slots immediately before the constructor so contextual injection always wins; immediately after construction, run `afterCreation`. Ordinary nodes use direct construction, while a node with contextual parameters uses Container `buildWith()` so the target class remains on the contextual build stack. Public `build()`/`buildWith()` are raw-construction APIs and bypass the Data class's `SelfBuilding` factory; only Container resolution dispatches that factory. Existing target instances and direct-returning named factories finish before this primitive. If ordinary construction reaches a private or protected constructor, throw `CannotCreateData` before PHP's access error: report the reflected visibility and that no matching named factory returned an instance, then direct the caller to return the target object from a matching public static `from*` method or make the constructor public. This is a payload-dependent creation failure, not invalid metadata. Keep the guard in the shared primitive so the measured direct-array exit inherits it; do not catch `Error`, analyze factory return paths, or duplicate constructor visibility as a metadata flag. Omit absent defaulted constructor arguments so PHP supplies their declared defaults, and never resolve contextual values for a graph rejected by validation. 11. Return the root object. -The engine has private/internal entry points for nested properties and collection items. A new nested node may select one compatible `from*` method, but a method's returned source is never matched again. Internal paths never call public `from()`/`factory()` or container `make()` for the data class. +The engine has private/internal entry points for nested properties and collection items. A new nested node may select one compatible `from*` method, but a method's returned source is never matched again. One shared unvalidated-node entry performs Fill and bottom-up construction for deferred root items and property-owned typed Data iterables. Internal paths never call public `from()`/`factory()` or container `make()` for the data class, never re-evaluate root validation/authorization, and reuse the root operation's extension/normalizer memo. A deferred collection deliberately retains that memo for its lifetime; extension objects therefore keep per-value state in the supplied operation context rather than on themselves. + +AutoLazy changes only when the existing per-property preparation runs. The eager loop records mapping and raw per-property provenance on the owning node, then skips structural Fill when no rules require the lazy value; validating properties use the ordinary body unchanged. Source selection remains property-specific across multiple input payloads. `prepareData` retains the corresponding raw source, while a validation hook that changes a property writes its final payload as the new provenance; this is an explicit `reconcileProperty()` operation, not an inferred side effect. At wrapper construction, the public `AutoLazy::build()` receives that payload, and the pruned copy discards only the consumed owning-node map while leaving descendant maps and selected paginator sources in place. Resolution clones the baseline, replays through ordinary `fillNode()` only for `AutoLazyReplayMode::Normal`, through reconciliation-owned `fillHookNode()` only for `Hook`, and otherwise calls the ordinary cast path directly. A nested Data value requires replay because no selected child class would otherwise leave `castProperty()` able to construct it; a paginator requires replay because its reconstruction source is retained during Fill. Because `Lazy` does not memoize, neither replay nor casting may mutate the retained baseline. The static closure resolves `DataCreator` from the container and creates a new operation memo for each resolution. -Root collection creation uses the same sequence over one keyed payload rather than starting one object operation per item. It batches model relation loading, fills every item into one state, applies collection-level validation hooks once, validates the complete graph once, casts/instantiates items through the shared bottom-up primitives, rebuilds the normalized source-shaped container, and selects any `collect*` method once against that exact container. Direct `DataCollection` construction enters the same item operation without collection-level magical dispatch. Declared paginator properties retain their source only in the optional structure-node slot described above; no paginator, request, or mutable container survives the root operation. +Root collection creation uses the same sequence over one keyed payload rather than starting one object operation per item. It batches model relation loading, fills every item into one state, applies collection-level validation hooks once, validates the complete graph once, casts/instantiates items through the shared bottom-up primitives, rebuilds the normalized source-shaped container, and selects any `collect*` method once against that exact container. Direct `DataCollection` construction enters the same item operation without collection-level magical dispatch. Declared paginator properties retain their source only in the optional structure-node slot described above. The ordinary root state never survives construction; an unresolved AutoLazy retains only its pruned property state and the selected subtree's required paginator sources. -`prepareData` receives the current node's normalized input-key array before child Fill. Preserve the normalized source list and resolve properties from a separate prepared payload. When that selected class enables `#[FailOnUnknownFields]`, record the pre-hook input at its observed root path by ordinary array copy-on-write for the one later root check; never deep-copy the graph or inspect keys introduced by `prepareData`. Direct Request entries use the tested body/JSON boundary, while other entries retain their complete normalized input. Model sources stay in property-name space and project only metadata-declared attributes and loaded/explicitly loadable relations. This preserves uniform hook behavior and the no-`Model::toArray()` contract. A named `from*` method, rather than model-wide serialization, is the class-owned escape hatch when construction genuinely needs undeclared model state. +`prepareData` receives the current node's normalized input-key array before child Fill. Preserve the normalized source list and aligned raw payload list while resolving construction properties from a separate prepared payload; this lets AutoLazy retain its upstream raw-payload extension contract without weakening hook authority over values. When that selected class enables `#[FailOnUnknownFields]`, record the pre-hook input at its observed root path by ordinary array copy-on-write for the one later root check; never deep-copy the graph or inspect keys introduced by `prepareData`. Direct Request entries use the tested body/JSON boundary, while other entries retain their complete normalized input. Model sources stay in property-name space and project only metadata-declared attributes and loaded/explicitly loadable relations. This preserves uniform hook behavior and the no-`Model::toArray()` contract. A named `from*` method, rather than model-wide serialization, is the class-owned escape hatch when construction genuinely needs undeclared model state. Treat a `FormRequest` as the `Request` it is: normalize `all()` and apply the Data class's validation/authorization lifecycle under the selected validation strategy. Do not add a privileged `FormRequestNormalizer` or silently reuse its validator, because that would make `Data::from($request)` depend on an unrelated request class's rules. A caller that deliberately wants the FormRequest result passes `$request->validated()` (or another explicit array/`Arrayable` projection) to `from()`; that input then follows the factory's selected non-request validation strategy. ### Measured direct array specialization -The fixed general engine is implemented and measured first. Add a specialized array branch only when retained same-machine benchmark results show a material CPU or allocation gap that justifies its permanent equivalence-test burden. If justified, eligibility is compiled per Data node: a general-path child does not force an otherwise eligible parent off its direct loop. A node is eligible when its input is an array and metadata says it has no validation, authorization, custom normalizer/cast, named factory, morph, contextual injection, relation loading, or per-operation hook. It then: +Apply the specialization only after removing universal overhead from the fixed engine. `BaseData::factory()` directly constructs its fresh caller-owned `CreationContextFactory` from container-resolved worker-shared `DataCreator` and `DataConfig`; it does not force a contextual reflection build for that mutable operation wrapper or memoize it statically. `DataProperty` compiles its mapped input path once, `SourceReader` consumes literal segment lists with a flat-array `array_key_exists()` fast path, and every `ConstructionState` property operation consumes that same list instead of splitting wire keys. The unvalidated internal-node entry above removes nested public dispatch and per-item memo recreation independently of the specialization. + +Retained same-machine profiling measures the corrected warm general path at about 39.6 microseconds for a simple object, while a conservative exact-array prototype using the shared instantiator takes about 5.0 microseconds. Trying the exact exit and falling through adds about 0.6 microseconds to the general path. This material hot-path gain justifies one narrow per-node branch and its equivalence coverage. Do not add an eager-collection specialization: root collection Fill owns validation uniformity, AutoLazy provenance, and paginator sources, while eligible child items reach the same per-node exit naturally. + +Compile one immutable `DataClass::$directArrayCreation` flag. It is false for an abstract or property-morphable class, a class declaring `normalizers()`, any configured global normalizer, any contextual constructor parameter, or any property with AutoLazy, `LoadRelation`, an attribute cast, a preselected configured cast, a Data-collectable type, or any typed iterable arm. Use `DataProperty::$configuredCasts`; do not reread configuration or build another eligibility graph. At runtime, attempt the exit inside the existing `fillNode()` invocation after named-factory matching only in `CreationMode::Create`, when validation and rule compilation are both disabled, the source is exactly one array, the metadata flag is true, and the operation has no normalizers, factory casts, `prepareData`, `beforeCreation`, or `afterCreation` hooks. Validation hooks need no separate gate because the two disabled validation flags make them unreachable. The explicit mode guard preserves the engine's array-returning validation contract independently of the ordinary factory's forced-validation setup. -1. Read each precompiled input key. -2. Apply the shared default/`Optional`/nullable/required absence operation. -3. Pass through already-valid values or call the fixed caster. -4. Instantiate with named constructor arguments. +The attempt reads only the plain array and immutable metadata until it succeeds. Resolve each property through the shared `propertyInputKey()` and `matchPropertySource()` mapping primitives, using `UnknownProperty` rather than `null` for absence. For each property: omit a missing computed/virtual value; route any supplied computed/virtual value, including explicit null, to the general path so its existing exception remains authoritative; omit a missing native default; materialize a missing `Optional` or nullable value; otherwise miss on absence. Retain supplied null and `Optional` for the shared instantiator. Retain every other supplied value only when `DataPropertyType::acceptsValue()` already accepts it; otherwise miss. The ordinary `castProperty()` pass-through must remain before its later date, enum, built-in, and castable conversions because this equivalence makes accepted non-iterable values safe for the exit. -It does not clone the full payload, construct a pipeline, resolve services per property, build validation paths, or allocate hook/context collections. This is not a second creator/resolver: both branches execute the same precompiled key-selection, absence, cast, and instantiation primitives, while the specialization omits whole stages whose feature bits are false. Keep the motivating measurement and targeted equivalence tests if the branch is added; if it is not measurably worthwhile, retain one lean fixed engine and remove its feature bit and planned branch tests. +On success, instantiate through the existing `DataInstantiator`; do not create a second constructor or casting path. On a miss, continue the same `fillNode()` invocation so named factories are never matched twice. A raw nested array makes its parent miss, while the selected child may take the exit when the general path reaches it; root collections gain the same per-item optimization without bypassing their shared state. Add short comments only for the same-invocation named-factory rule, missing computed omission, and the `castProperty()` ordering dependency. Do not add `ConstructionState`, source normalization, normalizer resolution, extension memoization, a property recipe object, recursive direct mapping, or eager collection machinery to the successful path. ### Metadata @@ -549,7 +575,8 @@ Metadata rules: - no closures, Request, Container, Validator, Model, or resolved service objects; cached `ReflectionClass`/`ReflectionParameter`/`ReflectionAttribute` references are permitted because they are immutable process metadata and are required to preserve Container contextual-attribute semantics without re-reflection; - package attributes that reduce completely to immutable strings, flags, mapper results, or operation codes are compiled and their instances discarded. Attributes containing object arguments, custom validation rules/references, or extension construction retain only their immutable `ReflectionAttribute` recipe and are materialized per root operation; never retain `ReflectionAttribute::getArguments()` results or an instantiated attribute/rule object in metadata; - feature bits skip entire subsystems on ordinary classes, including a `plainTransform` bit for objects whose declared values can be copied directly without mapping, partial, lazy, nested, or transformer work; -- built-in cast/transform operation codes and mapper results are stored directly on each property; only application replacements retain extension recipes; +- built-in cast/transform operation codes, mapper results, and literal mapped-input segment lists are stored directly on each property; the segment list is shared immutable worker metadata and consumers never copy or mutate it. Only application replacements retain extension recipes; +- `DataPropertyFactory` marks `AutoWhenLoadedLazy` properties non-validating at the same metadata boundary as computed, contextual, and `WithoutValidation` properties. This lets the existing preservation and unknown-field machinery handle their model-owned value without making validation graphs depend on relation-loaded state; - inferred string rules and declarative rule recipes may be cached, but instantiated rule objects and results from user lifecycle methods, closures, or container calls live only for the current root operation; - native reflection handles types/defaults/attributes; `phpstan/phpdoc-parser` handles collection generic annotations such as `@var FooData[]`; - each `DataParameter` compiles whether it is variadic, whether it carries attributes, its contextual recipe, and its public-`Reflector` single named class name. Non-variadic injectability and class-variadic emission derive from that one field plus the variadic flag. Reject a variadic `CreationContext` as an invalid factory declaration at metadata build; one operation has one context, and supporting a context-variadic mode would add ambiguous invocation machinery without a use case. `DataMethodMatch` records the selected argument map/list and container decision once; metadata matching performs no reflection or container lookup; @@ -632,7 +659,7 @@ Create the permission-style package skeleton: - `src/data/composer.json` - `src/data/LICENSE.md` retaining Spatie and Hypervel MIT notices -- `src/data/README.md` in the required minimal order: header; `Documentation: https://hypervel.org/docs/data-objects`; a concise `Differences From Laravel` section containing only lasting public differences and their Hypervel alternatives, including metadata-time rejection of mapping collisions that Spatie compiles independently; then `Ported from: https://github.com/spatie/laravel-data` +- `src/data/README.md` in the required minimal order: header; `Documentation: https://hypervel.org/docs/data-objects`; a concise `Differences From Laravel` section containing only lasting public differences and their Hypervel alternatives, including metadata-time rejection of mapping collisions that Spatie compiles independently, source-independent explicit-null handling for Model attributes, and first-source-wins multiple-payload construction; then `Ported from: https://github.com/spatie/laravel-data` - `src/data/config/data.php` - `src/data/src/Data.php`, `Dto.php`, `Resource.php`, `Optional.php`, `Lazy.php` - collection and paginator classes at the package root, matching upstream public names @@ -650,8 +677,8 @@ Register provider discovery in the component composer file. Add the package to r ### Framework-owned changes -1. Validation: extract FormRequest's unknown-field algorithm into `Hypervel\Validation\UnknownFields` with the exact/additional/subtree contract above. FormRequest preserves its body/JSON boundary and existing exact/structured-field behavior, while intentionally fixing free-form declared arrays: an `array` rule without descendants accepts its contents. Repair the optimized wildcard walk's Laravel partial-segment matching without weakening missing-leaf `required` rules, and complete literal-asterisk placeholder encoding/decoding across rules and dependent references. Restore current Laravel's normalize-before-wildcard-merge invariant in the optimized parser so an earlier wildcard can overlap a raw exact string rule without a `TypeError`; retain Laravel's later exact assignment because Data class-rule replacement and ordinary Validator precedence depend on it. Reset `implicitAttributes` and `implicitAttributeMap` whenever `Validator::setRules()` replaces the graph, and build the reverse implicit-attribute map with first-write-wins semantics so its O(1) lookup preserves Laravel's first-declared wildcard identity. Add the immutable per-plan consumable-presence count and guarded exact-rule database batching described above without changing unsafe fallbacks, and expose one Validation-owned predicate shared by parser preparation and Data's conservative accumulator comparison for objects that reduce to strings. Add owning Validation coverage for matching/non-matching/absent partial patterns, the missing-leaf guard, literal dot/asterisk keys, escaped public error keys, the documented trailing-backslash fail-closed boundary, wildcard/exact declarations in both orders and input forms, stale implicit-state replacement, overlapping broad/narrow wildcard `Distinct`, valid dependent-field substitution, dependent-field substitution arity, exact presence batching, and fallback behavior; add FormRequest coverage for associative and list values plus a structured wildcard regression. Give `NotIn::__construct()` the same `array|Arrayable|UnitEnum|string` native type as adjacent `In`. Split raw email's explicit `rfc` arm from unsupported modes and throw `InvalidArgumentException` with a safe diagnostic for the latter; retain bare/default and custom-class behavior, add focused tests, and document the failure contract. While adding Data's `Can` attribute wrapper, remove the redundant promoted-property self-assignments and replace its non-imperative `Constructor.` docblock with the package convention; retain rule behavior and focused tests. -2. Foundation: remove the `Support\DataObject` branch/import from `Http\Traits\HasCasts` and delete `AsDataObjectArray`/`AsDataObjectCollection`. Its generic `Castable` path remains unchanged; replacements live in Data. +1. Validation: extract FormRequest's unknown-field algorithm into `Hypervel\Validation\UnknownFields` with the exact/additional/subtree contract above. FormRequest preserves its body/JSON boundary and existing exact/structured-field behavior, while intentionally fixing free-form declared arrays: an `array` rule without descendants accepts its contents. Repair the optimized wildcard walk's Laravel partial-segment matching without weakening missing-leaf `required` rules, and complete literal-asterisk placeholder encoding/decoding across rules and dependent references. Restore current Laravel's normalize-before-wildcard-merge invariant in the optimized parser so an earlier wildcard can overlap a raw exact string rule without a `TypeError`; retain Laravel's later exact assignment because Data class-rule replacement and ordinary Validator precedence depend on it. Reset `implicitAttributes` and `implicitAttributeMap` whenever `Validator::setRules()` replaces the graph, and build the reverse implicit-attribute map with first-write-wins semantics so its O(1) lookup preserves Laravel's first-declared wildcard identity. Add `Validator::retainRules(array $attributes)` to narrow the current prepared graph by attribute name while leaving its implicit identity and original `initialRules` untouched; names absent from the prepared graph are ignored, and `setData()` rebuilds the full original graph. Add the immutable per-plan consumable-presence count and guarded exact-rule database batching described above without changing unsafe fallbacks, and expose one Validation-owned predicate shared by parser preparation and Data's conservative accumulator comparison for objects that reduce to strings. Add owning Validation coverage for matching/non-matching/absent partial patterns, the missing-leaf guard, literal dot/asterisk keys, escaped public error keys, the documented trailing-backslash fail-closed boundary, wildcard/exact declarations in both orders and input forms, stale implicit-state replacement, retained prepared-rule identity, `setData()` rebuilding after retention, overlapping broad/narrow wildcard `Distinct`, valid dependent-field substitution, dependent-field substitution arity, exact presence batching, and fallback behavior; add FormRequest coverage for associative and list values plus a structured wildcard regression. Give `NotIn::__construct()` the same `array|Arrayable|UnitEnum|string` native type as adjacent `In`. Split raw email's explicit `rfc` arm from unsupported modes and throw `InvalidArgumentException` with a safe diagnostic for the latter; retain bare/default and custom-class behavior, add focused tests, and document the failure contract. While adding Data's `Can` attribute wrapper, remove the redundant promoted-property self-assignments and replace its non-imperative `Constructor.` docblock with the package convention; retain rule behavior and focused tests. +2. Foundation: remove the `Support\DataObject` branch/import from `Http\Traits\HasCasts` and delete `AsDataObjectArray`/`AsDataObjectCollection`. Its generic `Castable` path remains unchanged; replacements live in Data. Switch both `ValidatesRequests` paths, FormRequest, and the Request validation macro from replacing filtered concrete rules through `setRules()` to narrowing the prepared graph through `retainRules()`. 3. HTTP: add `Resources\Json\ProvidesResourceWrapper` and let `ResourceResponse::wrapper()` prefer that per-instance value. This is a general coroutine-safe resource extension; ordinary JsonResource static wrapping and force-wrapping remain unchanged. 4. Database: delete `Eloquent\Casts\AsDataObject`; Eloquent data casting belongs to Data. 5. Support: delete the superseded `DataObject`. @@ -661,7 +688,7 @@ Register provider discovery in the component composer file. Add the package to r 9. Repository maintenance: add `spatie/laravel-data` to `docs/upstream-sync/sync.yaml` before permission with `release: 4.23.0`, `sync_date: 2026-08-30`, and an operational note that the initial port also reviewed main through `ce296f22` plus the v5 draft at `ed630ee1`, so overlapping future release work is recognized rather than re-ported. Correct `docs/upstream-sync/README.md` to point Laravel ports at the `AGENTS.md` Porting Packages section instead of nonexistent `docs/ai/porting.md`, and add the separate TypeScript-transformer package to `docs/todo.md` as a real deferred gap. 10. Saloon and Inertia: make no runtime framework edits. Adapt Data to their existing contracts and prop classes. 11. Foundation VarDumper: make no runtime framework edit. Data registers its own interface caster through Symfony's existing default-caster extension point. -12. Pagination: remove the unenforced `through()` `@method` tags from the paginator contracts. The contracts do not declare that method, and Data uses the real `items()` contract plus Hypervel abstract-paginator clone/`setCollection()` support instead. Concrete paginator behavior and the canonical Pagination documentation remain unchanged. +12. Pagination: give `AbstractPaginator::setCollection()` the same method-level key/value templates and `@phpstan-this-out static` contract as `AbstractCursorPaginator::setCollection()`. The identical Data paginator constructors then retain their new item type after cloning. ### Test layout @@ -670,7 +697,7 @@ Register provider discovery in the component composer file. Add the package to r - Replace useful assertions from `tests/Support/DataObjectTest.php`; delete that file rather than retaining a second API suite. - Update existing Container, Foundation, Validation, Database, HTTP resource, Testing cleanup, Saloon, and docs tests at their owning integration points. - Add focused caster/provider coverage under `tests/Data/Support/VarDumper`; every test that mutates `AbstractCloner::$defaultCasters` restores the previous entry in `finally` rather than adding global test cleanup. -- Add `types/Data/Data.php` as the dedicated max-level PHPStan fixture for `from()`, `optional()`, `collect()` target inference, DataCollection/paginator generics, and the distinct `Data`/`Dto`/`Resource` capability contracts. +- Add `types/Data/Data.php` as the dedicated max-level PHPStan fixture for `from()`, `optional()`, grouped `collect()` inference through `Data::collect()`, `factory()->collect()`, and a `class-string` call, plus `@use WithData` returning the precise associated class. Pin string-key preservation, direct `DataCollection` and both paginator wrappers, every exact concrete/abstract/contract/package/collection target literal, exact concrete source shapes, the normalized union for contract-only sources, a `class-string` dynamic target's complete union, arbitrary `Traversable` with an explicit target, the isolated `ArrayIterator`-with-null `never` terminal, and the distinct `Data`/`Dto`/`Resource` capability contracts. - `tests/Benchmarks/Data/benchmark.php` and `tests/Benchmarks/Data/README.md` for the retained benchmark harness. ## Upstream Port Ledger @@ -743,19 +770,19 @@ The ledger is an implementation artifact kept with the working notes until all e - Port/adapt attributes collection, class/property/method/parameter/type metadata. - Parse native types, constructor promotion/defaults, attributes, collection docblocks, unions, intersections, DNF types, enums, dates, iterable item types, virtual/computed fields, and named object/collection factories. -- Compile constructor argument order, input/output mapper keys, hook bits, rule templates, cast/transform recipes, and `plainTransform`; compile per-node direct-creation eligibility only if the measured specialization is retained. +- Compile constructor argument order, input/output mapper keys, hook bits, rule templates, cast/transform recipes, `plainTransform`, and the measured `directArrayCreation` eligibility flag. - Test immutable metadata; inherited native `self`/`parent` and late-bound `static` declarations across properties, constructors, and named factories; parent/child/constructor/inline generic-annotation precedence with distinct import scopes; import aliases winning over an existing same-namespace class; multi-namespace per-file import caching; recursive class references; ignored helper/static properties; constructor-bound readonly/mutable/defaulted properties; required constructor parameters overriding property defaults; invalid unbound readonly, computed-bound, contextual-name-collision, non-public-promoted, and alternate-constructor declarations; valid non-public-constructor metadata; declaration-order method metadata; bounded repository/resolver keys; cached contextual/extension reflection recipes; fresh object-bearing attribute arguments per operation; and absence of container/request/resolved extension objects. ### 4. Implement fixed construction - Implement normalized source adapters for arrays, JSON, `Arrayable`, plain objects, Model, Request/FormRequest, and custom normalizers. - Normalize plain objects from initialized public properties only; do not bypass visibility or invoke arbitrary serialization. -- Implement named object/collection factory dispatch and non-recursive internal construction. +- Implement named object/collection factory dispatch and non-recursive internal construction. Deferred root items and property-owned typed Data iterables use the shared unvalidated-node entry, retain one root extension memo, and never re-evaluate root request validation or authorization. - Implement default/`Optional`/nullable/required absence handling in the single documented precedence order. - Implement casts for built-ins, nested Data, data collections, dates, enums, iterables, unions, custom casts/castables, and morphs. - Mark contextual constructor slots during Fill, exclude promoted injected properties from payload validation, and resolve their values only at per-node instantiation. Pass non-promoted injected parameters only to the constructor and match normal per-parameter Container resolution without a cross-node value cache. - Preserve constructor-owned values for every constructor-bound property. Assign only supplied, unbound public mutable properties after construction while leaving computed/virtual properties to the class. -- Benchmark the completed fixed general array path against the retained manual baseline. Add the per-node direct specialization with shared property primitives and focused equivalence tests only if the retained measurement justifies it. +- Remove universal entry/read overhead first: directly construct the fresh fluent factory from container-resolved collaborators, compile mapped input segments once, route every source/state property operation through them, and delete duplicate runtime splitters. Retain the measured per-node exact-array exit inside `fillNode()` with shared mapping, absence, and instantiation primitives; a miss falls through without rematching named factories. - Add source-specific query-count and allocation-focused tests where measurable. ### 5. Implement validation @@ -772,8 +799,8 @@ The ledger is an implementation artifact kept with the working notes until all e - Implement direct transformation, context promotion, mapping, custom transformers, Optional omission, lazy/computed/hidden/appended values, partials, depth detection, JSON, and serialization. - Compile one immutable exact/prefix/subtree-aware `PartialTree` per partial mode and use `plainTransform` only when instance state and metadata prove the direct loop is equivalent. - Add nested instance-partial composition first at the two currently live `BaseData` edges and port the array-shaped, depth-three per-item part of upstream `PartialsTest.php:1068` in that slice. -- Port typed collections/paginators and preserve keys/laziness. Implement one root collection Fill/Validator operation, shared per-operation normalizer/extension memo, normalized source-shaped `collect*` selection, declared-shaped property rebuilding, Eloquent relation batching/root downgrade, exact multi-arm finished-container handling, Data and non-Data paginator source retention, cast-owned ambiguous unknown-field subtrees, and the Fill-time failure boundaries described above. Route every eager typed iterable through `DataCollectableFactory` and remove the duplicate creator rebuilder. Replace the two unreachable non-`BaseData` public-transform scaffolds with the shared internal collection loop, then port the complete upstream partial graph covering root-, collection-, and item-owned selections. -- Make collection iteration and keyed reads side-effect-free, route constructor and `offsetSet()` item conversion through the package-internal item operation, and remove Pagination's unenforced `through()` contract annotations. +- Correct `AbstractPaginator::setCollection()` generic rebinding before making collection item contracts precise. Port typed collections/paginators and preserve keys/laziness. Implement one root collection Fill/Validator operation, shared per-operation normalizer/extension memo, normalized source-shaped `collect*` selection, declared-shaped property rebuilding, Eloquent relation batching/root downgrade, exact multi-arm finished-container handling, Data and non-Data paginator source retention, cast-owned ambiguous unknown-field subtrees, and the Fill-time failure boundaries described above. Route every eager typed iterable through `DataCollectableFactory` and remove the duplicate creator rebuilder. Replace the two unreachable non-`BaseData` public-transform scaffolds with the shared internal collection loop, then port the complete upstream partial graph covering root-, collection-, and item-owned selections. +- Make collection iteration and keyed reads side-effect-free, and route constructor and `offsetSet()` item conversion through the package-internal item operation. - Add the stateless VarDumper caster and direct provider registration after `all()` semantics are complete; do not add a manager or mode setting. - Add live-property regression tests proving no output cache. - Benchmark simple/nested/collection output and peak memory. @@ -781,12 +808,12 @@ The ledger is an implementation artifact kept with the working notes until all e ### 7. Integrate framework surfaces and command - Add package-owned FormRequest casts, then remove Foundation's old DataObject branch and casts. -- Add Data/collection Eloquent casts, property-morphable and enforced-alias abstract forms, custom-codec handling, and encrypted variants; delete Database's old cast. +- Split Eloquent casting from the transformation contract, add the shared Data/Resource cast concern, keep direct casting only on `Data`, `Resource`, and `DataCollection`, and add the constructable transformation mode used by both casts. Cover hidden/computed/additional/mapped/partial/Lazy behavior before adding property-morphable and enforced-alias abstract forms, custom-codec handling, and encrypted variants; delete Database's old cast. - Add the HTTP wrapper interface and Data resource adapters with existing response/pagination machinery; override adapter `resolve()` to bypass the generic conditional-resource filter. - Add SelfBuilding request injection and Precognition integration tests. - Add optional Inertia lazy/deferred adapters and tests. - Add Saloon integration tests/docs; make no Saloon runtime change. -- Implement `Console\DataMakeCommand` using `Hypervel\Console\GeneratorCommand`, `#[AsCommand]`, a hardcoded `App\Data` default namespace, and the same application stub override convention as Hypervel's existing `make:*` commands. Do not auto-append `Data`; `make:data UserData` should behave like `make:request StoreUserRequest`. +- Implement `Console\DataMakeCommand` using `Hypervel\Console\GeneratorCommand`, `#[AsCommand]`, the application's root namespace plus `Data` (normally `App\Data`), and the same application stub override convention as Hypervel's existing `make:*` commands. Do not auto-append `Data`; `make:data UserData` should behave like `make:request StoreUserRequest`. - Test default/nested/explicitly qualified class names, force/no-force, strict-types stub, application stub override, and disposable Testbench paths; there are no namespace/suffix command settings. - Add and run the dedicated `types/Data/Data.php` fixture before broader static analysis; fix public generic contracts rather than adding PHPStan ignores. @@ -795,9 +822,9 @@ The ledger is an implementation artifact kept with the working notes until all e - Delete Support DataObject, Database AsDataObject, old tests, old cleanup, and every old import/comment/reference. - Update `src/docs/data-objects.md` section by section for the new API using targeted edits; do not replace the file wholesale or retain obsolete sections as migration guidance. - Update `src/docs/validation.md`, `eloquent-mutators.md`, `api-client.md`, `saloon.md`, and code examples. Update `src/docs/container.md`, `src/container/README.md`, and `src/docs/porting-from-laravel.md` for the new Container attributes and Hypervel's boot-stable `BindWhen` condition contract. Confirm the existing Data Objects entry in `src/docs/documentation.md` still resolves to the retained `data-objects` slug; do not add a duplicate navigation entry or invent separate search metadata. -- Document construction and absence semantics, mapping, validation, collections, Eloquent, resources, Inertia, clean VarDumper output, performance, extension contracts, and worker-lifetime constraints. State that dumps show the current logical view, so excluded `Lazy` and `Optional` values do not appear. -- In `Differences From Laravel`, record the fresh factory context, valid-state `Optional` rule, fixed nullable semantics/`#[Present]` alternative, safe `OnlyRequests` defaults for all SelfBuilding base classes, retained class `withValidator`, contextual constructor-only/always-wins behavior, property-extraction alternative, Hypervel wildcard/concrete rule modes, normalized-container `collect*` dispatch, and the distinction between raw-input `validate()` and a direct-returning named factory that owns `validateAndCreate()` validation. Explain that a `collect*` parameter declares the container of normalized Data objects it receives. Include every other lasting ledger divergence without repeating the canonical guide. -- Add a concise `porting-from-laravel.md` entry for applications moving from `spatie/laravel-data`, linking to the canonical Data Objects documentation rather than duplicating it. +- Document construction and absence semantics, mapping, validation, collections, Eloquent, resources, Inertia, clean VarDumper output, performance, extension contracts, and worker-lifetime constraints. Document `WithData` as a creation shortcut, including property-before-method precedence and that a FormRequest source runs the associated Data class's validation rather than reusing FormRequest rules. In the collection API, state that `$into` accepts `null`, `'array'`, or a class-string and that config-derived targets should be narrowed to `class-string`. Document the custom `AutoLazy::build()` payload contract: ordinary and post-`prepareData` values receive the aligned original raw caller payload (or first/empty fallback), while a value changed or introduced by validation-hook reconciliation receives that hook's final payload. A named factory that returns another normalizable value makes that return the sole aligned source under the same contract. A hook-selected morph target using `AutoWhenLoadedLazy` fails clearly because the authoritative hook payload is not a Model. Explain in the collections/transformation section that `Dto` has no transformation capability, so nested and collected DTOs remain raw objects in `toArray()` just like other non-`Arrayable` values; use `Data` or `Resource` when output mapping, `Optional` omission, or built-in value transformation is required. State that dumps show the current logical view, so excluded `Lazy` and `Optional` values do not appear. Explain that Eloquent storage is an internal constructable view keyed by PHP property names, that a conditional/relational Lazy must already be included at write time, that callback/Inertia Lazy values cannot be persisted, and that paginated wrappers must be reduced to `DataCollection` items before persistence. +- In `Differences From Laravel`, record the fresh factory context, valid-state `Optional` rule, fixed nullable semantics/`#[Present]` alternative, Model null remaining explicit, first-source-wins multiple-payload construction, safe `OnlyRequests` defaults for all SelfBuilding base classes, retained class `withValidator`, contextual constructor-only/always-wins behavior, property-extraction alternative, Hypervel wildcard/concrete rule modes, normalized-container `collect*` dispatch, and the distinction between raw-input `validate()` and a direct-returning named factory that owns `validateAndCreate()` validation. Explain that a `collect*` parameter declares the container of normalized Data objects it receives. Include every other lasting ledger divergence without repeating the canonical guide. +- Add a concise `porting-from-laravel.md` entry for applications moving from `spatie/laravel-data`, naming Model-null and first-source-wins multiple-payload behavior and linking to the canonical Data Objects documentation for detail. - Report that `packages/hypervel/docs/plans/sdk-generator/2026-08-29-1238-sdk-generator.md` still proposes `DataKey`/`MissingValue`/the old `DataObject` enhancement. Amend that separate private plan to `MapName`/`Optional`/`Data::from` and remove obsolete framework work only after the owner explicitly authorizes editing it; retain the generator's strict `Wire` boundary. - Run broad `grep` searches across active source, tests, types, config, package metadata, and canonical docs. Include the SDK-generator plan only if the owner authorizes its amendment; otherwise report its stale references without editing it. Eliminate stale APIs while retaining required port-maintenance notices for deliberate public omissions. Do not rewrite immutable completed plans, `_archive`, or installed `vendor` copies to imitate the new code. @@ -807,7 +834,7 @@ The ledger is an implementation artifact kept with the working notes until all e - Audit public names/signatures/order against the checked-out Spatie source/docs/tests and Laravel conventions. - Audit dependency direction and component composer requirements. - Audit all singleton/static properties for request state, closures, container values, and unbounded keys. -- Profile the fixed general paths and any retained measured specialization; remove abstractions that add cost without enabling an adopted feature. +- Profile the fixed general paths and the measured exact-array exit; verify miss overhead remains negligible and remove abstractions that add cost without enabling an adopted feature. - Run formatters, static analysis, focused suites, package-adjacent suites, then the repository suite according to AGENTS.md. - Review the final diff for dead compatibility code, duplicated serializers/validators/resources, stale comments/docs, and source unrelated to this package. @@ -816,6 +843,7 @@ The ledger is an implementation artifact kept with the working notes until all e ### Creation and types - array, JSON string, `Arrayable`, plain object, stdClass, Model, Request, FormRequest, multiple payloads, custom normalizer; +- root `WithData` property and method declarations, property precedence, missing/invalid declarations, precise generic return, FormRequest input using the associated Data class's validation, and the same invalid Model source skipping validation under `OnlyRequests`; - constructor promotion, inherited properties, defaults (including `new` object defaults), nullable omission to `null`, explicit null, Optional-preserved omission, missing non-nullable required values, empty data, public readonly promoted and constructor-bound non-promoted properties, constructor normalization preserved without post-assignment, unbound mutable properties, invalid unbound readonly and computed/virtual-bound declarations, and PHP 8.4 virtual/backed property hooks; - scalar/builtin coercion rules, including case-insensitive `true`/`false` strings, enums, exact date classes/interfaces/subclasses/timezones/formats; - declared-class collection items pass through with identity, while an unrelated `BaseData` item is normalized into the declared item class rather than being preserved as a finished value; @@ -823,7 +851,7 @@ The ledger is an implementation artifact kept with the working notes until all e - nullable/union/intersection/DNF/existing-instance handling and explicit ambiguity failures; - custom Cast/Castable, constructor arguments, Uncastable fallback, and morph discriminators restricted to declared concrete Data subtypes; - named object/collection factory declaration order; positional/named matching; exact-key rejection; zero-payload matches; dependency-first/interleaved parameters; union/intersection non-injectability; `CreationContext` identity and first/middle/trailing placement across named and positional invocation shapes; variadic-context declaration rejection; direct supplied-class payloads; omitted dependencies through first-class `Container::call()`; contextual build-stack bindings; non-variadic attribute callbacks; method bindings not intercepting factories; pure and prefixed variadics; skipped-default built-in variadics; class-name-key emission for attributed/injected prefixes; same-class prefix consumption without fabricated arguments; zero-payload class-variadic Container resolution; independent `$into` return matching; direct-object short circuit/authorization; private-constructor direct-return and existing-instance success; unmatched private-constructor and matched-normalizable-source `CannotCreateData` failures; protected visibility diagnostics; unchanged public construction; and recursive-public-entry regression; -- when benchmarks justify the specialization, direct/general branch equivalence for mapping, defaults/nullable omission, casts, nested values, dates, enums, errors, current property state, and a general-path child beneath a direct-path parent. +- nested typed Data iterables do not re-enter `OnlyRequests` authorization and instantiate each attribute cast/normalizer recipe at most once per root operation, including deferred traversal; mapped input path segments are literal and shared by source reading and construction state; whole-segment `*`/`{first}`/`{last}` keys, public object null, magic null, inaccessible null, and uninitialized public properties retain the documented presence/access boundaries; exact-array/general equivalence covers accepted scalars and ordinary objects, explicit null including a defaulted property, native defaults, `Optional`/nullable omission, mapped-key precedence and fallback, untyped arrays, unbound public properties, existing nested Data/date/enum values, a raw nested child exit reached from its general parent, and a child exit inside a root collection item; coercion, missing-required failures, custom/global/context casts and normalizers, AutoLazy, `LoadRelation`, morphs, contextual values, typed iterables, and creation hooks fall through; missing computed/virtual values succeed, while supplied non-null and null values reach the existing exception; named factories dispatch once including a normalizable array return; and a non-public constructor reaches the shared instantiator error. ### Mapping and validation @@ -844,7 +872,7 @@ The ledger is an implementation artifact kept with the working notes until all e - pre- and post-validation payload reconciliation for added, removed, scalar-to-structured, structured-to-scalar, morph-reselected, and collection-item values; reselect mapped and fallback wire keys for every changed node property, including scalars and canonical absent paths; use fixed Model/source normalization and named factories only for final changed values; and prove `prepareData`, custom normalizers, and unchanged sibling factories do not rerun while collection divergence remains safely concrete; - route/auth/config/request-attribute/custom contextual injection on promoted and distinct-name non-promoted constructor parameters; metadata-time rejection of a non-promoted contextual parameter/public-property name collision so client payload cannot overwrite a server value; route/user whole-value and `data_get()` property-path extraction; authoritative contextual `null` parity across constructor and `call()` resolution plus precedence over primitive/class contextual bindings and declared defaults; nullable auth contracts with and without defaults; missing non-nullable route models failing instead of becoming empty models; contextual-value-wins behavior over payload and `beforeCreation` output; mapped contextual echoes accepted as known-but-ignored exact/subtree input under `#[FailOnUnknownFields]`; exclusion of injected fields from validation; no contextual resolution before failed validation or successful `Precognition-Validate-Only`; normal construction for full-form precognitive submits; fresh handler invocation per constructed node; no container lookup when attributes are absent; and no injection support on a non-promoted public data property; - unknown fields with nested/exploded rules; strict and non-strict classes at different graph depths; a strict parent's pre-hook key removal; opaque declared arrays/mixed/object subtrees; structured nested Data remaining strict; exact and uniform-collection wildcard `WithoutValidation`/contextual paths; wildcard subtree descendants and empty-array leaves; escaped literal-star auxiliaries; inert unsupported partial-star auxiliaries; finished values; literal-dot/asterisk rule keys; unchanged unescaped public error keys; the trailing-backslash fail-closed boundary; confirmation fields; direct Request query omission versus complete nested array input; JSON/body input; hook-added keys ignored consistently across Request/array sources; and Precognition's unfiltered rules; -- Precognition successful/failed/authorization/filtering behavior, proof that successful `Precognition-Validate-Only` aborts before absence/casts/contextual resolution/construction, and proof that an ordinary precognitive submit constructs parameters before its dispatcher returns 204; +- Precognition successful/failed/authorization/filtering behavior across Foundation and Data, including wildcard display names, dependent-field substitution, and `Distinct` identity after filtering; contrast `setRules()` replacement with `retainRules()` current-graph narrowing and prove `setData()` rebuilds the complete original declarations; prove that successful `Precognition-Validate-Only` aborts before absence/casts/contextual resolution/construction, and that an ordinary precognitive submit constructs parameters before its dispatcher returns 204; - `Exists`/`Unique`/`Distinct` nested collections in eligible wildcard, dynamic-identical wildcard, and divergent forced-concrete modes; exact and wildcard database query counts; an isolated exact or one-item wildcard presence check retaining the ordinary path; two exact checks and a same-plan `exists|unique` pair entering batching; cache-hit and wildcard-expansion contributions recomputed on every `passes()`; safe facts reused by later mutation-aware consumers; different query shapes; callbacks, exclusions, nullable/missing/upload values, field references, stop-on-first-failure, custom validator/verifier fallbacks, repeated passes, and verifier restoration. Keep ordinary-versus-batched equivalence coverage explicit: a shared test-only `DatabasePresenceVerifier` subclass forces the ordinary arm, every intended batched arm supplies at least two checks, and an `after()` callback observes `PrecomputedPresenceVerifier` before restoration so one-query ordinary execution cannot make the test pass accidentally; - Validator wildcard/exact overlap in both declaration orders with string and array exact rules, preserving later exact replacement while eliminating the raw-string merge crash; `setRules()` wildcard-to-exact and nonempty-to-empty expansion regressions prove stale implicit state is cleared. @@ -854,10 +882,15 @@ The ledger is an implementation artifact kept with the working notes until all e - custom/date/enum/arrayable transformers and nested collection output; - Hidden, Computed, appended values, include/exclude/only/except, invalid paths; - nested instance include/exclude/only/except at depth two or greater; parent/instance tree union including parent pure-all plus instance `only`; array-shaped typed-item isolation matching the B1/B2 portion of upstream `PartialsTest.php:1068`; the same instance referenced twice with a temporary applying only at first reach and a permanent applying at both; collection-container ownership and the complete upstream graph once the internal collection loop exists; -- lazy default/conditional/relation/closure values, no evaluation when excluded, one evaluation when included; +- lazy default/conditional/relation/closure values; AutoLazy scalar/nested/collection/non-Data-iterable/paginator values; scalar and `string[]` properties retaining their source without a replay mode; a deferred paginator resolving through its retained reconstruction source; a deferred nested class proving its custom normalizer runs under normal replay but does not rerun for validation-hook replay; fresh native defaults; supplied null/`Optional`/existing `Lazy`; custom and factory cast behavior; no nested Fill, custom normalizer, `prepareData`, cast, Eloquent `loadMissing()` batch, or `LoadRelation` query before an unvalidated lazy is included; validating values built from the validated snapshot without rerunning hooks; sibling-reading casts seeing the same owning-node values as eager casting; and the same Data-collection/paginator lazy resolving twice with stable item counts, metadata, and equivalent values; +- pruned AutoLazy snapshots retain the exact original item-boundary path, owning-node sibling payload, divergent class/mapping overrides, descendant AutoLazy provenance, and nested/item paginator sources while dropping root/ancestor siblings, owning-node provenance, and `unknownInput`; use weak references to prove consumed raw sources and strict excluded input are not retained; resolve nested AutoLazy twice under both eager and deferred Fill, including an outer collection with genuinely divergent item overrides and AutoLazy collection items that themselves contain paginator properties; +- custom AutoLazy provenance for two properties supplied by different raw payloads, zero/default absence, post-`prepareData` values, scalar validation-hook replacement, and a hook-selected morph; reconciliation writes the hook payload explicitly rather than retaining stale caller provenance; +- `AutoWhenLoadedLazy` loaded, unloaded, custom relation, nullable wrapper, no-Model and supplied-non-Model failures, and hook-selected-morph failure behavior; live relation reads at resolution; a collection with mixed loaded state retaining one uniform validation graph; model null on non-nullable defaulted and non-defaulted properties producing the exact ordinary failure while null on a nullable property with a non-null default remains null; +- Inertia initial, partial, deferred, grouped, rescue, and serialization behavior, including exact identity/state preservation for an existing `DeferProp` with merge and once state; - recursive `Lazy`/relationship graphs stop at the exact configured maximum depth; cyclic object graphs are documented as unsupported rather than guarded with a per-node identity set; - JSON errors/options and PHP serialization of supported state; - collection keys, items/toCollection/count/iteration/offset operations; side-effect-free early-break iteration and successive keyed reads; internal constructor/offset assignment normalization; covariant package-collection pass-through and subsequent item coercion; one eager root operation and one Validator; collection hooks over the complete payload; source-object `OnlyRequests` behavior; normalized source-shaped `collect*` matching/invocation, including an exact Eloquent parameter not dispatching for an Eloquent source and ordinary collection fallback returning the requested result; independent `$into` return matching; contract-only paginator fallback; no lazy enumeration; per-operation normalizer reuse; and LazyCollection laziness when neither validation nor rule introspection needs its graph, with deliberate one-time materialization for either rule-producing operation; +- non-transformable `Dto` and custom modular `BaseData`/`BaseDataCollectable` values remain identical from `toArray()` and `all()` without consuming partials, while transformable `Data`/`Resource` values still transform; paginated and cursor-paginated `Dto` collections retain raw item identity under their wrapper key and preserve native metadata; - root and nested paginator metadata/links/cursors; Hypervel paginator clone-without-caller-mutation; declared paginated wrappers; raw Hypervel paginator conversion; scalar/date/enum typed paginator reconstruction; matching and mismatched package wrapper item classes; array-to-paginated failure at Fill; contract-only paginator finished/pass-through and conversion-failure boundaries; dedicated missing-retained-source failure; per-item paginator source isolation without template fallback or validation-uniformity loss; hook source replacement; eager/Lazy page-item reshaping; retained metadata when hooks change item count; and nullable/Optional/absent paginator properties; - ambiguous Data-object/container unions: per-arm PHPDoc item classes and annotation-order independence; finished base/Eloquent containers accepted through any compatible arm; custom attribute/configured/factory casts reached before ambiguity; strict unknown fields remain fail-closed without a cast and treat only cast-owned ambiguous shapes as opaque in create/validation-only modes; single-arm casts retain nested validation unless `WithoutValidation`; unrelated non-Data alternatives pass unchanged; raw Data-container sources fail with every candidate; and `Collection|array` is ambiguous when both arms carry Data item metadata; - declared property rebuilding: exact `array`/`iterable` return keyed arrays, ordinary/custom source subclasses rebuild as the declared collection class, valid `EloquentCollection` stays Eloquent, invalid Model/scalar/union/intersection/DNF item graphs are rejected by the structural guarantee check, and unsupported `Traversable` declarations fail with `CannotCreateDataCollectable`; @@ -869,7 +902,7 @@ The ledger is an implementation artifact kept with the working notes until all e - two interleaved requests cannot share construction state, injected users/routes, validators, factory hooks, wrappers, partials, additional fields, or lazy results; - `BindWhen` first-match, singleton/scoped lifetime, no-match, late-match reevaluation, `Bind` fallback, mixed declaration order, first-wildcard behavior, and worker-lifetime materialization, with closure-bearing fixtures loaded only on PHP 8.5+; - FormRequest direct/collection casts accept only package `BaseData` classes and reuse `from()`/`collect()` through Foundation's generic cast path; -- Eloquent null/default, empty object/list, full representation without instance mutation, property-morphable payloads, enforced abstract envelopes, unknown alias/FQCN and invalid subtype rejection, dirty tracking, custom codec, encrypted concrete/abstract data and collections, previous encryption keys, invalid JSON, and serialization; +- Eloquent capability separation for `Data`, `Resource`, `Dto`, `DataCollection`, and both paginator wrappers; null/default and empty object/list behavior, including collection-specific late-bound default storage; concrete and property-morphable stored collections using one internal root item operation with one normalizer-list resolution while preserving keyed malformed-item errors; complete constructable root/nested representations with PHP property names, hidden values retained, computed/virtual and appended values omitted, declared-value collisions preserved correctly, temporary/permanent partial stores neither consumed nor extended, and constructable state retained through every context copy; default/included Lazy resolution plus false-condition, unloaded-relation, callback, and Inertia rejection without relation queries; property-morphable payloads; enforced abstract envelopes; unknown alias/FQCN and invalid subtype rejection; one-way transformer guidance; dirty tracking for reordered root/nested/numeric-key objects, reordered lists, strict leaf changes, a null original, default-null equivalence, and encrypted values with and without previous keys; custom codec; encrypted concrete/abstract data and collections; invalid JSON; and serialization; - JsonResource legacy wrapper/force-wrap/additional/status behavior remains unchanged; - Data responses, wrapping, pagination, `with`, `additional`, JSON options, `withResponse`, default status 200, and a spy proving Data adapters bypass the generic conditional-resource filter; - Inertia initial/partial/deferred/group/rescue behavior; @@ -886,7 +919,7 @@ The ledger is an implementation artifact kept with the working notes until all e ### Command -- `make:data` `App\Data` default, nested/explicit class name, no implicit suffix, force/no-force, strict-types stub, application stub override, and disposable Testbench output paths. +- `make:data` application-root `Data` namespace (normally `App\Data`), custom application root namespace, nested/explicit class name, no implicit suffix, force/no-force, strict-types stub, application stub override, and disposable Testbench output paths. ### Performance harness @@ -895,9 +928,9 @@ Keep a developer-run harness patterned after `tests/Benchmarks/RateLimiter` with Scenarios: 1. Native constructor/manual array mapper baseline. -2. Cold and warm simple `Data::from(array)`. +2. Cold and warm simple `Data::from(array)`, with named measurements for factory creation, root setup/Fill, cast/instantiation, exact-array success, and exact-array miss overhead. 3. Deep and wide SDK-shaped graphs using the retained benchmark fixtures. -4. `collect()` over 1,000 objects and lazy traversal. +4. `collect()` over 1,000 objects, lazy traversal, and a large collection whose item class declares AutoLazy properties so its necessarily dense per-item provenance cost remains visible. 5. One 5,000-item nested validation graph. 6. Direct and container-resolved named factory dispatch, including collection-sized runs. 7. Mapped/custom-cast/morph/injection slow paths. @@ -930,7 +963,7 @@ Before final signoff, run `composer fix` once as the repository's prescribed for ## Completion Checklist - [ ] Public API is Spatie/Laravel-familiar and every divergence is documented as a Hypervel adaptation. -- [ ] Fixed creation/transformation paths are structurally lean and benchmarked; any retained direct specialization has a recorded benefit and full equivalence coverage. +- [ ] Fixed creation/transformation paths are structurally lean and benchmarked; the measured exact-array exit retains its recorded benefit, negligible miss cost, and full equivalence coverage. - [ ] General construction is fixed, non-recursive through public APIs, and built from validated values. - [ ] Default/Optional/nullable/required absence semantics, mapped validation paths, uniform-shape wildcard graphs, mixed-shape concrete rules, and dynamic rules are correct. - [ ] Metadata is immutable and worker-scoped; config is stable after boot except its documented morph-map registration; all operation/request state is isolated. From 454ecf8d6c99a7fe9c71039f0229eea0ae60d0a7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:34:58 +0000 Subject: [PATCH 29/35] Optimize Data construction and transformation hot paths Precompute bounded Data type partitions, contextual parameter ownership, constructor eligibility, and property-hook facts in immutable worker metadata. Remove repeated filtering and forwarding helpers from creation and validation. Add the measured exact constructor exit, reuse creator-owned configuration for fresh factories, and reject unsupported non-public or variadic ordinary construction through actionable shared errors. Keep direct-returning named factories valid and preserve all ordinary fallback behavior. Reuse immutable default, all, and persistence transformation contexts, retain fresh contexts for partial-bearing objects, and copy plain objects in metadata order while invoking property hooks only when selected. Cover direct-path equivalence, fallback guards, contextual ownership, virtual properties, partial consumption, and inherited property ordering. --- src/data/src/Concerns/BaseData.php | 11 +- src/data/src/Concerns/TransformableData.php | 13 +- src/data/src/Exceptions/CannotCreateData.php | 16 ++ .../src/Exceptions/InvalidDataDeclaration.php | 14 ++ src/data/src/Support/Creation/DataCreator.php | 98 +++----- .../src/Support/Creation/DataInstantiator.php | 22 ++ src/data/src/Support/DataClass.php | 6 + src/data/src/Support/DataProperty.php | 2 +- src/data/src/Support/DataPropertyType.php | 97 ++++++-- .../Support/Factories/DataClassFactory.php | 73 +++++- .../Support/Factories/DataPropertyFactory.php | 3 +- .../Transformation/DataTransformer.php | 78 +++++- .../TransformationContextFactory.php | 43 +++- .../Validation/DataValidationCompiler.php | 64 +---- .../Data/Support/Creation/DataCreatorTest.php | 84 ++++++- .../Support/Creation/DataInstantiatorTest.php | 65 ++++- tests/Data/Support/DataClassTest.php | 99 +++++++- tests/Data/Support/DataPropertyTest.php | 9 +- tests/Data/Support/DataTypeFactoryTest.php | 13 + .../Transformation/DataTransformerTest.php | 235 +++++++++++++++++- .../TransformationContextFactoryTest.php | 8 + 21 files changed, 850 insertions(+), 203 deletions(-) diff --git a/src/data/src/Concerns/BaseData.php b/src/data/src/Concerns/BaseData.php index 440fd64f0..8f6f5e104 100644 --- a/src/data/src/Concerns/BaseData.php +++ b/src/data/src/Concerns/BaseData.php @@ -14,7 +14,6 @@ use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Support\Creation\CreationContextFactory; use Hypervel\Data\Support\Creation\DataCreator; -use Hypervel\Data\Support\DataConfig; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; @@ -143,14 +142,10 @@ public static function collect(mixed $items, ?string $into = null): array|DataCo */ public static function factory(): CreationContextFactory { - $container = Container::getInstance(); - /** @var CreationContextFactory $factory */ - $factory = new CreationContextFactory( - $container->make(DataCreator::class), - $container->make(DataConfig::class), - static::class, - ); + $factory = Container::getInstance() + ->make(DataCreator::class) + ->factory(static::class); return $factory; } diff --git a/src/data/src/Concerns/TransformableData.php b/src/data/src/Concerns/TransformableData.php index c5f1390b8..4a1803b4d 100644 --- a/src/data/src/Concerns/TransformableData.php +++ b/src/data/src/Concerns/TransformableData.php @@ -20,15 +20,14 @@ trait TransformableData public function transform( TransformationContextFactory|TransformationContext|null $transformationContext = null, ): array { + $transformer = Container::getInstance()->make(DataTransformer::class); $transformationContext = match (true) { $transformationContext instanceof TransformationContext => $transformationContext, $transformationContext instanceof TransformationContextFactory => $transformationContext->get($this), - default => TransformationContextFactory::create()->get($this), + default => $transformer->defaultContext($this), }; - return Container::getInstance() - ->make(DataTransformer::class) - ->transform($this, $transformationContext); + return $transformer->transform($this, $transformationContext); } /** @@ -38,7 +37,11 @@ public function transform( */ public function all(): array { - return $this->transform(TransformationContextFactory::create()->withValueTransformation(false)); + $context = Container::getInstance() + ->make(DataTransformer::class) + ->allContext($this); + + return $this->transform($context); } /** diff --git a/src/data/src/Exceptions/CannotCreateData.php b/src/data/src/Exceptions/CannotCreateData.php index 273d81e70..3b50d114d 100644 --- a/src/data/src/Exceptions/CannotCreateData.php +++ b/src/data/src/Exceptions/CannotCreateData.php @@ -72,6 +72,22 @@ public static function nonPublicConstructor(DataClass $dataClass): self ); } + /** + * Create an exception for ordinary construction through a variadic constructor. + */ + public static function variadicConstructor(DataClass $dataClass): self + { + $parameter = $dataClass->constructorParameters[count($dataClass->constructorParameters) - 1]; + $declaringClass = $parameter->reflection->getDeclaringClass()?->getName() ?? $dataClass->name; + + return new self( + "Could not create data class [{$dataClass->name}] because its constructor parameter " + . "[{$declaringClass}::\${$parameter->name}] is variadic and no matching named factory returned an instance. " + . 'Data construction maps one value per public property and cannot infer a variadic argument list. ' + . 'Return the target instance from a matching public static from* method or make the constructor non-variadic.' + ); + } + /** * Create an exception for a missing unbound property value. */ diff --git a/src/data/src/Exceptions/InvalidDataDeclaration.php b/src/data/src/Exceptions/InvalidDataDeclaration.php index 9e760b023..d52bb9224 100644 --- a/src/data/src/Exceptions/InvalidDataDeclaration.php +++ b/src/data/src/Exceptions/InvalidDataDeclaration.php @@ -72,6 +72,20 @@ public static function computedConstructorProperty(string $class, DataProperty $ ); } + /** + * Create an exception for a write-only virtual property. + * + * @param class-string $class + */ + public static function writeOnlyProperty(string $class, DataProperty $property): self + { + return new self( + "Data class [{$class}] declares write-only virtual property " + . "[{$property->className}::\${$property->name}], which can be neither supplied nor emitted. " + . 'Add a get hook or make the helper property non-public.' + ); + } + /** * Create an exception for a contextual parameter conflicting with a data property. * diff --git a/src/data/src/Support/Creation/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index 3fcefb475..83e1af4e9 100644 --- a/src/data/src/Support/Creation/DataCreator.php +++ b/src/data/src/Support/Creation/DataCreator.php @@ -74,6 +74,19 @@ public function __construct( ) { } + /** + * Create a fresh construction factory for a data class. + * + * @template TData of BaseData + * + * @param class-string $class + * @return CreationContextFactory + */ + public function factory(string $class): CreationContextFactory + { + return new CreationContextFactory($this, $this->config, $class); + } + /** * Create a data object through one fixed construction operation. * @@ -818,7 +831,9 @@ protected function tryCreateDirectArrayNode( $properties[$property->name] = $value; } - return $this->instantiator->instantiate($dataClass, $properties); + return $dataClass->directConstructorInstantiation + ? $this->instantiator->instantiateDirect($dataClass, $properties) + : $this->instantiator->instantiate($dataClass, $properties); } /** @@ -890,7 +905,7 @@ protected function fillResolvedProperties( bool $compilesRules, bool $fromValidationHook, ): void { - $contextualParameters = $this->contextualParameterNames($dataClass); + $contextualParameters = $dataClass->contextualParameters; foreach ($dataClass->properties as $property) { [$wireKey, $value] = $resolvedProperties[$property->name]; @@ -988,9 +1003,9 @@ protected function fillResolvedProperty( bool $compilesRules, bool $fromValidationHook, ): void { - $dataIterable = $this->dataIterableType($property); + $dataIterable = $property->type->getDataCollectableType(); $typedIterable = $dataIterable === null - ? $this->typedIterableType($property) + ? $property->type->getNonDataIterableType() : null; if ($dataIterable !== null || $typedIterable !== null) { @@ -1071,7 +1086,7 @@ protected function fillResolvedProperty( return; } - $nestedDataClass = $this->nestedDataClass($property); + $nestedDataClass = $property->type->getDataObjectClass(); if ($nestedDataClass !== null && $value !== null @@ -1303,7 +1318,7 @@ protected function reconcileNode( $resolvedProperties = $class === $declaredClass ? $declaredProperties : $this->resolveProperties($dataClass, [$payload], $state->context); - $contextualParameters = $this->contextualParameterNames($dataClass); + $contextualParameters = $dataClass->contextualParameters; foreach ($dataClass->properties as $property) { $previousWireKey = $state->originalKey($property->name); @@ -1369,9 +1384,9 @@ protected function reconcileProperty( $state->recordAutoLazy($property->name, $autoLazySource); } - $dataIterable = $this->dataIterableType($property); + $dataIterable = $property->type->getDataCollectableType(); $typedIterable = $dataIterable === null - ? $this->typedIterableType($property) + ? $property->type->getNonDataIterableType() : null; if ($property->autoLazy !== null @@ -1423,7 +1438,7 @@ protected function reconcileProperty( return; } - $nestedDataClass = $this->nestedDataClass($property); + $nestedDataClass = $property->type->getDataObjectClass(); if ($nestedDataClass === null) { return; @@ -1618,7 +1633,7 @@ protected function castAndInstantiateNode( $class = $state->nodeClass() ?? $state->context->dataClass; $dataClass = $this->dataClasses->get($class); $properties = []; - $contextualParameters = $this->contextualParameterNames($dataClass); + $contextualParameters = $dataClass->contextualParameters; foreach ($dataClass->properties as $property) { if (isset($contextualParameters[$property->name])) { @@ -1753,7 +1768,7 @@ protected function castProperty( return $value; } - $dataIterable = $this->dataIterableType($property); + $dataIterable = $property->type->getDataCollectableType(); $shouldCast = ! is_object($value) || $dataIterable !== null || ! $property->type->acceptsValue($value); @@ -1773,7 +1788,7 @@ protected function castProperty( return $this->castDataIterable($property, $dataIterable, $value, $state, $extensions); } - $iterable = $this->typedIterableType($property); + $iterable = $property->type->getNonDataIterableType(); if ($iterable !== null) { return $this->castTypedIterable($property, $iterable, $value, $state, $extensions, $casts); @@ -2583,48 +2598,18 @@ protected function propertyDefaultValue(DataClass $dataClass, DataProperty $prop return UnknownProperty::create(); } - /** - * Get constructor parameter names resolved contextually by the container. - * - * @return array - */ - protected function contextualParameterNames(DataClass $dataClass): array - { - $names = []; - - foreach ($dataClass->constructorParameters as $parameter) { - if ($parameter->contextualAttribute !== null) { - $names[$parameter->name] = true; - } - } - - return $names; - } - - /** - * Get the one unambiguous nested data class declared by a property. - * - * @return null|class-string - */ - protected function nestedDataClass(DataProperty $property): ?string - { - $types = $property->type->getDataObjectTypes(); - - return count($types) === 1 ? $types[0]->dataClass : null; - } - /** * Determine if an automatic lazy property needs deferred Fill replay. */ protected function requiresAutoLazyReplay(DataProperty $property): bool { - if ($this->nestedDataClass($property) !== null - || $this->dataIterableType($property) !== null + if ($property->type->getDataObjectClass() !== null + || $property->type->getDataCollectableType() !== null ) { return true; } - $type = $this->typedIterableType($property); + $type = $property->type->getNonDataIterableType(); return $type !== null && ($type->kind->isPaginator() || $type->kind->isCursorPaginator()); @@ -2664,29 +2649,6 @@ protected function retainPaginatorSource( } } - /** - * Get the one unambiguous data iterable declared by a property. - */ - protected function dataIterableType(DataProperty $property): ?NamedType - { - $types = $property->type->getDataCollectableTypes(); - - return count($types) === 1 ? $types[0] : null; - } - - /** - * Get the one unambiguous non-data iterable declared by a property. - */ - protected function typedIterableType(DataProperty $property): ?NamedType - { - $types = array_values(array_filter( - $property->type->getIterableTypes(), - fn (NamedType $type): bool => ! $type->kind->isDataCollectable(), - )); - - return count($types) === 1 ? $types[0] : null; - } - /** * Convert an eager iterable to its keyed values. * diff --git a/src/data/src/Support/Creation/DataInstantiator.php b/src/data/src/Support/Creation/DataInstantiator.php index 3831c2998..c07068713 100644 --- a/src/data/src/Support/Creation/DataInstantiator.php +++ b/src/data/src/Support/Creation/DataInstantiator.php @@ -19,6 +19,24 @@ public function __construct( ) { } + /** + * Instantiate a metadata-proven exact-array node directly. + * + * Declaration validation owns constructor/property correspondence, exact-array + * creation omits computed keys, and class metadata proves public, complete, + * non-variadic constructor ownership without contextual parameters. + * + * @internal + * + * @param array $properties + */ + public function instantiateDirect(DataClass $dataClass, array $properties): BaseData + { + $class = $dataClass->name; + + return new $class(...$properties); + } + /** * Instantiate and assign one fully cast data node. * @@ -30,6 +48,10 @@ public function instantiate(DataClass $dataClass, array $properties): BaseData throw CannotCreateData::nonPublicConstructor($dataClass); } + if ($dataClass->constructor?->isVariadic()) { + throw CannotCreateData::variadicConstructor($dataClass); + } + $parameters = []; $requiresContainer = false; diff --git a/src/data/src/Support/DataClass.php b/src/data/src/Support/DataClass.php index cea9bfd9c..b2f377e8b 100644 --- a/src/data/src/Support/DataClass.php +++ b/src/data/src/Support/DataClass.php @@ -16,10 +16,14 @@ /** * Create a new data class definition. * + * Contextual parameter names include promoted and constructor-only forms. + * Declaration validation prevents constructor-only names from colliding with data properties. + * * @param class-string $name * @param array $properties * @param array $methods * @param list $constructorParameters + * @param array $contextualParameters * @param array $lifecycleMethods * @param array> $dataIterablePropertyAnnotations * @param array $outputMappedProperties @@ -30,6 +34,7 @@ public function __construct( public readonly array $methods, public readonly ?ReflectionMethod $constructor, public readonly array $constructorParameters, + public readonly array $contextualParameters, public readonly bool $isReadonly, public readonly bool $isAbstract, public readonly bool $isFinal, @@ -50,6 +55,7 @@ public function __construct( public readonly ?string $redirectRoute, public readonly bool $plainTransform, public readonly bool $directArrayCreation, + public readonly bool $directConstructorInstantiation, public readonly DataAttributesCollection $attributes, public readonly array $dataIterablePropertyAnnotations, public readonly array $outputMappedProperties, diff --git a/src/data/src/Support/DataProperty.php b/src/data/src/Support/DataProperty.php index db5514f8b..831b50937 100644 --- a/src/data/src/Support/DataProperty.php +++ b/src/data/src/Support/DataProperty.php @@ -40,7 +40,7 @@ public function __construct( public readonly bool $isPromoted, public readonly bool $isConstructorParameter, public readonly bool $isReadonly, - public readonly bool $isVirtual, + public readonly bool $hasGetHook, public readonly bool $morphable, public readonly bool $loadRelation, public readonly ?ReflectionAttribute $autoLazy, diff --git a/src/data/src/Support/DataPropertyType.php b/src/data/src/Support/DataPropertyType.php index 9c0b84796..c227ed224 100644 --- a/src/data/src/Support/DataPropertyType.php +++ b/src/data/src/Support/DataPropertyType.php @@ -4,12 +4,40 @@ namespace Hypervel\Data\Support; +use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Lazy; use Hypervel\Data\Support\Types\NamedType; use Hypervel\Data\Support\Types\Type; class DataPropertyType extends DataType { + /** + * The declared data object types. + * + * @var list + */ + protected readonly array $dataObjectTypes; + + protected readonly ?NamedType $dataObjectType; + + /** + * The declared data collection types. + * + * @var list + */ + protected readonly array $dataCollectableTypes; + + protected readonly ?NamedType $dataCollectableType; + + /** + * The declared iterable types with item metadata. + * + * @var list + */ + protected readonly array $iterableTypes; + + protected readonly ?NamedType $nonDataIterableType; + /** * Create a new data property type. * @@ -23,6 +51,36 @@ public function __construct( public readonly ?string $lazyType, ) { parent::__construct($type, $isNullable, $isMixed); + + $dataObjectTypes = []; + $dataCollectableTypes = []; + $iterableTypes = []; + $nonDataIterableTypes = []; + + foreach ($this->getNamedTypes() as $namedType) { + if ($namedType->kind->isDataObject()) { + $dataObjectTypes[] = $namedType; + } + + if ($namedType->kind->isDataCollectable()) { + $dataCollectableTypes[] = $namedType; + } + + if ($namedType->iterableItemType !== null) { + $iterableTypes[] = $namedType; + + if (! $namedType->kind->isDataCollectable()) { + $nonDataIterableTypes[] = $namedType; + } + } + } + + $this->dataObjectTypes = $dataObjectTypes; + $this->dataObjectType = count($dataObjectTypes) === 1 ? $dataObjectTypes[0] : null; + $this->dataCollectableTypes = $dataCollectableTypes; + $this->dataCollectableType = count($dataCollectableTypes) === 1 ? $dataCollectableTypes[0] : null; + $this->iterableTypes = $iterableTypes; + $this->nonDataIterableType = count($nonDataIterableTypes) === 1 ? $nonDataIterableTypes[0] : null; } /** @@ -32,10 +90,7 @@ public function __construct( */ public function getDataObjectTypes(): array { - return array_values(array_filter( - $this->getNamedTypes(), - fn (NamedType $type): bool => $type->kind->isDataObject(), - )); + return $this->dataObjectTypes; } /** @@ -43,9 +98,17 @@ public function getDataObjectTypes(): array */ public function getDataObjectType(): ?NamedType { - $types = $this->getDataObjectTypes(); + return $this->dataObjectType; + } - return count($types) === 1 ? $types[0] : null; + /** + * Get the one unambiguous declared data object class. + * + * @return null|class-string + */ + public function getDataObjectClass(): ?string + { + return $this->dataObjectType?->dataClass; } /** @@ -55,10 +118,7 @@ public function getDataObjectType(): ?NamedType */ public function getDataCollectableTypes(): array { - return array_values(array_filter( - $this->getNamedTypes(), - fn (NamedType $type): bool => $type->kind->isDataCollectable(), - )); + return $this->dataCollectableTypes; } /** @@ -66,9 +126,7 @@ public function getDataCollectableTypes(): array */ public function getDataCollectableType(): ?NamedType { - $types = $this->getDataCollectableTypes(); - - return count($types) === 1 ? $types[0] : null; + return $this->dataCollectableType; } /** @@ -78,9 +136,14 @@ public function getDataCollectableType(): ?NamedType */ public function getIterableTypes(): array { - return array_values(array_filter( - $this->getNamedTypes(), - fn (NamedType $type): bool => $type->iterableItemType !== null, - )); + return $this->iterableTypes; + } + + /** + * Get the one unambiguous non-data iterable with item metadata. + */ + public function getNonDataIterableType(): ?NamedType + { + return $this->nonDataIterableType; } } diff --git a/src/data/src/Support/Factories/DataClassFactory.php b/src/data/src/Support/Factories/DataClassFactory.php index e37783329..671f1d692 100644 --- a/src/data/src/Support/Factories/DataClassFactory.php +++ b/src/data/src/Support/Factories/DataClassFactory.php @@ -67,6 +67,7 @@ public function build(ReflectionClass $reflectionClass): DataClass $attributes = DataAttributesCollectionFactory::buildFromReflectionClass($reflectionClass); $constructor = $reflectionClass->getConstructor(); $constructorParameters = $this->resolveConstructorParameters($reflectionClass, $constructor); + $contextualParameters = $this->resolveContextualParameters($constructorParameters); $reflectionProperties = $this->resolveReflectionProperties($reflectionClass); $this->validateConstructorParameters($name, $constructorParameters, $reflectionProperties); @@ -112,6 +113,7 @@ public function build(ReflectionClass $reflectionClass): DataClass methods: $this->resolveMethods($reflectionClass), constructor: $constructor, constructorParameters: array_values($constructorParameters), + contextualParameters: $contextualParameters, isReadonly: $reflectionClass->isReadOnly(), isAbstract: $reflectionClass->isAbstract(), isFinal: $reflectionClass->isFinal(), @@ -133,11 +135,17 @@ public function build(ReflectionClass $reflectionClass): DataClass plainTransform: $this->isPlainTransform($properties), directArrayCreation: $this->supportsDirectArrayCreation( $reflectionClass, - $constructorParameters, + $contextualParameters, $properties, $lifecycleMethods, $propertyMorphable, ), + directConstructorInstantiation: $this->supportsDirectConstructorInstantiation( + $reflectionClass, + $constructor, + $contextualParameters, + $properties, + ), attributes: $attributes, dataIterablePropertyAnnotations: $iterableAnnotations, outputMappedProperties: $this->validateMappings($name, $properties), @@ -167,6 +175,26 @@ protected function resolveConstructorParameters( return $parameters; } + /** + * Get constructor parameters resolved from contextual attributes. + * + * @param array $parameters + * @return array + */ + protected function resolveContextualParameters(array $parameters): array + { + $contextualParameters = []; + + // Keep both forms together; declaration validation prevents constructor-only names from colliding with data properties. + foreach ($parameters as $parameter) { + if ($parameter->contextualAttribute !== null) { + $contextualParameters[$parameter->name] = true; + } + } + + return $contextualParameters; + } + /** * Get public, non-static data properties keyed by name. * @@ -277,6 +305,11 @@ classAutoLazy: $classAutoLazy, throw InvalidDataDeclaration::computedConstructorProperty($class, $property); } + // Backed set-only hooks remain readable through their backing storage. + if ($reflectionProperty->isVirtual() && ! $property->hasGetHook) { + throw InvalidDataDeclaration::writeOnlyProperty($class, $property); + } + if ($property->isReadonly && ! $property->isConstructorParameter && ! $property->computed) { throw InvalidDataDeclaration::unassignableReadonlyProperty($class, $property); } @@ -447,13 +480,13 @@ protected function validateMappings(string $class, array $properties): array * Determine if exact array values can bypass general construction. * * @param ReflectionClass $reflectionClass - * @param array $constructorParameters + * @param array $contextualParameters * @param array $properties * @param array $lifecycleMethods */ protected function supportsDirectArrayCreation( ReflectionClass $reflectionClass, - array $constructorParameters, + array $contextualParameters, array $properties, array $lifecycleMethods, bool $propertyMorphable, @@ -465,10 +498,8 @@ protected function supportsDirectArrayCreation( return false; } - foreach ($constructorParameters as $parameter) { - if ($parameter->contextualAttribute !== null) { - return false; - } + if ($contextualParameters !== []) { + return false; } foreach ($properties as $property) { @@ -485,6 +516,34 @@ protected function supportsDirectArrayCreation( return true; } + /** + * Determine if exact array values can be spread directly into the constructor. + * + * @param ReflectionClass $reflectionClass + * @param array $contextualParameters + * @param array $properties + */ + protected function supportsDirectConstructorInstantiation( + ReflectionClass $reflectionClass, + ?ReflectionMethod $constructor, + array $contextualParameters, + array $properties, + ): bool { + if ($reflectionClass->isAbstract() + || ($constructor !== null && (! $constructor->isPublic() || $constructor->isVariadic())) + || $contextualParameters !== []) { + return false; + } + + foreach ($properties as $property) { + if (! $property->computed && ! $property->isConstructorParameter) { + return false; + } + } + + return true; + } + /** * Determine if declared values can be copied directly during transformation. * diff --git a/src/data/src/Support/Factories/DataPropertyFactory.php b/src/data/src/Support/Factories/DataPropertyFactory.php index 264e5454d..070f206af 100644 --- a/src/data/src/Support/Factories/DataPropertyFactory.php +++ b/src/data/src/Support/Factories/DataPropertyFactory.php @@ -25,6 +25,7 @@ use Hypervel\Data\Support\NameMapperResolver; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; +use PropertyHookType; use ReflectionAttribute; use ReflectionClass; use ReflectionProperty; @@ -110,7 +111,7 @@ className: $reflectionProperty->class, isPromoted: $reflectionProperty->isPromoted(), isConstructorParameter: $constructorParameter !== null, isReadonly: $reflectionProperty->isReadOnly(), - isVirtual: $isVirtual, + hasGetHook: $reflectionProperty->hasHook(PropertyHookType::Get), morphable: $attributes->has(PropertyForMorph::class), loadRelation: $attributes->has(LoadRelation::class), autoLazy: $autoLazy, diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php index 696557efa..d5103de01 100644 --- a/src/data/src/Support/Transformation/DataTransformer.php +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -39,6 +39,12 @@ class DataTransformer { protected readonly ?DateTimeZone $dateTimezone; + protected readonly TransformationContext $defaultContext; + + protected readonly TransformationContext $allContext; + + protected readonly TransformationContext $persistenceContext; + /** * Create a data transformer. */ @@ -50,6 +56,54 @@ public function __construct( $this->dateTimezone = $config->dateTimezone === null ? null : new DateTimeZone($config->dateTimezone); + $this->defaultContext = new TransformationContext( + maxDepth: $config->maxTransformationDepth, + ); + $this->allContext = new TransformationContext( + transformValues: false, + maxDepth: $config->maxTransformationDepth, + ); + $this->persistenceContext = TransformationContextFactory::persistenceContext( + $config->maxTransformationDepth, + ); + } + + /** + * Get the default root context. + * + * Temporary instance partials are consumed when present. + */ + public function defaultContext(object $data): TransformationContext + { + if (! $data instanceof IncludeableData || $data->getPartialsDefinition()->isEmpty()) { + return $this->defaultContext; + } + + return TransformationContextFactory::create()->get($data); + } + + /** + * Get the non-transforming root context. + * + * Temporary instance partials are consumed when present. + */ + public function allContext(object $data): TransformationContext + { + if (! $data instanceof IncludeableData || $data->getPartialsDefinition()->isEmpty()) { + return $this->allContext; + } + + return TransformationContextFactory::create() + ->withoutValueTransformation() + ->get($data); + } + + /** + * Get the immutable constructable persistence context. + */ + public function persistenceContext(): TransformationContext + { + return $this->persistenceContext; } /** @@ -103,7 +157,6 @@ protected function transformData( } $dataClass = $this->dataClasses->get($data::class); - $values = get_object_vars($data); // The plain path includes computed output, which cannot reconstruct the object. if (! $context->constructable @@ -114,11 +167,13 @@ protected function transformData( return $this->finalizeTransformation( $data, $context, - $this->transformPlain($data, $dataClass, $values), + $this->transformPlain($data, $dataClass), $includeAdditionalData, ); } + // Raw storage keeps excluded property hooks from running as a side effect. + $values = get_mangled_object_vars($data); $transformed = []; foreach ($dataClass->properties as $property) { @@ -132,7 +187,7 @@ protected function transformData( continue; } - if ($property->isVirtual) { + if ($property->hasGetHook) { $value = $data->{$property->name}; } elseif (array_key_exists($property->name, $values)) { $value = $values[$property->name]; @@ -314,22 +369,19 @@ protected function finalizeTransformation( /** * Copy values for metadata proven to need no property transformation. * - * @param array $values * @return array */ - protected function transformPlain(BaseData $data, DataClass $dataClass, array $values): array + protected function transformPlain(BaseData $data, DataClass $dataClass): array { - $transformed = []; + // Every property is emitted, so public get hooks own the logical values. + $values = get_object_vars($data); + $transformed = array_intersect_key($dataClass->properties, $values); - foreach ($dataClass->properties as $property) { - if ($property->isVirtual) { - $transformed[$property->name] = $data->{$property->name}; - } elseif (array_key_exists($property->name, $values)) { - $transformed[$property->name] = $values[$property->name]; - } + if (count($transformed) !== count($values)) { + $values = array_intersect_key($values, $dataClass->properties); } - return $transformed; + return array_replace($transformed, $values); } /** diff --git a/src/data/src/Support/Transformation/TransformationContextFactory.php b/src/data/src/Support/Transformation/TransformationContextFactory.php index 270c4a827..d13fd8b40 100644 --- a/src/data/src/Support/Transformation/TransformationContextFactory.php +++ b/src/data/src/Support/Transformation/TransformationContextFactory.php @@ -50,7 +50,8 @@ public function __construct(DataConfig $config) */ public static function create(): static { - return Container::getInstance()->make(static::class); + // Mutable factories stay fresh; subclasses customize this path through late static binding, not container bindings. + return new static(Container::getInstance()->make(DataConfig::class)); } /** @@ -64,32 +65,54 @@ public static function forPersistence(): static return $factory; } + /** + * Create the immutable constructable persistence context. + */ + public static function persistenceContext(?int $maxDepth): TransformationContext + { + return new TransformationContext( + transformValues: true, + mapPropertyNames: false, + constructable: true, + include: PartialTree::compile(['*']), + wrapExecutionType: WrapExecutionType::Disabled, + maxDepth: $maxDepth, + ); + } + /** * Build the context for one root transformation. */ public function get(object $data): TransformationContext { if ($this->constructable) { + return static::persistenceContext($this->configuredMaxDepth); + } + + $dataPartials = $data instanceof IncludeableData + ? $data->getPartialsDefinition() + : null; + + if ($this->partialDefinitions->isEmpty() && ($dataPartials?->isEmpty() ?? true)) { return new TransformationContext( - transformValues: true, - mapPropertyNames: false, - constructable: true, - include: PartialTree::compile(['*']), - wrapExecutionType: WrapExecutionType::Disabled, - maxDepth: $this->configuredMaxDepth, + transformValues: $this->transformValues, + mapPropertyNames: $this->mapPropertyNames, + transformers: $this->transformers, + wrapExecutionType: $this->wrapExecutionType, + maxDepth: $this->maxDepth, ); } $partials = $this->partialDefinitions->resolve($data); - if ($data instanceof IncludeableData) { - $dataPartials = $data->getPartialsDefinition()->resolve( + if ($dataPartials !== null) { + $resolvedDataPartials = $dataPartials->resolve( $data, consumeTemporary: true, ); foreach ($partials as $type => $paths) { - array_push($partials[$type], ...$dataPartials[$type]); + array_push($partials[$type], ...$resolvedDataPartials[$type]); } } diff --git a/src/data/src/Support/Validation/DataValidationCompiler.php b/src/data/src/Support/Validation/DataValidationCompiler.php index a36edf163..f438d7d3f 100644 --- a/src/data/src/Support/Validation/DataValidationCompiler.php +++ b/src/data/src/Support/Validation/DataValidationCompiler.php @@ -139,7 +139,7 @@ protected function compileNode( bool $observed = true, ): void { $dataClass = $this->dataClasses->get($class); - $contextualProperties = $this->contextualPropertyNames($dataClass); + $contextualProperties = $dataClass->contextualParameters; foreach ($dataClass->properties as $property) { if ($property->computed) { @@ -180,7 +180,7 @@ protected function compileNode( continue; } - if ($this->isFinishedDataValue($property, $value)) { + if ($property->isFinishedValue($value)) { $accumulator->preservedPaths[] = $propertyPath; $accumulator->finishedStructuralPaths[$structuralPropertyPath->get()] = true; @@ -191,8 +191,8 @@ protected function compileNode( continue; } - $nestedDataClass = $this->nestedDataClass($property); - $dataIterable = $this->dataIterableType($property); + $nestedDataClass = $property->type->getDataObjectClass(); + $dataIterable = $property->type->getDataCollectableType(); $dataIterableClass = $dataIterable?->dataClass; $inferredRequired = false; $propertyRulePath = $propertyPath->get(); @@ -861,7 +861,7 @@ protected function translateRuleSegments( if ($property->computed || ! $property->validate - || isset($this->contextualPropertyNames($dataClass)[$property->name]) + || isset($dataClass->contextualParameters[$property->name]) ) { return []; } @@ -873,7 +873,7 @@ protected function translateRuleSegments( $hasValue = $observed && $state->hasValue($inputPath); $value = $hasValue ? $state->getValue($inputPath) : null; - if ($this->isFinishedDataValue($property, $value)) { + if ($property->isFinishedValue($value)) { return []; } @@ -884,7 +884,7 @@ protected function translateRuleSegments( )]; } - $nestedDataClass = $this->nestedDataClass($property); + $nestedDataClass = $property->type->getDataObjectClass(); if ($nestedDataClass !== null) { $state->enterProperty($property->name, $inputPath); @@ -904,7 +904,7 @@ protected function translateRuleSegments( } } - $dataIterable = $this->dataIterableType($property); + $dataIterable = $property->type->getDataCollectableType(); if ($dataIterable === null) { return [new TranslatedValidationPath( @@ -1238,32 +1238,6 @@ protected function wireKey( : $property->name; } - /** - * Determine if a supplied object is a finished declared Data value. - */ - protected function isFinishedDataValue(DataProperty $property, mixed $value): bool - { - return $property->isFinishedValue($value); - } - - /** - * Get constructor-backed properties resolved by contextual attributes. - * - * @return array - */ - protected function contextualPropertyNames(DataClass $dataClass): array - { - $properties = []; - - foreach ($dataClass->constructorParameters as $parameter) { - if ($parameter->isPromoted && $parameter->contextualAttribute !== null) { - $properties[$parameter->name] = true; - } - } - - return $properties; - } - /** * Record an exact field or opaque subtree excluded from rule compilation. */ @@ -1336,26 +1310,4 @@ protected function isUnstructuredObject(NamedType $type): bool && ! is_a($type->name, Optional::class, true) && ! is_a($type->name, Lazy::class, true); } - - /** - * Get the one unambiguous nested data class declared by a property. - * - * @return null|class-string - */ - protected function nestedDataClass(DataProperty $property): ?string - { - $types = $property->type->getDataObjectTypes(); - - return count($types) === 1 ? $types[0]->dataClass : null; - } - - /** - * Get the one unambiguous data iterable declared by a property. - */ - protected function dataIterableType(DataProperty $property): ?NamedType - { - $types = $property->type->getDataCollectableTypes(); - - return count($types) === 1 ? $types[0] : null; - } } diff --git a/tests/Data/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index da767b587..31708a066 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -40,6 +40,7 @@ use Hypervel\Data\Support\Creation\CreationMode; use Hypervel\Data\Support\Creation\DataCreator; use Hypervel\Data\Support\Creation\ValidationStrategy; +use Hypervel\Data\Support\DataClassRepository; use Hypervel\Data\Support\DataProperty; use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; @@ -228,11 +229,54 @@ public function testDirectArrayCreationIsCreateModeOnly(): void public function testDirectArrayCreationUsesTheSharedInstantiator(): void { $this->expectException(CannotCreateData::class); - $this->expectExceptionMessage('constructor is private'); + $this->expectExceptionMessageIsOrContains('constructor is private'); DirectPrivateConstructorCreationData::from(['value' => 'private']); } + /** + * Test exact array creation rejects a variadic ordinary constructor without nesting its value. + */ + public function testDirectArrayCreationRejectsVariadicOrdinaryConstructor(): void + { + $metadata = $this->app->make(DataClassRepository::class)->get( + DirectVariadicConstructorCreationData::class, + ); + + $this->assertTrue($metadata->directArrayCreation); + $this->assertFalse($metadata->directConstructorInstantiation); + + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('::$items] is variadic'); + $this->expectExceptionMessageMatches('/matching public static from\* method/'); + + DirectVariadicConstructorCreationData::from([ + 'name' => 'Taylor', + 'items' => [1, 2], + ]); + } + + /** + * Test a variadic constructor is rejected before missing parameters are inspected. + */ + public function testVariadicConstructorErrorPrecedesMissingParameterErrors(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('::$items] is variadic'); + + DirectVariadicConstructorCreationData::from(['other' => 'value']); + } + + /** + * Test a direct-returning factory can own a non-public variadic constructor. + */ + public function testNamedFactoryCanOwnNonPublicVariadicConstructor(): void + { + $data = DirectPrivateVariadicConstructorCreationData::from(['items' => [1, 2]]); + + $this->assertSame([1, 2], $data->items); + } + public function testCreatesNestedDataWithoutReenteringThePublicFactory(): void { $data = ParentCreationData::from([ @@ -323,7 +367,7 @@ public function testValidationHookCanReshapeRetainedPaginatorItems(): void public function testPaginatorPropertiesRejectItemOnlySourcesWithoutMetadata(): void { $this->expectException(CannotCreateDataCollectable::class); - $this->expectExceptionMessage('from `array`'); + $this->expectExceptionMessageIsOrContains('from `array`'); DataPaginatorCreationData::from([ 'children' => [['id' => '7']], @@ -514,7 +558,7 @@ public function testAutomaticLoadedRelationLazyUsesItsLiveModelSource(): void public function testAutomaticLoadedRelationLazyRequiresAModelSource(): void { $this->expectException(CannotCreateData::class); - $this->expectExceptionMessage('no Eloquent model source was supplied'); + $this->expectExceptionMessageIsOrContains('no Eloquent model source was supplied'); AutoWhenLoadedCreationData::from([ 'child' => ['id' => '1'], @@ -532,7 +576,7 @@ public function testAutomaticLoadedRelationLazyRejectsAHookSelectedMorphWithoutA ->from(['child' => ['type' => 'plain']]); $this->expectException(CannotCreateData::class); - $this->expectExceptionMessage('no Eloquent model source was supplied'); + $this->expectExceptionMessageIsOrContains('no Eloquent model source was supplied'); $data->child->resolve(); } @@ -794,7 +838,7 @@ public function testRejectsUnresolvedAndInvalidPropertyMorphs(): void public function testRejectsAmbiguousDataObjectUnionsWithoutAnExplicitCast(): void { $this->expectException(CannotCreateData::class); - $this->expectExceptionMessage('ambiguous data-object union'); + $this->expectExceptionMessageIsOrContains('ambiguous data-object union'); AmbiguousCreationData::from(['child' => ['id' => 1]]); } @@ -827,7 +871,7 @@ public function testRejectsSuppliedComputedValuesAndInvalidAfterCreationResults( } $this->expectException(CannotCreateData::class); - $this->expectExceptionMessage('instead of an instance of'); + $this->expectExceptionMessageIsOrContains('instead of an instance of'); BasicCreationData::factory() ->afterCreation(fn (): ChildCreationData => new ChildCreationData(1)) @@ -940,6 +984,34 @@ private function __construct(string $value) } } +class DirectVariadicConstructorCreationData extends Data +{ + public string $name; + + public array $items; + + public function __construct(string $name, mixed ...$items) + { + $this->name = $name; + $this->items = $items; + } +} + +class DirectPrivateVariadicConstructorCreationData extends Data +{ + public readonly array $items; + + private function __construct(mixed ...$items) + { + $this->items = $items; + } + + public static function fromPayload(array $payload): self + { + return new self(...$payload['items']); + } +} + class ChildCreationData extends Data { public function __construct( diff --git a/tests/Data/Support/Creation/DataInstantiatorTest.php b/tests/Data/Support/Creation/DataInstantiatorTest.php index f09f825bc..1419ab5c1 100644 --- a/tests/Data/Support/Creation/DataInstantiatorTest.php +++ b/tests/Data/Support/Creation/DataInstantiatorTest.php @@ -27,6 +27,25 @@ class DataInstantiatorTest extends TestCase { + /** + * Test direct and ordinary instantiation preserve constructor behavior. + */ + public function testDirectInstantiationMatchesTheOrdinaryConstructorPath(): void + { + $metadata = $this->metadata(InstantiatorDirectDataFixture::class); + $instantiator = new DataInstantiator(new Container); + + $this->assertTrue($metadata->directConstructorInstantiation); + $this->assertEquals( + $instantiator->instantiate($metadata, ['name' => 'taylor']), + $instantiator->instantiateDirect($metadata, ['name' => 'taylor']), + ); + $this->assertSame( + 'TAYLOR', + $instantiator->instantiateDirect($metadata, ['name' => 'taylor'])->name, + ); + } + /** * Test constructor-bound values are not overwritten after construction. */ @@ -74,7 +93,7 @@ public function testResolvesContextualConstructorParameters(): void public function testThrowsForMissingConstructorValues(): void { $this->expectException(CannotCreateData::class); - $this->expectExceptionMessage('Parameters missing: name'); + $this->expectExceptionMessageIsOrContains('Parameters missing: name'); (new DataInstantiator(new Container))->instantiate( $this->metadata(InstantiatorDataFixture::class), @@ -105,7 +124,7 @@ public function testMissingConstructorDiagnosticsExcludeContextualParameters(): public function testThrowsForMissingUnboundPropertyValues(): void { $this->expectException(CannotCreateData::class); - $this->expectExceptionMessage('required property'); + $this->expectExceptionMessageIsOrContains('required property'); (new DataInstantiator(new Container))->instantiate( $this->metadata(InstantiatorUnboundDataFixture::class), @@ -119,8 +138,8 @@ public function testThrowsForMissingUnboundPropertyValues(): void public function testThrowsForNonPublicOrdinaryConstruction(): void { $this->expectException(CannotCreateData::class); - $this->expectExceptionMessage('constructor is private'); - $this->expectExceptionMessage('matching public static from* method'); + $this->expectExceptionMessageIsOrContains('constructor is private'); + $this->expectExceptionMessageMatches('/matching public static from\* method/'); (new DataInstantiator(new Container))->instantiate( $this->metadata(InstantiatorPrivateDataFixture::class), @@ -128,6 +147,20 @@ public function testThrowsForNonPublicOrdinaryConstruction(): void ); } + /** + * Test ordinary construction reports a protected constructor accurately. + */ + public function testThrowsForProtectedOrdinaryConstruction(): void + { + $this->expectException(CannotCreateData::class); + $this->expectExceptionMessageIsOrContains('constructor is protected'); + + (new DataInstantiator(new Container))->instantiate( + $this->metadata(InstantiatorProtectedDataFixture::class), + ['name' => 'Taylor'], + ); + } + /** * Build metadata for a data fixture. * @@ -168,6 +201,17 @@ public function __construct(string $name) } } +class InstantiatorDirectDataFixture extends Data +{ + /** + * Create a direct-instantiation fixture. + */ + public function __construct(public string $name) + { + $this->name = strtoupper($name); + } +} + class InstantiatorDefaultDataFixture extends Data { public readonly InstantiatorDefaultValue $value; @@ -230,6 +274,19 @@ private function __construct(string $name) } } +class InstantiatorProtectedDataFixture extends Data +{ + public readonly string $name; + + /** + * Create a protected-constructor fixture. + */ + protected function __construct(string $name) + { + $this->name = $name; + } +} + #[Attribute(Attribute::TARGET_PARAMETER)] class InstantiatorContextualValue implements ContextualAttribute { diff --git a/tests/Data/Support/DataClassTest.php b/tests/Data/Support/DataClassTest.php index d48dec925..822d39c3d 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -84,6 +84,7 @@ public function testClassMetadataCompilesIntoImmutableArrays(): void 'last_name' => 'lastName', ], $class->outputMappedProperties); $this->assertFalse($class->plainTransform); + $this->assertTrue($class->directConstructorInstantiation); } /** @@ -105,6 +106,17 @@ public function testConstructorBoundPropertiesUseConstructorMetadata(): void $this->assertTrue($class->plainTransform); } + /** + * Test backed set-only hooks remain valid data properties. + */ + public function testBackedSetOnlyHookRemainsValid(): void + { + $class = $this->factory()->build(new ReflectionClass(BackedSetOnlyDataFixture::class)); + + $this->assertFalse($class->properties['name']->computed); + $this->assertFalse($class->properties['name']->hasGetHook); + } + /** * Test iterable annotation precedence and declaration scopes. */ @@ -126,13 +138,25 @@ public function testContextualParametersUseOneUnambiguousOwnershipForm(): void { $promoted = $this->factory()->build(new ReflectionClass(PromotedContextualDataFixture::class)); $constructorOnly = $this->factory()->build(new ReflectionClass(ConstructorOnlyContextualDataFixture::class)); + $defaultedConstructorOnly = $this->factory()->build( + new ReflectionClass(DefaultedConstructorOnlyContextualDataFixture::class), + ); $this->assertTrue($promoted->properties['userId']->isConstructorParameter); $this->assertFalse($promoted->properties['userId']->validate); $this->assertSame(ContextualValue::class, $promoted->constructorParameters[0]->contextualAttribute?->getName()); + $this->assertSame(['userId' => true], $promoted->contextualParameters); + $this->assertFalse($promoted->directArrayCreation); + $this->assertFalse($promoted->directConstructorInstantiation); $this->assertFalse($constructorOnly->properties['name']->isConstructorParameter); $this->assertTrue($constructorOnly->properties['name']->validate); $this->assertSame('userId', $constructorOnly->constructorParameters[0]->name); + $this->assertSame(['userId' => true], $constructorOnly->contextualParameters); + $this->assertFalse($constructorOnly->directArrayCreation); + $this->assertFalse($constructorOnly->directConstructorInstantiation); + $this->assertSame(['userId' => true], $defaultedConstructorOnly->contextualParameters); + $this->assertFalse($defaultedConstructorOnly->directArrayCreation); + $this->assertFalse($defaultedConstructorOnly->directConstructorInstantiation); } /** @@ -178,6 +202,34 @@ public function testDirectArrayCreationEligibilityUsesBootConfiguration(): void $this->assertFalse($configuredNormalizer->directArrayCreation); } + /** + * Test direct constructor instantiation requires complete public constructor ownership. + */ + public function testDirectConstructorInstantiationEligibilityUsesCompiledClassFacts(): void + { + $this->assertTrue( + $this->factory()->build(new ReflectionClass(DirectArrayCreationDataFixture::class)) + ->directConstructorInstantiation, + ); + $this->assertTrue( + $this->factory()->build(new ReflectionClass(ComputedOnlyDataFixture::class)) + ->directConstructorInstantiation, + ); + + foreach ([ + AbstractDirectArrayCreationDataFixture::class, + PrivateConstructorDataFixture::class, + PromotedContextualDataFixture::class, + ConstructorOnlyContextualDataFixture::class, + BackedSetOnlyDataFixture::class, + ] as $class) { + $this->assertFalse( + $this->factory()->build(new ReflectionClass($class))->directConstructorInstantiation, + $class, + ); + } + } + /** * Test invalid constructor/property ownership declarations. * @@ -189,7 +241,7 @@ public function testInvalidConstructorDeclarationsFailDuringMetadataBuild( string $message, ): void { $this->expectException(InvalidDataDeclaration::class); - $this->expectExceptionMessage($message); + $this->expectExceptionMessageIsOrContains($message); $this->factory()->build(new ReflectionClass($class)); } @@ -202,6 +254,7 @@ public static function invalidDeclarationProvider(): array return [ 'unbound readonly property' => [UnboundReadonlyDataFixture::class, 'cannot assign unbound readonly property'], 'computed constructor property' => [ComputedConstructorDataFixture::class, 'declares output-only property'], + 'write-only virtual property' => [WriteOnlyVirtualDataFixture::class, 'declares write-only virtual property'], 'contextual property collision' => [ContextualCollisionDataFixture::class, 'conflicts with public data property'], 'non-public promoted property' => [NonPublicPromotedDataFixture::class, 'promotes non-public property'], 'constructor parameter without property' => [MissingPropertyDataFixture::class, 'has no corresponding public data property'], @@ -217,7 +270,7 @@ public static function invalidDeclarationProvider(): array public function testMappingCollisionsFailDuringMetadataBuild(string $class, string $message): void { $this->expectException(InvalidDataDeclaration::class); - $this->expectExceptionMessage($message); + $this->expectExceptionMessageIsOrContains($message); $this->factory()->build(new ReflectionClass($class)); } @@ -281,8 +334,8 @@ public function testEloquentCollectionPropertiesAcceptModelItems(): void public function testEloquentCollectionPropertiesRejectItemsThatDoNotGuaranteeModels(string $class): void { $this->expectException(InvalidDataDeclaration::class); - $this->expectExceptionMessage('must guarantee'); - $this->expectExceptionMessage(Model::class); + $this->expectExceptionMessageIsOrContains('must guarantee'); + $this->expectExceptionMessageMatches('/' . preg_quote(Model::class, '/') . '/'); $this->factory()->build(new ReflectionClass($class)); } @@ -403,6 +456,15 @@ public function __construct( } } +class BackedSetOnlyDataFixture +{ + public string $name = 'Taylor' { + set { + $this->name = strtoupper($value); + } + } +} + class DirectArrayCreationDataFixture extends Data { /** @@ -533,6 +595,20 @@ public function __construct( } } +class DefaultedConstructorOnlyContextualDataFixture +{ + public string $name = 'Taylor'; + + /** + * Create a new defaulted constructor-only contextual fixture. + */ + public function __construct( + #[ContextualValue] + ?int $userId = null, + ) { + } +} + class UnboundReadonlyDataFixture { public readonly string $name; @@ -550,6 +626,21 @@ public function __construct( } } +class ComputedOnlyDataFixture +{ + public string $slug { + get => 'computed'; + } +} + +class WriteOnlyVirtualDataFixture +{ + public string $secret { + set { + } + } +} + class ContextualCollisionDataFixture { public int $authorId; diff --git a/tests/Data/Support/DataPropertyTest.php b/tests/Data/Support/DataPropertyTest.php index ac1091996..3cbe4096d 100644 --- a/tests/Data/Support/DataPropertyTest.php +++ b/tests/Data/Support/DataPropertyTest.php @@ -99,6 +99,7 @@ public function testDefaultsComputedAndVirtualPropertiesAreCompiled(): void ); $computed = $this->buildProperty($factory, $class, 'computed', $config, $mapperResolver); $virtual = $this->buildProperty($factory, $class, 'virtual', $config, $mapperResolver); + $backedHook = $this->buildProperty($factory, $class, 'backedHook', $config, $mapperResolver); $this->assertFalse($optional->hasDefaultValue); $this->assertTrue($optional->type->isOptional); @@ -107,9 +108,11 @@ public function testDefaultsComputedAndVirtualPropertiesAreCompiled(): void $this->assertTrue($nonPromoted->hasDefaultValue); $this->assertTrue($computed->computed); $this->assertFalse($computed->validate); - $this->assertTrue($virtual->isVirtual); + $this->assertTrue($virtual->hasGetHook); $this->assertTrue($virtual->computed); $this->assertFalse($virtual->validate); + $this->assertTrue($backedHook->hasGetHook); + $this->assertFalse($backedHook->computed); } /** @@ -297,6 +300,10 @@ public function __construct( get => 'virtual'; } + public string $backedHook = 'backed' { + get => strtoupper($this->backedHook); + } + public string $createdAt; #[MapInputName(0)] diff --git a/tests/Data/Support/DataTypeFactoryTest.php b/tests/Data/Support/DataTypeFactoryTest.php index 93ce3f39f..1d9aaaa80 100644 --- a/tests/Data/Support/DataTypeFactoryTest.php +++ b/tests/Data/Support/DataTypeFactoryTest.php @@ -88,6 +88,8 @@ public function testIterableItemTypesAreCompiledFromPhpDocAndAttributes(): void $imported = $this->property('imported'); $attributed = $this->property('attributed'); $unionItems = $this->property('unionItems'); + $strings = $this->property('strings'); + $ambiguousIterables = $this->property('ambiguousIterables'); $this->assertSame(DataTypeKind::DataArray, $imported->getNamedTypes()[0]->kind); $this->assertSame(GroupedImportedData::class, $imported->getNamedTypes()[0]->dataClass); @@ -99,6 +101,9 @@ public function testIterableItemTypesAreCompiledFromPhpDocAndAttributes(): void $this->assertTrue($itemType->acceptsValue('value')); $this->assertTrue($itemType->acceptsValue(m::mock(DataTypeFactoryItemData::class))); $this->assertSame(DataTypeFactoryItemData::class, $unionItems->getNamedTypes()[0]->dataClass); + $this->assertSame($strings->getIterableTypes()[0], $strings->getNonDataIterableType()); + $this->assertCount(2, $ambiguousIterables->getIterableTypes()); + $this->assertNull($ambiguousIterables->getNonDataIterableType()); } /** @@ -133,6 +138,7 @@ public function testNamedTypesUseNativePhpAcceptanceRules(): void $this->assertSame(DataTypeKind::DataObject, $data->getNamedTypes()[0]->kind); $this->assertSame($data->getNamedTypes()[0], $data->getDataObjectType()); + $this->assertSame(DataTypeFactoryItemData::class, $data->getDataObjectClass()); $this->assertNull($data->getDataCollectableType()); $this->assertTrue($data->acceptsValue(m::mock(DataTypeFactoryItemData::class))); $this->assertSame( @@ -140,6 +146,7 @@ public function testNamedTypesUseNativePhpAcceptanceRules(): void $dataCollection->getDataCollectableType(), ); $this->assertNull($dataCollection->getDataObjectType()); + $this->assertNull($dataCollection->getDataObjectClass()); $this->assertTrue($float->acceptsValue(10)); $this->assertTrue($float->acceptsValue(10.5)); } @@ -310,6 +317,12 @@ class DataTypeFactoryFixture /** @var array */ public array $unionItems; + /** @var array */ + public array $strings; + + /** @var array|Collection */ + public array|Collection $ambiguousIterables; + public DataTypeFactoryItemData $data; #[DataCollectionOf(DataTypeFactoryItemData::class)] diff --git a/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php index ceacc75dc..4a0647fe1 100644 --- a/tests/Data/Support/Transformation/DataTransformerTest.php +++ b/tests/Data/Support/Transformation/DataTransformerTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Data\Support\Transformation\DataTransformerTest; +use AllowDynamicProperties; use ArrayIterator; use BackedEnum; use Closure; @@ -26,6 +27,7 @@ use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalizer; use Hypervel\Data\Support\DataProperty; +use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Data\Support\Transformation\TransformationContext; use Hypervel\Data\Support\Transformation\TransformationContextFactory; use Hypervel\Data\Transformers\Transformer; @@ -33,6 +35,7 @@ use Hypervel\Inertia\DeferProp; use Hypervel\Inertia\OptionalProp; use Hypervel\Testbench\TestCase; +use RuntimeException; use Traversable; class DataTransformerTest extends TestCase @@ -74,6 +77,130 @@ public function testTransformsLiveMappedNestedAndBuiltInValues(): void ], $data->all()); } + /** + * Test plain transformation follows metadata order and declared properties. + */ + public function testPlainTransformPreservesMetadataOrderAndFiltersRuntimeKeys(): void + { + $data = new PlainOrderData; + $data->runtime = 'ignored'; + + $this->assertSame([ + 'child' => 'child', + 'redeclared' => 'child-redeclared', + 'parent' => 'parent', + ], $data->toArray()); + } + + /** + * Test plain transformation reads public property hooks exactly once. + */ + public function testPlainTransformReadsBackedAndVirtualHooksOnce(): void + { + PlainHookData::$backedReads = 0; + PlainHookData::$virtualReads = 0; + + $this->assertSame([ + 'backed' => 'BACKED', + 'virtual' => 'virtual', + ], (new PlainHookData)->toArray()); + $this->assertSame(1, PlainHookData::$backedReads); + $this->assertSame(1, PlainHookData::$virtualReads); + } + + /** + * Test general transformation reads hooks only after property selection. + */ + public function testGeneralTransformReadsHooksOnlyAfterSelection(): void + { + GeneralHookData::$visibleReads = 0; + $data = (new GeneralHookData) + ->only('value', 'hidden', 'excepted') + ->except('excepted'); + + $this->assertSame(['mapped_value' => 'VALUE'], $data->toArray()); + $this->assertSame(1, GeneralHookData::$visibleReads); + $this->assertSame( + ['value' => 'VALUE'], + $data->transform(TransformationContextFactory::forPersistence()), + ); + $this->assertSame(2, GeneralHookData::$visibleReads); + } + + /** + * Test backed set-only hooks normalize supplied values without a getter. + */ + public function testBackedSetOnlyHooksRemainConstructableAndTransformable(): void + { + $data = BackedSetOnlyData::from(['name' => 'taylor']); + + $this->assertSame('TAYLOR', $data->name); + $this->assertSame(['name' => 'TAYLOR'], $data->toArray()); + } + + /** + * Test stored root contexts match fresh factory contexts. + */ + public function testStoredRootContextsMatchFreshFactoryContexts(): void + { + $data = new SimpleData('value'); + $transformer = $this->app->make(DataTransformer::class); + $storedDefault = $transformer->defaultContext($data); + $storedAll = $transformer->allContext($data); + + $this->assertSame($storedDefault, $transformer->defaultContext($data)); + $this->assertSame($storedAll, $transformer->allContext($data)); + $this->assertSame($transformer->persistenceContext(), $transformer->persistenceContext()); + $this->assertEquals( + TransformationContextFactory::create()->get($data), + $storedDefault, + ); + $this->assertEquals( + TransformationContextFactory::create()->withoutValueTransformation()->get($data), + $storedAll, + ); + $this->assertEquals( + TransformationContextFactory::forPersistence()->get($data), + $transformer->persistenceContext(), + ); + + $defaultPartials = (new SimpleData('value')) + ->include('value') + ->excludePermanently('value'); + $firstDefault = $transformer->defaultContext($defaultPartials); + $secondDefault = $transformer->defaultContext($defaultPartials); + + $this->assertNotSame($storedDefault, $firstDefault); + $this->assertTrue($firstDefault->include?->selects('value')); + $this->assertTrue($firstDefault->exclude?->selects('value')); + $this->assertNull($secondDefault->include); + $this->assertTrue($secondDefault->exclude?->selects('value')); + + $allPartials = (new SimpleData('value')) + ->only('value') + ->exceptPermanently('value'); + $firstAll = $transformer->allContext($allPartials); + $secondAll = $transformer->allContext($allPartials); + + $this->assertNotSame($storedAll, $firstAll); + $this->assertTrue($firstAll->only?->selects('value')); + $this->assertTrue($firstAll->except?->selects('value')); + $this->assertNull($secondAll->only); + $this->assertTrue($secondAll->except?->selects('value')); + } + + /** + * Test all dispatches its cached context through the instance transform method. + */ + public function testAllRetainsTheInstanceTransformationBoundary(): void + { + OverrideTransformData::$context = null; + + $this->assertSame(['value' => 'value'], (new OverrideTransformData('value'))->all()); + $this->assertInstanceOf(TransformationContext::class, OverrideTransformData::$context); + $this->assertFalse(OverrideTransformData::$context->transformValues); + } + /** * Test operation transformers take precedence over fixed built-ins. */ @@ -526,7 +653,7 @@ public function testThrowsAtMaximumTransformationDepth(): void { $data = new NestedData(new NestedData(new SimpleData('deep'))); - $this->expectExceptionMessage('Max transformation depth of 1 reached.'); + $this->expectExceptionMessageIsOrContains('Max transformation depth of 1 reached.'); $data->transform(TransformationContextFactory::create()->maxDepth(1)); } @@ -603,7 +730,7 @@ public function testPersistenceRejectsLazyCallbackValues(): void $data = new ConstructableLazyData(Lazy::closure(static fn (): string => 'value')); $this->expectException(CannotTransformData::class); - $this->expectExceptionMessage('Lazy property [' . ConstructableLazyData::class . '::$value] does not resolve to constructable data.'); + $this->expectExceptionMessageIsOrContains('Lazy property [' . ConstructableLazyData::class . '::$value] does not resolve to constructable data.'); $data->transform(TransformationContextFactory::forPersistence()); } @@ -687,6 +814,110 @@ public function __construct(public string $value) } } +class OverrideTransformData extends Data +{ + public static ?TransformationContext $context = null; + + public function __construct(public string $value) + { + } + + /** + * Capture the context supplied through the public transformation boundary. + */ + public function transform( + TransformationContextFactory|TransformationContext|null $transformationContext = null, + ): array { + self::$context = $transformationContext instanceof TransformationContext + ? $transformationContext + : null; + + return parent::transform($transformationContext); + } +} + +class PlainOrderParentData extends Data +{ + protected string $redeclared = 'parent-redeclared'; + + public string $parent = 'parent'; +} + +#[AllowDynamicProperties] +class PlainOrderData extends PlainOrderParentData +{ + public string $child = 'child'; + + public string $redeclared = 'child-redeclared'; + + public string $uninitialized; +} + +class PlainHookData extends Data +{ + public static int $backedReads = 0; + + public static int $virtualReads = 0; + + public string $backed = 'backed' { + get { + ++self::$backedReads; + + return strtoupper($this->backed); + } + } + + public string $virtual { + get { + ++self::$virtualReads; + + return 'virtual'; + } + } +} + +class GeneralHookData extends Data +{ + public static int $visibleReads = 0; + + #[MapOutputName('mapped_value')] + public string $value = 'value' { + get { + ++self::$visibleReads; + + return strtoupper($this->value); + } + } + + #[Hidden] + public string $hidden { + get { + throw new RuntimeException('Hidden getter should not run.'); + } + } + + public string $excepted { + get { + throw new RuntimeException('Excepted getter should not run.'); + } + } + + public string $unselected { + get { + throw new RuntimeException('Unselected getter should not run.'); + } + } +} + +class BackedSetOnlyData extends Data +{ + public string $name = '' { + set { + $this->name = strtoupper($value); + } + } +} + class SimpleDto extends Dto { public function __construct( diff --git a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php index 191991069..69a32f8a6 100644 --- a/tests/Data/Support/Transformation/TransformationContextFactoryTest.php +++ b/tests/Data/Support/Transformation/TransformationContextFactoryTest.php @@ -75,10 +75,13 @@ public function testCreateReturnsFreshFactories(): void { $first = TransformationContextFactory::create()->maxDepth(1); $second = TransformationContextFactory::create(); + $custom = CustomTransformationContextFactory::create(); $this->assertNotSame($first, $second); + $this->assertInstanceOf(CustomTransformationContextFactory::class, $custom); $this->assertSame(1, $first->get(new stdClass)->maxDepth); $this->assertNull($second->get(new stdClass)->maxDepth); + $this->assertFalse($second->get(new stdClass)->hasPartials()); } public function testPersistenceFactoryDerivesACompleteConstructableView(): void @@ -102,11 +105,16 @@ public function testPersistenceFactoryDerivesACompleteConstructableView(): void $this->assertNull($context->except); $this->assertSame(WrapExecutionType::Disabled, $context->wrapExecutionType); $this->assertNull($context->maxDepth); + $this->assertEquals(TransformationContextFactory::persistenceContext(null), $context); $this->assertFalse($data->getPartialsDefinition()->isEmpty()); $this->assertSame(['name' => 'Taylor'], $data->toArray()); } } +class CustomTransformationContextFactory extends TransformationContextFactory +{ +} + class PersistenceContextData extends Data { public function __construct( From 75f67943930fdbb8d79702c7123525ba731d9f0c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:35:08 +0000 Subject: [PATCH 30/35] Reuse immutable contexts for Data persistence Resolve the worker-shared DataTransformer once per Eloquent caster and reuse its immutable constructable context for singular and collection writes. Continue dispatching through each Data object's public transform() boundary so application overrides remain authoritative. Add regressions proving singular and collection casts receive the persistence context, collection items share one context instance, and custom transformation overrides remain active without rebuilding mutable factories per stored value. --- .../src/Eloquent/AbstractDataEloquentCast.php | 4 ++ .../Eloquent/DataCollectionEloquentCast.php | 4 +- src/data/src/Eloquent/DataEloquentCast.php | 3 +- .../DataCollectionEloquentCastTest.php | 44 +++++++++++++++++++ tests/Data/Eloquent/DataEloquentCastTest.php | 37 ++++++++++++++++ 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/data/src/Eloquent/AbstractDataEloquentCast.php b/src/data/src/Eloquent/AbstractDataEloquentCast.php index db393bf94..f3d79fce1 100644 --- a/src/data/src/Eloquent/AbstractDataEloquentCast.php +++ b/src/data/src/Eloquent/AbstractDataEloquentCast.php @@ -10,6 +10,7 @@ use Hypervel\Data\Exceptions\CannotCastData; use Hypervel\Data\Support\DataClassRepository; use Hypervel\Data\Support\DataConfig; +use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Facades\Crypt; @@ -25,6 +26,8 @@ abstract class AbstractDataEloquentCast protected readonly DataClassRepository $dataClasses; + protected readonly DataTransformer $dataTransformer; + /** * Create a shared data Eloquent cast. * @@ -38,6 +41,7 @@ public function __construct( $container = Container::getInstance(); $this->dataConfig = $container->make(DataConfig::class); $this->dataClasses = $container->make(DataClassRepository::class); + $this->dataTransformer = $container->make(DataTransformer::class); if (! $this->dataClasses->get($this->dataClass)->transformable) { throw CannotCastData::dataClassMustBeTransformable($this->dataClass); diff --git a/src/data/src/Eloquent/DataCollectionEloquentCast.php b/src/data/src/Eloquent/DataCollectionEloquentCast.php index f85e35bd6..7da0d1a8e 100644 --- a/src/data/src/Eloquent/DataCollectionEloquentCast.php +++ b/src/data/src/Eloquent/DataCollectionEloquentCast.php @@ -9,7 +9,6 @@ use Hypervel\Data\Contracts\TransformableData; use Hypervel\Data\DataCollection; use Hypervel\Data\Exceptions\CannotCastData; -use Hypervel\Data\Support\Transformation\TransformationContextFactory; use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; @@ -104,6 +103,7 @@ public function set(Model $model, string $key, mixed $value, array $attributes): $payload = []; $isAbstractClassCast = $this->isAbstractClassCast(); + $context = $this->dataTransformer->persistenceContext(); foreach ($value as $itemKey => $item) { if (is_array($item) && ! $isAbstractClassCast) { @@ -122,7 +122,7 @@ public function set(Model $model, string $key, mixed $value, array $attributes): throw CannotCastData::shouldBeDataClass($model::class, $key, $this->dataClass); } - $itemPayload = $item->transform(TransformationContextFactory::forPersistence()); + $itemPayload = $item->transform($context); $payload[$itemKey] = $isAbstractClassCast ? $this->createMorphEnvelope($item, $itemPayload) : $itemPayload; diff --git a/src/data/src/Eloquent/DataEloquentCast.php b/src/data/src/Eloquent/DataEloquentCast.php index 9844fd259..6a37e3a78 100644 --- a/src/data/src/Eloquent/DataEloquentCast.php +++ b/src/data/src/Eloquent/DataEloquentCast.php @@ -8,7 +8,6 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Contracts\TransformableData; use Hypervel\Data\Exceptions\CannotCastData; -use Hypervel\Data\Support\Transformation\TransformationContextFactory; use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; @@ -74,7 +73,7 @@ public function set(Model $model, string $key, mixed $value, array $attributes): throw CannotCastData::shouldBeDataClass($model::class, $key, $this->dataClass); } - $payload = $value->transform(TransformationContextFactory::forPersistence()); + $payload = $value->transform($this->dataTransformer->persistenceContext()); if ($isAbstractClassCast) { $payload = $this->createMorphEnvelope($value, $payload); diff --git a/tests/Data/Eloquent/DataCollectionEloquentCastTest.php b/tests/Data/Eloquent/DataCollectionEloquentCastTest.php index fa7df08b3..eedc5a847 100644 --- a/tests/Data/Eloquent/DataCollectionEloquentCastTest.php +++ b/tests/Data/Eloquent/DataCollectionEloquentCastTest.php @@ -18,6 +18,8 @@ use Hypervel\Data\Exceptions\CannotCastData; use Hypervel\Data\Lazy; use Hypervel\Data\Support\DataConfig; +use Hypervel\Data\Support\Transformation\TransformationContext; +use Hypervel\Data\Support\Transformation\TransformationContextFactory; use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; @@ -154,6 +156,24 @@ public function testCollectionCastPersistsCompleteItemsWithoutMutatingPartials() $this->assertSame($secondPartials, $second->getPartialsDefinition()->resolve($second)); } + public function testCollectionCastUsesOneContextThroughEachItemTransformationBoundary(): void + { + CollectionOverrideData::$contexts = []; + $model = new CollectionCastModel; + $model->override_items = [ + new CollectionOverrideData('Taylor'), + new CollectionOverrideData('Abigail'), + ]; + + $this->assertSame([ + ['name' => 'Taylor'], + ['name' => 'Abigail'], + ], Json::decode($model->getAttributes()['override_items'])); + $this->assertCount(2, CollectionOverrideData::$contexts); + $this->assertSame(CollectionOverrideData::$contexts[0], CollectionOverrideData::$contexts[1]); + $this->assertTrue(CollectionOverrideData::$contexts[0]->constructable); + } + public function testCollectionDefaultUsesItsLateBoundEmptyListRepresentation(): void { $decoded = null; @@ -421,6 +441,7 @@ protected function casts(): array 'default_items' => DataCollection::class . ':' . CollectionItemData::class . ',default', 'custom_items' => CustomDataCollection::class . ':' . CollectionItemData::class, 'graph_items' => DataCollection::class . ':' . CollectionGraphItemData::class, + 'override_items' => DataCollection::class . ':' . CollectionOverrideData::class, 'abstract_items' => DataCollection::class . ':' . CollectionAbstractData::class, 'encrypted_items' => DataCollection::class . ':' . CollectionItemData::class . ',encrypted', 'encrypted_abstract_items' => DataCollection::class . ':' . CollectionAbstractData::class . ',encrypted', @@ -436,6 +457,29 @@ public function __construct(public string $name) } } +class CollectionOverrideData extends Data +{ + /** @var list */ + public static array $contexts = []; + + public function __construct(public string $name) + { + } + + /** + * Capture each Eloquent item persistence context. + */ + public function transform( + TransformationContextFactory|TransformationContext|null $transformationContext = null, + ): array { + if ($transformationContext instanceof TransformationContext) { + self::$contexts[] = $transformationContext; + } + + return parent::transform($transformationContext); + } +} + class CollectionInternalOperationData extends Data { public static int $normalizerCalls = 0; diff --git a/tests/Data/Eloquent/DataEloquentCastTest.php b/tests/Data/Eloquent/DataEloquentCastTest.php index 21f26b2c9..fb0d8aa2a 100644 --- a/tests/Data/Eloquent/DataEloquentCastTest.php +++ b/tests/Data/Eloquent/DataEloquentCastTest.php @@ -19,6 +19,8 @@ use Hypervel\Data\Exceptions\CannotCastData; use Hypervel\Data\Lazy; use Hypervel\Data\Support\DataConfig; +use Hypervel\Data\Support\Transformation\TransformationContext; +use Hypervel\Data\Support\Transformation\TransformationContextFactory; use Hypervel\Database\Eloquent\Casts\Json; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; @@ -124,6 +126,18 @@ public function testDataCastPersistsTheCompleteConstructableViewWithoutMutatingP $this->assertSame($itemPartials, $item->getPartialsDefinition()->resolve($item)); } + public function testDataCastUsesTheInstanceTransformationBoundary(): void + { + StoredOverrideData::$context = null; + $model = new DataCastModel; + $model->override_data = new StoredOverrideData('Taylor'); + + $this->assertSame(['name' => 'Taylor'], Json::decode($model->getAttributes()['override_data'])); + $this->assertInstanceOf(TransformationContext::class, StoredOverrideData::$context); + $this->assertTrue(StoredOverrideData::$context->constructable); + $this->assertFalse(StoredOverrideData::$context->mapPropertyNames); + } + public function testDataCastUsesTheConfiguredEloquentJsonCodec(): void { $caster = new DataEloquentCast(StoredSimpleData::class); @@ -409,6 +423,7 @@ protected function casts(): array 'empty_default_data' => StoredEmptyData::class . ':default', 'graph_data' => StoredGraphData::class, 'pair_data' => StoredPairData::class, + 'override_data' => StoredOverrideData::class, 'abstract_data' => StoredAbstractData::class, 'encrypted_data' => StoredSimpleData::class . ':encrypted', 'encrypted_abstract_data' => StoredAbstractData::class . ':encrypted', @@ -424,6 +439,28 @@ public function __construct(public string $name) } } +class StoredOverrideData extends Data +{ + public static ?TransformationContext $context = null; + + public function __construct(public string $name) + { + } + + /** + * Capture the Eloquent persistence context. + */ + public function transform( + TransformationContextFactory|TransformationContext|null $transformationContext = null, + ): array { + self::$context = $transformationContext instanceof TransformationContext + ? $transformationContext + : null; + + return parent::transform($transformationContext); + } +} + class StoredDefaultData extends Data { public function __construct(public string $name = 'default') From f6359b0f0148c6747b081b3dfc55d532dc790d18 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:35:18 +0000 Subject: [PATCH 31/35] Modernize Data date cast exception assertions Replace deprecated PHPUnit message expectations in the touched date cast suite with independent current constraints. Assert both the concrete target type and accepted format so successive expectations cannot silently overwrite one another. --- tests/Data/Casts/DateTimeInterfaceCastTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Data/Casts/DateTimeInterfaceCastTest.php b/tests/Data/Casts/DateTimeInterfaceCastTest.php index cd4b5f04b..fde44d4ed 100644 --- a/tests/Data/Casts/DateTimeInterfaceCastTest.php +++ b/tests/Data/Casts/DateTimeInterfaceCastTest.php @@ -153,8 +153,8 @@ public function testThrowsWhenNoDateFormatMatches(): void [$state, $context] = $this->operation(['Y-m-d']); $this->expectException(CannotCastDate::class); - $this->expectExceptionMessage(DateTimeImmutable::class); - $this->expectExceptionMessage('Y-m-d'); + $this->expectExceptionMessageIsOrContains(DateTimeImmutable::class); + $this->expectExceptionMessageMatches('/Y-m-d/'); (new DateTimeInterfaceCast)->cast( $this->property('immutable'), @@ -172,7 +172,7 @@ public function testThrowsForAbstractDateTarget(): void [$state, $context] = $this->operation(['Y-m-d']); $this->expectException(CannotCastDate::class); - $this->expectExceptionMessage(AbstractDateTimeImmutable::class); + $this->expectExceptionMessageIsOrContains(AbstractDateTimeImmutable::class); (new DateTimeInterfaceCast)->cast( $this->property('abstract'), From 60d15269c2282eb865d5f859df29311843503b6a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:35:33 +0000 Subject: [PATCH 32/35] Expand Data transformation benchmarks Add five- and twenty-property plain transformation scenarios to the retained developer harness. These workloads isolate metadata-ordered copying on narrow and SDK-shaped classes while preserving the existing same-machine median, percentile, throughput, and memory reporting. --- tests/Benchmarks/Data/benchmark.php | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/Benchmarks/Data/benchmark.php b/tests/Benchmarks/Data/benchmark.php index 193161123..04c7b946d 100644 --- a/tests/Benchmarks/Data/benchmark.php +++ b/tests/Benchmarks/Data/benchmark.php @@ -74,6 +74,52 @@ public function __construct( } } +class DataBenchmarkPlainFive extends Data +{ + public int $one = 1; + + public int $two = 2; + + public int $three = 3; + + public int $four = 4; + + public int $five = 5; +} + +class DataBenchmarkPlainTwenty extends DataBenchmarkPlainFive +{ + public int $six = 6; + + public int $seven = 7; + + public int $eight = 8; + + public int $nine = 9; + + public int $ten = 10; + + public int $eleven = 11; + + public int $twelve = 12; + + public int $thirteen = 13; + + public int $fourteen = 14; + + public int $fifteen = 15; + + public int $sixteen = 16; + + public int $seventeen = 17; + + public int $eighteen = 18; + + public int $nineteen = 19; + + public int $twenty = 20; +} + class DataBenchmarkLeaf extends Data { public function __construct( @@ -369,6 +415,8 @@ public function execute(): array $factoryIdentifiers = range(1, 1_000); $simpleData = DataBenchmarkUser::from($flatPayload); $nestedData = DataBenchmarkUser::from($nestedPayload); + $plainFiveData = new DataBenchmarkPlainFive; + $plainTwentyData = new DataBenchmarkPlainTwenty; $lazyTransformData = DataBenchmarkLazyItem::from($lazyRows[0]) ->includePermanently('address') ->onlyPermanently('id', 'address.lineOne'); @@ -504,6 +552,16 @@ function () use ($collectionRows): int { $standardWarmup, fn (): int => $simpleData->toArray()['id'], ], + 'transform-plain-five' => [ + $standardOperations, + $standardWarmup, + fn (): int => $plainFiveData->toArray()['five'], + ], + 'transform-plain-twenty' => [ + $standardOperations, + $standardWarmup, + fn (): int => $plainTwentyData->toArray()['twenty'], + ], 'transform-nested' => [ $standardOperations, $standardWarmup, From 3a1b7754e0ba1fb43a4e1d41b733b20e9a8cd21e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:35:44 +0000 Subject: [PATCH 33/35] Document variadic Data factories and package credits Explain that ordinary property-based construction cannot infer variadic argument expansion and direct users to a named factory that returns the target object. Add the standard Credits section and upstream Spatie attribution to the canonical Data Objects guide, matching the structure used by other ported Hypervel components. --- src/docs/data-objects.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index f8afc4db9..789d676f1 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -30,6 +30,7 @@ - [Saloon](#saloon) - [Generating Data Classes](#generating-data-classes) - [Worker Lifetime](#worker-lifetime) +- [Credits](#credits) ## Introduction @@ -195,6 +196,8 @@ class UserData extends Data Named methods may receive container-resolved dependencies and a `CreationContext`. A method that returns the target object owns that node completely; inferred validation, casts, and creation hooks do not run again for it. Methods returning another normalizable value continue through the ordinary engine without being matched a second time. +Ordinary construction maps one input value to each public property, so it does not infer how a property should expand into a variadic constructor. Use a named factory that returns the target object for that constructor shape. + Public static `collect*` methods provide the same escape hatch for a complete normalized collection. Their parameter receives the container of already-created data objects, not the raw source values. @@ -853,3 +856,8 @@ Register `Lazy`, `DataCollection`, `PaginatedDataCollection`, and `CursorPaginat VarDumper displays the current logical `all()` view for transformable data and an `items` envelope for data collections. It hides construction metadata, partial trees, and operation state without adding runtime work outside an explicit dump. Data objects do not implement `ArrayAccess`. Read public properties or call `toArray()`. Data collections retain keyed access and enumeration. + + +## Credits + +Hypervel Data began as a port of [Spatie Laravel Data](https://github.com/spatie/laravel-data) and has been adapted for Hypervel's framework architecture and coroutine runtime. From b743805313a0706a2bdf5bbe5e37dfd8c66bb520 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:35:58 +0000 Subject: [PATCH 34/35] Complete the Hypervel Data package plan Record the final measured hot-path design: immutable metadata partitions, creator-owned factories, exact-array and direct-constructor exits, shared readonly transformation contexts, metadata-ordered plain copying, and persistence dispatch through the public transform boundary. Capture the final variadic-constructor ownership and guard-ordering rules, bounded metadata cost, rejected speculative caches, benchmark evidence, focused regression coverage, and completed verification checklist. Mark the package implementation as implemented, verified, and reviewed. --- .../2026-08-30-0349-hypervel-data-package.md | 101 ++++++++++-------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/docs/plans/2026-08-30-0349-hypervel-data-package.md b/docs/plans/2026-08-30-0349-hypervel-data-package.md index d2ba8fe3e..ec20ea7a4 100644 --- a/docs/plans/2026-08-30-0349-hypervel-data-package.md +++ b/docs/plans/2026-08-30-0349-hypervel-data-package.md @@ -2,7 +2,7 @@ ## Status -- Implementation in progress with explicit owner approval. +- The feature-complete package, framework integrations, and measured Data hot-path refinements are implemented, verified, and reviewed. - Target repository: `contrib/hypervel/components-data`. - Target branch: `feature/data-package`, created from the greenfield `0.4` branch. - Package: `hypervel/data`, component directory `src/data`, namespace `Hypervel\Data`. @@ -23,7 +23,7 @@ The completed package must provide: - controller injection, FormRequest casting, Eloquent JSON casting, HTTP resources, Precognition, Inertia, and Saloon interoperability; - clean Symfony VarDumper output that shows each object's current logical view without exposing package internals; - immutable reflection metadata analyzed once per used class and retained for the worker lifetime; -- predictable performance for large SDK graphs and collections, without runtime discovery, generated metadata, or a service-locator pipeline in ordinary construction; +- predictable performance for large SDK graphs and collections, with bounded immutable metadata partitions and measured exact-path refinements but without runtime discovery, generated metadata, or a service-locator pipeline in ordinary construction; - first-party documentation, generators, tests, attribution, and component split metadata. Acceptance is behavioral and architectural, not merely API-shaped: ordinary `Data::from(array)` and `toArray()` calls must take lean fixed paths without service-locator pipelines, request-specific state must never be stored globally, measured specializations must earn their complexity, and every adopted feature must have focused tests. @@ -334,8 +334,14 @@ If an attribute maps to a Laravel rule that Hypervel Validation is unintentional - `Data::toArray()`, `all()`, `transform()`, `toJson()`, and `jsonSerialize()` always reflect current property values; there is no result cache or refresh protocol. - The ordinary transform loop reads precompiled property metadata and writes mapped output directly. -- Allocate a full `TransformationContext` only for lazy values, partials, a custom transformer, wrapping/additional resource data, or a configured maximum depth. A simple object does not build include/exclude trees. `PartialsDefinition::isEmpty()` is the required nested-node guard that preserves this invariant: an object with no instance definitions reuses its narrowed child context without resolving definitions or compiling trees. -- `TransformationContextFactory::forPersistence()` is the one named constructable-view selector used by Eloquent casts. Its immutable context carries one `constructable` flag through every child/copy operation and derives the complete invariant in `get()`: transform values, use PHP property names, include every default-lazy value and hidden declared property, omit computed/virtual and appended response-only values, ignore root and nested instance partial stores without consuming or adding to them, disable wrapping, and retain the configured maximum depth. Constructable transformation bypasses `plainTransform` because that shortcut intentionally emits computed output in ordinary views. Do not expose independent switches whose partial use could create an unreadable stored representation. +- `TransformationContextFactory::create()` constructs `new static(Container::getInstance()->make(DataConfig::class))`. This preserves late-static-bound custom factories and the documented `DataConfig` extension boundary without resolving the fresh mutable factory through the Container. +- `TransformationContextFactory::get()` returns immediately when no factory or instance partials exist. It does not allocate or compile empty partial trees merely to produce the default context. +- The worker-shared `DataTransformer` builds immutable `$defaultContext`, `$allContext`, and `$persistenceContext` values once from boot-stable `DataConfig`. `defaultContext(object $data)` and `allContext(object $data)` return their stored context when the instance has no partials; otherwise they resolve a fresh operation context and consume temporary instance partials exactly once. Document that consumption on both accessors. `persistenceContext()` is argument-free and side-effect-free. +- Preserve `TransformableData::transform()` as the one overridable transformation boundary. `transform(null)` asks `DataTransformer` for `defaultContext($this)` before entering the fixed engine; `all()` asks for `allContext($this)` and then calls `$this->transform($context)`; both Eloquent casters ask for `persistenceContext()` and pass it to each value's `transform()`. A custom override must honor a supplied context. Do not add compatibility machinery for an override that ignores it, even though `allContext()` deliberately consumes temporary partials before dispatch. +- `TransformationContextFactory::persistenceContext(?int $maxDepth)` is the one owner of the immutable constructable context recipe. Both `forPersistence()->get()` and `DataTransformer` use it, preventing drift across PHP-name mapping, hidden/computed state, wrapping, partials, and maximum depth. +- Keep `plainTransform` as the proof that no value transformation, mapping, partial, lazy, nested, or custom-transformer work is needed. Its direct copy performs one hook-aware `get_object_vars()` read, uses `DataClass::$properties` as the declared-order template, and filters dynamic public keys through an exact count-gated second intersection before replacing template values. This preserves inherited and redeclared metadata order, omits uninitialized properties, and invokes each backed or virtual get hook exactly once. The general path instead reads hook-free storage through `get_mangled_object_vars()` after the plain-path exit, then invokes a property's get hook only after hidden, constructable, `except`, and `only` guards. Keep a short WHY comment at each deliberately different read. +- Build a fresh root `TransformationContext` only when caller factory options or instance partials differ from the stored defaults. A simple object reuses an immutable root and never compiles empty include/exclude trees. `PartialsDefinition::isEmpty()` remains the nested-node guard: an object with no instance definitions reuses its narrowed child context without resolving definitions or compiling trees. +- `TransformationContextFactory::forPersistence()` remains the familiar named constructable-view selector. Its immutable context carries one `constructable` flag through every child/copy operation: transform values, use PHP property names, include every default-lazy value and hidden declared property, omit computed/virtual and appended response-only values, ignore root and nested instance partial stores without consuming or adding to them, disable wrapping, and retain the configured maximum depth. Constructable transformation bypasses both plain paths because they intentionally emit computed output in ordinary views. Do not expose independent switches whose partial use could create an unreadable stored representation. - A non-default `Lazy` value is constructable only when `resolvesToData()` is true and its intrinsic condition includes it. `ClosureLazy` and the Inertia lazy variants return false because they resolve to consumer callbacks/prop wrappers rather than data. An excluded conditional value or unloaded relation throws `CannotTransformData` before resolution; an included conditional or already-loaded relation resolves normally, and persistence never triggers a relation load. - Port `Hidden`, `Computed`, `Lazy`, `AutoLazy`, `AutoClosureLazy`, `AutoWhenLoadedLazy`, include/exclude/only/except, conditional inclusion, appended values, and maximum-depth protection. A supplied value for a `#[Computed]` or PHP 8.4 virtual property throws an actionable declaration/input exception; there is no compatibility switch that silently ignores it. - Automatic lazy casting reuses the fixed creation engine without retaining the mutable root operation. Each owning structure node stores one compiler-inert `autoLazy` map keyed only by its AutoLazy properties. An entry contains the raw source and, only when structural Fill work was deferred, an `AutoLazyReplayMode::Normal` or `Hook` enum case. Enum cases are worker singletons; do not add strings, a value object, a parallel map, or a DataClass feature bit. Replay is required only for an unambiguous nested Data object, an unambiguous Data iterable, or a Data/non-Data paginator or cursor-paginator. Scalars and non-paginator typed iterables need the pruned payload for casting but no replay entry because their Fill body reduces to the raw write and item casting already belongs to `castProperty()`. A missing native default follows the same predicate after materialization. @@ -367,7 +373,7 @@ If an attribute maps to a Laravel rule that Hypervel Validation is unintentional - `DataCollectableFactory` is the single owner of safe item extraction, root source-shaped rebuilding, explicit/inferred `$into` targets, paginator cloning, and declared-property reconstruction for Data and non-Data typed iterables. `DataCreator` retains Fill, reconciliation, casting, and instantiation; delete its duplicate eager iterable rebuilder. Cache each Data class's resolved custom normalizer list in the existing operation memo under a class-keyed entry; do not add another cache object or threaded parameter. - Root `collect()` preserves input keys and the original ordinary collection/paginator shape where it can be rebuilt safely. It explicitly downgrades an Eloquent source to base `Hypervel\Support\Collection` for empty and non-empty Data results; an explicit Eloquent Data target does the same. Property casting is instead declared-shaped: arrays and `iterable` become keyed arrays, declared ordinary collection classes are rebuilt as declared, and unsupported custom `Traversable` containers fail with `CannotCreateDataCollectable`. A declared Eloquent property is valid only when its complete item type guarantees `Model`; otherwise metadata rejects the invalid Eloquent generic. Batch `loadMissing()` for metadata-declared `LoadRelation` paths before collected Eloquent models are normalized. - One metadata-owned finished-value predicate is shared by Fill, hook reconciliation, validation compilation, and casting. It accepts an assignable `BaseData`; an assignable package `DataCollection`, `PaginatedDataCollection`, or `CursorPaginatedDataCollection` whose declared item class is covariantly compatible; or an assignable eager native object container whose safely extracted items are already instances of one accepting declared Data-container arm's item class. Extract eager items once and try every accepting arm because PHPDoc may assign different item classes to different union containers. Arrays are not finished object containers, and `LazyCollection` is never scanned. Casting returns a finished value before property casts or iterable rebuilding. Fill and reconciliation route every accepted value through explicit `ConstructionState` finished-property/item writes; those writes always latch active enclosing collections, while ordinary writes never inspect value types. This keeps one source of truth and removes finished-value checks from the ordinary write path. -- Put the pure singular metadata queries `getDataObjectType()` and `getDataCollectableType()` on `DataPropertyType`, matching its existing plural vocabulary. Keep the complete finished-value decision on `DataProperty`; arbitrary implementations of `BaseDataCollectable` are not treated as package containers merely because they implement the contract. +- Keep the complete singular/plural type vocabulary on `DataPropertyType`: `getDataObjectType()`, `getDataObjectClass()`, `getDataCollectableType()`, the corresponding stored partitions, and the one unambiguous non-Data iterable query. Creation and validation use these metadata methods directly rather than keeping forwarding aliases. Keep the complete finished-value decision on `DataProperty`; arbitrary implementations of `BaseDataCollectable` are not treated as package containers merely because they implement the contract. - Paginator-shaped properties retain only the reconstruction data they require. `ConstructionState` stores an optional live `AbstractPaginator`/`AbstractCursorPaginator` source on the existing traversed structure node for the root operation, cloning only during rebuild. Outside a collection item it uses the template node; inside an item it uses that item's sparse override and never falls back to the template. The slot is compiler-inert and never changes validation uniformity. The compiler does not read paginator identity, so otherwise identical outer items remain eligible for one wildcard graph. - Initial Fill and hook reconciliation record or replace that slot for both Data and non-Data typed paginator properties, only from a Hypervel abstract paginator or a package wrapper containing one. An array, eager Enumerable/DataCollection, or LazyCollection may reshape page items only when the exact node already has a retained source; materialize the LazyCollection because the declared paginator target cannot remain lazy. Absence, `null`, and `Optional` retain ordinary property semantics. A genuinely present item-only container with no exact source, a contract-only paginator needing conversion, or another unsupported value fails during Fill/reconciliation through `CannotCreateDataCollectable`, when reconstruction is first known to be impossible. A raw array therefore cannot create a paginated wrapper, while a Hypervel paginator can. Contract-only paginators may feed non-paginator targets through their declared `items()` method and may pass unchanged only when the native declaration accepts them and all items are already the declared Data type. Rebuild-time absence uses a dedicated missing-retained-source error rather than reporting the supplied type as `null`. - Extract paginator values through the declared `items()` method, never through iteration. Rebuild a Hypervel paginator by cloning it and replacing only its collection, so the caller is not mutated and total, per-page, cursor, path, query, fragment, and other response metadata remain authoritative. Collection hooks may change the current page's item count but do not recalculate paginator metadata; the hook author owns any related metadata change. @@ -422,7 +428,7 @@ protected function casts(): array - `TransformableData` describes transformation only. `Data` and `Resource` implement Eloquent `Castable` directly through a shared `EloquentCastableData` concern and return a package-owned `DataEloquentCast`; `Dto` is not persistable because it does not implement Eloquent `Castable`. - `DataCollection` implements Eloquent `Castable` directly, returns a package-owned `DataCollectionEloquentCast`, and remains the value returned by collection casts. Paginated wrappers are deliberately not Eloquent-castable; persist their page items as a `DataCollection`. Keep upstream `encrypted` and `default` cast arguments. Concrete and property-morphable stored collections validate their decoded item shapes and pass the complete payload to the collection constructor, so its one internal root item operation owns normalization and any globally configured `Always` validation produces indexed collection paths. Strict abstract envelopes resolve their enforced alias per item before construction because the single-class collection operation cannot represent heterogeneous target classes; do not add a polymorphic batch path without measured evidence that it earns the machinery. -- Use Hypervel's Eloquent JSON codec so custom encoders/decoders apply. Both casters persist the complete constructable representation through `TransformationContextFactory::forPersistence()`, never through a mutable option chain or by mutating instance partials with `include('*')`. Stored objects use PHP property names, include hidden declared state, omit computed/virtual and `with()`/`additional()` output, ignore partials without consuming them, and never retain a paginator wrapper. Output transformers still apply; an arbitrary one-way transformer therefore requires a matching input cast or `WithCastAndTransformer` for an Eloquent round trip. +- Use Hypervel's Eloquent JSON codec so custom encoders/decoders apply. Both casters obtain the worker-shared `DataTransformer`'s immutable persistence context and pass it to each value's `transform()` method, preserving the supported override boundary while avoiding one fresh factory/context per stored item. They never use a mutable option chain or mutate instance partials with `include('*')`. Stored objects use PHP property names, include hidden declared state, omit computed/virtual and `with()`/`additional()` output, ignore partials without consuming them, and never retain a paginator wrapper. Output transformers still apply; an arbitrary one-way transformer therefore requires a matching input cast or `WithCastAndTransformer` for an Eloquent round trip. - Abstract classes that self-discriminate through `PropertyMorphableData::morph()` persist their ordinary constructable representation. Other abstract-class values use `{type, data}` envelopes whose `type` is a required alias from the familiar boot-only `DataConfig::enforceMorphMap()` registry. Reads reject unknown aliases and require the result to be a concrete, transformable `BaseData` subtype of the declared abstract class before construction; payload-provided FQCN fallbacks are not accepted. Encrypt abstract collections as well as concrete ones. - Share Data/DataCollection persistence primitives through an internal generic `AbstractDataEloquentCast`: container-resolved config/repository setup, custom-codec decode, `encrypted`/`default` handling, abstract-class detection, dirty comparison, and recursive payload equality. The base reads its late-bound `DEFAULT_STORED_VALUE` (`{}` for Data, `[]` for DataCollection) through `static::`; each concrete caster retains its precise `CastsAttributes` generic contract and distinct `get()`/`set()` shape. Dirty comparison handles a reachable null original before comparing non-null decoded payloads recursively by count and key with strict leaves. This ignores JSON-object key reordering performed by JSON/JSONB stores, preserves list position because positions remain keys, performs no sort/allocation or Data reconstruction, and fixes nested and numeric-key object order without weakening scalar types. Encrypted casts return unequal while previous encryption keys are configured, matching the framework's rotation behavior. - Remove `Hypervel\Database\Eloquent\Casts\AsDataObject`; Database must not depend on an optional data package or a Support implementation. @@ -537,7 +543,7 @@ Each root construction owns the v5-shaped pair of operation objects. `Constructi 7. On a successful precognitive request with `Precognition-Validate-Only`, Foundation's registered after-validation hook aborts with 204 and unwinds the operation before absence resolution, casts, contextual resolution, or construction. Do not add a separate `isPrecognitive()` return branch: an ordinary full-form precognitive request must construct the promised `static` instance, after which the precognition dispatcher owns its 204 response. Validation-only APIs that do not promise an object disable creation through their context instead. 8. Resolve true absence in one order: declared constructor default, then `Optional`, then `null` for a nullable type; otherwise retain absence for a clear missing-value error. 9. Cast scalar leaves recursively using the selected class metadata; nested Data objects are not constructed during this pass. -10. Instantiate objects bottom up through one shared primitive. For each node, run `beforeCreation` over final casted payload values, then resolve contextual parameters into their slots immediately before the constructor so contextual injection always wins; immediately after construction, run `afterCreation`. Ordinary nodes use direct construction, while a node with contextual parameters uses Container `buildWith()` so the target class remains on the contextual build stack. Public `build()`/`buildWith()` are raw-construction APIs and bypass the Data class's `SelfBuilding` factory; only Container resolution dispatches that factory. Existing target instances and direct-returning named factories finish before this primitive. If ordinary construction reaches a private or protected constructor, throw `CannotCreateData` before PHP's access error: report the reflected visibility and that no matching named factory returned an instance, then direct the caller to return the target object from a matching public static `from*` method or make the constructor public. This is a payload-dependent creation failure, not invalid metadata. Keep the guard in the shared primitive so the measured direct-array exit inherits it; do not catch `Error`, analyze factory return paths, or duplicate constructor visibility as a metadata flag. Omit absent defaulted constructor arguments so PHP supplies their declared defaults, and never resolve contextual values for a graph rejected by validation. +10. Instantiate objects bottom up through one shared primitive. For each node, run `beforeCreation` over final casted payload values, then resolve contextual parameters into their slots immediately before the constructor so contextual injection always wins; immediately after construction, run `afterCreation`. Ordinary nodes use direct construction, while a node with contextual parameters uses Container `buildWith()` so the target class remains on the contextual build stack. A successful exact-array recipe may use `instantiateDirect()` only when immutable metadata also proves complete public non-variadic constructor ownership with no contextual slot; every other node uses the ordinary primitive. Public `build()`/`buildWith()` are raw-construction APIs and bypass the Data class's `SelfBuilding` factory; only Container resolution dispatches that factory. Existing target instances and direct-returning named factories finish before this primitive. If ordinary construction reaches a private/protected constructor or a variadic constructor, throw `CannotCreateData` before argument assembly, missing-value inspection, or PHP's access/named-variadic behavior: report the unsupported shape and that no matching named factory returned an instance, then direct the caller to return the target object from a matching public static `from*` method or use a supported constructor. These are payload-dependent creation failures, not invalid metadata, because a direct-returning named factory can validly own either shape. Keep both guards in the shared primitive so ineligible exact-array nodes inherit them; do not catch `Error` or analyze factory return paths. Omit absent defaulted constructor arguments so PHP supplies their declared defaults, and never resolve contextual values for a graph rejected by validation. 11. Return the root object. The engine has private/internal entry points for nested properties and collection items. A new nested node may select one compatible `from*` method, but a method's returned source is never matched again. One shared unvalidated-node entry performs Fill and bottom-up construction for deferred root items and property-owned typed Data iterables. Internal paths never call public `from()`/`factory()` or container `make()` for the data class, never re-evaluate root validation/authorization, and reuse the root operation's extension/normalizer memo. A deferred collection deliberately retains that memo for its lifetime; extension objects therefore keep per-value state in the supplied operation context rather than on themselves. @@ -550,17 +556,21 @@ Root collection creation uses the same sequence over one keyed payload rather th Treat a `FormRequest` as the `Request` it is: normalize `all()` and apply the Data class's validation/authorization lifecycle under the selected validation strategy. Do not add a privileged `FormRequestNormalizer` or silently reuse its validator, because that would make `Data::from($request)` depend on an unrelated request class's rules. A caller that deliberately wants the FormRequest result passes `$request->validated()` (or another explicit array/`Arrayable` projection) to `from()`; that input then follows the factory's selected non-request validation strategy. -### Measured direct array specialization +### Measured Data hot-path refinements -Apply the specialization only after removing universal overhead from the fixed engine. `BaseData::factory()` directly constructs its fresh caller-owned `CreationContextFactory` from container-resolved worker-shared `DataCreator` and `DataConfig`; it does not force a contextual reflection build for that mutable operation wrapper or memoize it statically. `DataProperty` compiles its mapped input path once, `SourceReader` consumes literal segment lists with a flat-array `array_key_exists()` fast path, and every `ConstructionState` property operation consumes that same list instead of splitting wire keys. The unvalidated internal-node entry above removes nested public dispatch and per-item memo recreation independently of the specialization. +Apply specializations only after shared overhead is removed, and retain each only with an isolated same-machine measurement. Three immutable `DataClass` booleans are justified: `plainTransform`, `directArrayCreation`, and its separately proven `directConstructorInstantiation` exit. Do not consolidate them into a runtime recipe or add another direct-path flag without its own material evidence. -Retained same-machine profiling measures the corrected warm general path at about 39.6 microseconds for a simple object, while a conservative exact-array prototype using the shared instantiator takes about 5.0 microseconds. Trying the exact exit and falling through adds about 0.6 microseconds to the general path. This material hot-path gain justifies one narrow per-node branch and its equivalence coverage. Do not add an eager-collection specialization: root collection Fill owns validation uniformity, AutoLazy provenance, and paginator sources, while eligible child items reach the same per-node exit naturally. +`DataPropertyType` precomputes its data-object, data-collectable, and typed-iterable partitions plus the one unambiguous non-Data iterable. Repeated getter medians fall from 298-703 ns to 37-38 ns. The complete contextual-parameter map and direct-constructor boolean retain only class-derived facts, while `DataProperty::$hasGetHook` replaces the existing virtuality slot rather than adding another per-property field. The final shape adds about 109 bytes per declared property (547 KB across 500 ten-property classes), remains bounded by declared Data metadata rather than requests or tenants, and reduces the focused cold metadata build from about 205.8 to 184.2 microseconds. -Compile one immutable `DataClass::$directArrayCreation` flag. It is false for an abstract or property-morphable class, a class declaring `normalizers()`, any configured global normalizer, any contextual constructor parameter, or any property with AutoLazy, `LoadRelation`, an attribute cast, a preselected configured cast, a Data-collectable type, or any typed iterable arm. Use `DataProperty::$configuredCasts`; do not reread configuration or build another eligibility graph. At runtime, attempt the exit inside the existing `fillNode()` invocation after named-factory matching only in `CreationMode::Create`, when validation and rule compilation are both disabled, the source is exactly one array, the metadata flag is true, and the operation has no normalizers, factory casts, `prepareData`, `beforeCreation`, or `afterCreation` hooks. Validation hooks need no separate gate because the two disabled validation flags make them unreachable. The explicit mode guard preserves the engine's array-returning validation contract independently of the ordinary factory's forced-validation setup. +The existing exact-array recipe remains one narrow per-node branch. `DataCreator::factory(class-string)` creates the fresh caller-owned `CreationContextFactory` from the creator's already-held `DataConfig`; public `BaseData::factory()` resolves only the worker-shared creator and delegates. `DataProperty` continues to own its compiled mapped path, and `SourceReader`/`ConstructionState` reuse those literal segments. Controlled factory-only medians improve from 810 to 488 ns with OPcache disabled and 671 to 430 ns with it enabled. -The attempt reads only the plain array and immutable metadata until it succeeds. Resolve each property through the shared `propertyInputKey()` and `matchPropertySource()` mapping primitives, using `UnknownProperty` rather than `null` for absence. For each property: omit a missing computed/virtual value; route any supplied computed/virtual value, including explicit null, to the general path so its existing exception remains authoritative; omit a missing native default; materialize a missing `Optional` or nullable value; otherwise miss on absence. Retain supplied null and `Optional` for the shared instantiator. Retain every other supplied value only when `DataPropertyType::acceptsValue()` already accepts it; otherwise miss. The ordinary `castProperty()` pass-through must remain before its later date, enum, built-in, and castable conversions because this equivalence makes accepted non-iterable values safe for the exit. +`DataClass::$directArrayCreation` remains false for an abstract or property-morphable class, class/global normalizers, any contextual constructor parameter, or a property with AutoLazy, `LoadRelation`, an attribute/configured cast, a Data-collectable type, or any typed iterable arm. At runtime, attempt it inside the existing `fillNode()` invocation after named-factory matching only in `CreationMode::Create`, when validation/rule compilation are disabled, the source is exactly one array, and no operation normalizer, cast, `prepareData`, `beforeCreation`, or `afterCreation` hook applies. The attempt reads only that array and immutable metadata: use the shared mapping match, preserve explicit null/`Optional`, apply native default/absence rules, and accept only values already accepted by `DataPropertyType::acceptsValue()`. A miss continues the same invocation without rematching a named factory. -On success, instantiate through the existing `DataInstantiator`; do not create a second constructor or casting path. On a miss, continue the same `fillNode()` invocation so named factories are never matched twice. A raw nested array makes its parent miss, while the selected child may take the exit when the general path reaches it; root collections gain the same per-item optimization without bypassing their shared state. Add short comments only for the same-invocation named-factory rule, missing computed omission, and the `castProperty()` ordering dependency. Do not add `ConstructionState`, source normalization, normalizer resolution, extension memoization, a property recipe object, recursive direct mapping, or eager collection machinery to the successful path. +Add `DataClass::$directConstructorInstantiation` only for a concrete class with a public non-variadic constructor or no constructor, no contextual parameter, and every non-computed Data property constructor-bound. On exact-array success, `DataInstantiator::instantiateDirect()` spreads the proven property map directly into the constructor; its docblock names the three cross-file proofs that make this safe: declaration validation owns constructor/property correspondence, the exact-array recipe excludes computed keys, and metadata requires public complete constructor ownership with no contextual slot. The ordinary instantiator remains the fallback and owns all actionable construction errors. A variadic constructor with a same-name public property may still satisfy `directArrayCreation`, but uses the ordinary instantiator so it receives the actionable unsupported-shape error; excluding it from the array recipe would duplicate the constructor proof. Alternating isolated medians improve flat exact construction from about 6.84 to 6.11 microseconds and nested construction from 25.76 to 24.56 microseconds. + +The transformation refinements above are likewise measured. Reusing immutable default/all root contexts saves about 0.86-1.03 microseconds per ordinary transformation before accounting for `all()`'s retained instance-dispatch singleton lookup. A fresh context per item versus one immutable persistence context measured 5.64 versus 3.07 milliseconds for a 1,000-item collection cast. Isolated probes of the declared-order plain copy remain positive at five and twenty properties, with the larger gain on wide SDK-shaped classes; differing microbenchmark ratios make the retained public-path benchmark after implementation authoritative rather than a pre-implementation percentage. + +Reject broader named-type/result/generated-metadata caches, validation-derived facts, child/depth/input-path caches, mutable shared contexts, runtime config invalidation, binding-aware factory fallbacks, and collection-wide persistence. In particular, compact validation-derived caching added about 35 bytes per property while changing 5,000-item ordinary validation from 104.25 to 106.41 milliseconds and email validation from 129.85 to 129.29 milliseconds. Compiler batching already removes the suspected repeated work, so that cache is memory and machinery without a useful gain. ### Metadata @@ -574,12 +584,14 @@ Metadata rules: - no closures, Request, Container, Validator, Model, or resolved service objects; cached `ReflectionClass`/`ReflectionParameter`/`ReflectionAttribute` references are permitted because they are immutable process metadata and are required to preserve Container contextual-attribute semantics without re-reflection; - package attributes that reduce completely to immutable strings, flags, mapper results, or operation codes are compiled and their instances discarded. Attributes containing object arguments, custom validation rules/references, or extension construction retain only their immutable `ReflectionAttribute` recipe and are materialized per root operation; never retain `ReflectionAttribute::getArguments()` results or an instantiated attribute/rule object in metadata; -- feature bits skip entire subsystems on ordinary classes, including a `plainTransform` bit for objects whose declared values can be copied directly without mapping, partial, lazy, nested, or transformer work; +- feature bits skip entire subsystems on ordinary classes. `plainTransform` proves the ordinary declared values need no mapping, partial, lazy, nested, or transformer work; its metadata-ordered copy handles inherited, redeclared, virtual, and backed-hook properties without another class flag. `directArrayCreation` proves one exact array can skip the general Fill path, while `directConstructorInstantiation` proves that successful recipe can also skip the general instantiator loop; - built-in cast/transform operation codes, mapper results, and literal mapped-input segment lists are stored directly on each property; the segment list is shared immutable worker metadata and consumers never copy or mutate it. Only application replacements retain extension recipes; +- `DataPropertyType` partitions its named types once into data objects, data collectables, typed iterables, and the one unambiguous non-Data iterable. Existing singular/plural getters return these stored values, and `getDataObjectClass()` returns the selected Data class string. Creation and validation call this vocabulary directly; delete their `nestedDataClass`, `dataIterableType`, `typedIterableType`, and contextual-name forwarding helpers rather than retaining aliases over metadata; +- `DataClass::$contextualParameters` is one complete `array` for promoted and non-promoted contextual constructor parameters. Any entry disables both direct construction flags because `instantiateDirect()` spreads only Data properties. Declaration validation prevents a non-promoted contextual name from colliding with a Data property, so Fill, reconciliation, validation, and dynamic-graph consumers safely query the same complete map without a second property-only map; - `DataPropertyFactory` marks `AutoWhenLoadedLazy` properties non-validating at the same metadata boundary as computed, contextual, and `WithoutValidation` properties. This lets the existing preservation and unknown-field machinery handle their model-owned value without making validation graphs depend on relation-loaded state; - inferred string rules and declarative rule recipes may be cached, but instantiated rule objects and results from user lifecycle methods, closures, or container calls live only for the current root operation; - native reflection handles types/defaults/attributes; `phpstan/phpdoc-parser` handles collection generic annotations such as `@var FooData[]`; -- each `DataParameter` compiles whether it is variadic, whether it carries attributes, its contextual recipe, and its public-`Reflector` single named class name. Non-variadic injectability and class-variadic emission derive from that one field plus the variadic flag. Reject a variadic `CreationContext` as an invalid factory declaration at metadata build; one operation has one context, and supporting a context-variadic mode would add ambiguous invocation machinery without a use case. `DataMethodMatch` records the selected argument map/list and container decision once; metadata matching performs no reflection or container lookup; +- each `DataParameter` compiles whether it is variadic, whether it carries attributes, its contextual recipe, and its public-`Reflector` single named class name. Non-variadic injectability and class-variadic emission derive from that one field plus the variadic flag. A non-contextual variadic constructor with no same-name public property is already an invalid alternate shape under constructor/property correspondence. A variadic constructor that otherwise remains valid metadata can be owned by a direct-returning named factory, is ineligible for direct constructor instantiation, and fails only if the ordinary instantiator is reached; named property spreading would otherwise silently nest the value inside the variadic argument map. Reject a variadic `CreationContext` as an invalid factory declaration at metadata build; one operation has one context, and supporting a context-variadic mode would add ambiguous invocation machinery without a use case. `DataMethodMatch` records the selected argument map/list and container decision once; metadata matching performs no reflection or container lookup; - Resolve native and PHPDoc types with separate target and declaration scopes. Native `self`/`parent` use the member's declaring class, while a method return `static` uses the target Data class. PHPDoc imports, unqualified names, `self`, and `parent` use the class that declared that annotation; PHPDoc `static` and `$this` use the target Data class. `DataIterableAnnotation` retains the declaring class string so container, item, and key nodes resolve uniformly without parallel scope arguments. - `DataClassFactory` is the one annotation-source and precedence owner: `DataCollectionOf` first, then a constructor-bound property's same-name constructor `@param`, then the property's inline `@var`, then the nearest class-level `@property` while walking child to ancestors, then native-only typing. Parent annotations are retained when no nearer declaration replaces their complete list. `DataTypeFactory` receives the selected annotations and contains no reader fallback or second precedence path. For a native iterable union arm, `matchingAnnotation()` checks every exact container annotation before considering a base/interface match, so PHPDoc union order cannot let a broader annotation hide the arm's exact item type. - Resolve fully qualified and same-namespace PHPDoc types without source access. For an imported or group-aliased short name, `PhpDocTypeNameResolver` tokenizes the declaring source file at most once per worker and retains immutable import maps for every namespace in that file. Imports are checked before same-namespace qualification, with no `class_exists()` branch whose result could make immutable metadata depend on worker load order. The resolver is an unbound auto-singleton and owns this naturally bounded cache; routing it through `DataClassRepository` would invert the factory dependency. `DataCollectionOf` avoids source parsing entirely and is preferred for generated SDKs. @@ -588,7 +600,7 @@ Metadata rules: - nested data metadata stores class strings and resolves them through the repository on demand. It does not embed recursive `DataClass` object graphs, so self-referencing classes are finite; - `DataProperty::isConstructorParameter` records one data-slot ownership decision. A public property is constructor-bound when the effective constructor has a same-name non-contextual parameter or when the parameter promotes that public property. A matching non-promoted contextual parameter is a declaration conflict, not another binding form. - constructor-bound properties take default presence and iterable `@param` annotations from their constructor parameter; unbound properties take defaults and inline annotations from the property declaration. Metadata retains only default presence. Construction omits absent defaulted arguments so PHP creates object/enum defaults correctly and no shared default object survives metadata build. -- constructor-bound properties are never assigned after construction, preserving constructor normalization and property-hook side effects. Only supplied, unbound public mutable properties are assigned afterward. `#[Computed]` and PHP 8.4 virtual properties are output-only and cannot be constructor-bound; an unbound non-computed readonly property is invalid because the engine cannot assign it. +- constructor-bound properties are never assigned after construction, preserving constructor normalization and property-hook side effects. Only supplied, unbound public mutable properties are assigned afterward. `#[Computed]` and PHP 8.4 virtual properties are output-only and cannot be constructor-bound; an unbound non-computed readonly property is invalid because the engine cannot assign it. Replace the stored `DataProperty::$isVirtual` fact with `hasGetHook`, which is the transformation decision and covers backed as well as virtual get hooks; keep virtuality local to metadata construction for computed classification. Reject a virtual property without a get hook after the more specific computed-constructor check because it can be neither supplied nor emitted. The condition must retain its virtual-property half so an ordinary backed set-only normalizer remains valid. - ignore ordinary non-promoted private/protected helper properties and static properties. Reject a non-public promoted property, a non-contextual constructor parameter with no corresponding public data property, or a non-promoted contextual parameter whose name conflicts with a public property; non-promoted contextual parameters with distinct names remain valid constructor-only dependencies, and named factories are the explicit path for other alternate constructor shapes. Do not reject a non-public constructor while building metadata: a direct-returning named factory or an existing target instance can use the class without ordinary construction. - `DataClassFactory` validates mapping ownership with two local maps while walking the complete inherited public-property metadata once. An input property claims its PHP name and any distinct mapped path; a non-hidden output property claims its mapped name or PHP name. A second different owner throws `InvalidDataDeclaration`, while repeat ownership by the same property is ignored. Move the named-factory variadic-`CreationContext` declaration error onto the same named exception through its own static factory; do not add a registry or runtime collision check. @@ -763,14 +775,14 @@ The ledger is an implementation artifact kept with the working notes until all e - Move unknown-field checking to Validation and keep focused FormRequest body/query/JSON integration behavior green before Data consumes it. Add regressions proving free-form `meta.foo` and scalar-list `tags.*` values are accepted under leaf `array` rules while structured `items.*.id` still rejects `items.0.unknown`. - Correct Container's contextual-null constructor behavior, raw `build()`/`buildWith()` handling of `SelfBuilding` classes, route/user extraction, and `RequestAttribute`; declare the Collections dependency; and keep the focused contextual, raw-construction, binding-precedence, scoping, and interleaving tests green before Data consumes them. Port `BindWhen` in the same Container parity slice with a conditionally loaded PHP 8.5 fixture and keep its focused declaration-order, reevaluation, lifetime, and fallback tests green on supported runtimes. - Add package composer/provider/config/README/license/root registration. -- Bind `DataConfig` with a provider factory because it is built from configuration. Leave `DataClassRepository` and stateless engines unbound so Hypervel auto-singletons them naturally; construct operation contexts and factories fresh. +- Bind `DataConfig` with a provider factory because it is built from configuration. Leave `DataClassRepository` and stateless engines unbound so Hypervel auto-singletons them naturally. Keep mutable creation contexts and fluent factories fresh; let the worker-shared transformer reuse only its immutable boot-derived default, all, and persistence contexts. - Add provider/config discovery, worker-lifetime morph-map, and typed-config tests. ### 3. Build metadata and type system - Port/adapt attributes collection, class/property/method/parameter/type metadata. - Parse native types, constructor promotion/defaults, attributes, collection docblocks, unions, intersections, DNF types, enums, dates, iterable item types, virtual/computed fields, and named object/collection factories. -- Compile constructor argument order, input/output mapper keys, hook bits, rule templates, cast/transform recipes, `plainTransform`, and the measured `directArrayCreation` eligibility flag. +- Compile constructor argument order, input/output mapper keys, hook bits, rule templates, cast/transform recipes, the three measured direct-path booleans, complete contextual-parameter names, and the reusable type partitions. - Test immutable metadata; inherited native `self`/`parent` and late-bound `static` declarations across properties, constructors, and named factories; parent/child/constructor/inline generic-annotation precedence with distinct import scopes; import aliases winning over an existing same-namespace class; multi-namespace per-file import caching; recursive class references; ignored helper/static properties; constructor-bound readonly/mutable/defaulted properties; required constructor parameters overriding property defaults; invalid unbound readonly, computed-bound, contextual-name-collision, non-public-promoted, and alternate-constructor declarations; valid non-public-constructor metadata; declaration-order method metadata; bounded repository/resolver keys; cached contextual/extension reflection recipes; fresh object-bearing attribute arguments per operation; and absence of container/request/resolved extension objects. ### 4. Implement fixed construction @@ -782,7 +794,7 @@ The ledger is an implementation artifact kept with the working notes until all e - Implement casts for built-ins, nested Data, data collections, dates, enums, iterables, unions, custom casts/castables, and morphs. - Mark contextual constructor slots during Fill, exclude promoted injected properties from payload validation, and resolve their values only at per-node instantiation. Pass non-promoted injected parameters only to the constructor and match normal per-parameter Container resolution without a cross-node value cache. - Preserve constructor-owned values for every constructor-bound property. Assign only supplied, unbound public mutable properties after construction while leaving computed/virtual properties to the class. -- Remove universal entry/read overhead first: directly construct the fresh fluent factory from container-resolved collaborators, compile mapped input segments once, route every source/state property operation through them, and delete duplicate runtime splitters. Retain the measured per-node exact-array exit inside `fillNode()` with shared mapping, absence, and instantiation primitives; a miss falls through without rematching named factories. +- Remove universal entry/read overhead first: let the worker-shared creator construct each fresh fluent factory from its owned config, compile mapped input segments and type partitions once, route source/state/type queries through that metadata, and delete forwarding helpers. Retain the measured per-node exact-array exit inside `fillNode()` with shared mapping and absence primitives; when its constructor proof is true, use the narrow direct instantiator, while every miss or ineligible class retains the ordinary path without rematching named factories. - Add source-specific query-count and allocation-focused tests where measurable. ### 5. Implement validation @@ -796,7 +808,7 @@ The ledger is an implementation artifact kept with the working notes until all e ### 6. Implement transformation and collections -- Implement direct transformation, context promotion, mapping, custom transformers, Optional omission, lazy/computed/hidden/appended values, partials, depth detection, JSON, and serialization. +- Implement direct transformation, context promotion, mapping, custom transformers, Optional omission, lazy/computed/hidden/appended values, partials, depth detection, JSON, and serialization. Build immutable default/all/persistence root contexts once on `DataTransformer`, retain fresh partial-bearing operation contexts, and keep `transform()` as the overridable dispatch boundary for `all()` and Eloquent persistence. - Compile one immutable exact/prefix/subtree-aware `PartialTree` per partial mode and use `plainTransform` only when instance state and metadata prove the direct loop is equivalent. - Add nested instance-partial composition first at the two currently live `BaseData` edges and port the array-shaped, depth-three per-item part of upstream `PartialsTest.php:1068` in that slice. - Correct `AbstractPaginator::setCollection()` generic rebinding before making collection item contracts precise. Port typed collections/paginators and preserve keys/laziness. Implement one root collection Fill/Validator operation, shared per-operation normalizer/extension memo, normalized source-shaped `collect*` selection, declared-shaped property rebuilding, Eloquent relation batching/root downgrade, exact multi-arm finished-container handling, Data and non-Data paginator source retention, cast-owned ambiguous unknown-field subtrees, and the Fill-time failure boundaries described above. Route every eager typed iterable through `DataCollectableFactory` and remove the duplicate creator rebuilder. Replace the two unreachable non-`BaseData` public-transform scaffolds with the shared internal collection loop, then port the complete upstream partial graph covering root-, collection-, and item-owned selections. @@ -834,7 +846,7 @@ The ledger is an implementation artifact kept with the working notes until all e - Audit public names/signatures/order against the checked-out Spatie source/docs/tests and Laravel conventions. - Audit dependency direction and component composer requirements. - Audit all singleton/static properties for request state, closures, container values, and unbounded keys. -- Profile the fixed general paths and the measured exact-array exit; verify miss overhead remains negligible and remove abstractions that add cost without enabling an adopted feature. +- Profile the fixed general paths, metadata partitions, factory ownership, cached immutable contexts, direct plain copy, exact-array exit, and exact-constructor exit. Verify fallthrough overhead remains negligible, memory stays bounded by declared Data metadata, controls remain stable, and no retained optimization adds cost without a measured benefit. - Run formatters, static analysis, focused suites, package-adjacent suites, then the repository suite according to AGENTS.md. - Review the final diff for dead compatibility code, duplicated serializers/validators/resources, stale comments/docs, and source unrelated to this package. @@ -843,15 +855,16 @@ The ledger is an implementation artifact kept with the working notes until all e ### Creation and types - array, JSON string, `Arrayable`, plain object, stdClass, Model, Request, FormRequest, multiple payloads, custom normalizer; +- stored type partitions and singular getters for scalar, nested Data, Data-collectable, non-Data iterable, and ambiguous unions; complete contextual-parameter metadata for promoted and non-promoted parameters; non-promoted contextual parameters both without a default and with a nullable default keep `directArrayCreation` and `directConstructorInstantiation` false; - root `WithData` property and method declarations, property precedence, missing/invalid declarations, precise generic return, FormRequest input using the associated Data class's validation, and the same invalid Model source skipping validation under `OnlyRequests`; -- constructor promotion, inherited properties, defaults (including `new` object defaults), nullable omission to `null`, explicit null, Optional-preserved omission, missing non-nullable required values, empty data, public readonly promoted and constructor-bound non-promoted properties, constructor normalization preserved without post-assignment, unbound mutable properties, invalid unbound readonly and computed/virtual-bound declarations, and PHP 8.4 virtual/backed property hooks; +- constructor promotion, inherited properties, defaults (including `new` object defaults), nullable omission to `null`, explicit null, Optional-preserved omission, missing non-nullable required values, empty data, public readonly promoted and constructor-bound non-promoted properties, constructor normalization preserved without post-assignment, unbound mutable properties, invalid unbound readonly, computed/virtual-bound, and write-only virtual declarations, plus PHP 8.4 virtual/backed get hooks and a backed set-only normalizer that builds, creates, and transforms successfully; an exact-array-eligible variadic constructor retains `directArrayCreation`, rejects direct constructor instantiation, and throws through the ordinary primitive without nesting its value, while a non-public variadic constructor remains usable through a direct-returning named factory; - scalar/builtin coercion rules, including case-insensitive `true`/`false` strings, enums, exact date classes/interfaces/subclasses/timezones/formats; - declared-class collection items pass through with identity, while an unrelated `BaseData` item is normalized into the declared item class rather than being preserved as a finished value; - nested Data, arrays, Collection, DataCollection, iterable annotations/attributes, paginator/cursor paginator, LazyCollection; - nullable/union/intersection/DNF/existing-instance handling and explicit ambiguity failures; - custom Cast/Castable, constructor arguments, Uncastable fallback, and morph discriminators restricted to declared concrete Data subtypes; -- named object/collection factory declaration order; positional/named matching; exact-key rejection; zero-payload matches; dependency-first/interleaved parameters; union/intersection non-injectability; `CreationContext` identity and first/middle/trailing placement across named and positional invocation shapes; variadic-context declaration rejection; direct supplied-class payloads; omitted dependencies through first-class `Container::call()`; contextual build-stack bindings; non-variadic attribute callbacks; method bindings not intercepting factories; pure and prefixed variadics; skipped-default built-in variadics; class-name-key emission for attributed/injected prefixes; same-class prefix consumption without fabricated arguments; zero-payload class-variadic Container resolution; independent `$into` return matching; direct-object short circuit/authorization; private-constructor direct-return and existing-instance success; unmatched private-constructor and matched-normalizable-source `CannotCreateData` failures; protected visibility diagnostics; unchanged public construction; and recursive-public-entry regression; -- nested typed Data iterables do not re-enter `OnlyRequests` authorization and instantiate each attribute cast/normalizer recipe at most once per root operation, including deferred traversal; mapped input path segments are literal and shared by source reading and construction state; whole-segment `*`/`{first}`/`{last}` keys, public object null, magic null, inaccessible null, and uninitialized public properties retain the documented presence/access boundaries; exact-array/general equivalence covers accepted scalars and ordinary objects, explicit null including a defaulted property, native defaults, `Optional`/nullable omission, mapped-key precedence and fallback, untyped arrays, unbound public properties, existing nested Data/date/enum values, a raw nested child exit reached from its general parent, and a child exit inside a root collection item; coercion, missing-required failures, custom/global/context casts and normalizers, AutoLazy, `LoadRelation`, morphs, contextual values, typed iterables, and creation hooks fall through; missing computed/virtual values succeed, while supplied non-null and null values reach the existing exception; named factories dispatch once including a normalizable array return; and a non-public constructor reaches the shared instantiator error. +- named object/collection factory declaration order; positional/named matching; exact-key rejection; zero-payload matches; dependency-first/interleaved parameters; union/intersection non-injectability; `CreationContext` identity and first/middle/trailing placement across named and positional invocation shapes; variadic-context declaration rejection; direct supplied-class payloads; omitted dependencies through first-class `Container::call()`; contextual build-stack bindings; non-variadic attribute callbacks; method bindings not intercepting factories; pure and prefixed variadics; skipped-default built-in variadics; class-name-key emission for attributed/injected prefixes; same-class prefix consumption without fabricated arguments; zero-payload class-variadic Container resolution; independent `$into` return matching; direct-object short circuit/authorization; private-constructor direct-return and existing-instance success; unmatched private-constructor and matched-normalizable-source `CannotCreateData` failures; protected visibility diagnostics; variadic-shape rejection before missing-parameter diagnostics; unchanged public construction; and recursive-public-entry regression; +- nested typed Data iterables do not re-enter `OnlyRequests` authorization and instantiate each attribute cast/normalizer recipe at most once per root operation, including deferred traversal; mapped input path segments are literal and shared by source reading and construction state; whole-segment `*`/`{first}`/`{last}` keys, public object null, magic null, inaccessible null, and uninitialized public properties retain the documented presence/access boundaries; exact-array/general equivalence covers accepted scalars and ordinary objects, explicit null including a defaulted property, native defaults, `Optional`/nullable omission, mapped-key precedence and fallback, untyped arrays, unbound public properties, existing nested Data/date/enum values, a raw nested child exit reached from its general parent, and a child exit inside a root collection item; coercion, missing-required failures, custom/global/context casts and normalizers, AutoLazy, `LoadRelation`, morphs, contextual values, typed iterables, and creation hooks fall through; missing computed/virtual values succeed, while supplied non-null and null values reach the existing exception; named factories dispatch once including a normalizable array return; direct constructor eligibility covers public/no constructor, complete constructor ownership, contextual slots, computed/unbound/inherited properties, and non-public constructors; the direct and ordinary instantiators remain behaviorally equivalent for valid exact arrays while the ordinary path retains every failure diagnostic. ### Mapping and validation @@ -879,6 +892,8 @@ The ledger is an implementation artifact kept with the working notes until all e ### Transformation and collection behavior - input/output mapper independence, live mutations, Optional omission, null retention; +- direct factory creation preserves subclasses and container-resolved `DataConfig`; stored default/all/persistence contexts match fresh factory contexts across every field and configured maximum depth; partial-bearing roots receive fresh contexts and consume temporary definitions exactly once; `all()` plus singular and collection Eloquent writes still dispatch a custom `transform()` override with the correct supplied context; +- plain transformation preserves metadata declaration order and values for five- and twenty-property classes; inherited and reversed public/protected redeclarations; uninitialized omission; supported dynamic public-key exclusion; and the combined redeclaration/dynamic shape. Backed and virtual get hooks emit their hooked values once on the plain path, while the general path invokes them once only after hidden, constructable, `except`, and `only` guards; excluded throwing getters never run; - custom/date/enum/arrayable transformers and nested collection output; - Hidden, Computed, appended values, include/exclude/only/except, invalid paths; - nested instance include/exclude/only/except at depth two or greater; parent/instance tree union including parent pure-all plus instance `only`; array-shaped typed-item isolation matching the B1/B2 portion of upstream `PartialsTest.php:1068`; the same instance referenced twice with a temporary applying only at first reach and a permanent applying at both; collection-container ownership and the complete upstream graph once the internal collection loop exists; @@ -928,17 +943,17 @@ Keep a developer-run harness patterned after `tests/Benchmarks/RateLimiter` with Scenarios: 1. Native constructor/manual array mapper baseline. -2. Cold and warm simple `Data::from(array)`, with named measurements for factory creation, root setup/Fill, cast/instantiation, exact-array success, and exact-array miss overhead. +2. Cold and warm simple `Data::from(array)`, with named measurements for factory creation, type-metadata getters, root setup/Fill, ordinary versus direct instantiation, exact-array success, and exact-array miss overhead. 3. Deep and wide SDK-shaped graphs using the retained benchmark fixtures. 4. `collect()` over 1,000 objects, lazy traversal, and a large collection whose item class declares AutoLazy properties so its necessarily dense per-item provenance cost remains visible. 5. One 5,000-item nested validation graph. 6. Direct and container-resolved named factory dispatch, including collection-sized runs. 7. Mapped/custom-cast/morph/injection slow paths. -8. Simple and nested `toArray()`, lazy/partial context promotion. -9. Cold metadata construction and first use versus warm worker-lifetime operations, without treating ordinary startup CPU as a defect. +8. Simple and nested `toArray()`, five- and twenty-property plain copies, fresh versus stored default/all/persistence contexts, custom override dispatch, and lazy/partial context promotion. +9. Cold metadata construction and first use versus warm worker-lifetime operations, plus retained memory for large class/property sets, without treating ordinary startup CPU as a defect. 10. Eloquent collection normalization with loaded and explicitly `LoadRelation` relations/query counts. -Do not encode invented time thresholds. Compare ratios to native/manual baselines and before/after results on the same machine. Correctness tests assert architectural invariants that benchmarks cannot enforce reliably: no pipeline resolution, one Validator per root graph, no metadata filesystem/discovery path, no per-property container access on ordinary DTOs, no `Model::toArray()`, no eager LazyCollection materialization when neither validation nor rule introspection is selected, and no user-produced or mutable object retained in worker metadata. +Do not encode invented time thresholds. Compare ratios to native/manual baselines and before/after results on the same machine, retain alternating controls, and report OPcache state. Correctness tests assert architectural invariants that benchmarks cannot enforce reliably: no pipeline resolution, one Validator per root graph, no metadata filesystem/discovery path, no repeated type filtering, no per-property container access on ordinary DTOs, no `Model::toArray()`, no eager LazyCollection materialization when neither validation nor rule introspection is selected, no shared mutable transformation context, and no user-produced or mutable object retained in worker metadata. ## Verification Commands @@ -962,18 +977,18 @@ Before final signoff, run `composer fix` once as the repository's prescribed for ## Completion Checklist -- [ ] Public API is Spatie/Laravel-familiar and every divergence is documented as a Hypervel adaptation. -- [ ] Fixed creation/transformation paths are structurally lean and benchmarked; the measured exact-array exit retains its recorded benefit, negligible miss cost, and full equivalence coverage. -- [ ] General construction is fixed, non-recursive through public APIs, and built from validated values. -- [ ] Default/Optional/nullable/required absence semantics, mapped validation paths, uniform-shape wildcard graphs, mixed-shape concrete rules, and dynamic rules are correct. -- [ ] Metadata is immutable and worker-scoped; config is stable after boot except its documented morph-map registration; all operation/request state is isolated. -- [ ] Data, Dto, Resource, Optional, Lazy, collections, validation attributes, mapping, casting, resources, Eloquent, Precognition, and Inertia are complete. -- [ ] VarDumper output presents the current logical Data/resource/collection view through one stateless, idempotently registered interface caster with no mode or manager. -- [ ] Container, Foundation, HTTP, Database, Validation, and Testing changes respect ownership and have local tests; Inertia and Saloon need no runtime changes. -- [ ] Old Support DataObject source/tests/docs/cleanup and Database cast are fully removed. -- [ ] Metadata is analyzed once per used class, retained only in worker memory, bounded by declared Data classes, and free of discovery, filesystem, remote I/O, and request-derived state. -- [ ] Upstream source/test ledger is fully reconciled, with only deliberate omissions recorded. -- [ ] README/license attribution and Hypervel difference documentation are complete. -- [ ] The owner has been told exactly which SDK-generator plan sections are superseded; if amendment is authorized, that plan references the final Data API and contains no obsolete proposed framework work. -- [ ] Focused tests, static analysis, formatting, benchmark review, and the final repository suite pass. -- [ ] Final grep/diff audit finds no stale APIs, compatibility switches, dead code, request-state globals, duplicated framework machinery, or unrelated churn. +- [x] Public API is Spatie/Laravel-familiar and every divergence is documented as a Hypervel adaptation. +- [x] Fixed creation/transformation paths are structurally lean and benchmarked; metadata partitions, creator-owned factories, immutable root contexts, metadata-ordered plain copying, the exact-array exit, and direct constructor instantiation retain their measured benefit and full equivalence coverage without shared mutable state. +- [x] General construction is fixed, non-recursive through public APIs, and built from validated values. +- [x] Default/Optional/nullable/required absence semantics, mapped validation paths, uniform-shape wildcard graphs, mixed-shape concrete rules, and dynamic rules are correct. +- [x] Metadata is immutable and worker-scoped; config is stable after boot except its documented morph-map registration; all operation/request state is isolated. +- [x] Data, Dto, Resource, Optional, Lazy, collections, validation attributes, mapping, casting, resources, Eloquent, Precognition, and Inertia are complete. +- [x] VarDumper output presents the current logical Data/resource/collection view through one stateless, idempotently registered interface caster with no mode or manager. +- [x] Container, Foundation, HTTP, Database, Validation, and Testing changes respect ownership and have local tests; Inertia and Saloon need no runtime changes. +- [x] Old Support DataObject source/tests/docs/cleanup and Database cast are fully removed. +- [x] Metadata is analyzed once per used class, retained only in worker memory, bounded by declared Data classes, and free of discovery, filesystem, remote I/O, and request-derived state. +- [x] Upstream source/test ledger is fully reconciled, with only deliberate omissions recorded. +- [x] README/license attribution and Hypervel difference documentation are complete. +- [x] The owner has been told exactly which SDK-generator plan sections are superseded; if amendment is authorized, that plan references the final Data API and contains no obsolete proposed framework work. +- [x] Focused tests, static analysis, formatting, benchmark review, and the final repository suite pass. +- [x] Final grep/diff audit finds no stale APIs, compatibility switches, dead code, request-state globals, duplicated framework machinery, or unrelated churn. From 42d85d80146dfa749687172e047e0dc7805ed130 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:32:14 +0000 Subject: [PATCH 35/35] Refine Data documentation prose Rewrite the Data guide around observable behavior and common usage, replacing internal creation, validation, transformation, resource, and metadata terminology with direct Laravel-style explanations. Clarify readonly computed properties, named factory and collection dispatch, extension reuse, unvalidated array keys, collection targets, Eloquent persistence, lazy materialization, and worker-lifetime configuration. Restore the public wildcard-rule, class-string, and structure-cache differences that porters need. Keep the package README concise, update the Eloquent cross-reference and Laravel porting guidance, and retain the Spatie credit while documenting the exact omitted APIs and their Hypervel alternatives. --- src/data/README.md | 14 ++-- src/docs/data-objects.md | 113 ++++++++++++++++++------------- src/docs/eloquent-mutators.md | 4 +- src/docs/porting-from-laravel.md | 4 +- 4 files changed, 78 insertions(+), 57 deletions(-) diff --git a/src/data/README.md b/src/data/README.md index 2efdd6efb..1b88fc97a 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -4,20 +4,20 @@ Documentation: https://hypervel.org/docs/data-objects ## Differences From Laravel -Hypervel Data keeps the familiar `spatie/laravel-data` vocabulary with fixed, coroutine-safe internals for long-lived workers. Metadata is analyzed once per used class and retained in worker memory; there is no discovery or deploy cache command. - -`Data`, `Dto`, and `Resource` use `OnlyRequests` validation by default, and Hypervel retains the class-level `withValidator()` hook. `validate()` disables named factories and returns validated input, while `validateAndCreate()` may use a direct-returning factory that owns its validation. Each `factory()` call starts a fresh operation. +`Data`, `Dto`, and `Resource` use `OnlyRequests` validation by default, and Hypervel retains the class-level `withValidator()` hook. The `validate()` method ignores named factories and returns validated input. The `validateAndCreate()` method may use a named factory that returns the finished object, in which case that factory is responsible for validation. Each call to `factory()` returns a new factory. Omitted nullable properties become `null`; use `Optional` to preserve absence and `#[Present]` when a nullable key must be supplied. A Model attribute containing `null` remains an explicit value, even for a non-nullable property with a default. With multiple payloads, the first source containing a property's input key wins, including when the value is `null`. -Input and output mapping collisions are rejected when metadata is built. Hypervel's compiled wildcard validation is used for uniform nested collections, with concrete indexed rules for dynamic shapes. +Hypervel rejects data classes with conflicting input or output mappings when the class is first used. Uniform nested collections use wildcard validation rules, while collections with different item shapes or rules use exact indexed rules. Constructor injection uses Hypervel contextual attributes, including property extraction through `CurrentUser` and `RouteParameter`. Their resolved value always wins over payload input, including `null`; use a named factory or creation hook when payload values should take precedence. -Data-specific `From*` aliases, optional-value factory switches, `SerializeTransformer`, and `UnserializeCast` are not included. Use Hypervel contextual attributes, declared `Optional` unions, native PHP serialization, or an explicit custom cast or transformer. +Spatie's `data:cache-structures` command is not included; Hypervel does not require a structure cache step during deployment. + +Spatie's data-specific `From*` attributes, `withOptionalValues()`, `withoutOptionalValues()`, `SerializeTransformer`, and `UnserializeCast` are not included. Use Hypervel's contextual attributes, declared `Optional` unions, native PHP serialization, or an explicit custom cast or transformer. -Named `collect*` methods receive the normalized container of created data objects rather than the raw source. An exact Eloquent collection parameter therefore does not match an Eloquent source after it has been normalized to a base collection. +Named `collect*` methods receive the source's own array, collection, or paginator shape after its values have been converted to data objects, rather than the original source values. An Eloquent collection source is provided as a base `Hypervel\Support\Collection`. When you pass an explicit `$into` target, the method's declared return type must also match that target. -Deprecated collection forwarding, Livewire integration, and TypeScript generation are not included. Use `toCollection()` for collection operations; TypeScript generation belongs in a general transformer package. +Deprecated collection proxy methods, Livewire integration, and TypeScript generation are not included. Use `toCollection()` for collection operations. TypeScript generation belongs in a general transformer package. Ported from: https://github.com/spatie/laravel-data diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 789d676f1..6c747e298 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -35,20 +35,20 @@ ## Introduction -Hypervel Data turns untyped input into typed PHP objects and can validate, transform, collect, return, and persist those objects. Its public API follows the familiar `spatie/laravel-data` vocabulary while its metadata and execution paths are designed for Hypervel's long-lived workers. +Hypervel Data provides an expressive way to turn arrays, requests, Eloquent models, and other input into typed PHP objects. These objects may validate incoming data, transform values for output, be collected, returned from routes and controllers, and stored with Eloquent. -Metadata is analyzed once for each used data class and retained for the worker lifetime. Request values, validation state, partial selections, lazy evaluation, and factory hooks stay within the current operation or object instance. +If you have used Spatie Laravel Data, the package's classes and methods should feel familiar. Hypervel Data provides this familiar API while remaining suitable for Hypervel's long-running workers. ## Choosing a Base Class -The package provides three base classes that share one construction engine: +The package provides three base classes: -- `Data` supports construction, validation, transformation, HTTP responses, collections, and Eloquent casting. -- `Dto` supports construction and validation without transformation or response behavior. Use it for commands, service boundaries, and domain input. -- `Resource` supports construction, transformation, HTTP responses, collections, and Eloquent casting without the public validation helpers. +- `Data` supports creation, validation, transformation, HTTP responses, collections, and Eloquent casting. +- `Dto` supports creation and validation without transformation or response behavior. It is a good choice for commands, service boundaries, and domain input. +- `Resource` supports creation, transformation, HTTP responses, collections, and Eloquent casting without the public validation methods. -Choose the smallest capability set that matches the object's role. Nested or collected `Dto` values remain objects when a surrounding `Data` object is transformed because `Dto` deliberately has no transformation contract. +Choose the base class that provides the behavior your object needs. Since `Dto` does not transform values, nested or collected DTOs remain objects when a surrounding `Data` object is transformed. Use `Data` or `Resource` when nested values should also be transformed. ## Creating Data Objects @@ -76,7 +76,7 @@ class UserData extends Data } ``` -Constructor-promoted `readonly` properties are supported. Public properties that are not constructor-bound may also be populated after construction, but an unbound `readonly` property is invalid because it cannot be assigned safely. +Constructor-promoted `readonly` properties are supported. Public properties that are not declared by the constructor are assigned after construction and therefore cannot be `readonly`, unless the class sets the value itself as a `#[Computed]` property. ```php ### Property Name Conversion @@ -243,12 +243,12 @@ $product->productName; `MapName` applies the same name in both directions. `MapInputName` and `MapOutputName` keep the directions independent. Class-level mappers such as `SnakeCaseMapper`, `CamelCaseMapper`, and `KebabCaseMapper` provide a convention for every property, while a property attribute overrides the class mapper. -Mapped input paths may use dot notation. When both the mapped input path and PHP property name are present, the mapped input wins. Hypervel rejects two properties that claim the same effective input path or output key when metadata is built instead of silently overwriting a value. +Mapped input paths may use dot notation. When both the mapped input path and PHP property name are present, the mapped input wins. Hypervel rejects a data class when two properties use the same input path or output key instead of silently overwriting a value. ## Type Conversion -`from` casts supported scalar values, backed enums, dates, nested data objects, and typed iterables to their declared PHP types. Existing values that already satisfy the type retain their identity. +`from` casts supported scalar values, backed enums, dates, nested data objects, and typed iterables to their declared PHP types. Values that already have the declared type are kept as-is. ```php class ProductData extends Data @@ -273,7 +273,9 @@ Ambiguous unions of data classes or typed data containers are not guessed. Use a ### Date and Time Values -Date interfaces use Hypervel's configured Date factory. A property that declares a concrete date class receives that exact class. Input is parsed with `data.date_format`, which accepts one format or an ordered list of formats. The `data.date_timezone` setting converts parsed and transformed dates to a target timezone. For a property with a different source timezone, set `timeZone` on `DateTimeInterfaceCast`; its `setTimeZone` argument overrides the target timezone for that property. +Date interfaces use Hypervel's configured Date factory. If a property declares a concrete date class, Hypervel creates an instance of that exact class. + +Input is parsed using the `data.date_format` configuration option, which accepts one format or an ordered list of formats. The `data.date_timezone` option converts parsed and transformed dates to the configured timezone. To use a different source timezone for one property, pass `timeZone` to `DateTimeInterfaceCast`. Its `setTimeZone` argument may be used to override the configured target timezone for that property. Dates are transformed using the configured output format unless a property transformer overrides it: @@ -369,7 +371,7 @@ $user->address->street; // 123 Main Street ``` -Nested construction works through the complete graph. Existing `AddressData` instances pass through unchanged. +This works at any nesting depth. Existing `AddressData` instances pass through unchanged. For a typed collection, use `DataCollectionOf` or a supported PHPDoc item annotation: @@ -387,7 +389,7 @@ class TeamData extends Data } ``` -The same typed item conversion works for arrays, ordinary collections, lazy collections, and supported paginator types. `DataCollectionOf` is preferred for generated classes because it is explicit and requires no PHPDoc parsing. +The same typed item conversion works for arrays, ordinary collections, lazy collections, and supported paginator types. `DataCollectionOf` is preferred for generated classes because it declares the item type explicitly. ### Backed Enums @@ -459,29 +461,31 @@ class InvoiceData extends Data A cast implements `Hypervel\Data\Casts\Cast`; a transformer implements `Hypervel\Data\Transformers\Transformer`. Return `Uncastable::create()` from a cast when the next applicable candidate should be tried. Returning `null` means the cast produced a real null value. -Use `Castable` when a value class owns its input conversion, `IterableItemCast` when a cast also applies to typed iterable items, or `factory()->withCast()` for one operation. Application-wide replacement casts and transformers belong in `config/data.php`; built-in date, enum, iterable, and `Arrayable` handling does not need to be configured. +Use `Castable` when a value class owns its input conversion, `IterableItemCast` when a cast also applies to typed iterable items, or `factory()->withCast()` for a single creation. Application-wide replacement casts and transformers belong in `config/data.php`; built-in date, enum, iterable, and `Arrayable` handling does not need to be configured. + +Custom normalizers convert a source value into input before Hypervel reads its properties. Declare normalizers for a data class with `normalizers()` or add them to a factory with `withNormalizers()`. Prefer a typed named factory when only one source type needs special handling. -Custom normalizers adapt whole source objects before properties are selected. Declare class-owned normalizers with `normalizers()` or add them to one factory with `withNormalizers()`. Prefer a typed named factory when only one source type needs special handling. +During a single creation or transformation, Hypervel may reuse the same cast, transformer, or normalizer instance for every matching value. Do not store per-value state on the extension object itself. ## Validation -`Data`, `Dto`, and `Resource` validate request input during construction by default. Arrays, models, JSON, and other non-request sources skip validation under the shipped `OnlyRequests` strategy, so trusted internal construction keeps the lean path. `Data` and `Dto` also expose `validateAndCreate` to validate any array-like payload explicitly: +By default, `Data`, `Dto`, and `Resource` validate input when they are created from a request. Arrays, models, JSON, and other sources are not validated under the default `OnlyRequests` strategy. `Data` and `Dto` also provide a `validateAndCreate` method for explicitly validating an array-like payload: ```php $user = UserData::validateAndCreate($payload); ``` -Use `validate` when only the validated payload is needed, or `getValidationRules` to inspect the compiled rules: +Use `validate` when you only need the validated payload, or `getValidationRules` to inspect the generated rules: ```php $validated = UserData::validate($payload); $rules = UserData::getValidationRules($payload); ``` -Hypervel infers presence, nullable, scalar, enum, date, nested data, and typed collection rules from PHP declarations. One Validator handles the complete nested graph. Uniform collections use wildcard rules and Hypervel's compiled validation plans; dynamic shapes use exact indexed rules. +Hypervel infers presence, nullable, scalar, enum, date, nested data, and typed collection rules from your PHP declarations. These rules cover the entire nested object, including items within typed collections. Uniform collections use wildcard rules, while collections with different item shapes or rules use exact indexed rules. -Construction uses the Validator's validated and exclusion-filtered payload. Properties marked `WithoutValidation` and finished nested data values preserve only their declared paths; unrelated unvalidated input is not merged back. +After validation, Hypervel creates the object from the validated values. Properties marked with `#[WithoutValidation]` are preserved, as are existing nested data objects. Other input is discarded. By default, this includes unvalidated keys nested inside an array; calling `Validator::includeUnvalidatedArrayKeys()` during your application's boot retains those nested keys. ### Validation Attributes @@ -531,7 +535,7 @@ Use `withValidator(Validator $validator)` and `after(): array` like a FormReques ### Creation Factories -`factory()` returns a fresh fluent factory for one operation: +The `factory` method returns a fluent factory for a single creation: ```php $user = UserData::factory() @@ -544,11 +548,20 @@ $user = UserData::factory() ->from($payload); ``` -Factories may change the validation strategy, enable or disable name mapping and named factories, ignore selected named methods, add casts or normalizers, and register the ordered `prepareData`, `beforeValidation`, `beforeRules`, `afterRules`, `withValidator`, `afterValidation`, `beforeCreation`, and `afterCreation` hooks. +Factories may change the validation strategy, enable or disable name mapping and named factories, ignore selected named methods, and add casts or normalizers. They also provide the following hooks, which run in this order: -For creation, `prepareData`, `beforeCreation`, and `afterCreation` run even when validation is skipped. `beforeValidation`, `beforeRules`, and `afterRules` run only when the operation validates or returns rules; `withValidator` and `afterValidation` run only when validation executes. Call `alwaysValidate()` when these validation hooks must apply to an array, model, JSON value, or another non-request source. +1. `prepareData` +2. `beforeValidation` +3. `beforeRules` +4. `afterRules` +5. `withValidator` +6. `afterValidation` +7. `beforeCreation` +8. `afterCreation` -Each call to `factory()` starts a new operation. Do not store or reuse a factory across requests. Hooks receive the current operation's values and are never cached in worker metadata. +The `prepareData`, `beforeCreation`, and `afterCreation` hooks run even when validation is skipped. The other hooks run while generating rules or validating, as appropriate. Call `alwaysValidate()` when validation hooks should also apply to an array, model, JSON value, or another non-request source. + +Each call to `factory()` returns a new factory. Configure and use the factory where it is created instead of storing one and reusing it across requests. ## Transformation @@ -563,7 +576,7 @@ $array = $product->toArray(); $json = $product->toJson(); ``` -`toArray()` recursively transforms nested transformable data, typed iterable items, dates, enums, and `Arrayable` values. `all()` returns visible values without transforming nested values. `transform()` accepts a `TransformationContext` or `TransformationContextFactory` for advanced one-operation control. +`toArray()` recursively transforms nested transformable data, typed iterable items, dates, enums, and `Arrayable` values. The `all()` method returns visible property values without transforming nested values. For more control over a single transformation, pass a `TransformationContext` or `TransformationContextFactory` to `transform()`. `Dto` has no transformation API. Use public properties directly, or choose `Data` or `Resource` when output mapping, `Optional` omission, lazy values, or built-in transformation is required. @@ -594,7 +607,9 @@ return $user->include('profile')->toArray(); `Lazy::when` and `Lazy::whenLoaded` add conditional and relation-aware values. `Lazy::closure` returns the closure itself for consumers that understand callback values. Add `#[AutoLazy]`, `#[AutoClosureLazy]`, or `#[AutoWhenLoadedLazy]` to let `from()` wrap supplied values automatically. -Automatic lazy values defer their nested construction work when validation does not require it. A custom `AutoLazy::build()` implementation receives the original raw source aligned with the property that won. Values changed by validation hooks receive the hook's final payload instead. A named factory returning another normalizable value makes that return the aligned source. `AutoWhenLoadedLazy` requires a Model source; a hook-selected morph with no Model fails clearly rather than retaining stale source state. +Automatic lazy values postpone creating their nested values until they are included, unless validation needs them first. + +When you create a custom `AutoLazy` attribute, its `build()` method receives the original source that supplied the property. If a validation hook changes the property, the method receives the payload returned by that hook instead. When a named factory returns another value for Hypervel to process, that value becomes the source. `AutoWhenLoadedLazy` requires an Eloquent model and throws an exception when no model source is available. ### Partial Trees @@ -629,7 +644,7 @@ public function with(): array return $user->additional(['meta' => ['version' => 1]]); ``` -These values participate in HTTP resource responses, not Eloquent persistence. Dumps show the current logical `all()` view, so hidden, excluded lazy, and `Optional` values do not expose package internals. +These values are included in HTTP resource responses but are not stored by Eloquent. When a data object is dumped, it displays the same values as `all()`, so hidden, excluded lazy, and `Optional` values are omitted. ## Collections @@ -638,18 +653,22 @@ Use `collect()` to create several objects while preserving supported source shap ```php $users = UserData::collect($rows); -$users = UserData::collect($rows, DataCollection::class); -$users = UserData::collect($rows, Collection::class); -$users = UserData::collect($rows, 'array'); +$dataCollection = UserData::collect($rows, DataCollection::class); +$collection = UserData::collect($rows, Collection::class); +$array = UserData::collect($rows, 'array'); ``` -The `$into` argument accepts `null`, `'array'`, or a class-string. Narrow values read from configuration to `class-string` before passing them. With `null`, arrays remain arrays, ordinary collections remain collections, lazy collections remain lazy when validation does not require materialization, and Hypervel paginators are cloned with their metadata intact. Eloquent sources become base support collections because data objects are not Eloquent models. +The `$into` argument accepts `null`, `'array'`, or a class name. When the target comes from configuration, narrow it to a `class-string` before passing it so static analysis can infer the return type. When `$into` is `null`, arrays remain arrays, ordinary collections remain collections, and lazy collections remain lazy unless validation needs to read their values. Hypervel paginators are cloned with their pagination details intact. Eloquent collections become base support collections because data objects are not Eloquent models. -`DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` provide typed items, keyed access, transformation, and response behavior. Use `toCollection()` for map, filter, reduce, and other collection operations. Paginator wrappers are not Eloquent-castable because their metadata cannot be reconstructed from a JSON item array; persist their items through `DataCollection`. +`DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` provide typed items, transformation, and response behavior. `DataCollection` also provides keyed access when its underlying collection supports it. Use `toCollection()` for map, filter, reduce, and other collection operations. Paginated data collections cannot be stored directly by Eloquent because their pagination details cannot be recreated from a JSON array. Store their items through a `DataCollection` instead. -When a source remains lazy, every traversal creates its items again and `count()` is also a traversal. If the items are needed more than once, materialize them once with `$collection->toCollection()->collect()` and reuse that eager collection. +When a source remains lazy, every traversal creates its items again, and calling `count()` also traverses the source. If you need the items more than once, collect them into an eager collection and reuse it: + +```php +$eagerUsers = $dataCollection->toCollection()->collect(); +``` -All eager items in one root `collect()` call share one construction operation and, when selected, one Validator. Eloquent collections batch explicitly requested `#[LoadRelation]` paths before item construction. +When validation is enabled, Hypervel validates the complete collection at once so collection rules and hooks can work with the entire payload. When collecting Eloquent models, Hypervel loads any relations requested with `#[LoadRelation]` before creating the data objects. ## HTTP Resources @@ -662,7 +681,7 @@ return UserData::from($user); return UserData::collect($users, DataCollection::class); ``` -Responses use Hypervel's JSON resource and paginator machinery, including native links and metadata. Use `wrap()` or `withoutWrapping()` on one object or collection. The global `data.wrap` setting supplies the package default without mutating `JsonResource::$wrap` during a request. +Responses use Hypervel's JSON resources and paginator support, including their links and pagination details. Use `wrap()` or `withoutWrapping()` on an object or collection. You may also define the default wrapper using the `data.wrap` configuration option. Override static `jsonOptions()` or `withResponse(Request $request, JsonResponse $response)` for Laravel-style response customization. Query-string `include`, `exclude`, `only`, and `except` selections are disabled unless the data class allows them through `allowedRequestIncludes()`, `allowedRequestExcludes()`, `allowedRequestOnly()`, or `allowedRequestExcept()`. @@ -738,9 +757,9 @@ class User extends Model } ``` -Eloquent stores a complete constructable view using PHP property names. Hidden declared values are included; computed, virtual, appended, and response-only values are omitted. Instance partials are ignored without being consumed. Output transformers still run, so a one-way transformer needs a matching input cast or `WithCastAndTransformer` for a round trip. +Eloquent stores all values needed to recreate the data object using its PHP property names. Hidden properties are stored, while computed, virtual, appended, and response-only values are omitted. Partial selections do not change the stored value and are not consumed. Output transformers still run, so a one-way transformer needs a matching input cast or `WithCastAndTransformer` to recreate the original value. -Conditional and relation lazy values must already be included when the model is saved. Persistence never loads a relation. Closure and Inertia lazy values cannot be stored because they do not resolve to constructable data. +Conditional and relation lazy values must already be included when the model is saved, and saving never loads a relation. Closure and Inertia lazy values cannot be stored because they do not resolve to ordinary data values. Both casts support `encrypted` and `default` arguments. Abstract data classes use an enforced alias map unless they select a concrete subtype through `PropertyMorphableData::morph()`: @@ -782,9 +801,9 @@ class UpdatePostData extends Data } ``` -Contextual values are resolved only after validation succeeds and always win over caller input and creation hooks, including when the resolved value is `null`. Promoted contextual properties are known but discarded from strict input validation. A distinct-name, non-promoted contextual parameter is constructor-only. Use a named factory or creation hook without the contextual attribute when payload input should win. +Contextual values are resolved only after validation succeeds. They always take precedence over input and creation hooks, including when the resolved value is `null`. Input supplied for a promoted contextual property is allowed by strict unknown-field validation but is ignored. A non-promoted contextual parameter with its own name is passed only to the constructor. Use a named factory or creation hook without the contextual attribute when input should take precedence. -`CurrentUser` and `RouteParameter` accept an optional `property` path and use `data_get()` semantics. Accessors and Eloquent relations may run while traversing that path. `RequestAttribute` selects an exact request-attributes key; `Config`, `Context`, `Give`, and custom contextual attributes work through the same constructor boundary. +`CurrentUser` and `RouteParameter` accept an optional `property` path and use `data_get()` semantics. Accessors and Eloquent relations may run while traversing that path. `RequestAttribute` selects an exact request attribute key. The `Config`, `Context`, and `Give` attributes are also supported, as are custom contextual attributes. ## Inertia @@ -804,7 +823,7 @@ $data = new DashboardData( ); ``` -`#[AutoInertiaLazy]` and `#[AutoInertiaDeferred]` provide automatic variants. Existing `DeferProp` instances retain their complete merge, caching, grouping, and rescue state. Ordinary Data creation and transformation do not resolve Inertia classes when the integration is unused. +`#[AutoInertiaLazy]` and `#[AutoInertiaDeferred]` provide automatic variants. Existing `DeferProp` instances retain their merge, caching, grouping, and rescue settings. ## Saloon @@ -833,7 +852,7 @@ public function createDtoFromResponse(Response $response): GitHubUserData } ``` -Saloon attaches its response through the existing `WithResponse` contract. `hypervel/data` has no Saloon dependency. +Saloon attaches the response to the data object through its existing `WithResponse` contract. ## Generating Data Classes @@ -849,13 +868,13 @@ The class is placed under your application's `Data` namespace, normally `App\Dat ## Worker Lifetime -Data metadata and typed configuration are retained for the worker lifetime. Metadata contains immutable class recipes, not requests, validators, models, resolved extensions, or factory hooks. There is no discovery or generated metadata cache command. +Hypervel analyzes each data class when it is first used and keeps that description for the worker lifetime. The cached description never contains request data or values from a data object. There is no metadata cache command to run when deploying your application. -Register `Lazy`, `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` macros during provider boot. Each class owns a worker-lifetime macro registry; do not register request-specific callbacks or values. Configure morph aliases during boot for the same reason. +Register `Lazy`, `DataCollection`, `PaginatedDataCollection`, and `CursorPaginatedDataCollection` macros during provider boot. These macros remain registered for the worker lifetime, so they must not contain request-specific callbacks or values. Configure morph aliases during boot for the same reason. -VarDumper displays the current logical `all()` view for transformable data and an `items` envelope for data collections. It hides construction metadata, partial trees, and operation state without adding runtime work outside an explicit dump. +When dumped, a transformable data object displays the same values as `all()`. Data collections display their values under an `items` key. Internal package state is not included. -Data objects do not implement `ArrayAccess`. Read public properties or call `toArray()`. Data collections retain keyed access and enumeration. +Data objects do not implement `ArrayAccess`. Read public properties or call `toArray()`. Data collections support enumeration and provide keyed access when their underlying collection supports it. ## Credits diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index 5ee35a681..1e25dc5db 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -364,9 +364,9 @@ protected function casts(): array } ``` -Both casts use Hypervel's configured Eloquent JSON codec and support `encrypted` and `default` arguments. They store a complete constructable view: PHP property names are used, hidden declared properties are retained, computed and appended output is omitted, and object partials are ignored without being consumed. Conditional and relation lazy values must already be included when saved; persistence never loads a relation. Closure and Inertia lazy values cannot be persisted. +Both casts use Hypervel's configured Eloquent JSON codec and support the `encrypted` and `default` arguments. When storing an object, the casts use PHP property names and include hidden properties. Computed and appended values are omitted, while partial selections are ignored without being consumed. Conditional and relation lazy values must already be included when the model is saved, and saving never loads a relation. Closure and Inertia lazy values cannot be stored. -`Dto` is not Eloquent-castable because it deliberately has no transformation contract. Paginated Data wrappers are also not castable because a JSON item array cannot reconstruct paginator metadata; persist their items through `DataCollection`. +`Dto` cannot be cast by Eloquent because it does not transform values. Paginated data collections also cannot be cast because their pagination details cannot be recreated from a JSON array. Store their items through a `DataCollection` instead. For the complete mapping, lazy-value, abstract morph, and encrypted-cast behavior, see the [Data Objects documentation](/docs/{{version}}/data-objects#eloquent-casting). diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 0d4664e3e..ad819354a 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -502,7 +502,9 @@ When porting schemas that place sibling assertions beside a local `$ref` or use ### Data Objects -When porting `spatie/laravel-data`, replace its namespace with `Hypervel\Data` and review the [Data Objects documentation](/docs/{{version}}/data-objects). Hypervel retains the familiar `Data`, `Dto`, `Resource`, `Optional`, mapping, casting, validation, lazy-value, collection, resource, and Eloquent APIs while adapting their internals to long-lived workers. +When porting `spatie/laravel-data`, replace its namespace with `Hypervel\Data` and review the [Data Objects documentation](/docs/{{version}}/data-objects). The familiar `Data`, `Dto`, `Resource`, `Optional`, mapping, casting, validation, lazy-value, collection, resource, and Eloquent APIs are all available. + +Replace Spatie's `From*` attributes with Hypervel contextual constructor attributes and its `withOptionalValues()` and `withoutOptionalValues()` factory switches with declared `Optional` unions. `SerializeTransformer` and `UnserializeCast` are not included; use native PHP serialization or explicit custom casts and transformers. Livewire and TypeScript integrations are also not included. Model attributes containing `null` remain explicit values, including for non-nullable properties with defaults. When several payloads are supplied to `from()`, the first payload containing a property's input key wins, including when its value is `null`.