From 712f6e1014fc84624d1cee9bef1549c2ebc72cc2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:05:53 +0000 Subject: [PATCH 1/5] Share fixed Data value conversions Extract the built-in, backed-enum, and date conversion rules into one internal ValueCaster used by both ordinary casts and lean construction recipes. This keeps coercion behavior, date formats and timezones, concrete date targets, and existing exception contracts in one authoritative implementation without retaining cast instances in worker metadata. --- src/data/src/Casts/BuiltinTypeCast.php | 25 +-- src/data/src/Casts/DateTimeInterfaceCast.php | 77 +-------- src/data/src/Casts/EnumCast.php | 21 +-- src/data/src/Support/Creation/ValueCaster.php | 159 ++++++++++++++++++ 4 files changed, 172 insertions(+), 110 deletions(-) create mode 100644 src/data/src/Support/Creation/ValueCaster.php diff --git a/src/data/src/Casts/BuiltinTypeCast.php b/src/data/src/Casts/BuiltinTypeCast.php index 94d2a5562..32f139b29 100644 --- a/src/data/src/Casts/BuiltinTypeCast.php +++ b/src/data/src/Casts/BuiltinTypeCast.php @@ -6,6 +6,7 @@ use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; +use Hypervel\Data\Support\Creation\ValueCaster; use Hypervel\Data\Support\DataProperty; class BuiltinTypeCast implements Cast, IterableItemCast @@ -49,28 +50,6 @@ public function castIterableItem( */ 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, - }; + return ValueCaster::castBuiltin($this->type, $value); } } diff --git a/src/data/src/Casts/DateTimeInterfaceCast.php b/src/data/src/Casts/DateTimeInterfaceCast.php index 022efb149..cc5900c8a 100644 --- a/src/data/src/Casts/DateTimeInterfaceCast.php +++ b/src/data/src/Casts/DateTimeInterfaceCast.php @@ -4,16 +4,11 @@ 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\Creation\ValueCaster; use Hypervel\Data\Support\DataProperty; -use Hypervel\Support\Facades\Date; -use Throwable; class DateTimeInterfaceCast implements Cast, IterableItemCast { @@ -73,68 +68,14 @@ protected function castValue( 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, - ): 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 DateTime && ! $datetime instanceof DateTimeImmutable) - || ! $datetime instanceof $type - ) { - return null; - } - - return $datetime; + return ValueCaster::castDate( + type: $type, + value: $value, + context: $context, + format: $this->format, + setTimeZone: $this->setTimeZone, + timeZone: $this->timeZone, + ); } /** diff --git a/src/data/src/Casts/EnumCast.php b/src/data/src/Casts/EnumCast.php index abdb4695b..fccbe6821 100644 --- a/src/data/src/Casts/EnumCast.php +++ b/src/data/src/Casts/EnumCast.php @@ -5,11 +5,10 @@ namespace Hypervel\Data\Casts; use BackedEnum; -use Hypervel\Data\Exceptions\CannotCastEnum; use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; +use Hypervel\Data\Support\Creation\ValueCaster; use Hypervel\Data\Support\DataProperty; -use Throwable; class EnumCast implements Cast, IterableItemCast { @@ -65,23 +64,7 @@ protected function castValue( 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); - } + return ValueCaster::castEnum($type, $value, $property); } /** diff --git a/src/data/src/Support/Creation/ValueCaster.php b/src/data/src/Support/Creation/ValueCaster.php new file mode 100644 index 000000000..1876a0601 --- /dev/null +++ b/src/data/src/Support/Creation/ValueCaster.php @@ -0,0 +1,159 @@ + self::castBoolean($value), + 'int' => (int) $value, + 'float' => (float) $value, + 'array' => (array) $value, + 'string' => (string) $value, + }; + } + + /** + * Cast one value to a backed enum. + * + * @param null|class-string $type + */ + public static function castEnum( + ?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); + } + } + + /** + * Cast one value to a declared date type. + * + * @param null|class-string $type + * @param null|non-empty-list|string $format + */ + public static function castDate( + ?string $type, + mixed $value, + CreationContext $context, + string|array|null $format = null, + ?string $setTimeZone = null, + ?string $timeZone = null, + ): Uncastable|DateTimeInterface { + if ($type === null) { + return Uncastable::create(); + } + + $formats = $format === null + ? $context->dateFormats + : (is_array($format) ? $format : [$format]); + + if (is_string($value)) { + $value = preg_replace('/(\.\d{6})\d+/', '$1', $value); + } + + $sourceTimeZone = $timeZone === null ? null : new DateTimeZone($timeZone); + + foreach ($formats as $format) { + try { + $datetime = self::createDate( + $type, + $format, + $value instanceof DateTimeInterface ? $value->format($format) : (string) $value, + $sourceTimeZone, + ); + } catch (Throwable) { + $datetime = null; + } + + if ($datetime === null) { + continue; + } + + $targetTimeZone = $setTimeZone ?? $context->dateTimezone; + + return $targetTimeZone === null + ? $datetime + : $datetime->setTimezone(new DateTimeZone($targetTimeZone)); + } + + throw CannotCastDate::create($formats, $type, $value); + } + + /** + * Cast one value to a boolean. + */ + protected static function castBoolean(mixed $value): bool + { + if (! is_string($value)) { + return (bool) $value; + } + + return match (strtolower($value)) { + 'true' => true, + 'false' => false, + default => (bool) $value, + }; + } + + /** + * Create a date using the declared concrete type or Hypervel's date factory. + * + * @param class-string $type + */ + protected static function createDate( + string $type, + string $format, + string $value, + ?DateTimeZone $timeZone, + ): 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 DateTime && ! $datetime instanceof DateTimeImmutable) + || ! $datetime instanceof $type + ) { + return null; + } + + return $datetime; + } +} From 970470886ff42a5e7dd31dff2348f5dd88911bbb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:06:18 +0000 Subject: [PATCH 2/5] Add automatic lean Data execution recipes Compile immutable per-class construction and transformation recipes from existing Data metadata, then select them automatically for supported runtime shapes while preserving the general engine as the single fallback. Reuse immutable default creation contexts, preflight complete nodes before conversion, share nested operation state, retain named-factory and hook boundaries, and keep bulk-copy transformation for plain objects. Unsupported declarations and ambiguous conversion families continue through the established path before construction. Defer PHPDoc parser setup when native types prove iterable metadata cannot apply. Add focused equivalence, fallback, metadata, lazy, partial, mapping, constructor, and transformation coverage so lean execution stays behaviorally identical to the general path. --- src/data/src/Enums/DataPropertyOperation.php | 14 + .../DataIterableAnnotationReader.php | 32 +- .../Creation/CreationContextFactory.php | 36 +- .../Support/Creation/DataCreationRecipe.php | 23 + src/data/src/Support/Creation/DataCreator.php | 224 +++++-- src/data/src/Support/DataClass.php | 9 +- src/data/src/Support/DataProperty.php | 5 + .../Support/Factories/DataClassFactory.php | 139 ++++- .../Support/Factories/DataPropertyFactory.php | 133 +++- .../DataTransformationRecipe.php | 23 + .../Transformation/DataTransformer.php | 95 ++- .../Data/Support/Creation/DataCreatorTest.php | 568 +++++++++++++++++- tests/Data/Support/DataClassTest.php | 174 +++++- .../DataIterableAnnotationReaderTest.php | 212 +++++++ .../Transformation/DataTransformerTest.php | 163 +++++ 15 files changed, 1728 insertions(+), 122 deletions(-) create mode 100644 src/data/src/Enums/DataPropertyOperation.php create mode 100644 src/data/src/Support/Creation/DataCreationRecipe.php create mode 100644 src/data/src/Support/Transformation/DataTransformationRecipe.php diff --git a/src/data/src/Enums/DataPropertyOperation.php b/src/data/src/Enums/DataPropertyOperation.php new file mode 100644 index 000000000..2429c0cd4 --- /dev/null +++ b/src/data/src/Enums/DataPropertyOperation.php @@ -0,0 +1,14 @@ +lexer = new Lexer($config); - $this->parser = new PhpDocParser( - $config, - new TypeParser($config, $constantExpressionParser), - $constantExpressionParser, - ); - } + protected ?PhpDocParser $parser = null; /** * Get iterable annotations declared for class properties. @@ -125,6 +109,18 @@ protected function parse(string|false $comment): ?PhpDocNode return null; } + if ($this->lexer === null || $this->parser === null) { + $config = new ParserConfig(usedAttributes: []); + $constantExpressionParser = new ConstExprParser($config); + + $this->lexer = new Lexer($config); + $this->parser = new PhpDocParser( + $config, + new TypeParser($config, $constantExpressionParser), + $constantExpressionParser, + ); + } + return $this->parser->parse(new TokenIterator($this->lexer->tokenize($comment))); } diff --git a/src/data/src/Support/Creation/CreationContextFactory.php b/src/data/src/Support/Creation/CreationContextFactory.php index bdaae9f29..e58369196 100644 --- a/src/data/src/Support/Creation/CreationContextFactory.php +++ b/src/data/src/Support/Creation/CreationContextFactory.php @@ -81,6 +81,7 @@ public function __construct( protected readonly DataCreator $creator, protected readonly DataConfig $config, public readonly string $dataClass, + protected ?CreationContext $createContext = null, ) { $this->validationStrategy = $this->config->validationStrategy; } @@ -93,6 +94,7 @@ public function __construct( public function validationStrategy(ValidationStrategy $validationStrategy): static { $this->validationStrategy = $validationStrategy; + $this->invalidateCreateContext(); return $this; } @@ -129,6 +131,7 @@ public function alwaysValidate(): static public function withPropertyNameMapping(bool $withPropertyNameMapping = true): static { $this->mapPropertyNames = $withPropertyNameMapping; + $this->invalidateCreateContext(); return $this; } @@ -139,6 +142,7 @@ public function withPropertyNameMapping(bool $withPropertyNameMapping = true): s public function withoutPropertyNameMapping(bool $withoutPropertyNameMapping = true): static { $this->mapPropertyNames = ! $withoutPropertyNameMapping; + $this->invalidateCreateContext(); return $this; } @@ -151,6 +155,7 @@ public function withoutPropertyNameMapping(bool $withoutPropertyNameMapping = tr public function withoutMagicalCreation(bool $withoutMagicalCreation = true): static { $this->disableMagicalCreation = $withoutMagicalCreation; + $this->invalidateCreateContext(); return $this; } @@ -161,6 +166,7 @@ public function withoutMagicalCreation(bool $withoutMagicalCreation = true): sta public function withMagicalCreation(bool $withMagicalCreation = true): static { $this->disableMagicalCreation = ! $withMagicalCreation; + $this->invalidateCreateContext(); return $this; } @@ -171,6 +177,7 @@ public function withMagicalCreation(bool $withMagicalCreation = true): static public function ignoreMagicalMethod(string ...$methods): static { array_push($this->ignoredMagicalMethods, ...$methods); + $this->invalidateCreateContext(); return $this; } @@ -183,6 +190,7 @@ public function ignoreMagicalMethod(string ...$methods): static public function withCast(string $castable, Cast|string $cast): static { $this->casts[$castable] = $cast; + $this->invalidateCreateContext(); return $this; } @@ -195,6 +203,7 @@ public function withCast(string $castable, Cast|string $cast): static public function withCastCollection(array $casts): static { $this->casts = array_replace($this->casts, $casts); + $this->invalidateCreateContext(); return $this; } @@ -207,6 +216,7 @@ public function withCastCollection(array $casts): static public function withNormalizers(Normalizer|string ...$normalizers): static { array_push($this->normalizers, ...$normalizers); + $this->invalidateCreateContext(); return $this; } @@ -217,6 +227,7 @@ public function withNormalizers(Normalizer|string ...$normalizers): static public function prepareData(Closure $hook): static { $this->prepareDataHooks[] = $hook; + $this->invalidateCreateContext(); return $this; } @@ -227,6 +238,7 @@ public function prepareData(Closure $hook): static public function beforeValidation(Closure $hook): static { $this->beforeValidationHooks[] = $hook; + $this->invalidateCreateContext(); return $this; } @@ -237,6 +249,7 @@ public function beforeValidation(Closure $hook): static public function beforeRules(Closure $hook): static { $this->beforeRulesHooks[] = $hook; + $this->invalidateCreateContext(); return $this; } @@ -247,6 +260,7 @@ public function beforeRules(Closure $hook): static public function afterRules(Closure $hook): static { $this->afterRulesHooks[] = $hook; + $this->invalidateCreateContext(); return $this; } @@ -257,6 +271,7 @@ public function afterRules(Closure $hook): static public function withValidator(Closure $hook): static { $this->withValidatorHooks[] = $hook; + $this->invalidateCreateContext(); return $this; } @@ -267,6 +282,7 @@ public function withValidator(Closure $hook): static public function afterValidation(Closure $hook): static { $this->afterValidationHooks[] = $hook; + $this->invalidateCreateContext(); return $this; } @@ -277,6 +293,7 @@ public function afterValidation(Closure $hook): static public function beforeCreation(Closure $hook): static { $this->beforeCreationHooks[] = $hook; + $this->invalidateCreateContext(); return $this; } @@ -287,6 +304,7 @@ public function beforeCreation(Closure $hook): static public function afterCreation(Closure $hook): static { $this->afterCreationHooks[] = $hook; + $this->invalidateCreateContext(); return $this; } @@ -298,7 +316,11 @@ public function afterCreation(Closure $hook): static */ public function get(CreationMode $mode = CreationMode::Create): CreationContext { - return new CreationContext( + if ($mode === CreationMode::Create && $this->createContext !== null) { + return $this->createContext; + } + + $context = new CreationContext( dataClass: $this->dataClass, mode: $mode, validationStrategy: $mode === CreationMode::Create @@ -322,6 +344,18 @@ public function get(CreationMode $mode = CreationMode::Create): CreationContext dateFormats: $this->config->dateFormats, dateTimezone: $this->config->dateTimezone, ); + + return $mode === CreationMode::Create + ? $this->createContext = $context + : $context; + } + + /** + * Invalidate the immutable Create context after factory customization. + */ + protected function invalidateCreateContext(): void + { + $this->createContext = null; } /** diff --git a/src/data/src/Support/Creation/DataCreationRecipe.php b/src/data/src/Support/Creation/DataCreationRecipe.php new file mode 100644 index 000000000..03ae2dde0 --- /dev/null +++ b/src/data/src/Support/Creation/DataCreationRecipe.php @@ -0,0 +1,23 @@ + $properties + */ + public function __construct( + public array $properties, + ) { + } +} diff --git a/src/data/src/Support/Creation/DataCreator.php b/src/data/src/Support/Creation/DataCreator.php index df4ef4617..2c4f7cc85 100644 --- a/src/data/src/Support/Creation/DataCreator.php +++ b/src/data/src/Support/Creation/DataCreator.php @@ -26,6 +26,7 @@ use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; use Hypervel\Data\Enums\CustomCreationMethodType; +use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\Exceptions\CannotCreateAbstractClass; use Hypervel\Data\Exceptions\CannotCreateData; use Hypervel\Data\Exceptions\CannotCreateDataCollectable; @@ -61,6 +62,9 @@ /** @phpstan-type OperationMemo array> */ class DataCreator { + /** @var array, CreationContext> */ + protected array $defaultContexts = []; + /** * Create a data creator. */ @@ -84,7 +88,16 @@ public function __construct( */ public function factory(string $class): CreationContextFactory { - return new CreationContextFactory($this, $this->config, $class); + $factory = new CreationContextFactory( + $this, + $this->config, + $class, + $this->defaultContexts[$class] ?? null, + ); + + $this->defaultContexts[$class] ??= $factory->get(); + + return $factory; } /** @@ -159,9 +172,10 @@ public function resolveAutoLazyProperty( if ($replay !== null && ! $property->isFinishedValue($value)) { $wireKey = $state->originalKey($propertyName); + $inputPath = $property->inputPath($wireKey); $this->fillResolvedProperty( $property, - $property->inputPath($wireKey), + $inputPath, $value, $state, $extensions, @@ -169,6 +183,7 @@ public function resolveAutoLazyProperty( false, $replay === AutoLazyReplayMode::Hook, ); + $value = $state->getValue($inputPath); } return $this->castProperty($property, $value, $state, $extensions); @@ -315,7 +330,7 @@ function (mixed $item) use ($class, $context, &$extensions): BaseData { return $this->createUnvalidatedNode( $class, $context, - $item, + [$item], $extensions, ); }, @@ -328,23 +343,48 @@ function (mixed $item) use ($class, $context, &$extensions): BaseData { * @template TData of BaseData * * @param class-string $class + * @param array $payloads caller keys remain meaningful until named factory matching * @param OperationMemo $extensions * @return TData */ protected function createUnvalidatedNode( string $class, CreationContext $context, - mixed $item, + array $payloads, array &$extensions, ): BaseData { - if ($item instanceof $class) { - return $item; + if (count($payloads) === 1) { + $key = array_key_first($payloads); + + if ($payloads[$key] instanceof $class) { + return $payloads[$key]; + } + } + + $dataClass = $this->dataClasses->get($class); + $payloads = $this->resolveFactoryPayloads($dataClass, $context, $payloads); + + if ($payloads instanceof BaseData) { + return $payloads; + } + + $direct = $this->tryCreateDirectArrayNode( + $dataClass, + $payloads, + $context, + $extensions, + false, + false, + ); + + if ($direct !== null) { + return $direct; } $state = ConstructionState::create($context, $class); - $direct = $this->fillNode( - $class, - [$item], + $this->fillGeneralNode( + $dataClass, + $payloads, $state, $extensions, false, @@ -353,7 +393,7 @@ protected function createUnvalidatedNode( // Named factories, morph selection, instantiation, and after-creation hooks all enforce this class. /** @var TData $data */ - $data = $direct ?? $this->castAndInstantiateNode($state, $extensions); + $data = $this->castAndInstantiateNode($state, $extensions); return $data; } @@ -593,12 +633,22 @@ protected function execute( $shouldValidate = $context->mode !== CreationMode::Rules && $this->validator->shouldValidate($context, $payloads); $compilesRules = $shouldValidate || $context->mode === CreationMode::Rules; + + $extensions = []; + + if ($context->mode === CreationMode::Create + && count($payloads) === 1 + && ! $shouldValidate + && ! $compilesRules + ) { + return $this->createUnvalidatedNode($class, $context, $payloads, $extensions); + } + $request = $shouldValidate ? $this->validator->authorize($class, $payloads) : null; $state = ConstructionState::create($context, $class); - $extensions = []; $direct = $this->fillNode( $class, $payloads, @@ -669,26 +719,17 @@ protected function fillNode( 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]; - } + $payloads = $this->resolveFactoryPayloads($dataClass, $state->context, $payloads); - if (! array_is_list($payloads)) { - $payloads = array_values($payloads); + if ($payloads instanceof BaseData) { + return $payloads; } $direct = $this->tryCreateDirectArrayNode( $dataClass, $payloads, - $state, + $state->context, + $extensions, $shouldValidate, $compilesRules, ); @@ -697,6 +738,33 @@ protected function fillNode( return $direct; } + $this->fillGeneralNode( + $dataClass, + $payloads, + $state, + $extensions, + $shouldValidate, + $compilesRules, + ); + + return null; + } + + /** + * Fill one post-factory value through the general construction path. + * + * @param array $payloads + * @param OperationMemo $extensions + */ + protected function fillGeneralNode( + DataClass $dataClass, + array $payloads, + ConstructionState $state, + array &$extensions, + bool $shouldValidate, + bool $compilesRules, + ): void { + $class = $dataClass->name; $normalizers = $this->resolveNormalizers($dataClass, $state->context, $extensions); $payloads = $payloads === [] ? [[]] : $payloads; $sources = []; @@ -750,30 +818,29 @@ protected function fillNode( $compilesRules, false, ); - - return null; } /** - * Create one exact array node without entering the general Fill path. + * Create one fixed array node without entering the general Fill path. * - * A miss remains in the current invocation so a named factory is never matched twice. + * The complete node is checked before conversion so a later miss cannot repeat a + * nested factory, hook, or constructor when the caller enters general Fill. * * @param array $payloads + * @param OperationMemo $extensions */ protected function tryCreateDirectArrayNode( DataClass $dataClass, array $payloads, - ConstructionState $state, + CreationContext $context, + array &$extensions, bool $shouldValidate, bool $compilesRules, ): ?BaseData { - $context = $state->context; - if ($context->mode !== CreationMode::Create || $shouldValidate || $compilesRules - || ! $dataClass->directArrayCreation + || $dataClass->creationRecipe === null || count($payloads) !== 1 || $context->normalizers !== [] || $context->casts !== [] @@ -790,11 +857,26 @@ protected function tryCreateDirectArrayNode( } $properties = []; + $conversions = []; + $requiresOrdinaryInstantiation = false; - foreach ($dataClass->properties as $property) { + foreach ($dataClass->creationRecipe->properties as $property) { $mappedKey = $this->propertyInputKey($property, $context); - $match = $this->matchPropertySource($payload, $property, $mappedKey); - $value = $match === null ? UnknownProperty::create() : $match[1]; + + // This recipe only accepts arrays, so one-segment paths do not need SourceReader's Normalized branch. + $value = $context->mapPropertyNames + && $property->inputMappedPath !== null + && count($property->inputMappedPath) > 1 + ? SourceReader::read($payload, $property->inputMappedPath, $property) + : (array_key_exists($mappedKey, $payload) + ? $payload[$mappedKey] + : UnknownProperty::create()); + + if ($value instanceof UnknownProperty && $mappedKey !== $property->name) { + $value = array_key_exists($property->name, $payload) + ? $payload[$property->name] + : UnknownProperty::create(); + } if ($value instanceof UnknownProperty) { // Computed values are assigned by the class and never enter construction input. @@ -818,11 +900,13 @@ protected function tryCreateDirectArrayNode( continue; } - return null; + $requiresOrdinaryInstantiation = true; + + continue; } if ($property->computed) { - return null; + throw CannotSetComputedValue::create($property); } if ($value === null || $value instanceof Optional) { @@ -831,14 +915,39 @@ protected function tryCreateDirectArrayNode( continue; } - if (! $property->type->acceptsValue($value)) { - return null; + // Null and Optional values exited above, so DataType's wrapper checks are redundant here. + if (! $property->type->type->acceptsValue($value)) { + $operation = $property->constructionOperation; + + if ($operation === DataPropertyOperation::Copy) { + return null; + } + + $conversions[] = [$property, $value, $operation]; + + continue; } $properties[$property->name] = $value; } - return $dataClass->directConstructorInstantiation + foreach ($conversions as [$property, $value, $operation]) { + // Targetless families compile to Copy and return before reaching conversion. + $target = $property->constructionTarget; + $properties[$property->name] = match ($operation) { + DataPropertyOperation::Builtin => ValueCaster::castBuiltin($target, $value), + DataPropertyOperation::Enum => ValueCaster::castEnum($target, $value, $property), + DataPropertyOperation::Date => ValueCaster::castDate($target, $value, $context), + DataPropertyOperation::Data => $this->createUnvalidatedNode( + $target, + $context, + [$value], + $extensions, + ), + }; + } + + return $dataClass->directConstructorInstantiation && ! $requiresOrdinaryInstantiation ? $this->instantiator->instantiateDirect($dataClass, $properties) : $this->instantiator->instantiate($dataClass, $properties); } @@ -1935,7 +2044,7 @@ function (mixed $item) use ($dataClass, $state, &$extensions): BaseData { return $this->createUnvalidatedNode( $dataClass, $state->context, - $item, + [$item], $extensions, ); }, @@ -1967,7 +2076,7 @@ function (mixed $item) use ($dataClass, $state, &$extensions): BaseData { ? $this->createUnvalidatedNode( $dataClass, $state->context, - $item, + [$item], $extensions, ) : $this->castAndInstantiateNode($state, $extensions); @@ -2500,6 +2609,35 @@ protected function matchNamedFactory( return null; } + /** + * Resolve a named factory and normalize its remaining payloads. + * + * Caller keys remain meaningful through factory matching and are discarded afterwards. + * + * @param array $payloads + * @return BaseData|list + */ + protected function resolveFactoryPayloads( + DataClass $dataClass, + CreationContext $context, + array $payloads, + ): BaseData|array { + $class = $dataClass->name; + $match = $this->matchNamedFactory($dataClass, $context, $payloads); + + if ($match !== null) { + $result = $this->invokeNamedFactory($dataClass, ...$match); + + if ($result instanceof $class) { + return $result; + } + + $payloads = [$result]; + } + + return array_is_list($payloads) ? $payloads : array_values($payloads); + } + /** * Find the first compatible named collection factory. */ diff --git a/src/data/src/Support/DataClass.php b/src/data/src/Support/DataClass.php index dbef9c4d4..a83f05d77 100644 --- a/src/data/src/Support/DataClass.php +++ b/src/data/src/Support/DataClass.php @@ -6,6 +6,8 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Support\Annotations\DataIterableAnnotation; +use Hypervel\Data\Support\Creation\DataCreationRecipe; +use Hypervel\Data\Support\Transformation\DataTransformationRecipe; use ReflectionMethod; /** @@ -18,6 +20,8 @@ * * Contextual parameter names include promoted and constructor-only forms. * Declaration validation prevents constructor-only names from colliding with data properties. + * Bulk-copy transformation and fixed transformation recipes are mutually exclusive; + * when both are absent, transformation uses the general property loop. * * @param class-string $name * @param array $properties @@ -53,8 +57,9 @@ public function __construct( public ?string $errorBag, public ?string $redirect, public ?string $redirectRoute, - public bool $plainTransform, - public bool $directArrayCreation, + public bool $bulkCopyTransformation, + public ?DataTransformationRecipe $transformationRecipe, + public ?DataCreationRecipe $creationRecipe, public bool $directConstructorInstantiation, public DataAttributesCollection $attributes, public array $dataIterablePropertyAnnotations, diff --git a/src/data/src/Support/DataProperty.php b/src/data/src/Support/DataProperty.php index 6870ff203..a8d1b48b5 100644 --- a/src/data/src/Support/DataProperty.php +++ b/src/data/src/Support/DataProperty.php @@ -10,6 +10,7 @@ use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; +use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\PaginatedDataCollection; use Hypervel\Data\Transformers\Transformer; use Hypervel\Database\Eloquent\Model; @@ -29,6 +30,7 @@ class DataProperty * @param null|ReflectionAttribute $cast * @param null|ReflectionAttribute $transformer * @param null|non-empty-list $inputMappedPath + * @param null|'array'|'bool'|'float'|'int'|'string'|class-string $constructionTarget * @param list> $configuredCasts * @param list> $configuredTransformers */ @@ -36,6 +38,9 @@ public function __construct( public readonly string $name, public readonly string $className, public readonly DataPropertyType $type, + public readonly DataPropertyOperation $constructionOperation, + public readonly ?string $constructionTarget, + public readonly ?DataPropertyOperation $transformationOperation, public readonly bool $validate, public readonly bool $computed, public readonly bool $hidden, diff --git a/src/data/src/Support/Factories/DataClassFactory.php b/src/data/src/Support/Factories/DataClassFactory.php index 671f1d692..f466aba13 100644 --- a/src/data/src/Support/Factories/DataClassFactory.php +++ b/src/data/src/Support/Factories/DataClassFactory.php @@ -4,6 +4,8 @@ namespace Hypervel\Data\Support\Factories; +use BackedEnum; +use DateTimeInterface; use Hypervel\Data\Attributes\AutoLazy; use Hypervel\Data\Attributes\MergeValidationRules; use Hypervel\Data\Contracts\AppendableData; @@ -18,18 +20,23 @@ use Hypervel\Data\Data; use Hypervel\Data\Dto; use Hypervel\Data\Enums\CustomCreationMethodType; +use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\Exceptions\InvalidDataDeclaration; +use Hypervel\Data\Lazy; use Hypervel\Data\Mappers\NameMapper; use Hypervel\Data\Mappers\ProvidedNameMapper; +use Hypervel\Data\Optional; use Hypervel\Data\Resource; use Hypervel\Data\Support\Annotations\DataIterableAnnotation; use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; +use Hypervel\Data\Support\Creation\DataCreationRecipe; use Hypervel\Data\Support\DataClass; use Hypervel\Data\Support\DataConfig; use Hypervel\Data\Support\DataMethod; use Hypervel\Data\Support\DataParameter; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\NameMapperResolver; +use Hypervel\Data\Support\Transformation\DataTransformationRecipe; use Hypervel\Foundation\Http\Attributes\ErrorBag; use Hypervel\Foundation\Http\Attributes\FailOnUnknownFields; use Hypervel\Foundation\Http\Attributes\RedirectTo; @@ -37,8 +44,13 @@ use Hypervel\Foundation\Http\Attributes\StopOnFirstFailure; use ReflectionAttribute; use ReflectionClass; +use ReflectionIntersectionType; use ReflectionMethod; +use ReflectionNamedType; +use ReflectionParameter; use ReflectionProperty; +use ReflectionType; +use ReflectionUnionType; class DataClassFactory { @@ -106,6 +118,9 @@ public function build(ReflectionClass $reflectionClass): DataClass $redirectRoute = $attributes->first(RedirectToRoute::class)?->newInstance(); $lifecycleMethods = $this->resolveLifecycleMethods($reflectionClass); $propertyMorphable = $reflectionClass->implementsInterface(PropertyMorphableData::class); + $transformationRecipe = $this->resolveTransformationRecipe($properties); + $bulkCopyTransformation = $transformationRecipe !== null + && $this->supportsBulkCopyTransformation($properties); return new DataClass( name: $name, @@ -132,8 +147,9 @@ public function build(ReflectionClass $reflectionClass): DataClass errorBag: $errorBag?->name, redirect: $redirect?->url, redirectRoute: $redirectRoute?->route, - plainTransform: $this->isPlainTransform($properties), - directArrayCreation: $this->supportsDirectArrayCreation( + bulkCopyTransformation: $bulkCopyTransformation, + transformationRecipe: $bulkCopyTransformation ? null : $transformationRecipe, + creationRecipe: $this->resolveCreationRecipe( $reflectionClass, $contextualParameters, $properties, @@ -260,9 +276,12 @@ protected function resolveProperties( ?ReflectionAttribute $classAutoLazy, ): array { $constructorAnnotations = $constructor === null + || ! $this->hasIterableAnnotationCandidate($constructor->getParameters()) ? [] : $this->iterableAnnotationReader->getForMethod($constructor); - $classAnnotations = $this->resolveClassAnnotations($reflectionClass); + $classAnnotations = $this->hasIterableAnnotationCandidate($reflectionProperties) + ? $this->resolveClassAnnotations($reflectionClass) + : []; $properties = []; $selectedAnnotations = []; @@ -272,7 +291,9 @@ protected function resolveProperties( && ($parameter->isPromoted || $parameter->contextualAttribute === null) ? $parameter : null; - $propertyAnnotations = $this->iterableAnnotationReader->getForProperty($reflectionProperty); + $propertyAnnotations = $this->typeCanUseIterableAnnotation($reflectionProperty->getType()) + ? $this->iterableAnnotationReader->getForProperty($reflectionProperty) + : []; $annotations = $constructorParameter === null ? [] : ($constructorAnnotations[$name] ?? []); @@ -324,6 +345,58 @@ classAutoLazy: $classAutoLazy, return [$properties, $selectedAnnotations]; } + /** + * Determine if any declaration can use iterable item metadata. + * + * @param iterable $declarations + */ + protected function hasIterableAnnotationCandidate(iterable $declarations): bool + { + foreach ($declarations as $declaration) { + if ($this->typeCanUseIterableAnnotation($declaration->getType())) { + return true; + } + } + + return false; + } + + /** + * Determine if a native type can use iterable item metadata. + */ + protected function typeCanUseIterableAnnotation(?ReflectionType $type): bool + { + if ($type === null) { + return true; + } + + if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) { + foreach ($type->getTypes() as $namedType) { + if ($this->typeCanUseIterableAnnotation($namedType)) { + return true; + } + } + + return false; + } + + if (! $type instanceof ReflectionNamedType) { + return true; + } + + $name = $type->getName(); + + if ($type->isBuiltin()) { + return in_array($name, ['array', 'iterable', 'mixed', 'object', 'callable'], true); + } + + return ! is_a($name, BaseData::class, true) + && ! is_a($name, DateTimeInterface::class, true) + && ! is_a($name, BackedEnum::class, true) + && ! is_a($name, Optional::class, true) + && ! is_a($name, Lazy::class, true); + } + /** * Resolve nearest class-level iterable annotations across inheritance. * @@ -477,29 +550,29 @@ protected function validateMappings(string $class, array $properties): array } /** - * Determine if exact array values can bypass general construction. + * Resolve a fixed construction recipe for an eligible declaration. * * @param ReflectionClass $reflectionClass * @param array $contextualParameters * @param array $properties * @param array $lifecycleMethods */ - protected function supportsDirectArrayCreation( + protected function resolveCreationRecipe( ReflectionClass $reflectionClass, array $contextualParameters, array $properties, array $lifecycleMethods, bool $propertyMorphable, - ): bool { + ): ?DataCreationRecipe { if ($reflectionClass->isAbstract() || $propertyMorphable || isset($lifecycleMethods['normalizers']) || $this->config->normalizers !== []) { - return false; + return null; } if ($contextualParameters !== []) { - return false; + return null; } foreach ($properties as $property) { @@ -509,11 +582,11 @@ protected function supportsDirectArrayCreation( || $property->configuredCasts !== [] || $property->type->getDataCollectableTypes() !== [] || $property->type->getIterableTypes() !== []) { - return false; + return null; } } - return true; + return new DataCreationRecipe(array_values($properties)); } /** @@ -545,30 +618,48 @@ protected function supportsDirectConstructorInstantiation( } /** - * Determine if declared values can be copied directly during transformation. + * Resolve a fixed transformation recipe for an eligible declaration. * * @param array $properties */ - protected function isPlainTransform(array $properties): bool + protected function resolveTransformationRecipe(array $properties): ?DataTransformationRecipe { + $visibleProperties = []; + foreach ($properties as $property) { - if ($property->hidden - || $property->outputMappedName !== null + if ($property->hidden) { + continue; + } + + if ($property->transformationOperation === null || $property->transformer !== null || $property->configuredTransformers !== [] || $property->type->lazyType !== null || $property->type->isOptional - || $property->type->isMixed) { - return false; + || $property->type->isMixed + || $property->type->getDataCollectableTypes() !== [] + || $property->type->getIterableTypes() !== []) { + return null; } - foreach ($property->type->getNamedTypes() as $type) { - if (! $type->builtIn - || $type->kind->isNonDataIterable() - || $type->kind->isDataRelated() - || $type->name === 'object') { - return false; - } + $visibleProperties[] = $property; + } + + return new DataTransformationRecipe(properties: $visibleProperties); + } + + /** + * Determine if declared values can be copied directly during transformation. + * + * @param array $properties + */ + protected function supportsBulkCopyTransformation(array $properties): bool + { + foreach ($properties as $property) { + if ($property->hidden + || $property->outputMappedName !== null + || $property->transformationOperation !== DataPropertyOperation::Copy) { + return false; } } diff --git a/src/data/src/Support/Factories/DataPropertyFactory.php b/src/data/src/Support/Factories/DataPropertyFactory.php index 070f206af..6b07a9a41 100644 --- a/src/data/src/Support/Factories/DataPropertyFactory.php +++ b/src/data/src/Support/Factories/DataPropertyFactory.php @@ -4,6 +4,8 @@ namespace Hypervel\Data\Support\Factories; +use BackedEnum; +use DateTimeInterface; use Hypervel\Data\Attributes\AutoLazy; use Hypervel\Data\Attributes\AutoWhenLoadedLazy; use Hypervel\Data\Attributes\Computed; @@ -14,6 +16,7 @@ use Hypervel\Data\Attributes\WithCastAndTransformer; use Hypervel\Data\Attributes\WithoutValidation; use Hypervel\Data\Attributes\WithTransformer; +use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\Exceptions\InvalidDataDeclaration; use Hypervel\Data\Mappers\NameMapper; use Hypervel\Data\Optional; @@ -97,11 +100,17 @@ public function build( $isVirtual = $reflectionProperty->isVirtual(); $computed = $attributes->has(Computed::class) || $isVirtual; + $configuredCasts = $this->applicableExtensions($type, $this->config->casts); + $configuredTransformers = $this->applicableExtensions($type, $this->config->transformers); + [$constructionOperation, $constructionTarget] = $this->resolveConstructionOperation($type, $computed); $property = new DataProperty( name: $reflectionProperty->name, className: $reflectionProperty->class, type: $type, + constructionOperation: $constructionOperation, + constructionTarget: $constructionTarget, + transformationOperation: $this->resolveTransformationOperation($type), validate: ! $computed && $constructorParameter?->contextualAttribute === null && ! $attributes->has(AutoWhenLoadedLazy::class) @@ -124,8 +133,8 @@ className: $reflectionProperty->class, ? null : (is_int($inputMappedName) ? [$inputMappedName] : explode('.', $inputMappedName)), outputMappedName: $outputMappedName, - configuredCasts: $this->applicableExtensions($type, $this->config->casts), - configuredTransformers: $this->applicableExtensions($type, $this->config->transformers), + configuredCasts: $configuredCasts, + configuredTransformers: $configuredTransformers, attributes: $attributes, reflection: $reflectionProperty, ); @@ -144,6 +153,126 @@ className: $reflectionProperty->class, return $property; } + /** + * Resolve the fixed construction operation for a property declaration. + * + * Classification stops at the first conversion family present, matching the general + * engine's priority. Copy delegates ambiguous or custom conversion to that engine. + * + * @return array{DataPropertyOperation, null|string} + */ + protected function resolveConstructionOperation( + DataPropertyType $type, + bool $computed, + ): array { + if ($computed) { + return [DataPropertyOperation::Copy, null]; + } + + $dataObjectTypes = $type->getDataObjectTypes(); + + if ($dataObjectTypes !== []) { + return count($dataObjectTypes) === 1 + ? [DataPropertyOperation::Data, $dataObjectTypes[0]->dataClass] + : [DataPropertyOperation::Copy, null]; + } + + foreach ($type->getNamedTypes() as $namedType) { + if ($namedType->isCastable) { + return [DataPropertyOperation::Copy, null]; + } + } + + $dateTypes = $this->acceptedTypes($type, DateTimeInterface::class); + + if ($dateTypes !== []) { + return count($dateTypes) === 1 + ? [DataPropertyOperation::Date, $dateTypes[0]] + : [DataPropertyOperation::Copy, null]; + } + + $enumTypes = $this->acceptedTypes($type, BackedEnum::class); + + if ($enumTypes !== []) { + return count($enumTypes) === 1 + ? [DataPropertyOperation::Enum, $enumTypes[0]] + : [DataPropertyOperation::Copy, null]; + } + + if (($target = $type->type->getSingleBuiltinType()) !== null) { + return [DataPropertyOperation::Builtin, $target]; + } + + return [DataPropertyOperation::Copy, null]; + } + + /** + * Resolve the fixed transformation operation for a property declaration. + */ + protected function resolveTransformationOperation(DataPropertyType $type): ?DataPropertyOperation + { + $operations = []; + $targets = []; + + foreach ($type->getNamedTypes() as $namedType) { + $operation = match (true) { + $namedType->kind->isDataObject() => DataPropertyOperation::Data, + ! $namedType->builtIn && is_a($namedType->name, DateTimeInterface::class, true) => DataPropertyOperation::Date, + ! $namedType->builtIn && is_a($namedType->name, BackedEnum::class, true) => DataPropertyOperation::Enum, + $namedType->builtIn && in_array($namedType->name, [ + 'array', + 'bool', + 'false', + 'float', + 'int', + 'null', + 'string', + 'true', + ], true) => DataPropertyOperation::Copy, + default => null, + }; + + if ($operation === null) { + return null; + } + + if ($operation !== DataPropertyOperation::Copy) { + $operations[$operation->name] = $operation; + $targets[$operation->name][$namedType->name] = true; + } + } + + if (count($operations) > 1) { + return null; + } + + $operation = $operations === [] + ? DataPropertyOperation::Copy + : array_values($operations)[0]; + + return count($targets[$operation->name] ?? []) > 1 ? null : $operation; + } + + /** + * Find the declared types accepted by a base class. + * + * @return list + */ + protected function acceptedTypes(DataPropertyType $type, string $baseType): array + { + $types = []; + + foreach ($type->getNamedTypes() as $namedType) { + if ($namedType->builtIn || ! is_a($namedType->name, $baseType, true)) { + continue; + } + + $types[$namedType->name] = $namedType->name; + } + + return array_values($types); + } + /** * Select configured extensions that apply to a property type. * diff --git a/src/data/src/Support/Transformation/DataTransformationRecipe.php b/src/data/src/Support/Transformation/DataTransformationRecipe.php new file mode 100644 index 000000000..dacc7395d --- /dev/null +++ b/src/data/src/Support/Transformation/DataTransformationRecipe.php @@ -0,0 +1,23 @@ + $properties + */ + public function __construct( + public array $properties, + ) { + } +} diff --git a/src/data/src/Support/Transformation/DataTransformer.php b/src/data/src/Support/Transformation/DataTransformer.php index 69c65a3d9..edead0d9b 100644 --- a/src/data/src/Support/Transformation/DataTransformer.php +++ b/src/data/src/Support/Transformation/DataTransformer.php @@ -20,6 +20,7 @@ use Hypervel\Data\Contracts\WrappableData; use Hypervel\Data\CursorPaginatedDataCollection; use Hypervel\Data\DataCollection; +use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\Exceptions\CannotTransformData; use Hypervel\Data\Exceptions\MaxTransformationDepthReached; use Hypervel\Data\Lazy; @@ -147,6 +148,7 @@ public function transformForResourceResponse( * Transform a nested data object within the current root operation. * * @param array $extensions + * @param-out array $extensions */ protected function transformData( BaseData&TransformableData $data, @@ -160,18 +162,32 @@ protected function transformData( $dataClass = $this->dataClasses->get($data::class); - // The plain path includes computed output, which cannot reconstruct the object. - if (! $context->constructable - && $dataClass->plainTransform - && ! $context->hasPartials() - && $context->transformers === [] - ) { - return $this->finalizeTransformation( - $data, - $context, - $this->transformPlain($data, $dataClass), - $includeAdditionalData, - ); + if ($dataClass->bulkCopyTransformation) { + // Bulk copy preserves values and includes computed output, so it cannot reconstruct the object. + if (! $context->constructable + && $context->transformers === [] + && ! $context->hasPartials() + ) { + return $this->finalizeTransformation( + $data, + $context, + $this->transformBulkCopy($data, $dataClass), + $includeAdditionalData, + ); + } + } elseif (($recipe = $dataClass->transformationRecipe) !== null) { + if ($context->transformValues + && ! $context->constructable + && $context->transformers === [] + && ! $context->hasPartials() + ) { + return $this->finalizeTransformation( + $data, + $context, + $this->transformUsingRecipe($data, $recipe, $context, $extensions), + $includeAdditionalData, + ); + } } // Raw storage keeps excluded property hooks from running as a side effect. @@ -387,7 +403,7 @@ protected function finalizeTransformation( * * @return array */ - protected function transformPlain(BaseData $data, DataClass $dataClass): array + protected function transformBulkCopy(BaseData $data, DataClass $dataClass): array { // Every property is emitted, so public get hooks own the logical values. $values = get_object_vars($data); @@ -400,6 +416,59 @@ protected function transformPlain(BaseData $data, DataClass $dataClass): array return array_replace($transformed, $values); } + /** + * Transform values through immutable class metadata. + * + * @param array $extensions + * @param-out array $extensions + * @return array + */ + protected function transformUsingRecipe( + BaseData $data, + DataTransformationRecipe $recipe, + TransformationContext $context, + array &$extensions, + ): array { + // Raw storage keeps uninitialized properties from invoking public access. + $values = get_mangled_object_vars($data); + $transformed = []; + + foreach ($recipe->properties as $property) { + if ($property->hasGetHook) { + $value = $data->{$property->name}; + } elseif (array_key_exists($property->name, $values)) { + $value = $values[$property->name]; + } else { + continue; + } + + if ($value !== null) { + $value = match ($property->transformationOperation) { + DataPropertyOperation::Date, + DataPropertyOperation::Enum => $this->transformBuiltIn($value), + DataPropertyOperation::Data => $value instanceof BaseData + ? $this->transformNested( + $value, + $context->child( + $property->name, + $this->resolveWrapExecutionType($value, $context), + ), + $extensions, + ) + : $value, + default => $value, + }; + } + + $name = $context->mapPropertyNames && $property->outputMappedName !== null + ? $property->outputMappedName + : $property->name; + $transformed[$name] = $value; + } + + return $transformed; + } + /** * Determine if a lazy property is visible for this transformation. */ diff --git a/tests/Data/Support/Creation/DataCreatorTest.php b/tests/Data/Support/Creation/DataCreatorTest.php index bda153667..f4e6c9211 100644 --- a/tests/Data/Support/Creation/DataCreatorTest.php +++ b/tests/Data/Support/Creation/DataCreatorTest.php @@ -6,6 +6,7 @@ use Attribute; use Closure; +use DateTime; use DateTimeImmutable; use Hypervel\Container\Attributes\Config; use Hypervel\Contracts\Foundation\Application; @@ -20,11 +21,13 @@ use Hypervel\Data\Attributes\PropertyForMorph; use Hypervel\Data\Attributes\WithCast; use Hypervel\Data\Casts\Cast; +use Hypervel\Data\Casts\Castable; use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; use Hypervel\Data\DataServiceProvider; use Hypervel\Data\Dto; +use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\Exceptions\CannotCreateAbstractClass; use Hypervel\Data\Exceptions\CannotCreateData; use Hypervel\Data\Exceptions\CannotCreateDataCollectable; @@ -37,6 +40,7 @@ use Hypervel\Data\Support\Creation\AutoLazyReplayMode; use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; +use Hypervel\Data\Support\Creation\CreationContextFactory; use Hypervel\Data\Support\Creation\CreationMode; use Hypervel\Data\Support\Creation\DataCreator; use Hypervel\Data\Support\Creation\ValidationStrategy; @@ -49,7 +53,10 @@ use Hypervel\Support\LazyCollection; use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionFunction; +use Throwable; +use TypeError; use WeakReference; class DataCreatorTest extends TestCase @@ -96,6 +103,147 @@ public function testFirstSourceContainingAPropertyWinsAndMappingCanBeDisabled(): $this->assertNotSame(BasicCreationData::factory(), BasicCreationData::factory()); } + public function testFreshDefaultFactoriesShareOnlyTheirImmutableContext(): void + { + $first = BasicCreationData::factory(); + $second = BasicCreationData::factory(); + $other = ChildCreationData::factory(); + + $this->assertNotSame($first, $second); + $this->assertSame($first->get(), $second->get()); + $this->assertNotSame($first->get(), $other->get()); + + $default = $first->get(); + $first->withoutPropertyNameMapping(); + + $this->assertNotSame($default, $first->get()); + $this->assertSame($default, BasicCreationData::factory()->get()); + } + + public function testEveryFactoryMutatorInvalidatesAndRebuildsTheCreateContext(): void + { + $hook = static fn (mixed $value): mixed => $value; + $cases = [ + 'validationStrategy' => [ + static fn (CreationContextFactory $factory) => $factory->validationStrategy(ValidationStrategy::Disabled), + fn (CreationContext $context) => $this->assertSame(ValidationStrategy::Disabled, $context->validationStrategy), + ], + 'withoutValidation' => [ + static fn (CreationContextFactory $factory) => $factory->withoutValidation(), + fn (CreationContext $context) => $this->assertSame(ValidationStrategy::Disabled, $context->validationStrategy), + ], + 'onlyValidateRequests' => [ + static fn (CreationContextFactory $factory) => $factory->onlyValidateRequests(), + fn (CreationContext $context) => $this->assertSame(ValidationStrategy::OnlyRequests, $context->validationStrategy), + ], + 'alwaysValidate' => [ + static fn (CreationContextFactory $factory) => $factory->alwaysValidate(), + fn (CreationContext $context) => $this->assertSame(ValidationStrategy::Always, $context->validationStrategy), + ], + 'withPropertyNameMapping' => [ + static fn (CreationContextFactory $factory) => $factory->withPropertyNameMapping(), + fn (CreationContext $context) => $this->assertTrue($context->mapPropertyNames), + ], + 'withoutPropertyNameMapping' => [ + static fn (CreationContextFactory $factory) => $factory->withoutPropertyNameMapping(), + fn (CreationContext $context) => $this->assertFalse($context->mapPropertyNames), + ], + 'withoutMagicalCreation' => [ + static fn (CreationContextFactory $factory) => $factory->withoutMagicalCreation(), + fn (CreationContext $context) => $this->assertTrue($context->disableMagicalCreation), + ], + 'withMagicalCreation' => [ + static fn (CreationContextFactory $factory) => $factory->withMagicalCreation(), + fn (CreationContext $context) => $this->assertFalse($context->disableMagicalCreation), + ], + 'ignoreMagicalMethod' => [ + static fn (CreationContextFactory $factory) => $factory->ignoreMagicalMethod('fromString'), + fn (CreationContext $context) => $this->assertSame(['fromString'], $context->ignoredMagicalMethods), + ], + 'withCast' => [ + static fn (CreationContextFactory $factory) => $factory->withCast(CreationSource::class, CreationIdentifierCast::class), + fn (CreationContext $context) => $this->assertSame( + [CreationSource::class => CreationIdentifierCast::class], + $context->casts, + ), + ], + 'withCastCollection' => [ + static fn (CreationContextFactory $factory) => $factory->withCastCollection([ + CreationSource::class => CreationIdentifierCast::class, + ]), + fn (CreationContext $context) => $this->assertSame( + [CreationSource::class => CreationIdentifierCast::class], + $context->casts, + ), + ], + 'withNormalizers' => [ + static fn (CreationContextFactory $factory) => $factory->withNormalizers(CreationSourceNormalizer::class), + fn (CreationContext $context) => $this->assertSame( + [CreationSourceNormalizer::class], + $context->normalizers, + ), + ], + 'prepareData' => [ + static fn (CreationContextFactory $factory) => $factory->prepareData($hook), + fn (CreationContext $context) => $this->assertSame([$hook], $context->prepareDataHooks), + ], + 'beforeValidation' => [ + static fn (CreationContextFactory $factory) => $factory->beforeValidation($hook), + fn (CreationContext $context) => $this->assertSame([$hook], $context->beforeValidationHooks), + ], + 'beforeRules' => [ + static fn (CreationContextFactory $factory) => $factory->beforeRules($hook), + fn (CreationContext $context) => $this->assertSame([$hook], $context->beforeRulesHooks), + ], + 'afterRules' => [ + static fn (CreationContextFactory $factory) => $factory->afterRules($hook), + fn (CreationContext $context) => $this->assertSame([$hook], $context->afterRulesHooks), + ], + 'withValidator' => [ + static fn (CreationContextFactory $factory) => $factory->withValidator($hook), + fn (CreationContext $context) => $this->assertSame([$hook], $context->withValidatorHooks), + ], + 'afterValidation' => [ + static fn (CreationContextFactory $factory) => $factory->afterValidation($hook), + fn (CreationContext $context) => $this->assertSame([$hook], $context->afterValidationHooks), + ], + 'beforeCreation' => [ + static fn (CreationContextFactory $factory) => $factory->beforeCreation($hook), + fn (CreationContext $context) => $this->assertSame([$hook], $context->beforeCreationHooks), + ], + 'afterCreation' => [ + static fn (CreationContextFactory $factory) => $factory->afterCreation($hook), + fn (CreationContext $context) => $this->assertSame([$hook], $context->afterCreationHooks), + ], + ]; + + foreach ($cases as $name => [$mutate, $verify]) { + $factory = BasicCreationData::factory(); + $before = $factory->get(); + + $this->assertSame($before, $factory->get(), $name); + $mutate($factory); + $after = $factory->get(); + + $this->assertNotSame($before, $after, $name); + $this->assertSame($after, $factory->get(), $name); + $verify($after); + } + } + + public function testFromRetainsTheLateStaticFactoryBoundary(): void + { + FactoryOverrideCreationData::$factoryCalls = 0; + + $data = FactoryOverrideCreationData::from([ + 'name' => 'Raw', + 'profile' => ['name' => 'Mapped'], + ]); + + $this->assertSame('Raw', $data->name); + $this->assertSame(1, FactoryOverrideCreationData::$factoryCalls); + } + /** * Test exact array creation preserves mapping, absence, and accepted values. */ @@ -107,6 +255,7 @@ public function testDirectArrayCreationPreservesExactValues(): void $data = DirectArrayCreationData::from([ 'profile' => ['name' => 'Mapped'], 'name' => 'Fallback', + 'nullable_value' => null, 'defaultedNullable' => null, 'metadata' => ['role' => 'maintainer'], 'child' => $child, @@ -117,6 +266,7 @@ public function testDirectArrayCreationPreservesExactValues(): void ]); $fallback = DirectArrayCreationData::from([ 'name' => 'Fallback', + 'nullable' => 'raw-fallback', 'child' => $child, 'date' => $date, 'status' => CreationStatus::Inactive, @@ -139,6 +289,7 @@ public function testDirectArrayCreationPreservesExactValues(): void $this->assertSame('computed', $data->computed); $this->assertSame('virtual', $data->virtual); $this->assertSame('Fallback', $fallback->name); + $this->assertSame('raw-fallback', $fallback->nullable); $this->assertSame('fallback', $fallback->defaultedNullable); $this->assertSame([], $fallback->metadata); } @@ -146,25 +297,109 @@ public function testDirectArrayCreationPreservesExactValues(): void /** * Test direct array misses retain the authoritative general construction path. */ - public function testDirectArrayCreationFallsThroughForNestedAndConvertedValues(): void + public function testLeanCreationMatchesGeneralCreationForNestedAndConvertedValues(): void { - $nested = DirectNestedCreationData::from(['child' => ['id' => 42]]); - $converted = DirectConvertedCreationData::from([ + $nestedPayload = ['child' => ['id' => '42']]; + $convertedPayload = [ 'id' => '7', 'date' => '2026-09-02T12:00:00+00:00', 'status' => 'active', - ]); + ]; + $nested = DirectNestedCreationData::from($nestedPayload); + $generalNested = DirectNestedCreationData::factory() + ->beforeCreation(static fn (array $properties): array => $properties) + ->from($nestedPayload); + $converted = DirectConvertedCreationData::from($convertedPayload); + $generalConverted = DirectConvertedCreationData::factory() + ->beforeCreation(static fn (array $properties): array => $properties) + ->from($convertedPayload); $items = DirectNestedCreationData::collect([ ['child' => new ChildCreationData(8)], ], 'array'); $this->assertSame(42, $nested->child->id); + $this->assertSame($nested->child->id, $generalNested->child->id); $this->assertSame(7, $converted->id); + $this->assertSame($converted->id, $generalConverted->id); $this->assertInstanceOf(DateTimeImmutable::class, $converted->date); + $this->assertEquals($converted->date, $generalConverted->date); $this->assertSame(CreationStatus::Active, $converted->status); + $this->assertSame($converted->status, $generalConverted->status); $this->assertSame(8, $items[0]->child->id); } + /** + * Test lean construction preserves higher-priority conversion behavior. + * + * @param class-string $class + */ + #[DataProvider('leanConstructionPriorityProvider')] + public function testLeanConstructionPreservesHigherPriorityConversionBehavior( + string $class, + mixed $value, + ): void { + $property = $this->app->make(DataClassRepository::class)->get($class)->properties['value']; + + $this->assertSame(DataPropertyOperation::Copy, $property->constructionOperation); + $this->assertNull($property->constructionTarget); + + $lean = $this->captureCreationOutcome( + static fn (): mixed => $class::from(['value' => $value])->value, + ); + $general = $this->captureCreationOutcome( + static fn (): mixed => $class::factory() + ->beforeCreation(static fn (array $properties): array => $properties) + ->from(['value' => $value]) + ->value, + ); + + $this->assertEquals($general, $lean); + } + + /** + * Provide order-sensitive construction declarations. + * + * @return array, mixed}> + */ + public static function leanConstructionPriorityProvider(): array + { + return [ + 'ambiguous Data before date' => [AmbiguousDataBeforeDateCreationData::class, '2026-01-01'], + 'Castable before date' => [CastableBeforeDateCreationData::class, '2026-01-01'], + 'ambiguous date before enum' => [AmbiguousDateBeforeEnumCreationData::class, '2026-01-01'], + 'ambiguous enum before built-in' => [AmbiguousEnumBeforeBuiltinCreationData::class, '1'], + ]; + } + + public function testLeanCreationPreflightsBeforeRunningNestedConstruction(): void + { + PreflightChildCreationData::$constructorCalls = 0; + + try { + PreflightParentCreationData::from([ + 'child' => ['id' => '9'], + 'source' => 'unsupported', + ]); + $this->fail('Expected the unsupported object value to be rejected.'); + } catch (TypeError) { + $this->assertSame(1, PreflightChildCreationData::$constructorCalls); + } + } + + public function testLeanParentSharesResolvedExtensionsAcrossGeneralChildren(): void + { + DeferredItemCreationCast::$instances = 0; + + $data = LeanParentWithGeneralChildrenData::from([ + 'first' => ['id' => '17'], + 'second' => ['id' => '18'], + ]); + + $this->assertSame(17, $data->first->id); + $this->assertSame(18, $data->second->id); + $this->assertSame(1, DeferredItemCreationCast::$instances); + } + /** * Test computed and virtual input keeps the existing rejection behavior. */ @@ -244,7 +479,7 @@ public function testDirectArrayCreationRejectsVariadicOrdinaryConstructor(): voi DirectVariadicConstructorCreationData::class, ); - $this->assertTrue($metadata->directArrayCreation); + $this->assertNotNull($metadata->creationRecipe); $this->assertFalse($metadata->directConstructorInstantiation); $this->expectException(CannotCreateData::class); @@ -491,6 +726,56 @@ public function testAutomaticLazyReplayIsLimitedToStructuralProperties(): void $this->assertSame('named', $named->title->resolve()); } + public function testAutomaticLazyReplayConstructsExactAndCoercingChildrenOnce(): void + { + foreach ([7, '7'] as $id) { + CountingAutoLazyChildData::$constructorCalls = 0; + $data = CountingAutoLazyParentData::from(['child' => ['id' => $id]]); + + $this->assertSame(7, $data->child->resolve()->id); + $this->assertSame(1, CountingAutoLazyChildData::$constructorCalls); + } + } + + public function testAutomaticLazyReplayConsumesMappedAndUnmappedStateValues(): void + { + $mapped = MappedAutoLazyParentData::from([ + 'profile' => ['child' => ['id' => 11]], + ]); + $unmapped = MappedAutoLazyParentData::factory() + ->withoutPropertyNameMapping() + ->from(['child' => ['id' => 12]]); + + $this->assertSame(11, $mapped->child->resolve()->id); + $this->assertSame(12, $unmapped->child->resolve()->id); + } + + public function testAutomaticLazyPaginatorItemsConstructAndRunFactoriesOnce(): void + { + CountingAutoLazyChildData::$constructorCalls = 0; + $source = new Paginator([ + 'exact' => ['id' => 13], + 'coercing' => ['id' => '14'], + ], 15, 2); + $plain = CountingAutoLazyCollectionData::from(['children' => $source]) + ->children + ->resolve(); + + $this->assertInstanceOf(Paginator::class, $plain); + $this->assertSame(2, $plain->currentPage()); + $this->assertSame(['exact', 'coercing'], array_keys($plain->items())); + $this->assertSame([13, 14], array_column($plain->items(), 'id')); + $this->assertSame(2, CountingAutoLazyChildData::$constructorCalls); + + CountingAutoLazyFactoryChildData::$factoryCalls = 0; + $factory = CountingAutoLazyFactoryCollectionData::from([ + 'children' => new Paginator(['factory' => ['id' => 15]], 15, 3), + ])->children->resolve(); + + $this->assertSame(15, $factory->items()['factory']->id); + $this->assertSame(1, CountingAutoLazyFactoryChildData::$factoryCalls); + } + public function testAutomaticLazyReplayUsesNormalAndHookSpecificFillPaths(): void { AutoLazyCountingNormalizer::$calls = 0; @@ -556,6 +841,24 @@ public function testAutomaticLoadedRelationLazyUsesItsLiveModelSource(): void $this->assertNull(AutoWhenLoadedCreationData::from($nullModel)->child); } + public function testAutomaticLoadedRelationLazyConsumesARecipeEligibleChild(): void + { + $model = new AutoLazyRelationModel; + $model->setRelation('child', ['id' => 16]); + + $child = RecipeAutoWhenLoadedCreationData::from($model)->child->resolve(); + + $this->assertInstanceOf(CountingAutoLazyChildData::class, $child); + $this->assertSame(16, $child->id); + } + + public function testNonReplayAutomaticLazyUsesTheResolvedClosureValue(): void + { + $data = NonReplayAutoLazyCreationData::from(['value' => 'filled']); + + $this->assertSame('resolved', $data->value->resolve()); + } + public function testAutomaticLoadedRelationLazyRequiresAModelSource(): void { $this->expectException(CannotCreateData::class); @@ -679,7 +982,7 @@ public function testGenericPhpDocTypesKeepScalarAndIterableCreationSemantics(): ->get(IntegerRangeCreationData::class); $this->assertSame(7, $integer->value); - $this->assertTrue($metadata->directArrayCreation); + $this->assertNotNull($metadata->creationRecipe); $this->assertContainsOnlyInstancesOf(ChildCreationData::class, $children->children); $this->assertSame(9, $children->children[0]->id); } @@ -994,6 +1297,24 @@ public function testRejectsSuppliedComputedValuesAndInvalidAfterCreationResults( ->from(['name' => 'Taylor']); } + /** + * Capture a creation value or its exact failure contract. + * + * @return array{result: 'exception', class: class-string, message: string}|array{result: 'value', value: mixed} + */ + protected function captureCreationOutcome(Closure $create): array + { + try { + return ['result' => 'value', 'value' => $create()]; + } catch (Throwable $exception) { + return [ + 'result' => 'exception', + 'class' => $exception::class, + 'message' => $exception->getMessage(), + ]; + } + } + /** * Configure a global data normalizer. */ @@ -1015,6 +1336,24 @@ public function __construct( } } +class FactoryOverrideCreationData extends Data +{ + public static int $factoryCalls = 0; + + public function __construct( + #[MapInputName('profile.name')] + public string $name, + ) { + } + + public static function factory(): CreationContextFactory + { + ++self::$factoryCalls; + + return parent::factory()->withoutPropertyNameMapping(); + } +} + class DirectArrayCreationData extends Data { public string $assigned; @@ -1031,6 +1370,7 @@ class DirectArrayCreationData extends Data public function __construct( #[MapInputName('profile.name')] public string $name, + #[MapInputName('nullable_value')] public ?string $nullable, public string|Optional $optional, public ChildCreationData $child, @@ -1061,6 +1401,43 @@ public function __construct( } } +class PreflightChildCreationData extends Data +{ + public static int $constructorCalls = 0; + + public function __construct(public int $id) + { + ++self::$constructorCalls; + } +} + +class PreflightParentCreationData extends Data +{ + public function __construct( + public PreflightChildCreationData $child, + public CreationSource $source, + ) { + } +} + +class GeneralChildCreationData extends Data +{ + public function __construct( + #[WithCast(DeferredItemCreationCast::class)] + public int $id, + ) { + } +} + +class LeanParentWithGeneralChildrenData extends Data +{ + public function __construct( + public GeneralChildCreationData $first, + public GeneralChildCreationData $second, + ) { + } +} + class DirectOutputOnlyCreationData extends Data { #[Computed] @@ -1289,6 +1666,78 @@ public function __construct( } } +class CountingAutoLazyChildData extends Data +{ + public static int $constructorCalls = 0; + + public function __construct(public int $id) + { + ++self::$constructorCalls; + } +} + +class CountingAutoLazyParentData extends Data +{ + public function __construct( + #[AutoLazy] + public Lazy|CountingAutoLazyChildData $child, + ) { + } +} + +class MappedAutoLazyParentData extends Data +{ + public function __construct( + #[AutoLazy, MapInputName('profile.child')] + public Lazy|CountingAutoLazyChildData $child, + ) { + } +} + +class CountingAutoLazyCollectionData extends Data +{ + /** + * Create a counting automatic-lazy collection fixture. + * + * @param Lazy|Paginator $children + */ + public function __construct( + #[AutoLazy, DataCollectionOf(CountingAutoLazyChildData::class)] + public Lazy|Paginator $children, + ) { + } +} + +class CountingAutoLazyFactoryChildData extends Data +{ + public static int $factoryCalls = 0; + + public function __construct(public int $id) + { + } + + public static function fromArray(array $payload): self + { + ++self::$factoryCalls; + + return new self((int) $payload['id']); + } +} + +class CountingAutoLazyFactoryCollectionData extends Data +{ + /** + * Create a counting automatic-lazy factory collection fixture. + * + * @param Lazy|Paginator $children + */ + public function __construct( + #[AutoLazy, DataCollectionOf(CountingAutoLazyFactoryChildData::class)] + public Lazy|Paginator $children, + ) { + } +} + class AutoLazyFirstSource { public function __construct( @@ -1450,6 +1899,40 @@ public function __construct( } } +class RecipeAutoWhenLoadedCreationData extends Data +{ + public function __construct( + #[AutoWhenLoadedLazy] + public Lazy|CountingAutoLazyChildData|null $child, + ) { + } +} + +class NonReplayAutoLazyCreationData extends Data +{ + public function __construct( + #[ResolvedValueAutoLazy] + public Lazy|string $value, + ) { + } +} + +#[Attribute(Attribute::TARGET_PROPERTY)] +class ResolvedValueAutoLazy extends AutoLazy +{ + /** + * Build an automatic lazy value from a distinct resolved input. + */ + public function build( + Closure $castValue, + mixed $payload, + DataProperty $property, + mixed $value, + ): Lazy { + return Lazy::create(static fn () => $castValue('resolved')); + } +} + class AutoLazyRelationModel extends Model { } @@ -1809,6 +2292,79 @@ public function __construct( } } +class AmbiguousDataBeforeDateCreationData extends Data +{ + public function __construct( + public ChildCreationData|AlternateChildCreationData|DateTimeImmutable $value, + ) { + } +} + +class CastableBeforeDateCreationData extends Data +{ + public function __construct( + public PriorityCreationCastable|DateTimeImmutable $value, + ) { + } +} + +class AmbiguousDateBeforeEnumCreationData extends Data +{ + public function __construct( + public DateTimeImmutable|DateTime|PriorityCreationStatus $value, + ) { + } +} + +class AmbiguousEnumBeforeBuiltinCreationData extends Data +{ + public function __construct( + public PriorityCreationStatus|AlternatePriorityCreationStatus|int $value, + ) { + } +} + +class PriorityCreationCastable implements Castable +{ + public function __construct( + public readonly string $value, + ) { + } + + /** + * Create the cast for this type. + */ + public static function dataCastUsing(array $arguments): Cast + { + return new PriorityCreationCast; + } +} + +class PriorityCreationCast implements Cast +{ + /** + * Cast a value into the declared Castable type. + */ + public function cast( + DataProperty $property, + mixed $value, + ConstructionState $state, + CreationContext $context, + ): PriorityCreationCastable { + return new PriorityCreationCastable((string) $value); + } +} + +enum PriorityCreationStatus: string +{ + case Active = 'active'; +} + +enum AlternatePriorityCreationStatus: string +{ + case Inactive = 'inactive'; +} + class AmbiguousDataCollectableCreationData extends Data { /** diff --git a/tests/Data/Support/DataClassTest.php b/tests/Data/Support/DataClassTest.php index 822d39c3d..242c850c2 100644 --- a/tests/Data/Support/DataClassTest.php +++ b/tests/Data/Support/DataClassTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Data\Support; use Attribute; +use DateTimeImmutable; use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Contracts\Container\ContextualAttribute; @@ -18,10 +19,12 @@ use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Attributes\MergeValidationRules; use Hypervel\Data\Attributes\WithCast; +use Hypervel\Data\Attributes\WithTransformer; use Hypervel\Data\Casts\Cast; use Hypervel\Data\Contracts\PropertyMorphableData; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; +use Hypervel\Data\Enums\DataPropertyOperation; use Hypervel\Data\Exceptions\InvalidDataDeclaration; use Hypervel\Data\Lazy; use Hypervel\Data\Mappers\SnakeCaseMapper; @@ -38,7 +41,9 @@ use Hypervel\Data\Support\Factories\DataPropertyFactory; use Hypervel\Data\Support\Factories\DataTypeFactory; use Hypervel\Data\Support\NameMapperResolver; +use Hypervel\Data\Support\Transformation\TransformationContext; use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +use Hypervel\Data\Transformers\Transformer; use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Http\Attributes\ErrorBag; @@ -55,6 +60,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use RuntimeException; +use stdClass; class DataClassTest extends TestCase { @@ -83,7 +89,9 @@ public function testClassMetadataCompilesIntoImmutableArrays(): void 'first_name' => 'firstName', 'last_name' => 'lastName', ], $class->outputMappedProperties); - $this->assertFalse($class->plainTransform); + $this->assertFalse($class->bulkCopyTransformation); + $this->assertNotNull($class->transformationRecipe); + $this->assertNotNull($class->creationRecipe); $this->assertTrue($class->directConstructorInstantiation); } @@ -103,7 +111,8 @@ public function testConstructorBoundPropertiesUseConstructorMetadata(): void $this->assertTrue($class->properties['normalizedName']->hasDefaultValue); $this->assertFalse($class->properties['unbound']->isConstructorParameter); $this->assertTrue($class->properties['unbound']->hasDefaultValue); - $this->assertTrue($class->plainTransform); + $this->assertTrue($class->bulkCopyTransformation); + $this->assertNull($class->transformationRecipe); } /** @@ -117,6 +126,71 @@ public function testBackedSetOnlyHookRemainsValid(): void $this->assertFalse($class->properties['name']->hasGetHook); } + /** + * Test lean recipes retain ordered property operations and exact construction targets. + */ + public function testLeanRecipesCompileOrderedPropertyMetadata(): void + { + $class = $this->factory()->build(new ReflectionClass(RecipeMetadataDataFixture::class)); + + $this->assertSame( + ['id', 'status', 'createdAt', 'child', 'displayName', 'secret'], + array_column($class->creationRecipe?->properties ?? [], 'name'), + ); + $this->assertSame([ + ['id', DataPropertyOperation::Builtin, 'int'], + ['status', DataPropertyOperation::Enum, RecipeMetadataStatus::class], + ['createdAt', DataPropertyOperation::Date, DateTimeImmutable::class], + ['child', DataPropertyOperation::Data, RecipeMetadataChildFixture::class], + ['displayName', DataPropertyOperation::Builtin, 'string'], + ['secret', DataPropertyOperation::Builtin, 'string'], + ], array_map( + static fn (DataProperty $property): array => [ + $property->name, + $property->constructionOperation, + $property->constructionTarget, + ], + $class->creationRecipe?->properties ?? [], + )); + + $this->assertSame( + ['id', 'status', 'createdAt', 'child', 'displayName'], + array_column($class->transformationRecipe?->properties ?? [], 'name'), + ); + $this->assertSame([ + DataPropertyOperation::Copy, + DataPropertyOperation::Enum, + DataPropertyOperation::Date, + DataPropertyOperation::Data, + DataPropertyOperation::Copy, + ], array_column($class->transformationRecipe?->properties ?? [], 'transformationOperation')); + $this->assertFalse($class->bulkCopyTransformation); + + $arbitraryObject = $this->factory()->build(new ReflectionClass(ArbitraryObjectDataFixture::class)); + + $this->assertNotNull($arbitraryObject->creationRecipe); + $this->assertSame(DataPropertyOperation::Copy, $arbitraryObject->properties['value']->constructionOperation); + $this->assertFalse($arbitraryObject->bulkCopyTransformation); + $this->assertNull($arbitraryObject->transformationRecipe); + } + + /** + * Test bulk-copy metadata depends on the complete transformation classifier. + */ + public function testBulkCopyMetadataDistinguishesArrayAndExtensionShapes(): void + { + $plainArray = $this->factory()->build(new ReflectionClass(PlainArrayTransformationFixture::class)); + $transformed = $this->factory()->build(new ReflectionClass(PropertyTransformerDataFixture::class)); + $annotated = $this->factory()->build(new ReflectionClass(AnnotatedDataArrayFixture::class)); + + $this->assertTrue($plainArray->bulkCopyTransformation); + $this->assertNull($plainArray->transformationRecipe); + $this->assertFalse($transformed->bulkCopyTransformation); + $this->assertNull($transformed->transformationRecipe); + $this->assertFalse($annotated->bulkCopyTransformation); + $this->assertNull($annotated->transformationRecipe); + } + /** * Test iterable annotation precedence and declaration scopes. */ @@ -146,26 +220,26 @@ public function testContextualParametersUseOneUnambiguousOwnershipForm(): void $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->assertNull($promoted->creationRecipe); $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->assertNull($constructorOnly->creationRecipe); $this->assertFalse($constructorOnly->directConstructorInstantiation); $this->assertSame(['userId' => true], $defaultedConstructorOnly->contextualParameters); - $this->assertFalse($defaultedConstructorOnly->directArrayCreation); + $this->assertNull($defaultedConstructorOnly->creationRecipe); $this->assertFalse($defaultedConstructorOnly->directConstructorInstantiation); } /** * Test direct array creation requires a fixed array-safe class shape. */ - public function testDirectArrayCreationEligibilityUsesCompiledClassAndPropertyFacts(): void + public function testCreationRecipeEligibilityUsesCompiledClassAndPropertyFacts(): void { - $this->assertTrue( - $this->factory()->build(new ReflectionClass(DirectArrayCreationDataFixture::class))->directArrayCreation, + $this->assertNotNull( + $this->factory()->build(new ReflectionClass(DirectArrayCreationDataFixture::class))->creationRecipe, ); foreach ([ @@ -179,8 +253,8 @@ public function testDirectArrayCreationEligibilityUsesCompiledClassAndPropertyFa DataCollectableDirectArrayCreationDataFixture::class, TypedIterableDirectArrayCreationDataFixture::class, ] as $class) { - $this->assertFalse( - $this->factory()->build(new ReflectionClass($class))->directArrayCreation, + $this->assertNull( + $this->factory()->build(new ReflectionClass($class))->creationRecipe, $class, ); } @@ -189,7 +263,7 @@ public function testDirectArrayCreationEligibilityUsesCompiledClassAndPropertyFa /** * Test configured creation extensions disable the direct array path. */ - public function testDirectArrayCreationEligibilityUsesBootConfiguration(): void + public function testCreationRecipeEligibilityUsesBootConfiguration(): void { $configuredCast = $this->factory([ 'casts' => ['string' => DirectArrayCreationCast::class], @@ -198,8 +272,8 @@ public function testDirectArrayCreationEligibilityUsesBootConfiguration(): void 'normalizers' => [DirectArrayCreationNormalizer::class], ])->build(new ReflectionClass(DirectArrayCreationDataFixture::class)); - $this->assertFalse($configuredCast->directArrayCreation); - $this->assertFalse($configuredNormalizer->directArrayCreation); + $this->assertNull($configuredCast->creationRecipe); + $this->assertNull($configuredNormalizer->creationRecipe); } /** @@ -475,6 +549,80 @@ public function __construct(public string $value = 'default') } } +enum RecipeMetadataStatus: string +{ + case Active = 'active'; +} + +class RecipeMetadataChildFixture extends Data +{ + /** + * Create a new recipe metadata child fixture. + */ + public function __construct(public int $id) + { + } +} + +class RecipeMetadataDataFixture extends Data +{ + /** + * Create a new recipe metadata fixture. + */ + public function __construct( + public int $id, + public RecipeMetadataStatus $status, + public DateTimeImmutable $createdAt, + public RecipeMetadataChildFixture $child, + #[MapOutputName('display_name')] + public string $displayName, + #[Hidden] + public string $secret, + ) { + } +} + +class ArbitraryObjectDataFixture extends Data +{ + /** + * Create a new arbitrary-object fixture. + */ + public function __construct(public stdClass $value) + { + } +} + +class PlainArrayTransformationFixture extends Data +{ + public array $values = []; +} + +class PropertyTransformerDataFixture extends Data +{ + #[WithTransformer(DataClassTransformerFixture::class)] + public string $value = 'value'; +} + +class AnnotatedDataArrayFixture extends Data +{ + /** @var list */ + public array $values = []; +} + +class DataClassTransformerFixture implements Transformer +{ + /** + * Transform the fixture value. + */ + public function transform( + DataProperty $property, + mixed $value, + TransformationContext $context, + ): mixed { + return $value; + } +} + abstract class AbstractDirectArrayCreationDataFixture extends DirectArrayCreationDataFixture { } diff --git a/tests/Data/Support/DataIterableAnnotationReaderTest.php b/tests/Data/Support/DataIterableAnnotationReaderTest.php index 1debbf684..42f2152ac 100644 --- a/tests/Data/Support/DataIterableAnnotationReaderTest.php +++ b/tests/Data/Support/DataIterableAnnotationReaderTest.php @@ -4,9 +4,33 @@ namespace Hypervel\Tests\Data\Support; +use Countable; +use DateTimeImmutable; +use Hypervel\Config\Repository; +use Hypervel\Container\Container; +use Hypervel\Data\Data; +use Hypervel\Data\Lazy; +use Hypervel\Data\Optional; use Hypervel\Data\Support\Annotations\DataIterableAnnotation; use Hypervel\Data\Support\Annotations\DataIterableAnnotationReader; +use Hypervel\Data\Support\DataConfig; +use Hypervel\Data\Support\DataProperty; +use Hypervel\Data\Support\Factories\DataClassFactory; +use Hypervel\Data\Support\Factories\DataMethodFactory; +use Hypervel\Data\Support\Factories\DataParameterFactory; +use Hypervel\Data\Support\Factories\DataPropertyFactory; +use Hypervel\Data\Support\Factories\DataTypeFactory; +use Hypervel\Data\Support\NameMapperResolver; +use Hypervel\Data\Support\Types\PhpDocTypeNameResolver; +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 IteratorAggregate; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\PhpDocParser; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; @@ -64,6 +88,75 @@ public function testClassAndMethodAnnotationsAreKeyedByTheirDeclarationNames(): $this->assertAnnotation($method['values'][0], 'Collection', 'FooData'); } + /** + * Test native types screen only declarations that cannot use iterable metadata. + */ + public function testNativeTypesScreenIterableAnnotations(): void + { + $factory = $this->factory(new DataIterableAnnotationReader); + $method = new ReflectionMethod($factory, 'typeCanUseIterableAnnotation'); + $class = new ReflectionClass(DataIterableNativeTypeFixture::class); + $expected = [ + 'scalar' => false, + 'nullableScalar' => false, + 'enum' => false, + 'date' => false, + 'data' => false, + 'optional' => false, + 'lazy' => false, + 'array' => true, + 'iterable' => true, + 'mixed' => true, + 'object' => true, + 'custom' => true, + 'union' => true, + 'intersection' => true, + ]; + + foreach ($expected as $property => $canUseAnnotation) { + $this->assertSame( + $canUseAnnotation, + $method->invoke($factory, $class->getProperty($property)->getType()), + $property, + ); + } + + $this->assertTrue($method->invoke( + $factory, + $class->getMethod('acceptsCallable')->getParameters()[0]->getType(), + )); + } + + /** + * Test parser construction is lazy and preserves annotation precedence. + */ + public function testParserIsBuiltOnceForEligibleMetadata(): void + { + $reader = new DataIterableAnnotationReader; + $factory = $this->factory($reader); + + $factory->build(new ReflectionClass(DataIterableScalarOnlyData::class)); + + $this->assertNull($this->readerProperty($reader, 'lexer')); + $this->assertNull($this->readerProperty($reader, 'parser')); + + $class = $factory->build(new ReflectionClass(ChildAnnotations::class)); + $lexer = $this->readerProperty($reader, 'lexer'); + $parser = $this->readerProperty($reader, 'parser'); + + $this->assertInstanceOf(Lexer::class, $lexer); + $this->assertInstanceOf(PhpDocParser::class, $parser); + $this->assertSame(ParentClassItem::class, $this->iterableItemClass($class->properties['parentOnly'])); + $this->assertSame(ChildClassItem::class, $this->iterableItemClass($class->properties['classItems'])); + $this->assertSame(InlineItem::class, $this->iterableItemClass($class->properties['inlineItems'])); + $this->assertSame(ConstructorItem::class, $this->iterableItemClass($class->properties['constructorItems'])); + + $reader->getForProperty(new ReflectionProperty(DataIterablePropertyFixture::class, 'array')); + + $this->assertSame($lexer, $this->readerProperty($reader, 'lexer')); + $this->assertSame($parser, $this->readerProperty($reader, 'parser')); + } + /** * Assert one parsed iterable annotation. */ @@ -75,6 +168,43 @@ protected function assertAnnotation( $this->assertSame($container, $annotation->containerType); $this->assertSame($item, (string) $annotation->itemType); } + + /** + * Create the metadata factory with the given annotation reader. + */ + protected function factory(DataIterableAnnotationReader $reader): DataClassFactory + { + $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 DataClassFactory( + new DataPropertyFactory($typeFactory, $config, $nameMapperResolver), + new DataMethodFactory($parameterFactory, $typeFactory), + $parameterFactory, + $reader, + $nameMapperResolver, + $config, + ); + } + + /** + * Get an internal parser dependency for verification. + */ + protected function readerProperty(DataIterableAnnotationReader $reader, string $name): ?object + { + return (new ReflectionProperty($reader, $name))->getValue($reader); + } + + /** + * Get the concrete iterable item class from property metadata. + */ + protected function iterableItemClass(DataProperty $property): string + { + return $property->type->getIterableTypes()[0]->iterableItemType?->getNamedTypes()[0]->name ?? ''; + } } class DataIterablePropertyFixture @@ -112,3 +242,85 @@ public function handle(object $values): void { } } + +enum DataIterableScalarStatus: string +{ + case Ready = 'ready'; +} + +class DataIterableNestedData extends Data +{ + public function __construct(public int $id) + { + } +} + +class DataIterableCustomContainer +{ +} + +class DataIterableNativeTypeFixture +{ + public string $scalar; + + public ?int $nullableScalar; + + public DataIterableScalarStatus $enum; + + public DateTimeImmutable $date; + + public DataIterableNestedData $data; + + public Optional|int $optional; + + public Lazy|string $lazy; + + public array $array; + + public iterable $iterable; + + public mixed $mixed; + + public object $object; + + public DataIterableCustomContainer $custom; + + public array|string $union; + + public Countable&IteratorAggregate $intersection; + + public function acceptsCallable(callable $callback): void + { + } +} + +/** + * @property array $identifier + * @property array $name + */ +class DataIterableScalarOnlyData extends Data +{ + /** @var array */ + public string $name; + + /** + * @param array $identifier + * @param array $name + * @param array $optional + * @param array $lazy + * @param array $date + * @param array $status + * @param array $child + */ + public function __construct( + public int $identifier, + string $name, + public Optional|int $optional, + public Lazy|string $lazy, + public DateTimeImmutable $date, + public DataIterableScalarStatus $status, + public DataIterableNestedData $child, + ) { + $this->name = $name; + } +} diff --git a/tests/Data/Support/Transformation/DataTransformerTest.php b/tests/Data/Support/Transformation/DataTransformerTest.php index 2a54239b5..266453502 100644 --- a/tests/Data/Support/Transformation/DataTransformerTest.php +++ b/tests/Data/Support/Transformation/DataTransformerTest.php @@ -17,6 +17,8 @@ use Hypervel\Data\Attributes\DataCollectionOf; use Hypervel\Data\Attributes\Hidden; use Hypervel\Data\Attributes\MapOutputName; +use Hypervel\Data\Attributes\WithTransformer; +use Hypervel\Data\Contracts\BaseData; use Hypervel\Data\Contracts\BaseDataCollectable; use Hypervel\Data\Data; use Hypervel\Data\DataCollection; @@ -26,6 +28,7 @@ use Hypervel\Data\Lazy; use Hypervel\Data\Normalizers\Normalized\Normalized; use Hypervel\Data\Normalizers\Normalizer; +use Hypervel\Data\Support\DataClass; use Hypervel\Data\Support\DataProperty; use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Data\Support\Transformation\TransformationContext; @@ -79,6 +82,94 @@ public function testTransformsLiveMappedNestedAndBuiltInValues(): void ], $data->all()); } + /** + * Test fixed output recipes match the general transformation path. + */ + public function testFixedOutputRecipeMatchesGeneralTransformation(): void + { + $date = new DateTimeImmutable('2026-08-31T10:30:00+00:00'); + $data = new RecipeOutputData( + 'Taylor', + $date, + Status::Ready, + new SimpleData('nested'), + ); + $irrelevantTransformer = new class implements Transformer { + public function transform( + DataProperty $property, + mixed $value, + TransformationContext $context, + ): mixed { + return $value; + } + }; + $general = TransformationContextFactory::create() + ->withTransformer(RuntimeException::class, $irrelevantTransformer); + + $this->assertSame([ + 'computed' => 'computed', + 'display_name' => 'Taylor', + 'createdAt' => '2026-08-31T10:30:00+00:00', + 'status' => 'ready', + 'nested' => ['value' => 'nested'], + ], $data->toArray()); + $this->assertSame($data->toArray(), $data->transform($general)); + + $unmapped = TransformationContextFactory::create()->withoutPropertyNameMapping(); + $generalUnmapped = TransformationContextFactory::create() + ->withoutPropertyNameMapping() + ->withTransformer(RuntimeException::class, $irrelevantTransformer); + + $this->assertSame($data->transform($generalUnmapped), $data->transform($unmapped)); + $this->assertArrayHasKey('name', $data->transform($unmapped)); + $this->assertArrayNotHasKey('display_name', $data->transform($unmapped)); + } + + /** + * Test plain values retain bulk-copy transformation without value conversion. + */ + public function testAllUsesBulkCopyForPlainData(): void + { + $transformer = $this->app->make(BulkCopyRecordingDataTransformer::class); + $this->app->instance(DataTransformer::class, $transformer); + $data = new ArrayData(['nested' => ['value' => 'value']]); + $transformed = $data->toArray(); + + $this->assertSame(1, $transformer->bulkCopyCalls); + $this->assertSame($transformed, $data->all()); + $this->assertSame(2, $transformer->bulkCopyCalls); + } + + /** + * Test property transformers prevent bulk-copy transformation. + */ + public function testPropertyTransformersPreventBulkCopy(): void + { + $transformer = $this->app->make(BulkCopyRecordingDataTransformer::class); + $this->app->instance(DataTransformer::class, $transformer); + + $this->assertSame( + ['value' => 'TRANSFORMED'], + (new PropertyTransformedData('transformed'))->toArray(), + ); + $this->assertSame(0, $transformer->bulkCopyCalls); + } + + /** + * Test data-annotated arrays prevent bulk-copy transformation. + */ + public function testDataAnnotatedArraysPreventBulkCopy(): void + { + $transformer = $this->app->make(BulkCopyRecordingDataTransformer::class); + $this->app->instance(DataTransformer::class, $transformer); + + $this->assertSame( + ['items' => [['value' => 'nested']]], + (new AnnotatedArrayOwnerData([new SimpleData('nested')]))->toArray(), + ); + $this->assertNotContains(AnnotatedArrayOwnerData::class, $transformer->bulkCopiedClasses); + } + /** * Test nested paginator properties retain native metadata. */ @@ -848,6 +939,25 @@ public function testPersistenceResolvesIncludedConditionalAndLoadedRelationalVal } } +class BulkCopyRecordingDataTransformer extends DataTransformer +{ + public int $bulkCopyCalls = 0; + + /** @var list> */ + public array $bulkCopiedClasses = []; + + /** + * Record and perform one bulk-copy transformation. + */ + protected function transformBulkCopy(BaseData $data, DataClass $dataClass): array + { + ++$this->bulkCopyCalls; + $this->bulkCopiedClasses[] = $data::class; + + return parent::transformBulkCopy($data, $dataClass); + } +} + enum Status: string { case Ready = 'ready'; @@ -860,6 +970,39 @@ public function __construct(public string $value) } } +class PropertyTransformedData extends Data +{ + public function __construct( + #[WithTransformer(UppercasePropertyTransformer::class)] + public string $value, + ) { + } +} + +class UppercasePropertyTransformer implements Transformer +{ + /** + * Transform the fixture value to uppercase. + */ + public function transform( + DataProperty $property, + mixed $value, + TransformationContext $context, + ): string { + return strtoupper((string) $value); + } +} + +class AnnotatedArrayOwnerData extends Data +{ + /** + * @param list $items + */ + public function __construct(public array $items) + { + } +} + class OverrideTransformData extends Data { public static ?TransformationContext $context = null; @@ -1032,6 +1175,26 @@ public function __construct( } } +class RecipeOutputData extends Data +{ + #[Computed] + public string $computed = 'computed'; + + #[Hidden] + public string $hidden = 'hidden'; + + public string $uninitialized; + + public function __construct( + #[MapOutputName('display_name')] + public string $name, + public DateTimeImmutable $createdAt, + public Status $status, + public SimpleData $nested, + ) { + } +} + class PaginatorItemData extends Data { public function __construct( From 49287a2561b51f8919cb6ef9bf9faba05119283b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:06:33 +0000 Subject: [PATCH 3/5] Warm the Data service graph before worker fork Resolve DataCreator and DataTransformer from an application booted callback after all providers have configured their dependencies. Production workers inherit the initialized immutable service graph instead of making the first request pay that fixed setup cost. Keep unit-test applications on demand so repeated Testbench boots remain fast, and cover both testing and non-testing application lifecycles with stable instance assertions. --- src/data/src/DataServiceProvider.php | 10 ++++++ tests/Data/DataServiceProviderTest.php | 43 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/data/src/DataServiceProvider.php b/src/data/src/DataServiceProvider.php index 65f049140..b12964595 100644 --- a/src/data/src/DataServiceProvider.php +++ b/src/data/src/DataServiceProvider.php @@ -4,9 +4,12 @@ namespace Hypervel\Data; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\Console\DataMakeCommand; use Hypervel\Data\Contracts\TransformableData; +use Hypervel\Data\Support\Creation\DataCreator; use Hypervel\Data\Support\DataConfig; +use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Data\Support\VarDumper\DataVarDumperCaster; use Hypervel\Support\ServiceProvider; use Symfony\Component\VarDumper\Cloner\AbstractCloner; @@ -35,6 +38,13 @@ public function boot(): void // Build the typed configuration once during worker boot. $this->app->make(DataConfig::class); + if (! $this->app->runningUnitTests()) { + $this->app->booted(static function (Application $app): void { + $app->make(DataCreator::class); + $app->make(DataTransformer::class); + }); + } + AbstractCloner::$defaultCasters[TransformableData::class] ??= [DataVarDumperCaster::class, 'cast']; diff --git a/tests/Data/DataServiceProviderTest.php b/tests/Data/DataServiceProviderTest.php index 14274f979..cf8742ec3 100644 --- a/tests/Data/DataServiceProviderTest.php +++ b/tests/Data/DataServiceProviderTest.php @@ -4,10 +4,16 @@ namespace Hypervel\Tests\Data; +use Hypervel\Config\Repository; +use Hypervel\Container\Container; use Hypervel\Contracts\Foundation\Application; use Hypervel\Data\DataServiceProvider; +use Hypervel\Data\Support\Creation\DataCreator; use Hypervel\Data\Support\DataConfig; +use Hypervel\Data\Support\Transformation\DataTransformer; +use Hypervel\Foundation\Application as FoundationApplication; use Hypervel\Testbench\TestCase; +use Mockery as m; class DataServiceProviderTest extends TestCase { @@ -22,6 +28,8 @@ protected function getPackageProviders(Application $app): array public function testProviderBuildsOneBootStableConfiguration(): void { $this->assertTrue($this->app->resolved(DataConfig::class)); + $this->assertFalse($this->app->resolved(DataCreator::class)); + $this->assertFalse($this->app->resolved(DataTransformer::class)); $dataConfig = $this->app->make(DataConfig::class); @@ -35,4 +43,39 @@ public function testProviderBuildsOneBootStableConfiguration(): void $this->assertSame([DATE_ATOM], $dataConfig->dateFormats); $this->assertNull($dataConfig->wrap); } + + public function testProviderWarmsFixedServicesAfterNonTestingApplicationBoot(): void + { + $originalContainer = Container::getInstance(); + + try { + $application = new FoundationApplication; + $application->instance('env', 'production'); + $application->instance('config', new Repository); + $application->setRunningInConsole(false); + + $creator = m::mock(DataCreator::class); + $transformer = m::mock(DataTransformer::class); + + $application->singleton(DataCreator::class, static fn (): DataCreator => $creator); + $application->singleton(DataTransformer::class, static fn (): DataTransformer => $transformer); + + $provider = new DataServiceProvider($application); + $provider->register(); + $provider->boot(); + + $this->assertTrue($application->resolved(DataConfig::class)); + $this->assertFalse($application->resolved(DataCreator::class)); + $this->assertFalse($application->resolved(DataTransformer::class)); + + $application->boot(); + + $this->assertTrue($application->resolved(DataCreator::class)); + $this->assertTrue($application->resolved(DataTransformer::class)); + $this->assertSame($creator, $application->make(DataCreator::class)); + $this->assertSame($transformer, $application->make(DataTransformer::class)); + } finally { + Container::setInstance($originalContainer); + } + } } From b2297ca5c8b56ae73a44a194aa62521ce7451b03 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:06:50 +0000 Subject: [PATCH 4/5] Add reproducible Data performance comparisons Expand the developer benchmark matrix across construction, validation, transformation, collections, resources, persistence, metadata, and first-use boundaries. Add a dedicated historical DataObject comparison harness under the test namespace so supported shapes can be measured against the removed mapper without restoring it as framework API. Document both commands and keep raw reports opt-in and outside the repository. --- tests/Benchmarks/Data/Fixtures/DataObject.php | 640 ++++++++++++++ tests/Benchmarks/Data/README.md | 14 +- tests/Benchmarks/Data/benchmark.php | 51 +- tests/Benchmarks/Data/compare-data-object.php | 819 ++++++++++++++++++ 4 files changed, 1518 insertions(+), 6 deletions(-) create mode 100644 tests/Benchmarks/Data/Fixtures/DataObject.php create mode 100644 tests/Benchmarks/Data/compare-data-object.php diff --git a/tests/Benchmarks/Data/Fixtures/DataObject.php b/tests/Benchmarks/Data/Fixtures/DataObject.php new file mode 100644 index 000000000..2e5dfd540 --- /dev/null +++ b/tests/Benchmarks/Data/Fixtures/DataObject.php @@ -0,0 +1,640 @@ + [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/Benchmarks/Data/README.md b/tests/Benchmarks/Data/README.md index 8009f7e84..f6cc8584b 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 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. +This developer-only harness measures Data construction against native constructors and explicit array mapping, plus collection, validation, named-factory, transforming and non-transforming output, resource-response, metadata, Eloquent persistence, and relation-loading 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,6 +8,14 @@ Run it from the components repository root: php tests/Benchmarks/Data/benchmark.php ``` +To compare supported construction and transformation shapes with the removed `DataObject` implementation, run the historical comparison harness: + +```shell +php tests/Benchmarks/Data/compare-data-object.php +``` + +The comparison fixture is kept under `Fixtures/` and loaded only by this command. + 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: @@ -15,12 +23,12 @@ Raw reports are opt-in and should be written outside the repository so local mea ```shell php tests/Benchmarks/Data/benchmark.php \ --operations=20000 \ - --samples=7 \ + --samples=21 \ --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 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. +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. The class and parser first-use rows expose demand-built metadata costs separately. Eloquent scenarios use a disposable SQLite database and cover persistence plus 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 04c7b946d..5c09fe746 100644 --- a/tests/Benchmarks/Data/benchmark.php +++ b/tests/Benchmarks/Data/benchmark.php @@ -16,6 +16,7 @@ use Hypervel\Data\Data; use Hypervel\Data\DataCollection; use Hypervel\Data\DataServiceProvider; +use Hypervel\Data\Eloquent\DataEloquentCast; use Hypervel\Data\Lazy; use Hypervel\Data\Support\Creation\ConstructionState; use Hypervel\Data\Support\Creation\CreationContext; @@ -29,6 +30,7 @@ use Hypervel\Database\Eloquent\Relations\HasOne; use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Http\Request; use Hypervel\Support\ClassMetadataCache; use Hypervel\Support\LazyCollection; use Hypervel\Testbench\Bootstrapper; @@ -189,6 +191,16 @@ public function __construct( } } +class DataBenchmarkAnnotatedItems extends Data +{ + /** + * @param list $items + */ + public function __construct(public array $items) + { + } +} + class DataBenchmarkPrefixCast implements Cast { /** @@ -417,19 +429,31 @@ public function execute(): array $nestedData = DataBenchmarkUser::from($nestedPayload); $plainFiveData = new DataBenchmarkPlainFive; $plainTwentyData = new DataBenchmarkPlainTwenty; + $request = Request::create('/'); + $model = new DataBenchmarkUserModel; + /** @var DataEloquentCast $eloquentCast */ + $eloquentCast = new DataEloquentCast(DataBenchmarkUser::class); + $encodedNestedData = $eloquentCast->set($model, 'payload', $nestedData, []) + ?? throw new LogicException('The benchmark Data cast returned null for a Data value.'); $lazyTransformData = DataBenchmarkLazyItem::from($lazyRows[0]) ->includePermanently('address') ->onlyPermanently('id', 'address.lineOne'); $results = [ $this->benchmarkOnce( - 'data-from-cold-first-use', + 'data-class-first-use', fn (): int => DataBenchmarkColdData::from([ 'id' => 1, 'name' => 'Cold', 'active' => true, ])->id, ), + $this->benchmarkOnce( + 'metadata-parser-first-use', + fn (): int => count($this->dataClassFactory + ->build(ClassMetadataCache::reflectClass(DataBenchmarkAnnotatedItems::class)) + ->properties), + ), ]; $standardOperations = $this->operations; @@ -562,6 +586,11 @@ function () use ($collectionRows): int { $standardWarmup, fn (): int => $plainTwentyData->toArray()['twenty'], ], + 'transform-plain-twenty-all' => [ + $standardOperations, + $standardWarmup, + fn (): int => $plainTwentyData->all()['twenty'], + ], 'transform-nested' => [ $standardOperations, $standardWarmup, @@ -572,6 +601,22 @@ function () use ($collectionRows): int { $nestedWarmup, fn (): int => $lazyTransformData->toArray()['id'], ], + 'resource-response' => [ + $nestedOperations, + $nestedWarmup, + fn (): int => strlen((string) $nestedData->toResponse($request)->getContent()), + ], + 'eloquent-cast-get' => [ + $standardOperations, + $standardWarmup, + fn (): int => $eloquentCast->get($model, 'payload', $encodedNestedData, [])?->id ?? 0, + ], + 'eloquent-cast-set' => [ + $standardOperations, + $standardWarmup, + fn (): int => strlen($eloquentCast->set($model, 'payload', $nestedData, []) + ?? throw new LogicException('The benchmark Data cast returned null for a Data value.')), + ], 'metadata-analysis' => [ $nestedOperations, $nestedWarmup, @@ -871,7 +916,7 @@ function main(): int try { $operations = parseIntegerOption($options, 'operations', 20_000, 1, 1_000_000); - $samples = parseIntegerOption($options, 'samples', 7, 1, 100); + $samples = parseIntegerOption($options, 'samples', 21, 1, 100); $warmup = parseIntegerOption($options, 'warmup', 1_000, 0, 100_000); $databasePath = tempnam(sys_get_temp_dir(), 'hypervel-data-benchmark-'); @@ -1090,7 +1135,7 @@ function printUsage(): void Options: --operations=COUNT Operations measured per sample (default: 20000) - --samples=COUNT Number of measured samples (default: 7) + --samples=COUNT Number of measured samples (default: 21) --warmup=COUNT Unmeasured warmup operations (default: 1000) --json=PATH Write the complete report as JSON --csv=PATH Write scenario results as CSV diff --git a/tests/Benchmarks/Data/compare-data-object.php b/tests/Benchmarks/Data/compare-data-object.php new file mode 100644 index 000000000..f5edbdcad --- /dev/null +++ b/tests/Benchmarks/Data/compare-data-object.php @@ -0,0 +1,819 @@ +#!/usr/bin/env php + $samples[4], + 'p95' => $samples[8], + ]; +} + +/** + * Measure one operation in a fresh process. + */ +function coldMeasurement(string $mode): int +{ + $application = TestbenchApplication::create(options: ['load_environment_variables' => false]); + $application->register(DataServiceProvider::class); + + try { + if ($mode === 'data-class') { + run(static fn (): NewWarm => NewWarm::from(['id' => 1])); + } + + $elapsed = 0; + $completed = run(static function () use ($application, $mode, &$elapsed): void { + $startedAt = hrtime(true); + + match ($mode) { + 'old' => OldCold::from(['id' => 1, 'name' => 'cold', 'active' => true]), + 'data-first', 'data-class' => NewCold::from(['id' => 1, 'name' => 'cold', 'active' => true]), + 'data-config' => $application->make(DataConfig::class), + 'annotation-reader' => $application->make(DataIterableAnnotationReader::class), + 'class-factory' => $application->make(DataClassFactory::class), + 'class-repository' => $application->make(DataClassRepository::class), + 'data-validator' => $application->make(DataValidator::class), + 'data-creator' => $application->make(DataCreator::class), + 'data-transformer' => $application->make(DataTransformer::class), + 'data-runtime' => [$application->make(DataCreator::class), $application->make(DataTransformer::class)], + default => throw new InvalidArgumentException("Unknown cold mode [{$mode}]."), + }; + + $elapsed = hrtime(true) - $startedAt; + }); + + if (! $completed) { + throw new RuntimeException('Cold benchmark coroutine did not complete.'); + } + + return $elapsed; + } finally { + $application->terminate(); + } +} + +/** + * Return p50/p95 from fresh-process cold measurements. + * + * @return array{p50: float, p95: float} + */ +function measureCold(string $mode): array +{ + $samples = []; + + for ($sample = 0; $sample < SAMPLES; ++$sample) { + $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(__FILE__) + . ' --cold=' . escapeshellarg($mode); + $output = shell_exec($command); + + if ($output === null || ! is_numeric(trim($output))) { + throw new RuntimeException("Cold subprocess [{$mode}] failed."); + } + + $samples[] = (float) trim($output); + } + + sort($samples, SORT_NUMERIC); + + return ['p50' => $samples[4], 'p95' => $samples[8]]; +} + +/** + * Measure retained instance bytes over a large held set. + * + * @param Closure(int): object $factory + */ +function retainedInstanceBytes(Closure $factory): float +{ + gc_collect_cycles(); + $baseline = memory_get_usage(false); + $instances = []; + + for ($index = 1; $index <= 20_000; ++$index) { + $instances[] = $factory($index); + } + + $bytes = (memory_get_usage(false) - $baseline) / count($instances); + unset($instances); + gc_collect_cycles(); + + return $bytes; +} + +/** + * Print one benchmark row. + */ +function printRow(string $scenario, array $old, array $new): void +{ + printf( + "%-38s %12.1f %12.1f %8.2fx %12.1f %12.1f\n", + $scenario, + $old['p50'], + $new['p50'], + $new['p50'] / $old['p50'], + $old['p95'], + $new['p95'], + ); +} + +/** + * Run the comparison matrix. + */ +function execute(): void +{ + $application = TestbenchApplication::create(options: ['load_environment_variables' => false]); + $application->register(DataServiceProvider::class); + + $flat = ['id' => 1, 'name' => 'Taylor', 'email' => 'taylor@example.com', 'active' => true, 'score' => 9.5]; + $coerced = ['id' => '1', 'name' => 123, 'email' => 456, 'active' => 1, 'score' => '9.5']; + $defaults = ['id' => 1]; + $wide = array_combine( + ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty'], + range(1, 20), + ); + $oldLeaf = ['id' => 1, 'code' => 'leaf', 'enabled' => true, 'score' => 9.5]; + $newLeaf = $oldLeaf; + $oldNested = ['child' => $oldLeaf, 'id' => 2, 'name' => 'nested', 'active' => true, 'note' => null]; + $newNested = ['child' => $newLeaf, 'id' => 2, 'name' => 'nested', 'active' => true, 'note' => null]; + $oldDeep = ['child' => ['child' => $oldLeaf, 'id' => 2, 'name' => 'middle'], 'id' => 3, 'name' => 'deep']; + $newDeep = ['child' => ['child' => $newLeaf, 'id' => 2, 'name' => 'middle'], 'id' => 3, 'name' => 'deep']; + $enum = ['id' => 1, 'status' => 'active']; + $oldDate = ['id' => 1, 'created_at' => '2026-09-04 12:34:56']; + $newDate = ['id' => 1, 'created_at' => '2026-09-04T12:34:56+00:00']; + $oldMixed = [ + 'external_id' => '9', + 'display_name' => 123, + 'status' => 'active', + 'created_at' => '2026-09-04 12:34:56', + 'child' => ['id' => '1', 'code' => 456, 'enabled' => 1, 'score' => '9.5'], + ]; + $newMixed = [...$oldMixed, 'created_at' => '2026-09-04T12:34:56+00:00']; + $rows = array_fill(0, 1_000, $flat); + + try { + run(function () use ( + $application, + $coerced, + $defaults, + $enum, + $flat, + $newDate, + $newDeep, + $newMixed, + $newNested, + $oldDeep, + $oldDate, + $oldMixed, + $oldNested, + $rows, + $wide, + ): void { + NewFlat::from($flat); + OldFlat::from($flat); + + $construction = [ + 'flat-5-scalars' => [ + fn (): int => OldFlat::from($flat)->id, + fn (): int => NewFlat::from($flat)->id, + ], + 'with-defaults' => [ + fn (): int => OldDefaults::from($defaults)->id, + fn (): int => NewDefaults::from($defaults)->id, + ], + 'wide-20-scalars' => [ + fn (): int => OldWide::from($wide)->twenty, + fn (): int => NewWide::from($wide)->twenty, + ], + 'flat-requiring-coercion' => [ + fn (): int => OldFlat::from($coerced)->id, + fn (): int => NewFlat::from($coerced)->id, + ], + 'nested-1-level' => [ + fn (): int => OldNested::from($oldNested, true)->child->id, + fn (): int => NewNested::from($newNested)->child->id, + ], + 'deep-3-levels' => [ + fn (): int => OldDeep::from($oldDeep, true)->child->child->id, + fn (): int => NewDeep::from($newDeep)->child->child->id, + ], + 'backed-enum' => [ + fn (): int => OldEnum::from($enum, true)->status === BenchStatus::Active ? 1 : 0, + fn (): int => NewEnum::from($enum)->status === BenchStatus::Active ? 1 : 0, + ], + 'date-time' => [ + fn (): int => OldDate::from($oldDate, true)->id, + fn (): int => NewDate::from($newDate)->id, + ], + 'mixed-api-payload' => [ + fn (): int => OldMixed::from($oldMixed, true)->child->id, + fn (): int => NewMixed::from($newMixed)->child->id, + ], + '1000-item-from-loop' => [ + function () use ($rows): int { + $checksum = 0; + foreach ($rows as $row) { + $checksum += OldFlat::from($row)->id; + } + return $checksum; + }, + function () use ($rows): int { + $checksum = 0; + foreach ($rows as $row) { + $checksum += NewFlat::from($row)->id; + } + return $checksum; + }, + ], + ]; + + printf("Construction (nanoseconds per operation)\n"); + printf("%-38s %12s %12s %9s %12s %12s\n", 'scenario', 'old p50', 'data p50', 'ratio', 'old p95', 'data p95'); + + foreach ($construction as $name => [$old, $new]) { + $divisor = $name === '1000-item-from-loop' ? 1_000 : 1; + $operations = $divisor === 1 ? OPERATIONS : 60; + $warmup = $divisor === 1 ? WARMUP : 10; + $oldResult = measure($old, $operations, $warmup); + $newResult = measure($new, $operations, $warmup); + $oldResult = ['p50' => $oldResult['p50'] / $divisor, 'p95' => $oldResult['p95'] / $divisor]; + $newResult = ['p50' => $newResult['p50'] / $divisor, 'p95' => $newResult['p95'] / $divisor]; + printRow($name, $oldResult, $newResult); + } + + $oldFlatObject = OldFlat::from($flat); + $newFlatObject = NewFlat::from($flat); + $oldWideObject = OldWide::from($wide); + $newWideObject = NewWide::from($wide); + $oldNestedObject = OldNested::from($oldNested, true); + $newNestedObject = NewNested::from($newNested); + $oldDeepObject = OldDeep::from($oldDeep, true); + $newDeepObject = NewDeep::from($newDeep); + $oldObjects = array_fill(0, 1_000, null); + $newObjects = array_fill(0, 1_000, null); + + foreach (array_keys($oldObjects) as $index) { + $oldObjects[$index] = OldFlat::from($flat); + $newObjects[$index] = NewFlat::from($flat); + } + + $transformation = [ + 'flat-uncached' => [ + fn (): int => $oldFlatObject->refresh()->toArray()['id'], + fn (): int => $newFlatObject->toArray()['id'], + ], + 'wide-uncached' => [ + fn (): int => $oldWideObject->refresh()->toArray()['twenty'], + fn (): int => $newWideObject->toArray()['twenty'], + ], + 'nested-whole-tree' => [ + function () use ($oldNestedObject): int { + $oldNestedObject->child->refresh(); + return $oldNestedObject->refresh()->toArray()['child']['id']; + }, + fn (): int => $newNestedObject->toArray()['child']['id'], + ], + 'deep-whole-tree' => [ + function () use ($oldDeepObject): int { + $oldDeepObject->child->child->refresh(); + $oldDeepObject->child->refresh(); + return $oldDeepObject->refresh()->toArray()['child']['child']['id']; + }, + fn (): int => $newDeepObject->toArray()['child']['child']['id'], + ], + 'json-encode-nested' => [ + function () use ($oldNestedObject): int { + $oldNestedObject->child->refresh(); + $oldNestedObject->refresh(); + return strlen((string) json_encode($oldNestedObject, JSON_THROW_ON_ERROR)); + }, + fn (): int => strlen((string) json_encode($newNestedObject, JSON_THROW_ON_ERROR)), + ], + '1000-object-transform' => [ + function () use ($oldObjects): int { + $checksum = 0; + foreach ($oldObjects as $object) { + $checksum += $object->refresh()->toArray()['id']; + } + return $checksum; + }, + function () use ($newObjects): int { + $checksum = 0; + foreach ($newObjects as $object) { + $checksum += $object->toArray()['id']; + } + return $checksum; + }, + ], + 'property-read' => [ + fn (): int => $oldFlatObject->id, + fn (): int => $newFlatObject->id, + ], + ]; + + printf("\nTransformation (nanoseconds per operation)\n"); + printf("%-38s %12s %12s %9s %12s %12s\n", 'scenario', 'old p50', 'data p50', 'ratio', 'old p95', 'data p95'); + + foreach ($transformation as $name => [$old, $new]) { + $divisor = $name === '1000-object-transform' ? 1_000 : 1; + $operations = $divisor === 1 ? OPERATIONS : 80; + $warmup = $divisor === 1 ? WARMUP : 10; + $oldResult = measure($old, $operations, $warmup); + $newResult = measure($new, $operations, $warmup); + $oldResult = ['p50' => $oldResult['p50'] / $divisor, 'p95' => $oldResult['p95'] / $divisor]; + $newResult = ['p50' => $newResult['p50'] / $divisor, 'p95' => $newResult['p95'] / $divisor]; + printRow($name, $oldResult, $newResult); + } + + printf("\nRetained instance bytes\n"); + printf("%-38s %12.1f %12.1f\n", 'flat', retainedInstanceBytes(fn (int $id): OldFlat => OldFlat::from([...$flat, 'id' => $id])), retainedInstanceBytes(fn (int $id): NewFlat => NewFlat::from([...$flat, 'id' => $id]))); + printf("%-38s %12.1f %12.1f\n", 'wide', retainedInstanceBytes(fn (int $id): OldWide => OldWide::from([...$wide, 'one' => $id])), retainedInstanceBytes(fn (int $id): NewWide => NewWide::from([...$wide, 'one' => $id]))); + + $repository = $application->make(DataClassRepository::class); + $repository->get(NewWarm::class); + gc_collect_cycles(); + $before = memory_get_usage(false); + $repository->get(NewCold::class); + $newMetadata = memory_get_usage(false) - $before; + + DataObject::flushState(); + gc_collect_cycles(); + $before = memory_get_usage(false); + $oldObject = OldCold::from(['id' => 1, 'name' => 'cold', 'active' => true]); + unset($oldObject); + gc_collect_cycles(); + $oldMetadata = memory_get_usage(false) - $before; + + printf("\nRetained metadata bytes (one small class, warm services)\n"); + printf("%-38s %12d %12d\n", 'metadata', $oldMetadata, $newMetadata); + }); + } finally { + $application->terminate(); + } + + printf("\nFresh-process first-use (nanoseconds)\n"); + printf("%-38s %12s %12s\n", 'scenario', 'p50', 'p95'); + foreach (['old', 'data-first', 'data-class'] as $mode) { + $result = measureCold($mode); + printf("%-38s %12.1f %12.1f\n", $mode, $result['p50'], $result['p95']); + } +} + +/** + * Measure the major default creation layers after warm metadata. + */ +function profileDefaultCreation(): void +{ + $application = TestbenchApplication::create(options: ['load_environment_variables' => false]); + $application->register(DataServiceProvider::class); + $flat = ['id' => 1, 'name' => 'Taylor', 'email' => 'taylor@example.com', 'active' => true, 'score' => 9.5]; + + try { + run(function () use ($application, $flat): void { + NewFlat::from($flat); + $factory = NewFlat::factory(); + $context = $factory->get(); + $creator = $application->make(DataCreator::class); + + $operations = [ + 'native-constructor' => fn (): int => (new NewFlat(1, 'Taylor', 'taylor@example.com', true, 9.5))->id, + 'base-data-from' => fn (): int => NewFlat::from($flat)->id, + 'prepared-factory-from' => fn (): int => $factory->from($flat)->id, + 'creator-with-context' => fn (): int => $creator->create(NewFlat::class, $context, $flat)->id, + 'factory-get-context' => fn (): int => $factory->get()->mapPropertyNames ? 1 : 0, + 'base-data-factory' => fn (): int => NewFlat::factory()->dataClass === NewFlat::class ? 1 : 0, + ]; + + printf("%-38s %12s %12s\n", 'layer', 'p50', 'p95'); + + foreach ($operations as $name => $operation) { + $result = measure($operation); + printf("%-38s %12.1f %12.1f\n", $name, $result['p50'], $result['p95']); + } + }); + } finally { + $application->terminate(); + } +} + +/** + * Measure first resolution of each fixed Data service graph boundary. + */ +function profileColdServices(): void +{ + printf("%-38s %12s %12s\n", 'service', 'p50', 'p95'); + + foreach (['data-config', 'annotation-reader', 'class-factory', 'class-repository', 'data-validator', 'data-creator', 'data-transformer', 'data-runtime'] as $mode) { + $result = measureCold($mode); + printf("%-38s %12.1f %12.1f\n", $mode, $result['p50'], $result['p95']); + } +} + +/** + * Measure the fixed transformation boundary separately from public dispatch. + */ +function profileTransformation(): void +{ + $application = TestbenchApplication::create(options: ['load_environment_variables' => false]); + $application->register(DataServiceProvider::class); + $flatPayload = ['id' => 1, 'name' => 'Taylor', 'email' => 'taylor@example.com', 'active' => true, 'score' => 9.5]; + $nestedPayload = [ + 'child' => ['id' => 1, 'code' => 'leaf', 'enabled' => true, 'score' => 9.5], + 'id' => 2, + 'name' => 'nested', + 'active' => true, + 'note' => null, + ]; + + try { + run(function () use ($application, $flatPayload, $nestedPayload): void { + $flat = NewFlat::from($flatPayload); + $nested = NewNested::from($nestedPayload); + $transformer = $application->make(DataTransformer::class); + $flatContext = $transformer->defaultContext($flat); + $nestedContext = $transformer->defaultContext($nested); + + $operations = [ + 'manual-flat-array' => fn (): int => ['id' => $flat->id, 'name' => $flat->name, 'email' => $flat->email, 'active' => $flat->active, 'score' => $flat->score]['id'], + 'transformer-flat' => fn (): int => $transformer->transform($flat, $flatContext)['id'], + 'public-flat-to-array' => fn (): int => $flat->toArray()['id'], + 'manual-nested-array' => fn (): int => ['child' => ['id' => $nested->child->id, 'code' => $nested->child->code, 'enabled' => $nested->child->enabled, 'score' => $nested->child->score], 'id' => $nested->id, 'name' => $nested->name, 'active' => $nested->active, 'note' => $nested->note]['child']['id'], + 'transformer-nested' => fn (): int => $transformer->transform($nested, $nestedContext)['child']['id'], + 'public-nested-to-array' => fn (): int => $nested->toArray()['child']['id'], + ]; + + printf("%-38s %12s %12s\n", 'layer', 'p50', 'p95'); + + foreach ($operations as $name => $operation) { + $result = measure($operation); + printf("%-38s %12.1f %12.1f\n", $name, $result['p50'], $result['p95']); + } + }); + } finally { + $application->terminate(); + } +} + +$options = getopt('', ['cold:', 'profile', 'cold-profile', 'transform-profile']); + +if (is_array($options) && isset($options['cold']) && is_string($options['cold'])) { + echo coldMeasurement($options['cold']); + exit(0); +} + +if (is_array($options) && array_key_exists('profile', $options)) { + profileDefaultCreation(); + exit(0); +} + +if (is_array($options) && array_key_exists('cold-profile', $options)) { + profileColdServices(); + exit(0); +} + +if (is_array($options) && array_key_exists('transform-profile', $options)) { + profileTransformation(); + exit(0); +} + +execute(); From dc48fa653bec7c4a23cc987b0a402815b7715488 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:07:07 +0000 Subject: [PATCH 5/5] Document automatic lean Data execution Record the measured baseline, immutable recipe design, one-engine fallback rules, lifecycle constraints, integration boundaries, test matrix, performance acceptance criteria, and rejected alternatives. The plan also preserves the required post-checkpoint merge and benchmark work so the current 0.4 enum and morph behavior is reconciled before final integration. --- ...9-04-0853-data-automatic-lean-execution.md | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 docs/plans/2026-09-04-0853-data-automatic-lean-execution.md diff --git a/docs/plans/2026-09-04-0853-data-automatic-lean-execution.md b/docs/plans/2026-09-04-0853-data-automatic-lean-execution.md new file mode 100644 index 000000000..8349404b8 --- /dev/null +++ b/docs/plans/2026-09-04-0853-data-automatic-lean-execution.md @@ -0,0 +1,459 @@ +# Data Automatic Lean Execution + +## Status + +- Target repository: `contrib/hypervel/components-data-fix`. +- Target branch: `feature/data-lean-tier`, created from `0.4`. +- Implementation is in progress. +- This follow-up supersedes the completed package plan where its measured hot-path decisions differ, including the old monolithic `directArrayCreation` and `plainTransform` predicates. Unrelated package design remains unchanged. +- The existing `Data`, `Dto`, and `Resource` APIs remain the only public data-object model. Do not restore `Hypervel\Support\DataObject`, add a fast-mode API, or expose recipe selection to applications. + +## Outcome + +Make ordinary `Data::from()` and `toArray()` work proportional to the declared values they actually need to process, while preserving the complete package contract. Immutable metadata selects a lean internal recipe automatically. Unsupported declarations, customized operations, validation, and runtime misses continue through the existing fixed engine. + +The result must: + +- materially reduce construction of scalar, coercing, enum, date, and nested data objects; +- retain the current fast bulk-copy transformation for plain scalar objects and extend automatic lean transformation to mapped, enum, date, and nested properties; +- resolve the fixed Data service graph after application providers boot and before the production server forks workers, so the first request does not pay its initialization cost without slowing unit-test application boot; +- avoid PHPDoc parsing when native declarations prove iterable item metadata cannot be used; +- preserve every current API, exception, mapping, factory, hook, lazy, partial, validation, resource, and persistence behavior; +- remain one construction and transformation engine with one general fallback, not two implementations that can drift. + +## Constraints + +1. Follow the root `CLAUDE.md` and components `AGENTS.md` in full. +2. Public Laravel and Spatie-style ergonomics are unchanged. `from()`, `factory()`, `collect()`, `transform()`, `all()`, and `toArray()` keep their signatures and extension boundaries. +3. Recipes are immutable worker-owned metadata. Payloads, constructed objects, operation state, and request state must never enter them. +4. Each root creation keeps one operation memo. Nested lean and general nodes share it and never redispatch through a public `from()` method. +5. A recipe miss falls into the current general path before a constructor runs. Never construct speculatively and retry. +6. Do not duplicate casts, date parsing, enum errors, nested partial handling, finalization, or instantiation. Extract the lowest shared primitive when both paths need the same behavior. +7. Retain a specialization only when same-machine p50 and p95 measurements show a repeatable material improvement. Do not trade clarity or correctness for benchmark noise. +8. Worker memory remains naturally bounded by used Data classes and their declared properties. Do not add eviction, discovery, generated metadata, cache commands, or deploy files. +9. If implementation requires a second construction engine, a public mode, or duplicated feature logic, stop and notify the owner before proceeding rather than forcing this design or restoring `DataObject` silently. + +## Research Baseline + +### Current architecture + +The package already follows the fixed-flow direction of the Spatie v5 draft at `/home/binaryfire/workspace/references/spatie/laravel-data` (`origin/v5` at `927870922b`): one `ConstructionState`, one immutable `CreationContext`, direct action calls, and no configurable resolver pipeline. The proposed work specializes that engine; it does not replace its architecture or copy Spatie's request-lifetime cache machinery. + +Relevant current boundaries: + +- `BaseData::from()` calls `static::factory()->from()`. Every call resolves the worker-shared `DataCreator`, allocates a `CreationContextFactory`, and builds a new 18-field `CreationContext`. The late-static factory dispatch is an existing extension boundary and must remain intact. +- `DataCreator::execute()` decides validation, then allocates `ConstructionState` before `fillNode()` can attempt the exact-array exit. +- `fillNode()` matches a named factory and `tryCreateDirectArrayNode()` accepts only already-correct values. The first value requiring a fixed built-in, enum, date, or nested Data conversion sends the complete node through normalization, state population, casting, and bottom-up construction. +- `createUnvalidatedNode()` currently serves deferred collection items and `LazyCollection` mapping, and allocates state before factory matching or the direct attempt. This plan also makes it the shared nested-value boundary. +- `DataTransformer::transformData()` has a safe `plainTransform` bulk-copy exit. Mapped, enum, date, and nested declarations use the general property loop even when no partial, lazy, or custom transformer can apply. +- `DataIterableAnnotationReader` constructs the PHPStan lexer/parser with the Data service graph. `DataClassFactory` asks it to inspect constructor, class, and property PHPDoc even when native types make iterable item metadata unusable. +- `DataServiceProvider::boot()` warms `DataConfig`, but not `DataCreator` or `DataTransformer`. Package discovery loads this provider only in applications that install `hypervel/data`; the monorepo root registers it so the component test environment exercises the complete package graph. + +### Reproduced comparison + +The scratch baseline used the exact removed `DataObject` source from `2454bbabb^`, the current branch, PHP 8.4.23 on Linux x86-64 with CLI OPcache and JIT disabled, nine repeated batches, and fresh PHP processes for cold boundaries. Each implementation received a valid configured date format. These are planning measurements from one machine, not release claims. + +Construction p50: + +| Scenario | Old `DataObject` | Current `Data` | Ratio | +| --- | ---: | ---: | ---: | +| Flat, five scalars | 1.812 us | 5.961 us | 3.29x | +| Defaults | 1.161 us | 5.133 us | 4.42x | +| Wide, twenty scalars | 6.258 us | 13.553 us | 2.17x | +| Scalar coercion | 1.967 us | 32.934 us | 16.74x | +| One nested level | 4.032 us | 28.664 us | 7.11x | +| Three nested levels | 5.077 us | 42.861 us | 8.44x | +| Backed enum | 1.388 us | 18.632 us | 13.42x | +| Date | 5.448 us | 23.122 us | 4.24x | +| Mixed API payload | 9.179 us | 69.354 us | 7.56x | +| 1,000-item `from()` loop, per item | 1.859 us | 6.051 us | 3.25x | + +Transformation p50: + +| Scenario | Old `DataObject` | Current `Data` | Ratio | +| --- | ---: | ---: | ---: | +| Flat, uncached | 0.659 us | 1.736 us | 2.64x | +| Wide, uncached | 1.797 us | 2.436 us | 1.36x | +| Nested tree | 1.427 us | 5.738 us | 4.02x | +| Deep tree | 1.876 us | 8.227 us | 4.39x | +| Nested `json_encode()` | 2.042 us | 6.654 us | 3.26x | +| 1,000 objects, per item | 0.617 us | 1.715 us | 2.78x | +| Property read | 29.7 ns | 30.8 ns | 1.04x | + +The comparison confirms the need, but does not define the implementation. `Data` performs much more work and must keep its richer semantics. The goal is to remove work proven unnecessary for each declaration, not to copy `DataObject`'s behavior or stale per-instance array cache. + +### Isolated costs + +| Default creation layer | p50 | +| --- | ---: | +| Native constructor | 175 ns | +| `Data::from()` | 6.012 us | +| Prepared `CreationContextFactory::from()` | 5.099 us | +| `DataCreator::create()` with retained context | 3.858 us | +| `CreationContextFactory::get()` | 857 ns | +| `Data::factory()` | 554 ns | + +A cached immutable default context removes the measured 857 ns `get()` layer while retaining fresh factories and late-static dispatch. The remaining gap requires attempting the recipe before `ConstructionState` allocation. + +| Fresh fixed service boundary | p50 | +| --- | ---: | +| Annotation reader | 2.687 ms | +| Class factory | 4.613 ms | +| Class repository | 4.975 ms | +| Validator | 13.594 ms | +| Creator | 15.714 ms | +| Transformer | 6.150 ms | +| Creator and transformer sequentially | 16.413 ms | + +Fresh-process first use measured 17.951 ms for the first Data class and 170.7 us for another class after the services were warm. The fixed dependency graph, especially Validation, dominates first use; parser construction is a separate measurable part. + +| Transformation layer | p50 | +| --- | ---: | +| Manual flat array | 101 ns | +| Transformer, flat | 1.127 us | +| Public flat `toArray()` | 1.727 us | +| Manual nested array | 188 ns | +| Transformer, nested | 4.520 us | +| Public nested `toArray()` | 5.535 us | + +The current flat bulk-copy path is already compact. It is a regression guard, not code to replace with an unconditional per-property recipe loop. + +## Design + +### One automatic engine + +There is no new public concept. The same declaration may contain lean and general nodes: + +1. Public `from()` retains `static::factory()` dispatch; the default implementation creates a fresh factory seeded with a worker-cached immutable default context. +2. The creator determines validation and named-factory behavior exactly once. +3. An eligible array node runs its immutable construction recipe. +4. A child whose declaration is not eligible enters the existing general Fill path, while eligible siblings remain lean. +5. Transformation independently selects bulk copy, a fixed output recipe, or the current general loop. + +Do not specialize `collect()` separately. Albert's collection construction figure is a 1,000-item `from()` loop, and current eager/lazy collection operations deliberately share one root state and validation graph. Per-node recipes improve ordinary collection casting through the existing internal boundary without another collection engine. + +### Recipe metadata + +Replace `DataClass::$directArrayCreation` with a nullable typed creation recipe. Replace `DataClass::$plainTransform` with a `bulkCopyTransformation` boolean and a nullable fixed transformation recipe. Keep `directConstructorInstantiation`; it proves a distinct constructor invariant used after a creation recipe succeeds. + +Add these internal immutable types: + +- `Enums\DataPropertyOperation`: `Copy`, `Builtin`, `Enum`, `Date`, and `Data` cases. +- `Support\Creation\DataCreationRecipe`: an ordered list of eligible `DataProperty` metadata. +- `Support\Transformation\DataTransformationRecipe`: an ordered list of visible `DataProperty` metadata for fixed per-property operations. + +Store nullable `constructionOperation`, `constructionTarget`, and `transformationOperation` fields directly on the immutable `DataProperty` that owns the rest of the per-property metadata. Do not allocate a forwarding recipe wrapper around every property. + +Recipes retain only ordered references to existing class/property metadata. They must not retain closures, contexts, payloads, application instances, constructed Data objects, or mutable extensions. A nested property's target stores the child class string and resolves that child's already-cached metadata only when the value is reached; metadata construction must not recurse across the class graph. + +`DataClassFactory` owns recipe compilation: + +- A construction recipe is unavailable for abstract/property-morphable classes, class or configured normalizers, contextual parameters, AutoLazy, `LoadRelation`, property/configured casts, Data collectables, or typed iterables. +- Construction classification follows the general engine's conversion priority: Data, declared `Castable`, date, enum, then built-in. Stop at the first family present. Compile its fixed operation only when that family has one unambiguous target; otherwise compile `Copy`. Any declared `Castable` arm also compiles `Copy` because its behavior belongs to the general cast boundary. Every operation first accepts a value already valid for the complete declared union, so accepted values remain lean while values needing an ambiguous, custom, or unsupported conversion miss to the general path before side effects. +- Pin the four order-sensitive mixed declarations: ambiguous Data before date, `Castable` before date, ambiguous date before enum, and ambiguous enum before built-in. Two change success or failure: `Castable|DateTimeImmutable` must retain the general path's successful cast, while `StatusA|StatusB|int` must raise the general path's enum error instead of accepting a built-in integer. The other two must retain the general path's exact exception class and message. +- Construction recipes retain computed properties as `Copy` entries regardless of their declared type, but never execute that operation: absence emits no constructor value and presence throws the existing supplied-value exception. This avoids making an otherwise eligible class general merely because a class-owned value has an object type. +- A transformation recipe is unavailable for lazy, Optional, mixed, custom-transformer, Data-collectable, typed-iterable, or arbitrary object behavior. Hidden properties are omitted. Static output mappings, scalar/array copies, one enum, one date, and one nested Data class are supported. +- Resolve that complete eligibility classifier before checking the narrower bulk-copy proof. A class uses bulk copy only when the eligible recipe exists and every property is visible, unmapped, and `Copy`; store the boolean and discard the recipe so bulk classes retain no unused ordered property array. Do not consult the narrow bulk proof independently because `Copy` types can still have a property transformer or Data iterable annotation. +- The boolean and nullable recipe form three mutually exclusive states: bulk copy (`true`, `null`), fixed property operations (`false`, recipe), or the general loop (`false`, `null`). Document this invariant on `DataClass`. +- Unlike `0.4`'s plain predicate, an unannotated array property may use bulk copy because the complete classifier proves it has no iterable item metadata or transformer and the no-partials path copies it unchanged. Annotated Data arrays remain general. + +Update metadata tests to assert the three transformation states plus ordered recipe properties and operation/target fields. Measure retained memory for a representative 500-class graph before accepting the object shape. Expected growth is bounded to the low single-digit megabytes for an intentionally large graph; if the two class-level property lists are materially wasteful, measure an existing-metadata alternative rather than add cache eviction or compact untyped arrays. + +### Default factory entry + +Keep `BaseData::from()` routing through `static::factory()->from()`. This preserves an existing late-static extension boundary and the familiar Spatie call chain. + +`DataCreator` caches one immutable default Create context per used Data class. `DataCreator::factory()` still returns a newly constructed `CreationContextFactory`, preserving its existing fresh-instance semantics, and seeds it with the cached context. + +```php +public function factory(string $class): CreationContextFactory +{ + $factory = new CreationContextFactory( + $this, + $this->config, + $class, + $this->defaultContexts[$class] ?? null, + ); + + $this->defaultContexts[$class] ??= $factory->get(); + + return $factory; +} +``` + +On the first factory for a class, `get()` builds and memoizes the default Create context on that factory before the creator records it; later factories receive the cached context directly. Every fluent mutator clears the factory's nullable Create-context field at the same point it changes state. `get(CreationMode::Create)` returns the cached immutable context or rebuilds it once after a mutation; Validate and Rules modes continue building their mode-specific contexts. Repeated use of an unchanged customized factory may reuse its rebuilt immutable context, because all operation state lives below that boundary. + +The default-context cache is a normal property on the auto-singleton creator. It has one entry per used Data class, the same bounded key space as `DataClassRepository`, and retains no payload, operation, request, validator, or resolved extension state. It needs no static cleanup or eviction. Do not add a factory-prototype cache unless later measurements prove cloning materially beats ordinary factory construction. + +`execute()` retains the existing supplied-instance exit and authoritative validation decision. Only Create mode with one payload and no validation/rule compilation delegates to the enhanced `createUnvalidatedNode()`. Multiple payloads, Request/Always validation, Validate mode, and Rules mode keep the current stateful flow. + +### Lean node construction + +Refactor named-factory handling so it cannot run twice: + +- `fillNode()` continues matching factories for validation and multi-payload graphs, then delegates the post-factory work to one internal helper. +- `createUnvalidatedNode()` gets class metadata and matches the factory before allocating state. A returned target object exits. An array result may use the recipe. Any other result goes to the post-factory general helper without rematching. +- On a recipe miss, allocate `ConstructionState` once and continue through that same helper. Constructors are never invoked during eligibility checks or misses. + +Attempt the recipe only when all of these runtime facts hold: + +- `CreationMode::Create`; +- validation and rule compilation are authoritatively false; +- the post-factory value is one array; +- class metadata has a construction recipe; +- operation `casts`, `normalizers`, `prepareDataHooks`, `beforeCreationHooks`, and `afterCreationHooks` are empty. + +Do not gate on `beforeValidationHooks`, `beforeRulesHooks`, `afterRulesHooks`, `withValidatorHooks`, or `afterValidationHooks` after validation/rule compilation is known to be false; those hooks cannot execute in that operation. Both property-name mapping modes remain eligible. For mapping enabled, use the compiled mapped path first and the raw property path as the existing fallback. For mapping disabled, read only the raw property path. + +For every property: + +1. Missing computed properties are omitted; a supplied computed/virtual value immediately throws the existing `CannotSetComputedValue` exception at the same property-order boundary as general Fill. +2. Missing declared defaults are omitted so PHP supplies the exact constructor/property default. +3. Missing Optional properties receive `Optional::create()`. +4. Missing nullable properties receive `null`. +5. A missing required property is omitted and forces ordinary `instantiate()` for that node, even when direct construction was otherwise eligible. Conversions run before instantiation exactly as in the general path, then the ordinary instantiator owns the established missing-constructor/property exception. +6. Explicit `null` and `Optional` remain unchanged. +7. Already-correct values remain unchanged. +8. Built-in, enum, date, and nested Data operations use their fixed conversion. +9. Any unsupported runtime value misses before construction. + +The recipe reads raw keys and one-segment mapped keys directly from the proven array payload with `array_key_exists()`, preserving explicit `null`. Only multi-segment mapped paths use `SourceReader`; a mapped miss still falls back to the raw property name when the names differ. After explicit `null` and `Optional` values have exited, runtime acceptance calls the underlying `Type::acceptsValue()` directly because `DataType`'s nullable and mixed wrapper checks are then redundant. Keep a short comment at each bypass naming the local precondition; these shortcuts must not be copied into source paths that also accept `Normalized` values or have not handled nullable values. + +Recipe execution preflights the complete node before any conversion with side effects or exceptions. The first pass reads every property, applies presence/default/Optional/null and runtime-shape checks, records raw values, and records only values that still require conversion. If that conversion list is empty, the exact-value case instantiates after the single property pass. Otherwise, only the recorded conversions run after the node is known not to miss; nested creation runs at this point, and cast exceptions propagate directly rather than retrying. This prevents an early nested factory, hook, or constructor from running twice when a later property requires the general path without adding a second traversal to the current exact-value fast case. + +Nested `Data` operations call `createUnvalidatedNode()` with the same context and operation memo. This allows each child to choose its own recipe or general fallback and preserves custom extension resolution once per root. Deferred AutoLazy replay must consume the state-owned post-Fill value at the property's compiled input path, not the stale closure argument. This preserves the nested source while ensuring directly finished nested and collectable values, including named-factory results, are constructed exactly once. + +Successful properties use `instantiateDirect()` only when `directConstructorInstantiation` is true. All other recipe successes use the ordinary `DataInstantiator::instantiate()`, retaining constructor visibility, variadic, missing-parameter, contextual, non-promoted assignment, and property-default behavior. + +### Shared fixed conversions + +Add one internal `Support\Creation\ValueCaster` containing the pure low-level built-in, backed-enum, and date conversions. `BuiltinTypeCast`, `EnumCast`, and `DateTimeInterfaceCast` delegate to it, and the recipe calls the same methods directly. + +The extraction must preserve exactly: + +- case-insensitive string `true`/`false` handling and PHP coercion for other built-ins; +- already-correct and other-backed-enum handling, using current `0.4`'s shared `enum_from()` coercion semantics; +- `CannotCastEnum` and `CannotCastDate` types and messages; +- ordered date formats, fractional-second trimming, source timezone, target timezone, concrete mutable/immutable targets, and the Hypervel Date factory boundary; +- `Uncastable` when a cast has no applicable target. + +Do not store Cast instances in worker metadata or duplicate reduced conversion code inside `DataCreator`. + +### Lean transformation + +Keep two focused execution methods: `transformBulkCopy()` owns the existing plain array operations, while `transformUsingRecipe()` owns fixed per-property operations. Choosing between them at the call site removes unused arguments and a branch from bulk copy without duplicating transformation behavior. `transformData()` still checks maximum depth before metadata or recipe execution and always routes the result through `finalizeTransformation()`. + +The recipe gate is structural, not dependent on identity of a cached context. Test the class discriminator before context work so general-only classes do not pay partial-tree checks: + +```php +if ($dataClass->bulkCopyTransformation) { + if (! $context->constructable + && $context->transformers === [] + && ! $context->hasPartials() + ) { + // Execute bulk copy and finalize. + } +} elseif (($recipe = $dataClass->transformationRecipe) !== null) { + if ($context->transformValues + && ! $context->constructable + && $context->transformers === [] + && ! $context->hasPartials() + ) { + // Execute fixed property operations and finalize. + } +} +``` + +Keep the repeated context guards inline. A helper call would cost more than the dispatch work it removes and would obscure the distinct non-transforming bulk boundary. + +The fixed property loop uses the same value-read rules as the general loop: public get hooks own their logical value, declared backing storage is read without exposing runtime properties, and uninitialized properties are omitted. It selects the mapped or raw output name from `context->mapPropertyNames`. + +- `Copy` writes the value unchanged. +- `Enum` and `Date` call the existing shared fixed transformation behavior. +- `Data` creates the child context with `child($property->name, resolveWrapExecutionType(...))`, then calls `transformNested()`. That method remains the sole boundary that merges instance partials before `transformData()`. +- `null` is copied before the operation. + +Bulk copy remains available to non-transforming contexts such as `all()` because it emits declared values unchanged. Fixed output recipes require value transformation because they convert enum, date, and nested values. Both paths remain unavailable for Eloquent persistence (`constructable` is true), partial selections, runtime transformer overrides, lazies, collectables, and unsupported property shapes. Wrapping and additional data remain in `finalizeTransformation()`. `transform()` remains the only overridable public transformation boundary, so `all()`, resources, and both Eloquent casts retain existing override behavior. + +### PHPDoc and process boot + +Make `DataIterableAnnotationReader` construct its lexer/parser lazily on the first eligible non-empty PHPDoc comment. It remains an auto-singleton; nullable instance fields are sufficient and require no static cache or cleanup. + +Add a native-type predicate used before each read: + +- skip a constructor docblock only when none of its parameter types can carry iterable item metadata; +- skip an individual property docblock only when its native type cannot carry it; +- skip the class-level inheritance walk only when no reflected property can carry it; +- keep current constructor, inline property, nearest class, and parent precedence unchanged. + +Parsing is required for no type, `mixed`, `object`, arrays, `iterable`, Traversable/Enumerable/paginator/Data-collectable families, and any union or intersection containing a possible arm. A type is impossible only when every arm is a scalar built-in, null, backed enum, Data class, date class, `Optional`, or the package-owned `Lazy` family. Keep conservative parsing for other class types so user collection implementations are not silently reclassified. + +The measured fixed graph is request-visible today. `DataServiceProvider::boot()` continues resolving `DataConfig`, then, unless `Application::runningUnitTests()` is true, registers an application `booted` callback that resolves `DataCreator` and `DataTransformer` in that order. Waiting until the application is booted lets later providers finish configuring shared services before the graph is retained. During production server startup this runs once in the booted master process before Swoole forks workers; the workers inherit the resolved auto-singletons. Applications pay this fixed CPU cost only when they install and discover `hypervel/data`. This moves roughly 16 ms out of the first request without slowing each Testbench application, class discovery, I/O, generated cache files, or deployment configuration. Class metadata remains demand-built because discovering application Data classes would add filesystem work and deployment machinery. + +Provider tests must prove unit-test application boot resolves only stable `DataConfig`, while an isolated non-testing application runs the post-provider-boot callback and retains stable `DataCreator` and `DataTransformer` instances. The lazy parser test must inspect the reader's private nullable parser fields through reflection: simple scalar metadata leaves them uninitialized, while an eligible generic declaration initializes one parser pair and preserves annotation precedence. Do not add a production inspection API for this test. + +## File Map + +### Source + +- Modify `src/data/src/DataServiceProvider.php` to warm the fixed creator/transformer graph. +- Modify `src/data/src/Support/DataClass.php` to hold nullable recipes instead of the two old booleans. +- Add `src/data/src/Enums/DataPropertyOperation.php`; modify `src/data/src/Support/DataProperty.php` to store its construction/transformation operation metadata. +- Add `src/data/src/Support/Creation/DataCreationRecipe.php`. +- Modify `src/data/src/Support/Creation/CreationContextFactory.php` for immutable Create-context reuse with explicit invalidation by fluent mutators. +- Modify `src/data/src/Support/Creation/DataCreator.php` for cached default contexts, pre-state factory/recipe selection, shared fallback, and recursive recipe execution. +- Add `src/data/src/Support/Transformation/DataTransformationRecipe.php`. +- Modify `src/data/src/Support/Transformation/DataTransformer.php` for bulk-copy/fixed recipe execution. +- Modify `src/data/src/Support/Factories/DataClassFactory.php` for recipe compilation and annotation screening. +- Modify `src/data/src/Support/Annotations/DataIterableAnnotationReader.php` for lazy parser construction and native-type eligibility. +- Add `src/data/src/Support/Creation/ValueCaster.php`; modify the three built-in cast adapters to delegate. + +No changes are currently warranted in Validation, Container, HTTP, Foundation, Database, Inertia, or Saloon: the required extension points and first-party integrations already exist. Any defect exposed while tracing implementation still follows the normal stop, investigation, and root-fix workflow. + +### Tests and benchmarks + +- Update `tests/Data/DataServiceProviderTest.php`. +- Update `tests/Data/Support/DataClassTest.php` and its fixtures for recipe metadata. +- Update `tests/Data/Support/Creation/DataCreatorTest.php` for fresh factory/default-context reuse, recipe success/fallback, and equivalence. +- Update `tests/Data/Support/Transformation/DataTransformerTest.php` for output recipes and boundary guards. +- Update `tests/Data/Support/DataIterableAnnotationReaderTest.php` and annotation fixtures for screening and lazy construction. +- Update the built-in, enum, and date cast tests to pin shared conversion behavior. +- Add the historical mapper under `tests/Benchmarks/Data/Fixtures/DataObject.php`, adapting only its namespace to the test PSR-4 root, and require it only from the dedicated comparison harness. +- Add `tests/Benchmarks/Data/compare-data-object.php`; update `benchmark.php` with missing Data-only scenarios and update the benchmark README. + +No user documentation or Laravel porting-guide change is required because the public contract is unchanged. Keep the existing Spatie v5 reconciliation item in `docs/todo.md` unchanged. + +## Implementation Order + +1. Add the reproducible historical comparison harness and record the pre-change JSON/CSV reports outside the repository. +2. Add typed recipe metadata and replace the old eligibility booleans. Run `DataClassTest` and memory measurements before changing execution. +3. Extract and test `ValueCaster`; run the three cast test files and verify exact exceptions. +4. Add cached default contexts and the pre-state node recipe/fallback flow. Run creator, factory, named-factory, collection, lazy, and contextual tests after each coherent change. +5. Add transformation recipe execution while retaining bulk copy. Run transformer, partial, Eloquent cast, resource, and response tests. +6. Add PHPDoc screening/lazy parser and run annotation/type metadata tests. +7. Register post-provider application-boot warming for creator and transformer outside unit tests, then run provider and package integration tests. +8. After an owner-authorized checkpoint commit, merge current `0.4` into the branch. Preserve its shared enum coercion in `ValueCaster` and morph resolution, then run `EnumCastTest`, `DataCreatorTest`, `FormRequestCastTest`, and `CapabilityTest` before benchmarking. +9. Rerun the comparison and standard benchmark harnesses. Remove any specialization that does not earn its code or regresses p95/memory materially. +10. Run `composer fix`, perform a complete caller/callee and edge-case self-review, then obtain code-review signoff. + +## Test Plan + +### Metadata + +- Recipe present for flat built-ins, defaults, static mappings, enums, dates, nested Data, non-promoted public properties, inheritance, readonly promotion, computed output, and supported property hooks. +- Construction recipe absent for abstract/morphable, contextual, AutoLazy, `LoadRelation`, custom/configured casts, configured/class normalizers, Data collections, and typed iterables. An ambiguous conversion family or declared `Castable` arm keeps the class recipe but compiles that property as `Copy`, allowing accepted values to stay lean while conversion-required values fall back. Transformation recipe absent for custom/configured transformers, lazy, Optional, mixed, Data collection, typed iterable, ambiguous transform unions, and arbitrary objects. +- Transformation metadata distinguishes plain unannotated arrays (`true`, `null`), property-transformed and Data-annotated arrays (`false`, `null`), fixed non-bulk declarations (`false`, recipe), and unsupported arbitrary objects (`false`, `null`). Functional tests prove the first copies unchanged and the rejected forms still transform through the general loop. +- Construction and transformation targets are exact; nested recipe compilation does not build child metadata recursively. +- `directConstructorInstantiation` remains independently true/false for the existing proven shapes. + +### Construction equivalence + +For each supported shape, compare default lean creation with a factory forced through the general path by an identity `beforeCreation` hook: + +- exact scalars and scalar coercion, including string booleans; +- explicit null, missing nullable, missing default, Optional, missing required, and supplied computed/virtual values; +- mapped path, raw fallback, and `withoutPropertyNameMapping()`; +- already-correct enum/date/Data instances and raw enum/date/nested values; +- nested and deep mixed lean/general graphs; +- inherited, readonly, non-promoted, uninitialized, and property-hook declarations; +- public, protected/private, variadic, and incomplete constructors; +- exception class and message equality for enum/date/constructor failures. + +Pin the two exceptional property states independently: a supplied computed/virtual value throws directly during recipe traversal without allocating general state, while a missing required value completes fixed conversions and delegates to ordinary `instantiate()` without calling `instantiateDirect()` or restarting through general Fill. + +Also cover: + +- distinct fresh default factories sharing one immutable context per class, distinct classes, every fluent mutator invalidating the cached context and applying its change, repeated customized-factory reuse, and no customized state leaking into later factories; +- direct raw and one-segment mapped reads preserving explicit `null`, multi-segment mapping, mapped-first precedence, and raw-name fallback; +- a Data class overriding `factory()` still controls `from()`; +- named factory called once when it returns an object, eligible array, or non-array general source; +- Request and Always validation, validation/rules modes, multiple/zero payloads, custom normalizer/cast, and all creation hooks using the general path; +- validation-only hooks not blocking an otherwise unvalidated array operation; +- nested AutoLazy/general fallback receiving the nested source and resolving later, with exact and coercing child values constructed once; +- AutoLazy collectables preserving keyed paginator metadata while plain children construct once and named factories run once per item; +- mapped and unmapped AutoLazy nested values, recipe-eligible `AutoWhenLoadedLazy` relations, and non-replay AutoLazy values retaining their closure-resolved input; +- one operation memo shared when a lean parent reaches multiple general children; +- eager/lazy collections retaining keys, source shapes, validation batching, and one root operation. + +### Transformation equivalence + +Compare recipe output with a structurally equivalent forced-general context that carries an irrelevant runtime transformer mapping, disabling the recipe without changing any tested value, for: + +- plain flat and wide objects, including inherited ordering and runtime-key filtering; +- mapped/unmapped names, hidden and computed values, backed/virtual get hooks read once; +- enum, date/timezone, nested and deep Data, null, and mixed lean/general child graphs; +- wrapping and additional data finalization; +- runtime transformer overrides, custom transformers, typed iterables, Data collections, paginators, and arbitrary Arrayable values falling back; +- temporary/permanent include, exclude, only, and except selections; +- bulk-copy classes using the bulk method under `all()` while retaining output and key order; the test uses a bound test-local transformer subclass because output equality alone cannot distinguish the general path; +- the existing mapped date, enum, and nested Data `all()` assertion keeping fixed recipes disabled when values are not transformed; +- resource responses, constructable persistence, and both Eloquent cast paths retaining their current semantics; +- maximum depth checked at every nested node and nested instance partials merged before transformation. + +### PHPDoc and boot + +- Scalar-only constructor/property/class comments never initialize the parser. +- `Lazy|string` and `Optional|int` declarations do not initialize the parser, while either package type unioned with an array/iterable-capable arm still parses that arm. +- Array, iterable, collection, paginator, Data-collectable, untyped, mixed, object, union, intersection, and custom possible types remain parsed. +- Constructor beats property, property beats nearest class, and child class beats parent exactly as today. +- One reader instance initializes one parser pair at most once. +- Unit-test application boot resolves stable `DataConfig` without resolving `DataCreator` or `DataTransformer`; isolated non-testing application boot resolves both services from the application `booted` callback and subsequent resolutions return the retained instances. + +### Verification + +Run targeted files throughout, then the package-focused suite. At the final checkpoint run `composer fix` once. After review corrections, rerun targeted checks and repeat the full command only when a correction can affect the wider repository. + +## Performance Acceptance + +The committed harness must report p50, p95, operations per second, peak/retained memory, PHP/OS/extensions, OPcache/JIT state, commit, operation counts, and checksums. Run alternating before/after measurements on an idle machine. Acceptance comparisons use at least three alternating fresh processes, enough samples to measure the tail, and the actual 95th-percentile sample at index `ceil(0.95 * count) - 1`; the slowest sample is not labeled p95. Attribute dispatch changes through an in-tree A/B that varies only the gate; use clean `0.4` versus the branch to accept the complete result, not to assign every small difference to one line. + +Required comparisons: + +- current branch before/after for every changed hot path; +- native constructor and explicit manual mapper floors; +- historical `DataObject` for the applicable Albert matrix only; +- exact recipe, coerced recipe, nested/deep mixed graph, named factory, Request validation, eager/lazy collection, plain/mapped/nested transformation, resource, and persistence cases; +- first package use, next class after warm services, repository hit, parser initialization, and provider boot; +- isolated unit-test/non-testing application boot and the Testbench suite wall time; +- retained metadata for 1, 100, and 500 representative classes plus retained flat/wide instance memory. + +Acceptance rules: + +- Flat and wide plain transformation, including `all()` on bulk-copy shapes, may not regress beyond measurement noise. +- General validation, collection, resource, persistence, and customized factory paths may not regress materially. +- Default-context caching and each construction/output recipe must show a repeatable material p50 and p95 win in the cases it claims to optimize. +- First request after server worker start must not resolve the fixed creator/transformer service graph. +- Unit-test application boot and the full Testbench suite must remain within measurement noise of the unwarmed baseline; record suite wall time before and after. +- Metadata growth must remain proportional to used classes/properties, with no payload-derived key space or retained runtime objects. An intentionally large 500-class graph should add no more than a few megabytes for recipes and default contexts. +- Old `DataObject` parity is a useful target, not permission to weaken Data semantics or add duplicate machinery. +- Apply the material-win stopping rule to the direct array read. The underlying `Type::acceptsValue()` call removes a redundant wrapper without adding machinery and remains regardless of benchmark noise. +- After the direct read is measured, treat the remaining flat-creation cost from container resolution, late-static factory dispatch, variadic forwarding, recipe gating, and instantiation as the price of the supported contract rather than grounds for another execution engine. + +## Rejected Designs + +- Restoring `Hypervel\Support\DataObject`: duplicates concepts and casting semantics, forces users to choose up front, and creates two engines to maintain. +- Public `fast()`/attribute/config modes: expose an implementation decision and allow callers to select an unsafe path. +- Bypassing `static::factory()` inside `BaseData::from()`: saves one call by breaking the existing factory extension path. Cache the default implementation's immutable state instead. +- Generated metadata or warm-class lists: require discovery, filesystem I/O, invalidation, and deployment ceremony for work already cached per worker. +- Recursive recipe compilation: risks cycles and eagerly builds unused class graphs. +- Stored closures or Cast/Transformer instances in metadata: retain mutable extension state across requests. +- A second built-in acceptance table on `ValueCaster`: duplicates `NamedType` semantics without improving on calling the compiled type directly. +- A class-target `instanceof` flag on `DataProperty`: saves at most about 2% in the measured worst case while adding metadata and a union-sensitive hot-path branch. +- Mirroring the general path's first declared date or enum target in recipe metadata: duplicates a subtle declaration-order tie-break for rare ambiguous unions and cannot optimize the important ambiguous-Data or `Castable` cases. Compile `Copy` and delegate conversion to the authoritative general path instead. +- Cached `CreationContextFactory` prototypes: cloning saved under 2% of the whole creation path while adding retained mutable factory state and an invisible clone-safety invariant. Keep immutable default contexts instead. +- Caching `DataCreator` statically in `BaseData`: would retain a creator across container resets and swaps. Resolve it through the current container to preserve the extension boundary. +- Extending non-transforming recipe execution to Copy-only mapped or hidden shapes: requires more metadata for behavior already handled correctly by the general loop and has no measured hot caller. +- Collection-wide specialization: duplicates the established one-root validation and source-shape machinery without evidence. +- Caching transformed instance arrays: becomes stale after ordinary public property mutation. +- Lazy container resolution of Validation to improve first use: moves fixed service work between requests and complicates the creator; post-provider application boot before the server forks is the correct production boundary. + +## Completion Checklist + +- [ ] Reproducible baseline and historical fixture exist only under benchmarks. +- [ ] One public Data family remains; no fast-mode surface exists. +- [ ] Recipes are immutable, bounded, non-recursive, and contain no runtime state. +- [ ] Named factories, validation, operation memos, and constructors execute once at their owning boundary. +- [ ] Lean/general equivalence and fallback tests cover every supported operation and failure. +- [ ] First-request service initialization is moved to post-provider application boot without slowing unit-test apps. +- [ ] Benchmark wins and memory bounds are recorded; unearned machinery is removed. +- [ ] Targeted tests, `composer fix`, self-review, and peer code review are green.