From 33dfeb1678d170caa319684964d36a37d04a8207 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:36:44 +0000 Subject: [PATCH 01/14] Clarify components verification scope Use focused formatting, analysis, and affected tests for isolated changes instead of requiring the full repository suite at every checkpoint. Reserve composer fix for work that can affect code beyond focused tests, clarify when PHPUnit or ParaTest is appropriate, and point full static analysis at the dedicated composer analyse command. --- AGENTS.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca85bcdd1..c91c1205c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,11 +109,9 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi During implementation, run new or changed test files immediately. After completing a coherent implementation slice, run the affected package or focused test suite. -At a meaningful checkpoint—such as before code review or after completing a substantial slice—run `composer fix` once. It runs `lint:fix`, both PHPStan configurations, the full parallel suite, the Testbench suite, and dogfood tests, so do not run those full checks separately at the same checkpoint. +Use checks that match the change. For isolated changes, run `composer lint:fix`, `composer analyse`, and the affected tests. Run a single affected test file with PHPUnit. Use ParaTest when the affected tests span multiple files. Only run `composer fix` when changes could affect code beyond the affected tests; it already runs formatting, analysis, and all test suites, so do not run those checks separately first. -After review fixes, run the relevant targeted tests. Repeat `composer fix` only when the changes warrant another full-repository check. - -If `composer fix` fails, use targeted checks while correcting the issue. Afterwards, inspect the `fix` script in `composer.json` and run the failed check plus each remaining entry. Rerun an earlier check only if the correction could affect it. +If a check fails, use targeted checks while correcting the issue, then run the failed check and each remaining check. Rerun an earlier check only if the correction could affect it. ## Development Conventions @@ -735,7 +733,7 @@ See the existing entries for database, Redis, Meilisearch, and Typesense as exam The `tests/` directory is excluded from phpstan. Do not run phpstan on tests. -Full PHPStan runs through `composer fix` at checkpoints. During implementation, use targeted PHPStan only when investigating or validating a specific type issue. +Run full PHPStan checks with `composer analyse`. During implementation, use targeted PHPStan only when investigating or validating a specific type issue. `phpstan.types.neon.dist` validates only the committed `types/` fixtures. Never pass source or test paths to it. From d479cc0fec8b23048ff9c605ea39d321a277c5f6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:48:13 +0000 Subject: [PATCH 02/14] Add lightweight support data objects Introduce Hypervel\Support\DataObject for trusted internal envelopes and high-throughput value mapping without pulling in the full Hypervel Data feature engine. Compile a compact immutable recipe once per used class, then construct through exact named arguments with strict scalar conversion, backed-enum support, configured date handling, nested data objects, and recursive array or JSON output. Keep the class transient and retain no request data, reflection objects, or transformed instance cache. Cover construction precedence, invalid declarations, scalar conversion, enums, relative and inherited object types, date targets, transformation, mutation, serialization, cache ownership, and native failure boundaries. --- src/support/src/DataObject.php | 400 +++++++++++ tests/Support/DataObjectTest.php | 1077 ++++++++++++++++++++++++++++++ 2 files changed, 1477 insertions(+) create mode 100644 src/support/src/DataObject.php create mode 100644 tests/Support/DataObjectTest.php diff --git a/src/support/src/DataObject.php b/src/support/src/DataObject.php new file mode 100644 index 000000000..bdca3d26a --- /dev/null +++ b/src/support/src/DataObject.php @@ -0,0 +1,400 @@ + + */ +abstract class DataObject implements Arrayable, Jsonable, JsonSerializable, Transient +{ + private const int KIND_PASSTHROUGH = 0; + + private const int KIND_ARRAY = 1; + + private const int KIND_BOOLEAN = 2; + + private const int KIND_FLOAT = 3; + + private const int KIND_INTEGER = 4; + + private const int KIND_STRING = 5; + + private const int KIND_ENUM = 6; + + private const int KIND_DATA_OBJECT = 7; + + private const int KIND_DATE = 8; + + /** + * The compiled construction recipes. + * + * @var array> + */ + private static array $recipes = []; + + /** + * Create a new data object from the given values. + */ + public static function from(array $data): static + { + $class = static::class; + $arguments = []; + + foreach (self::recipe($class) as $property) { + $name = $property['name']; + + if (isset($data[$name])) { + $arguments[$name] = self::convert($data[$name], $property, $class); + + continue; + } + + if (array_key_exists($name, $data)) { + $arguments[$name] = null; + + continue; + } + + if ($property['hasDefault']) { + continue; + } + + if ($property['allowsNull']) { + $arguments[$name] = null; + + continue; + } + + throw new InvalidArgumentException(sprintf( + 'Cannot create %s: required property [%s] is missing.', + $class, + $name, + )); + } + + return new static(...$arguments); + } + + /** + * Convert the data object to an array. + */ + public function toArray(): array + { + $values = (array) $this; + $result = []; + + foreach (self::recipe(static::class) as $property) { + $value = $values[$property['name']]; + $result[$property['name']] = ! is_array($value) && ! is_object($value) + ? $value + : self::normalize($value); + } + + return $result; + } + + /** + * Convert the object into something JSON serializable. + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Convert the data object to JSON. + * + * @throws JsonException + */ + public function toJson(int $options = 0): string + { + return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); + } + + /** + * Get the compiled construction recipe for the given class. + * + * @param class-string $class + * @return list + */ + private static function recipe(string $class): array + { + return self::$recipes[$class] ??= self::compileRecipe($class); + } + + /** + * Compile the construction recipe for the given class. + * + * @param class-string $class + * @return list + */ + private static function compileRecipe(string $class): array + { + $reflection = new ReflectionClass($class); + $parameters = $reflection->getConstructor()?->getParameters() ?? []; + $recipe = []; + $promoted = []; + + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + + if (! $parameter->isPromoted() + || ! $parameter->getDeclaringClass()?->getProperty($name)->isPublic()) { + throw new LogicException(sprintf( + '%s constructor parameter [%s] must be a public promoted property.', + $class, + $name, + )); + } + + $kind = self::KIND_PASSTHROUGH; + $target = null; + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && $type->isBuiltin()) { + $kind = match ($type->getName()) { + 'array' => self::KIND_ARRAY, + 'bool' => self::KIND_BOOLEAN, + 'float' => self::KIND_FLOAT, + 'int' => self::KIND_INTEGER, + 'string' => self::KIND_STRING, + default => self::KIND_PASSTHROUGH, + }; + } elseif (($namedTarget = Reflector::getParameterClassName($parameter)) !== null) { + if (enum_exists($namedTarget) && is_a($namedTarget, BackedEnum::class, true)) { + $kind = self::KIND_ENUM; + $target = $namedTarget; + } elseif (is_a($namedTarget, self::class, true)) { + $kind = self::KIND_DATA_OBJECT; + $target = $namedTarget; + } elseif (is_a($namedTarget, DateTimeInterface::class, true)) { + $kind = self::KIND_DATE; + $target = $namedTarget; + } + } + + $recipe[] = [ + 'name' => $name, + 'kind' => $kind, + 'target' => $target, + 'allowsNull' => $parameter->allowsNull(), + 'hasDefault' => $parameter->isDefaultValueAvailable(), + ]; + $promoted[$name] = true; + } + + foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if (! $property->isStatic() && ! isset($promoted[$property->getName()])) { + throw new LogicException(sprintf( + '%s public property [%s] must be promoted by its constructor.', + $class, + $property->getName(), + )); + } + } + + return $recipe; + } + + /** + * Convert a supplied value according to its compiled property kind. + * + * @param Recipe $property + * @param class-string $class + */ + private static function convert(mixed $value, array $property, string $class): mixed + { + /** @var class-string $target */ + $target = $property['target']; + + return match ($property['kind']) { + self::KIND_ARRAY => is_array($value) + ? $value + : self::throwInvalidValue($class, $property['name'], 'array', $value), + self::KIND_BOOLEAN => self::convertBoolean($value) + ?? self::throwInvalidValue($class, $property['name'], 'bool', $value), + self::KIND_FLOAT => self::convertFloat($value) + ?? self::throwInvalidValue($class, $property['name'], 'float', $value), + self::KIND_INTEGER => self::convertInteger($value) + ?? self::throwInvalidValue($class, $property['name'], 'int', $value), + self::KIND_STRING => self::convertString($value) + ?? self::throwInvalidValue($class, $property['name'], 'string', $value), + self::KIND_ENUM => $value instanceof $target ? $value : enum_from($target, $value), + self::KIND_DATA_OBJECT => is_array($value) ? $target::from($value) : $value, + self::KIND_DATE => self::convertDate($value, $target), + default => $value, + }; + } + + /** + * Convert the given value to a boolean. + */ + private static function convertBoolean(mixed $value): ?bool + { + return is_bool($value) + ? $value + : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + + /** + * Convert the given value to a float. + */ + private static function convertFloat(mixed $value): ?float + { + if (is_float($value)) { + return $value; + } + + if (is_int($value)) { + return (float) $value; + } + + return is_string($value) + ? filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE) + : null; + } + + /** + * Convert the given value to an integer. + */ + private static function convertInteger(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) || is_float($value) + ? filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE) + : null; + } + + /** + * Convert the given value to a string. + */ + private static function convertString(mixed $value): ?string + { + return match (true) { + is_string($value) => $value, + is_scalar($value), $value instanceof BaseStringable => (string) $value, + default => null, + }; + } + + /** + * Convert the given value to a date. + * + * @param class-string $target + */ + private static function convertDate(mixed $value, string $target): mixed + { + if ($value instanceof $target) { + return $value; + } + + if ($value instanceof DateTimeInterface) { + $date = $value; + } elseif (is_int($value) || is_float($value)) { + $date = Date::createFromTimestamp($value, date_default_timezone_get()); + } elseif (is_string($value)) { + $date = Date::parse($value); + } else { + return $value; + } + + if ($target === DateTimeInterface::class || $target === CarbonInterface::class) { + return $value instanceof DateTimeInterface + ? Date::instance($value) + : $date; + } + + if (is_a($target, CarbonInterface::class, true)) { + return $target::instance($date); + } + + return $target::createFromInterface($date); + } + + /** + * Throw an exception for a value that cannot be converted. + * + * @param class-string $class + */ + private static function throwInvalidValue( + string $class, + string $property, + string $expected, + mixed $value, + ): never { + $supplied = is_scalar($value) ? var_export($value, true) : get_debug_type($value); + + throw new InvalidArgumentException(sprintf( + 'Cannot create %s: property [%s] expects %s; received %s.', + $class, + $property, + $expected, + $supplied, + )); + } + + /** + * Normalize a value for array and JSON output. + */ + private static function normalize(mixed $value): mixed + { + if (! is_array($value) && ! is_object($value)) { + return $value; + } + + if (is_array($value)) { + $normalized = []; + + foreach ($value as $key => $item) { + $normalized[$key] = self::normalize($item); + } + + return $normalized; + } + + return match (true) { + $value instanceof DateTimeInterface => $value->format(DATE_ATOM), + $value instanceof BackedEnum => $value->value, + $value instanceof self => $value->toArray(), + $value instanceof Arrayable => self::normalize($value->toArray()), + default => $value, + }; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + self::$recipes = []; + } +} diff --git a/tests/Support/DataObjectTest.php b/tests/Support/DataObjectTest.php new file mode 100644 index 000000000..e0193644e --- /dev/null +++ b/tests/Support/DataObjectTest.php @@ -0,0 +1,1077 @@ + 'ignored', + 'note' => null, + 'tags' => ['framework'], + 'score' => 9.5, + 'active' => true, + 'age' => 37, + 'name' => 'Taylor', + ]); + + $this->assertSame([ + 'name' => 'Taylor', + 'age' => 37, + 'active' => true, + 'score' => 9.5, + 'tags' => ['framework'], + 'note' => null, + ], $data->toArray()); + } + + public function testExactPropertyNamesAreNotMapped(): void + { + $data = CamelCaseDataObject::from(['displayName' => 'Taylor']); + + $this->assertSame('Taylor', $data->displayName); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('required property [displayName] is missing'); + + CamelCaseDataObject::from(['display_name' => 'Taylor']); + } + + public function testDefaultsNullableValuesAndExplicitNullUseNativePrecedence(): void + { + $data = DefaultsDataObject::from([]); + + $this->assertSame('default', $data->name); + $this->assertNull($data->note); + + $this->expectException(TypeError::class); + + DefaultsDataObject::from(['name' => null]); + } + + public function testMissingNullableValueWithoutDefaultReceivesNull(): void + { + $data = NullableDataObject::from([]); + + $this->assertNull($data->note); + } + + public function testMissingRequiredValueNamesTheClassAndProperty(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(RequiredDataObject::class); + $this->expectExceptionMessage('required property [name] is missing'); + + RequiredDataObject::from([]); + } + + public function testObjectDefaultsAreEvaluatedForEveryConstruction(): void + { + $first = ObjectDefaultDataObject::from([]); + $second = ObjectDefaultDataObject::from([]); + + $this->assertNotSame($first->marker, $second->marker); + } + + public function testReadonlyPropertiesAreSupported(): void + { + $data = ReadonlyDataObject::from(['name' => 'Taylor']); + + $this->assertSame('Taylor', $data->name); + } + + public function testDataObjectsAreAlwaysTransient(): void + { + $this->assertInstanceOf(Transient::class, RequiredDataObject::from(['name' => 'Taylor'])); + } + + public function testDirectConstructionTransformsBeforeRecipeCompilation(): void + { + DirectConstructionDataObject::flushState(); + $data = new DirectConstructionDataObject('Taylor', 37); + + $this->assertSame(['name' => 'Taylor', 'age' => 37], $data->toArray()); + } + + public function testMutationIsVisibleInEveryTransformation(): void + { + $data = MutableDataObject::from(['name' => 'Taylor']); + $this->assertSame(['name' => 'Taylor'], $data->toArray()); + + $data->name = 'Abigail'; + + $this->assertSame(['name' => 'Abigail'], $data->toArray()); + $this->assertSame('{"name":"Abigail"}', $data->toJson()); + } + + #[DataProvider('validIntegerProvider')] + public function testItConvertsValidIntegers(mixed $value, int $expected): void + { + $this->assertSame($expected, IntegerDataObject::from(['value' => $value])->value); + } + + public static function validIntegerProvider(): iterable + { + yield 'native' => [42, 42]; + yield 'zero' => [0, 0]; + yield 'negative' => [-42, -42]; + yield 'trimmed string' => [' 42 ', 42]; + yield 'whole float' => [42.0, 42]; + } + + #[DataProvider('invalidIntegerProvider')] + public function testItRejectsInvalidIntegers(mixed $value): void + { + $this->assertInvalidScalar(IntegerDataObject::class, 'int', $value); + } + + public static function invalidIntegerProvider(): iterable + { + yield 'fractional float' => [1.5]; + yield 'decimal string' => ['1.5']; + yield 'text' => ['one']; + yield 'true' => [true]; + yield 'false' => [false]; + yield 'empty string' => ['']; + yield 'array' => [[]]; + yield 'object' => [new stdClass]; + yield 'numeric stringable' => [new NumericDataObjectStringable]; + } + + #[DataProvider('validFloatProvider')] + public function testItConvertsValidFloats(mixed $value, float $expected): void + { + $this->assertSame($expected, FloatDataObject::from(['value' => $value])->value); + } + + public static function validFloatProvider(): iterable + { + yield 'native' => [1.5, 1.5]; + yield 'integer' => [2, 2.0]; + yield 'decimal string' => ['1.5', 1.5]; + yield 'scientific notation' => ['1e3', 1000.0]; + yield 'trimmed string' => [' 2.5 ', 2.5]; + } + + #[DataProvider('invalidFloatProvider')] + public function testItRejectsInvalidFloats(mixed $value): void + { + $this->assertInvalidScalar(FloatDataObject::class, 'float', $value); + } + + public static function invalidFloatProvider(): iterable + { + yield 'text' => ['one']; + yield 'true' => [true]; + yield 'false' => [false]; + yield 'empty string' => ['']; + yield 'array' => [[]]; + yield 'object' => [new stdClass]; + yield 'numeric stringable' => [new NumericDataObjectStringable]; + } + + #[DataProvider('validBooleanProvider')] + public function testItConvertsValidBooleans(mixed $value, bool $expected): void + { + $this->assertSame($expected, BooleanDataObject::from(['value' => $value])->value); + } + + public static function validBooleanProvider(): iterable + { + yield 'native true' => [true, true]; + yield 'native false' => [false, false]; + yield 'integer one' => [1, true]; + yield 'integer zero' => [0, false]; + yield 'true' => ['true', true]; + yield 'uppercase true' => ['TRUE', true]; + yield 'false' => ['false', false]; + yield 'yes' => ['yes', true]; + yield 'uppercase yes' => ['YES', true]; + yield 'no' => ['no', false]; + yield 'on' => ['on', true]; + yield 'off' => ['off', false]; + yield 'empty string' => ['', false]; + } + + #[DataProvider('invalidBooleanProvider')] + public function testItRejectsInvalidBooleans(mixed $value): void + { + $this->assertInvalidScalar(BooleanDataObject::class, 'bool', $value); + } + + public static function invalidBooleanProvider(): iterable + { + yield 'integer two' => [2]; + yield 'text' => ['sometimes']; + yield 'array' => [[]]; + yield 'object' => [new stdClass]; + } + + #[DataProvider('validStringProvider')] + public function testItConvertsValidStrings(mixed $value, string $expected): void + { + $this->assertSame($expected, StringDataObject::from(['value' => $value])->value); + } + + public static function validStringProvider(): iterable + { + yield 'native' => ['value', 'value']; + yield 'integer' => [42, '42']; + yield 'float' => [1.5, '1.5']; + yield 'true' => [true, '1']; + yield 'false' => [false, '']; + yield 'stringable' => [new DataObjectStringable, 'stringable']; + } + + #[DataProvider('invalidStringProvider')] + public function testItRejectsInvalidStrings(mixed $value): void + { + $this->assertInvalidScalar(StringDataObject::class, 'string', $value); + } + + public static function invalidStringProvider(): iterable + { + yield 'array' => [[]]; + yield 'object' => [new stdClass]; + } + + public function testArraysAreAcceptedWithoutItemInference(): void + { + $items = [['name' => 'Taylor'], ['name' => 'Abigail']]; + $data = ArrayDataObject::from(['items' => $items]); + + $this->assertSame($items, $data->items); + } + + public function testNonArrayInputIsRejectedForArrayProperty(): void + { + try { + ArrayDataObject::from(['items' => 'item']); + $this->fail('Invalid array input was accepted.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString(ArrayDataObject::class, $exception->getMessage()); + $this->assertStringContainsString('property [items]', $exception->getMessage()); + $this->assertStringContainsString('expects array', $exception->getMessage()); + } + } + + public function testInvalidScalarMessageContainsItsContext(): void + { + try { + IntegerDataObject::from(['value' => 'invalid']); + $this->fail('Invalid integer input was accepted.'); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString(IntegerDataObject::class, $exception->getMessage()); + $this->assertStringContainsString('property [value]', $exception->getMessage()); + $this->assertStringContainsString('expects int', $exception->getMessage()); + $this->assertStringContainsString('invalid', $exception->getMessage()); + } + } + + public function testBackedEnumsAcceptCasesAndBackingValues(): void + { + $existing = EnumDataObject::from([ + 'stringStatus' => DataObjectStringStatus::Ready, + 'integerStatus' => DataObjectIntegerStatus::Ready, + ]); + $converted = EnumDataObject::from([ + 'stringStatus' => 'ready', + 'integerStatus' => '1', + ]); + + $this->assertSame(DataObjectStringStatus::Ready, $existing->stringStatus); + $this->assertSame(DataObjectIntegerStatus::Ready, $existing->integerStatus); + $this->assertSame(DataObjectStringStatus::Ready, $converted->stringStatus); + $this->assertSame(DataObjectIntegerStatus::Ready, $converted->integerStatus); + } + + public function testInvalidBackedEnumValuePreservesValueError(): void + { + $this->expectException(ValueError::class); + + EnumDataObject::from([ + 'stringStatus' => 'missing', + 'integerStatus' => 1, + ]); + } + + public function testEnumInterfacesUsePassThroughBehavior(): void + { + $existing = DataObjectStringStatus::Ready; + + $this->assertSame($existing, EnumInterfaceDataObject::from(['value' => $existing])->value); + + $this->expectException(TypeError::class); + + EnumInterfaceDataObject::from(['value' => 'ready']); + } + + public function testNestedDataObjectsAcceptArraysAndExistingInstances(): void + { + $existing = new NestedDataObject('existing'); + $data = NestedContainerDataObject::from([ + 'first' => ['name' => 'created'], + 'second' => $existing, + ]); + + $this->assertSame('created', $data->first->name); + $this->assertSame($existing, $data->second); + } + + public function testNestedDataObjectsLeaveUnsupportedValuesToPhp(): void + { + $this->expectException(TypeError::class); + + NestedContainerDataObject::from([ + 'first' => 'text', + 'second' => new NestedDataObject('existing'), + ]); + } + + public function testNullableSelfTypeHydratesRecursiveValues(): void + { + $node = RecursiveDataObject::from([ + 'name' => 'one', + 'next' => [ + 'name' => 'two', + 'next' => [ + 'name' => 'three', + 'next' => [ + 'name' => 'four', + ], + ], + ], + ]); + + $this->assertSame('one', $node->name); + $this->assertSame('two', $node->next?->name); + $this->assertSame('three', $node->next?->next?->name); + $this->assertSame('four', $node->next?->next?->next?->name); + } + + public function testInheritedSelfTypeRetainsItsDeclarationScope(): void + { + $node = RecursiveChildDataObject::from([ + 'name' => 'child', + 'next' => ['name' => 'parent'], + ]); + + $this->assertInstanceOf(RecursiveParentDataObject::class, $node->next); + $this->assertNotInstanceOf(RecursiveChildDataObject::class, $node->next); + } + + public function testParentTypeHydratesItsDeclaringParent(): void + { + $data = RelativeParentDataObject::from(['value' => []]); + + $this->assertInstanceOf(RelativeBaseDataObject::class, $data->value); + } + + public function testUnionTypesPassExistingValuesThrough(): void + { + $value = new stdClass; + $data = UnionDataObject::from(['value' => $value]); + + $this->assertSame($value, $data->value); + } + + public function testUnionTypesDoNotGuessAnObjectArmForArrays(): void + { + $this->expectException(TypeError::class); + + UnionDataObject::from(['value' => ['name' => 'Taylor']]); + } + + public function testUnknownObjectsAreNotConstructedFromArrays(): void + { + UnknownObject::$constructions = 0; + + try { + UnknownTargetDataObject::from(['value' => []]); + $this->fail('Unknown object target accepted an array.'); + } catch (TypeError) { + $this->assertSame(0, UnknownObject::$constructions); + } + } + + public function testConfiguredDateFactoryOwnsInterfaceTargets(): void + { + DateFactory::use(Carbon::class); + + $data = DateInterfaceDataObject::from([ + 'date' => '2026-09-05 12:34:56', + 'carbon' => '2026-09-05 12:34:56', + ]); + + $this->assertSame(Carbon::class, $data->date::class); + $this->assertSame(Carbon::class, $data->carbon::class); + } + + public function testDateInterfacesPassThroughMatchingValuesAndAdaptNativeValuesToCarbon(): void + { + DateFactory::use(Carbon::class); + $native = new DateTimeImmutable('2026-09-05T12:34:56.123456+02:00'); + + $data = DateInterfaceDataObject::from([ + 'date' => $native, + 'carbon' => $native, + ]); + + $this->assertSame($native, $data->date); + $this->assertSame(Carbon::class, $data->carbon::class); + $this->assertSame($native->format('U.u'), $data->carbon->format('U.u')); + } + + public function testConcreteDateTargetsPreserveTheirExactClasses(): void + { + $value = '2026-09-05 12:34:56'; + $data = DateTargetsDataObject::from([ + 'nativeMutable' => $value, + 'nativeImmutable' => $value, + 'hypervelMutable' => $value, + 'hypervelImmutable' => $value, + 'baseMutable' => $value, + 'baseImmutable' => $value, + 'customNative' => $value, + 'customCarbon' => $value, + ]); + + $this->assertSame(DateTime::class, $data->nativeMutable::class); + $this->assertSame(DateTimeImmutable::class, $data->nativeImmutable::class); + $this->assertSame(Carbon::class, $data->hypervelMutable::class); + $this->assertSame(CarbonImmutable::class, $data->hypervelImmutable::class); + $this->assertSame(BaseCarbon::class, $data->baseMutable::class); + // Carbon retains the configured immutable subclass when adapting through its base type. + $this->assertInstanceOf(BaseCarbonImmutable::class, $data->baseImmutable); + $this->assertSame(CustomNativeDateTime::class, $data->customNative::class); + $this->assertSame(CustomCarbonImmutable::class, $data->customCarbon::class); + } + + public function testExistingAndCrossDateInstancesAreAdaptedCorrectly(): void + { + $existing = new CustomCarbonImmutable('2026-09-05 12:34:56'); + $data = DateAdaptationDataObject::from([ + 'existing' => $existing, + 'native' => new DateTimeImmutable('2026-09-05 12:34:56'), + 'carbon' => new DateTimeImmutable('2026-09-05 12:34:56'), + ]); + + $this->assertSame($existing, $data->existing); + $this->assertSame(CustomNativeDateTime::class, $data->native::class); + $this->assertSame(CustomCarbonImmutable::class, $data->carbon::class); + } + + public function testApplicationDateSubclassesArePreservedForTimestamps(): void + { + $data = DateAdaptationDataObject::from([ + 'existing' => 1_700_000_000, + 'native' => 1_700_000_000, + 'carbon' => 1_700_000_000, + ]); + + $this->assertSame(CustomCarbonImmutable::class, $data->existing::class); + $this->assertSame(CustomNativeDateTime::class, $data->native::class); + $this->assertSame(CustomCarbonImmutable::class, $data->carbon::class); + } + + public function testOnlyActualNumbersAreTimestamps(): void + { + $timestamp = 1_700_000_000; + $numeric = DateValueDataObject::from(['date' => $timestamp]); + $decimal = DateValueDataObject::from(['date' => $timestamp + 0.5]); + $numericString = DateValueDataObject::from(['date' => '20240101']); + + $this->assertSame($timestamp, $numeric->date->getTimestamp()); + $this->assertSame('2024-01-01', $numericString->date->format('Y-m-d')); + $this->assertSame('500000', $decimal->date->format('u')); + } + + public function testTimestampConversionUsesThePhpDefaultTimezone(): void + { + $previous = date_default_timezone_get(); + + try { + date_default_timezone_set('America/Toronto'); + $data = DateValueDataObject::from(['date' => 1_700_000_000]); + + $this->assertSame('America/Toronto', $data->date->getTimezone()->getName()); + $this->assertSame(1_700_000_000, $data->date->getTimestamp()); + } finally { + date_default_timezone_set($previous); + } + } + + public function testInvalidDateStringsPreserveCarbonFailure(): void + { + $this->expectException(InvalidFormatException::class); + + DateValueDataObject::from(['date' => 'definitely not a date']); + } + + public function testTransformationRecursivelyNormalizesSupportedValues(): void + { + $data = TransformationDataObject::from([ + 'values' => [ + 'nested' => new NestedDataObject('Taylor'), + 'enum' => DataObjectStringStatus::Ready, + 'date' => new DateTimeImmutable('2026-09-05T12:34:56+02:00'), + 'arrayable' => new NestedArrayable, + 'object' => $object = new stdClass, + ], + ]); + + $this->assertSame([ + 'values' => [ + 'nested' => ['name' => 'Taylor'], + 'enum' => 'ready', + 'date' => '2026-09-05T12:34:56+02:00', + 'arrayable' => [ + 'date' => '2026-09-05T10:00:00+00:00', + 'enum' => 1, + ], + 'object' => $object, + ], + ], $data->toArray()); + } + + public function testJsonSerializationMatchesArrayTransformationAndHonorsFlags(): void + { + $data = JsonDataObject::from(['url' => 'https://hypervel.org/data']); + + $this->assertSame($data->toArray(), $data->jsonSerialize()); + $this->assertSame('{"url":"https:\/\/hypervel.org\/data"}', $data->toJson()); + $this->assertSame('{"url":"https://hypervel.org/data"}', $data->toJson(JSON_UNESCAPED_SLASHES)); + } + + public function testJsonSerializationThrowsForUnsupportedValues(): void + { + $resource = fopen('php://memory', 'r'); + + try { + $this->expectException(JsonException::class); + + MixedDataObject::from(['value' => $resource])->toJson(); + } finally { + fclose($resource); + } + } + + public function testProtectedConstructorsAreSupported(): void + { + $data = ProtectedConstructorDataObject::from(['name' => 'Taylor']); + + $this->assertSame('Taylor', $data->name); + } + + public function testClassesWithoutConstructorsOrPropertiesAreSupported(): void + { + $this->assertSame([], EmptyDataObject::from([])->toArray()); + } + + public function testNonPromotedConstructorParametersAreRejected(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('constructor parameter [name] must be a public promoted property'); + + NonPromotedDataObject::from(['name' => 'Taylor']); + } + + public function testNonPublicPromotedPropertiesAreRejected(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('constructor parameter [name] must be a public promoted property'); + + ProtectedPromotedDataObject::from(['name' => 'Taylor']); + } + + public function testPrivatePromotedPropertiesAreRejected(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('constructor parameter [name] must be a public promoted property'); + + PrivatePromotedDataObject::from(['name' => 'Taylor']); + } + + public function testInheritedPrivatePromotedPropertiesAreRejectedConsistently(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('constructor parameter [name] must be a public promoted property'); + + InheritedPrivatePromotedDataObject::from(['name' => 'Taylor']); + } + + public function testExtraPublicInstancePropertiesAreRejected(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('public property [extra] must be promoted by its constructor'); + + ExtraPublicPropertyDataObject::from(['name' => 'Taylor']); + } + + public function testPrivateAndProtectedStateIsExcluded(): void + { + $data = InternalStateDataObject::from(['name' => 'Taylor']); + + $this->assertSame(['name' => 'Taylor'], $data->toArray()); + } + + public function testPublicStaticStateIsExcludedAndCannotShadowTheBaseCache(): void + { + StaticStateDataObject::$recipes = ['application']; + $data = StaticStateDataObject::from(['name' => 'Taylor']); + + $this->assertSame(['name' => 'Taylor'], $data->toArray()); + $this->assertSame(['application'], StaticStateDataObject::$recipes); + } + + public function testInheritedPromotedPropertiesKeepConstructorOrder(): void + { + $data = InheritedDataObject::from(['first' => 'one', 'second' => 2]); + + $this->assertSame(['first' => 'one', 'second' => 2], $data->toArray()); + } + + public function testChildConstructorCannotLeaveInheritedPublicDataOutsideItsRecipe(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('public property [name] must be promoted by its constructor'); + + InvalidChildDataObject::from(['id' => 1]); + } + + private function assertInvalidScalar(string $class, string $expected, mixed $value): void + { + try { + $class::from(['value' => $value]); + $this->fail("Invalid {$expected} input was accepted."); + } catch (InvalidArgumentException $exception) { + $this->assertStringContainsString($class, $exception->getMessage()); + $this->assertStringContainsString('property [value]', $exception->getMessage()); + $this->assertStringContainsString("expects {$expected}", $exception->getMessage()); + } + } +} + +final class ScalarDataObject extends DataObject +{ + public function __construct( + public string $name, + public int $age, + public bool $active, + public float $score, + public array $tags, + public ?string $note = null, + ) { + } +} + +final class CamelCaseDataObject extends DataObject +{ + public function __construct(public string $displayName) + { + } +} + +final class DefaultsDataObject extends DataObject +{ + public function __construct( + public string $name = 'default', + public ?string $note = null, + ) { + } +} + +final class NullableDataObject extends DataObject +{ + public function __construct(public ?string $note) + { + } +} + +final class RequiredDataObject extends DataObject +{ + public function __construct(public string $name) + { + } +} + +final class ObjectDefaultDataObject extends DataObject +{ + public function __construct(public stdClass $marker = new stdClass) + { + } +} + +final class ReadonlyDataObject extends DataObject +{ + public function __construct(public readonly string $name) + { + } +} + +final class DirectConstructionDataObject extends DataObject +{ + public function __construct( + public string $name, + public int $age, + ) { + } +} + +final class MutableDataObject extends DataObject +{ + public function __construct(public string $name) + { + } +} + +final class IntegerDataObject extends DataObject +{ + public function __construct(public int $value) + { + } +} + +final class FloatDataObject extends DataObject +{ + public function __construct(public float $value) + { + } +} + +final class BooleanDataObject extends DataObject +{ + public function __construct(public bool $value) + { + } +} + +final class StringDataObject extends DataObject +{ + public function __construct(public string $value) + { + } +} + +final class ArrayDataObject extends DataObject +{ + public function __construct(public array $items) + { + } +} + +final class DataObjectStringable implements Stringable +{ + public function __toString(): string + { + return 'stringable'; + } +} + +final class NumericDataObjectStringable implements Stringable +{ + public function __toString(): string + { + return '42'; + } +} + +enum DataObjectStringStatus: string +{ + case Ready = 'ready'; +} + +enum DataObjectIntegerStatus: int +{ + case Ready = 1; +} + +final class EnumDataObject extends DataObject +{ + public function __construct( + public DataObjectStringStatus $stringStatus, + public DataObjectIntegerStatus $integerStatus, + ) { + } +} + +final class EnumInterfaceDataObject extends DataObject +{ + public function __construct(public BackedEnum $value) + { + } +} + +class NestedDataObject extends DataObject +{ + public function __construct(public string $name) + { + } +} + +final class NestedContainerDataObject extends DataObject +{ + public function __construct( + public NestedDataObject $first, + public NestedDataObject $second, + ) { + } +} + +class RecursiveDataObject extends DataObject +{ + public function __construct( + public string $name, + public ?self $next = null, + ) { + } +} + +class RecursiveParentDataObject extends DataObject +{ + public function __construct( + public string $name, + public ?self $next = null, + ) { + } +} + +final class RecursiveChildDataObject extends RecursiveParentDataObject +{ +} + +class RelativeBaseDataObject extends DataObject +{ +} + +final class RelativeParentDataObject extends RelativeBaseDataObject +{ + public function __construct(public parent $value) + { + } +} + +final class UnionDataObject extends DataObject +{ + public function __construct(public NestedDataObject|stdClass $value) + { + } +} + +final class UnknownTargetDataObject extends DataObject +{ + public function __construct(public UnknownObject $value) + { + } +} + +final class UnknownObject +{ + public static int $constructions = 0; + + public function __construct() + { + ++self::$constructions; + } +} + +final class DateInterfaceDataObject extends DataObject +{ + public function __construct( + public DateTimeInterface $date, + public CarbonInterface $carbon, + ) { + } +} + +final class DateTargetsDataObject extends DataObject +{ + public function __construct( + public DateTime $nativeMutable, + public DateTimeImmutable $nativeImmutable, + public Carbon $hypervelMutable, + public CarbonImmutable $hypervelImmutable, + public BaseCarbon $baseMutable, + public BaseCarbonImmutable $baseImmutable, + public CustomNativeDateTime $customNative, + public CustomCarbonImmutable $customCarbon, + ) { + } +} + +final class DateAdaptationDataObject extends DataObject +{ + public function __construct( + public CustomCarbonImmutable $existing, + public CustomNativeDateTime $native, + public CustomCarbonImmutable $carbon, + ) { + } +} + +final class DateValueDataObject extends DataObject +{ + public function __construct(public DateTimeInterface $date) + { + } +} + +final class CustomNativeDateTime extends DateTimeImmutable +{ +} + +final class CustomCarbonImmutable extends CarbonImmutable +{ +} + +final class TransformationDataObject extends DataObject +{ + public function __construct(public array $values) + { + } +} + +final class NestedArrayable implements Arrayable +{ + public function toArray(): array + { + return [ + 'date' => new DateTimeImmutable('2026-09-05T10:00:00+00:00'), + 'enum' => DataObjectIntegerStatus::Ready, + ]; + } +} + +final class JsonDataObject extends DataObject +{ + public function __construct(public string $url) + { + } +} + +final class MixedDataObject extends DataObject +{ + public function __construct(public mixed $value) + { + } +} + +final class ProtectedConstructorDataObject extends DataObject +{ + protected function __construct(public string $name) + { + } +} + +final class EmptyDataObject extends DataObject +{ +} + +final class NonPromotedDataObject extends DataObject +{ + public function __construct(string $name) + { + } +} + +final class ProtectedPromotedDataObject extends DataObject +{ + public function __construct(protected string $name) + { + } +} + +class PrivatePromotedDataObject extends DataObject +{ + public function __construct(private string $name) + { + } +} + +final class InheritedPrivatePromotedDataObject extends PrivatePromotedDataObject +{ +} + +final class ExtraPublicPropertyDataObject extends DataObject +{ + public string $extra = 'extra'; + + public function __construct(public string $name) + { + } +} + +final class InternalStateDataObject extends DataObject +{ + protected string $protectedState = 'protected'; + + private string $privateState = 'private'; + + public function __construct(public string $name) + { + } +} + +final class StaticStateDataObject extends DataObject +{ + public static array $recipes = []; + + public function __construct(public string $name) + { + } +} + +class InheritedParentDataObject extends DataObject +{ + public function __construct( + public string $first, + public int $second, + ) { + } +} + +final class InheritedDataObject extends InheritedParentDataObject +{ +} + +class InvalidParentDataObject extends DataObject +{ + public function __construct(public string $name) + { + } +} + +final class InvalidChildDataObject extends InvalidParentDataObject +{ + public function __construct(public int $id) + { + parent::__construct('hidden'); + } +} From 9ef14a92d723eb601bc2fe4095e4ef9084a4fda7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:48:20 +0000 Subject: [PATCH 03/14] Document lightweight data objects Add a Laravel-style guide for choosing Support DataObject when trusted internal values need fast typed construction and recursive array or JSON output. Explain the exact-key contract, strict common conversions, nested object behavior, explicit list conversion, and the boundary with Data, Dto, Resource, and DataCollection so developers can select the smaller API without confusing it with the full data package. --- src/docs/data-objects.md | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 22982668b..2fbeb3cf8 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -2,6 +2,7 @@ - [Introduction](#introduction) - [Choosing a Base Class](#choosing-a-base-class) +- [Lightweight Data Objects](#lightweight-data-objects) - [Creating Data Objects](#creating-data-objects) - [Creating Instances](#creating-instances) - [Associating a Data Class](#associating-a-data-class) @@ -50,6 +51,65 @@ The package provides three base classes: Choose the base class that provides the behavior your object needs. Since `Dto` does not transform values, nested or collected DTOs remain objects when a surrounding `Data` object is transformed. Use `Data` or `Resource` when nested values should also be transformed. +For trusted internal values that only need typed construction and array or JSON output, consider a [lightweight data object](#lightweight-data-objects). + + +## Lightweight Data Objects + +The `Hypervel\Support\DataObject` class provides a small mapper for internal message envelopes, per-item value objects, and other trusted values used in performance-sensitive code. It does not provide validation, property mapping, lazy properties, partials, resources, or persistence. Use `Data`, `Dto`, or `Resource` when you need those features. + +To define a lightweight data object, extend `DataObject` and promote every constructor parameter as a public property: + +```php + 'msg_01', + 'type' => 'created', + 'payload' => ['name' => 'Taylor'], + 'receivedAt' => '2026-09-05 12:34:56', +]); +``` + +In this example, `MessageType` is a string-backed enum and `MessagePayload` is another lightweight data object. + +Constructor property names are the exact input and output keys. Unknown input keys are ignored, but names are not converted between camel case and snake case. Omitted parameters use their declared defaults, while omitted nullable parameters without a default receive `null`. + +Common integer, float, boolean, and string representations are converted strictly. Backed enums, dates, and properties typed as a concrete `DataObject` are also converted. Invalid scalar values throw an `InvalidArgumentException` instead of being silently coerced. Use an application named factory when an external payload needs different names or custom conversion. + +The `toArray` and `toJson` methods recursively normalize nested data objects, backed enums, dates, and `Arrayable` values. Public properties remain ordinary PHP properties and may be read or changed directly unless they are declared `readonly`. + +An `array` property retains its input items as-is during construction. Convert a one-off list explicitly: + +```php +$items = array_map(ItemData::from(...), $payload['items']); +``` + +Use Hypervel Data and `DataCollection` when a reusable typed collection needs validation, mapping, transformation controls, or response behavior. + ## Creating Data Objects From 0db384fc2c86c119a6185c72b91505ef5abbda08 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:48:28 +0000 Subject: [PATCH 04/14] Benchmark lightweight and full data objects Keep a reproducible comparison between the supported Support DataObject and full Hypervel Data across construction, transformation, retained memory, and first-use behavior. Add reverse measurement order for checking ordering bias and preserve equal-output collection scenarios. Remove the frozen historical mapper now that acceptance measurements are complete, avoiding permanent retention of code with known recursion, coercion, and cache defects. --- tests/Benchmarks/Data/Fixtures/DataObject.php | 640 ------------------ tests/Benchmarks/Data/README.md | 8 +- tests/Benchmarks/Data/compare-data-object.php | 524 +++++++------- 3 files changed, 289 insertions(+), 883 deletions(-) delete mode 100644 tests/Benchmarks/Data/Fixtures/DataObject.php diff --git a/tests/Benchmarks/Data/Fixtures/DataObject.php b/tests/Benchmarks/Data/Fixtures/DataObject.php deleted file mode 100644 index 2e5dfd540..000000000 --- a/tests/Benchmarks/Data/Fixtures/DataObject.php +++ /dev/null @@ -1,640 +0,0 @@ - [ReflectionParameter]). - */ - public static array $reflectionParametersCache = []; - - /** - * Property map cache (class name => [snake_case key => camelCase property]). - */ - public static array $propertyMapCache = []; - - /** - * Reversed property map cache (class name => [camelCase key => snake_case property]). - */ - public static array $reversedPropertyMapCache = []; - - /** - * Flag to indicate if auto-casting is enabled. - */ - protected static bool $autoCasting = true; - - /** - * Cache for dependencies map (class name => dependencies array). - */ - protected static array $dependenciesMapCache = []; - - /** - * The date format for DateTime properties. - */ - protected static string $dateFormat = self::DEFAULT_DATE_FORMAT; - - /** - * Cache for the array representation of the object. - */ - protected array $arrayCache = []; - - /** - * Create an instance of the class using the provided data array. - */ - public static function make(array $data, bool $autoResolve = false): static - { - $properties = static::getReversedPropertyMap(); - if ($autoResolve) { - $data = static::getConvertedData($data); - } - - $constructorArgs = []; - foreach (static::getReflectionParameters() as $parameter) { - $paramName = $parameter->getName(); - $dataKey = $properties[$paramName]; - $dataValue = null; - - // check if the data key exists in the array - // and convert the value to the correct type automatically - if (array_key_exists($dataKey, $data)) { - $dataValue = $data[$dataKey]; - if (static::$autoCasting) { - $dataValue = static::convertValueToType($dataValue, $parameter); - } - // use the default value if available - } elseif ($parameter->isDefaultValueAvailable()) { - $dataValue = $parameter->getDefaultValue(); - } else { - $dataValue = static::getDefaultValueForType($parameter); - } - - $constructorArgs[$paramName] = $dataValue; - } - - return new static(...$constructorArgs); - } - - /** - * Create an instance of the class using the provided data array. - * This is an alias of the `make` method. - */ - public static function from(array $data, bool $autoResolve = false): static - { - return static::make($data, $autoResolve); - } - - /** - * Get the customized dependencies map. - * - * @return array - */ - protected static function getCustomizedDependencies(): array - { - $dependencies = []; - $dateTargets = [ - DateTimeInterface::class, - CarbonInterface::class, - DateTime::class, - DateTimeImmutable::class, - Carbon::class, - CarbonImmutable::class, - BaseCarbon::class, - BaseCarbonImmutable::class, - ]; - - foreach ($dateTargets as $target) { - $dependencies[$target] = static fn (mixed $value): ?DateTimeInterface => $value === [] ? null : static::asDateTime($value, $target); - } - - return $dependencies; - } - - /** - * Get the serialization handlers for specific dependency types. - * - * @return array - */ - protected static function getSerializers(): array - { - return [ - DateTimeInterface::class => static fn (DateTimeInterface $value): string => $value->format('c'), - ]; - } - - /** - * Convert a value to the declared date target. - * - * @param BaseCarbon::class|BaseCarbonImmutable::class|Carbon::class|CarbonImmutable::class|CarbonInterface::class|DateTime::class|DateTimeImmutable::class|DateTimeInterface::class $target - */ - protected static function asDateTime(mixed $value, string $target): DateTimeInterface - { - if ($value instanceof DateTimeInterface) { - $date = Date::instance($value); - } elseif (is_numeric($value)) { - $date = Date::createFromTimestamp( - $value, - date_default_timezone_get() - ); - } elseif (static::isStandardDateFormat($value)) { - $date = Date::parse($value)->startOfDay(); - } else { - try { - $date = Date::createFromFormat(static::$dateFormat, $value); - // @phpstan-ignore catch.neverThrown (the Date facade's magic dispatch hides Carbon's @throws from analysis) - } catch (InvalidFormatException) { - $date = null; - } - - $date ??= Date::parse($value); - } - - return match ($target) { - DateTimeInterface::class, CarbonInterface::class => $date, - DateTime::class => DateTime::createFromInterface($date), - DateTimeImmutable::class => DateTimeImmutable::createFromInterface($date), - // instance() clones same-mutability subclasses, so cross the mutability - // boundary first to honor the exact target while retaining Carbon settings. - Carbon::class => Carbon::instance($date->toImmutable()), - CarbonImmutable::class => CarbonImmutable::instance($date->toMutable()), - BaseCarbon::class => BaseCarbon::instance($date->toImmutable()), - BaseCarbonImmutable::class => BaseCarbonImmutable::instance($date->toMutable()), - }; - } - - /** - * Determine if the given value is a standard date format. - */ - protected static function isStandardDateFormat(mixed $value): bool - { - return (bool) preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', (string) $value); - } - - /** - * Get the converted data array with dependencies resolved. - */ - protected static function getConvertedData(array $data): array - { - if (! $dependencies = static::getDependenciesData()) { - return $data; - } - - return static::replaceDependenciesData( - $dependencies, - $data - ); - } - - /** - * Get the dependencies map for the current class. - * - * @return array - */ - protected static function getDependenciesData(): array - { - if (array_key_exists(static::class, static::$dependenciesMapCache)) { - return static::$dependenciesMapCache[static::class]; - } - - return static::$dependenciesMapCache[static::class] = static::resolveDependenciesMap(static::class); - } - - protected static function getDependencyFromUnionType(ReflectionUnionType $type): ?ReflectionNamedType - { - foreach ($type->getTypes() as $namedType) { - if (! $namedType instanceof ReflectionNamedType) { - continue; - } - - $className = $namedType->getName(); - if ( - is_subclass_of($className, DataObject::class) - || is_a($className, DateTimeInterface::class, true) - ) { - return $namedType; - } - } - - return null; - } - - /** - * Check if the union type allows null. - */ - protected static function hasNullableUnionType(ReflectionUnionType $type): bool - { - foreach ($type->getTypes() as $namedType) { - if ($namedType->allowsNull()) { - return true; - } - } - - return false; - } - - /** - * Recursively resolve the dependencies map for the given class. - * - * @param array $visited - * @return array - */ - protected static function resolveDependenciesMap(string $class, array &$visited = []): array - { - if (isset($visited[$class])) { - return []; - } - - $visited[$class] = true; - $reflection = new ReflectionClass($class); - $properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); - $customizedDependencies = $class::getCustomizedDependencies(); - - $result = []; - foreach ($properties as $property) { - if ($property->isStatic()) { - continue; - } - $propertyType = $property->getType(); - - if (! $propertyType instanceof ReflectionNamedType && ! $propertyType instanceof ReflectionUnionType) { - continue; - } - - $allowsNull = $propertyType->allowsNull(); - if ($propertyType instanceof ReflectionUnionType) { - $allowsNull = static::hasNullableUnionType($propertyType); - $propertyType = static::getDependencyFromUnionType($propertyType); - - if ($propertyType === null) { - continue; - } - } - - $typeName = $propertyType->getName(); - $dataKey = $class::isAutoCasting() - ? $class::convertPropertyToDataKey($property->getName()) - : $property->getName(); - - if (is_subclass_of($typeName, DataObject::class)) { - $result[$dataKey] = [ - 'handler' => fn ($value) => $value instanceof $typeName ? $value : $typeName::make($value), - 'nullable' => $allowsNull, - 'children' => static::resolveDependenciesMap($typeName, $visited), - ]; - continue; - } - if (enum_exists($typeName) && is_subclass_of($typeName, BackedEnum::class, true)) { - $result[$dataKey] = [ - 'handler' => fn ($value) => $value instanceof $typeName ? $value : $typeName::from($value), - 'nullable' => $allowsNull, - 'children' => [], - ]; - continue; - } - if ($resolver = $customizedDependencies[$typeName] ?? null) { - $result[$dataKey] = [ - 'handler' => $resolver, - 'nullable' => $allowsNull, - 'children' => [], - ]; - continue; - } - } - - unset($visited[$class]); - - return $result; - } - - /** - * Recursively replace dependencies data in the given data array. - */ - protected static function replaceDependenciesData(array $dependencies, array $data): array - { - foreach ($dependencies as $key => $dependency) { - if (! array_key_exists($key, $data)) { - continue; - } - - $handler = $dependency['handler']; - $children = $dependency['children'] ?? []; - $nullable = $dependency['nullable'] ?? false; - $matched = $data[$key]; - - if ($nullable && $matched === null) { - continue; - } - if (! is_array($matched)) { - $data[$key] = $handler($matched === null ? [] : $matched); - continue; - } - - if ($children) { - $data[$key] = static::replaceDependenciesData($children, $matched); - } - - $data[$key] = $handler($data[$key]); - } - - return $data; - } - - /** - * Enable or disable auto-casting of data values. - * - * Boot-only. The auto-casting flag persists in a static property for the - * worker lifetime and affects every subsequent data object hydration. - */ - public static function enableAutoCasting(): void - { - static::$autoCasting = true; - } - - /** - * Enable or disable auto-casting of data values. - */ - public static function isAutoCasting(): bool - { - return static::$autoCasting; - } - - /** - * Disable auto-casting of data values. - * - * Boot-only. The auto-casting flag persists in a static property for the - * worker lifetime and affects every subsequent data object hydration. - */ - public static function disableAutoCasting(): void - { - static::$autoCasting = false; - } - - /** - * Convert the property name to the data key format. - * It converts camelCase to snake_case by default. - */ - public static function convertPropertyToDataKey(string $input): string - { - return Str::snake($input); - } - - /** - * Convert the data key to the property name format. - * It converts snake_case to camelCase by default. - */ - public static function convertDataKeyToProperty(string $input): string - { - return Str::camel($input); - } - - /** - * Get the reflection parameters for the constructor. - * - * @return ReflectionParameter[] - */ - protected static function getReflectionParameters(): array - { - if (! is_null($parameters = static::$reflectionParametersCache[static::class] ?? null)) { - return $parameters; - } - - $reflection = new ReflectionClass(static::class); - $constructor = $reflection->getConstructor(); - $parameters = $constructor ? $constructor->getParameters() : []; - - return static::$reflectionParametersCache[static::class] = $parameters; - } - - /** - * Convert the value to the correct type based on the parameter type. - */ - protected static function convertValueToType(mixed $value, ReflectionParameter $parameter): mixed - { - if (! $type = $parameter->getType()) { - return $value; - } - if ($type->allowsNull() && is_null($value)) { - return null; - } - - if ($type instanceof ReflectionNamedType) { - return match ($type->getName()) { - 'int' => (int) $value, - 'float' => (float) $value, - 'string' => (string) $value, - 'bool' => (bool) $value, - 'array' => is_array($value) ? $value : [$value], - default => $value, - }; - } - - return $value; - } - - /** - * Get default value for the parameter type. - */ - protected static function getDefaultValueForType(ReflectionParameter $parameter): mixed - { - $type = $parameter->getType(); - if (! $type || $type->allowsNull()) { - return null; - } - - throw new RuntimeException( - "Missing required property `{$parameter->name}` in `" . static::class . '`' - ); - } - - /** - * Get property map (snake_case key => camelCase property). - * - * @return array - */ - protected static function getPropertyMap(): array - { - if (array_key_exists(static::class, static::$propertyMapCache)) { - return static::$propertyMapCache[static::class]; - } - - $reflection = new ReflectionClass(static::class); - $properties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); - $map = []; - - foreach ($properties as $property) { - if ($property->isStatic()) { - continue; - } - $propName = $property->getName(); - $snakeKey = static::convertPropertyToDataKey($propName); - $map[$snakeKey] = $propName; - } - - return static::$propertyMapCache[static::class] = $map; - } - - /** - * Get reversed property map (camelCase key => snake_case property). - * - * @return array - */ - protected static function getReversedPropertyMap(): array - { - if (array_key_exists(static::class, static::$reversedPropertyMapCache)) { - return static::$reversedPropertyMapCache[static::class]; - } - - return static::$reversedPropertyMapCache[static::class] = array_flip( - static::getPropertyMap() - ); - } - - /** - * Update the object properties with the provided data array. - */ - public function update(array $data): static - { - $properties = static::getPropertyMap(); - foreach ($data as $key => $value) { - $this->{$properties[$key]} = $value; - } - - $this->refresh(); - - return $this; - } - - /** - * Check if the offset exists. - */ - public function offsetExists(mixed $offset): bool - { - return array_key_exists($offset, static::getPropertyMap()); - } - - /** - * Get the value at the specified offset. - */ - public function offsetGet(mixed $offset): mixed - { - if (array_key_exists($offset, $this->toArray())) { - return $this->toArray()[$offset]; - } - - throw new OutOfBoundsException("Undefined offset: {$offset}"); - } - - /** - * Set the value at the specified offset. - */ - public function offsetSet(mixed $offset, mixed $value): void - { - throw new LogicException('Data object may not be mutated using array access.'); - } - - /** - * Unset the value at the specified offset. - */ - public function offsetUnset(mixed $offset): void - { - throw new LogicException('Data object may not be mutated using array access.'); - } - - /** - * Convert the object to an array representation. - */ - public function toArray(): array - { - if ($this->arrayCache) { - return $this->arrayCache; - } - - $result = []; - $map = static::getPropertyMap(); - - $serializers = static::getSerializers(); - foreach ($map as $snakeKey => $propName) { - $value = $this->{$propName}; - // recursively convert nested objects to arrays - if ($value instanceof self) { - $value = $value->toArray(); - } elseif ( - $value instanceof DateTimeInterface - && $serializer = $serializers[DateTimeInterface::class] ?? null - ) { - $value = $serializer($value); - } elseif ( - is_object($value) - && $serializer = $serializers[$value::class] ?? null - ) { - $value = $serializer($value); - } elseif (is_object($value) && method_exists($value, 'toArray')) { - $value = $value->toArray(); - } - $result[$snakeKey] = $value; - } - - return $this->arrayCache = $result; - } - - /** - * JSON serialize the object. - */ - public function jsonSerialize(): array - { - return $this->toArray(); - } - - /** - * Return a refreshed instance of the object with cleared cache. - */ - public function refresh(): static - { - $this->arrayCache = []; - - return $this; - } - - /** - * Flush all static state. - */ - public static function flushState(): void - { - static::$reflectionParametersCache = []; - static::$propertyMapCache = []; - static::$reversedPropertyMapCache = []; - static::$autoCasting = true; - static::$dependenciesMapCache = []; - static::$dateFormat = self::DEFAULT_DATE_FORMAT; - } -} diff --git a/tests/Benchmarks/Data/README.md b/tests/Benchmarks/Data/README.md index f6cc8584b..a1f92d5c0 100644 --- a/tests/Benchmarks/Data/README.md +++ b/tests/Benchmarks/Data/README.md @@ -8,13 +8,17 @@ 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: +To compare the lightweight Support `DataObject` with full Hypervel Data across supported construction, transformation, memory, and first-use shapes, run: ```shell php tests/Benchmarks/Data/compare-data-object.php ``` -The comparison fixture is kept under `Fixtures/` and loaded only by this command. +Set `BENCHMARK_REVERSE=1` to measure Data before `DataObject` when checking for ordering bias: + +```shell +BENCHMARK_REVERSE=1 php tests/Benchmarks/Data/compare-data-object.php +``` 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. diff --git a/tests/Benchmarks/Data/compare-data-object.php b/tests/Benchmarks/Data/compare-data-object.php index 153f1b67e..4fe6b5a35 100644 --- a/tests/Benchmarks/Data/compare-data-object.php +++ b/tests/Benchmarks/Data/compare-data-object.php @@ -3,6 +3,7 @@ declare(strict_types=1); +use Hypervel\Data\Attributes\DataCollectionOf; use Hypervel\Data\Attributes\MapInputName; use Hypervel\Data\Attributes\MapOutputName; use Hypervel\Data\Data; @@ -14,9 +15,9 @@ use Hypervel\Data\Support\Factories\DataClassFactory; use Hypervel\Data\Support\Transformation\DataTransformer; use Hypervel\Data\Support\Validation\DataValidator; +use Hypervel\Support\DataObject as SupportDataObject; use Hypervel\Testbench\Bootstrapper; use Hypervel\Testbench\Foundation\Application as TestbenchApplication; -use Hypervel\Tests\Benchmarks\Data\Fixtures\DataObject; use function Hypervel\Coroutine\run; @@ -25,7 +26,6 @@ const OPERATIONS = 30_000; require dirname(__DIR__, 3) . '/tests/bootstrap.php'; -require __DIR__ . '/Fixtures/DataObject.php'; Bootstrapper::bootstrap(); @@ -35,7 +35,7 @@ enum BenchStatus: string case Pending = 'pending'; } -class OldFlat extends DataObject +class DataFlat extends Data { public function __construct( public int $id, @@ -47,31 +47,7 @@ public function __construct( } } -class NewFlat extends Data -{ - public function __construct( - public int $id, - public string $name, - public string $email, - public bool $active, - public float $score, - ) { - } -} - -class OldDefaults extends DataObject -{ - public function __construct( - public int $id, - public string $name = 'default', - public bool $active = true, - public float $score = 1.5, - public ?string $note = null, - ) { - } -} - -class NewDefaults extends Data +class DataDefaults extends Data { public function __construct( public int $id, @@ -83,7 +59,7 @@ public function __construct( } } -class OldWide extends DataObject +class DataWide extends Data { public function __construct( public int $one, @@ -110,203 +86,242 @@ public function __construct( } } -class NewWide extends Data +class DataLeaf extends Data { public function __construct( - public int $one, - public int $two, - public int $three, - public int $four, - public int $five, - public int $six, - public int $seven, - public int $eight, - public int $nine, - public int $ten, - public int $eleven, - public int $twelve, - public int $thirteen, - public int $fourteen, - public int $fifteen, - public int $sixteen, - public int $seventeen, - public int $eighteen, - public int $nineteen, - public int $twenty, + public int $id, + public string $code, + public bool $enabled, + public float $score, ) { } } -class OldLeaf extends DataObject +class DataNested extends Data { public function __construct( + public DataLeaf $child, public int $id, - public string $code, - public bool $enabled, - public float $score, + public string $name, + public bool $active, + public ?string $note, ) { } } -class NewLeaf extends Data +class DataMiddle extends Data { public function __construct( + public DataLeaf $child, public int $id, - public string $code, - public bool $enabled, - public float $score, + public string $name, ) { } } -class OldNested extends DataObject +class DataDeep extends Data { public function __construct( - public OldLeaf $child, + public DataMiddle $child, public int $id, public string $name, - public bool $active, - public ?string $note, ) { } } -class NewNested extends Data +class DataEnum extends Data { public function __construct( - public NewLeaf $child, public int $id, - public string $name, - public bool $active, - public ?string $note, + public BenchStatus $status, ) { } } -class OldMiddle extends DataObject +class DataDate extends Data { public function __construct( - public OldLeaf $child, public int $id, - public string $name, + #[MapInputName('created_at')] + public DateTimeImmutable $createdAt, ) { } } -class NewMiddle extends Data +class DataMixed extends Data { public function __construct( - public NewLeaf $child, - public int $id, - public string $name, + #[MapInputName('external_id')] + #[MapOutputName('external_id')] + public int $externalId, + #[MapInputName('display_name')] + #[MapOutputName('display_name')] + public string $displayName, + public BenchStatus $status, + #[MapInputName('created_at')] + #[MapOutputName('created_at')] + public DateTimeImmutable $createdAt, + public DataLeaf $child, ) { } } -class OldDeep extends DataObject +class DataCold extends Data +{ + public function __construct(public int $id, public string $name, public bool $active) + { + } +} + +class DataWarm extends Data +{ + public function __construct(public int $id) + { + } +} + +class LightweightFlat extends SupportDataObject { public function __construct( - public OldMiddle $child, public int $id, public string $name, + public string $email, + public bool $active, + public float $score, ) { } } -class NewDeep extends Data +class LightweightDefaults extends SupportDataObject { public function __construct( - public NewMiddle $child, public int $id, - public string $name, + public string $name = 'default', + public bool $active = true, + public float $score = 1.5, + public ?string $note = null, + ) { + } +} + +class LightweightWide extends SupportDataObject +{ + public function __construct( + public int $one, + public int $two, + public int $three, + public int $four, + public int $five, + public int $six, + public int $seven, + public int $eight, + public int $nine, + public int $ten, + public int $eleven, + public int $twelve, + public int $thirteen, + public int $fourteen, + public int $fifteen, + public int $sixteen, + public int $seventeen, + public int $eighteen, + public int $nineteen, + public int $twenty, ) { } } -class OldEnum extends DataObject +class LightweightLeaf extends SupportDataObject { public function __construct( public int $id, - public BenchStatus $status, + public string $code, + public bool $enabled, + public float $score, ) { } } -class NewEnum extends Data +class LightweightNested extends SupportDataObject { public function __construct( + public LightweightLeaf $child, public int $id, - public BenchStatus $status, + public string $name, + public bool $active, + public ?string $note, ) { } } -class OldDate extends DataObject +class LightweightMiddle extends SupportDataObject { public function __construct( + public LightweightLeaf $child, public int $id, - public DateTimeImmutable $createdAt, + public string $name, ) { } } -class NewDate extends Data +class LightweightDeep extends SupportDataObject { public function __construct( + public LightweightMiddle $child, public int $id, - #[MapInputName('created_at')] - public DateTimeImmutable $createdAt, + public string $name, ) { } } -class OldMixed extends DataObject +class LightweightEnum extends SupportDataObject { public function __construct( - public int $externalId, - public string $displayName, + public int $id, public BenchStatus $status, + ) { + } +} + +class LightweightDate extends SupportDataObject +{ + public function __construct( + public int $id, public DateTimeImmutable $createdAt, - public OldLeaf $child, ) { } } -class NewMixed extends Data +class LightweightMixed extends SupportDataObject { public function __construct( - #[MapInputName('external_id')] - #[MapOutputName('external_id')] public int $externalId, - #[MapInputName('display_name')] - #[MapOutputName('display_name')] public string $displayName, public BenchStatus $status, - #[MapInputName('created_at')] - #[MapOutputName('created_at')] public DateTimeImmutable $createdAt, - public NewLeaf $child, + public LightweightLeaf $child, ) { } } -class OldCold extends DataObject +class LightweightCold extends SupportDataObject { public function __construct(public int $id, public string $name, public bool $active) { } } -class NewCold extends Data +class LightweightItemList extends SupportDataObject { - public function __construct(public int $id, public string $name, public bool $active) + public function __construct(public array $items) { } } -class NewWarm extends Data +// Data needs explicit item metadata to produce the same nested-array output as DataObject. +class DataItemList extends Data { - public function __construct(public int $id) + public function __construct(#[DataCollectionOf(DataLeaf::class)] public array $items) { } } @@ -359,7 +374,7 @@ function coldMeasurement(string $mode): int try { if ($mode === 'data-class') { - run(static fn (): NewWarm => NewWarm::from(['id' => 1])); + run(static fn (): DataWarm => DataWarm::from(['id' => 1])); } $elapsed = 0; @@ -367,8 +382,8 @@ function coldMeasurement(string $mode): int $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-object' => LightweightCold::from(['id' => 1, 'name' => 'cold', 'active' => true]), + 'data-first', 'data-class' => DataCold::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), @@ -449,16 +464,16 @@ function retainedInstanceBytes(Closure $factory, ?Closure $prepare = null): floa /** * Print one benchmark row. */ -function printRow(string $scenario, array $old, array $new): void +function printRow(string $scenario, array $dataObject, array $data): 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'], + $dataObject['p50'], + $data['p50'], + $data['p50'] / $dataObject['p50'], + $dataObject['p95'], + $data['p95'], ); } @@ -469,6 +484,7 @@ function execute(): void { $application = TestbenchApplication::create(options: ['load_environment_variables' => false]); $application->register(DataServiceProvider::class); + $reverseOrder = getenv('BENCHMARK_REVERSE') === '1'; $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']; @@ -477,24 +493,28 @@ function execute(): void ['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']; + $leaf = ['id' => 1, 'code' => 'leaf', 'enabled' => true, 'score' => 9.5]; + $nested = ['child' => $leaf, 'id' => 2, 'name' => 'nested', 'active' => true, 'note' => null]; + $deep = ['child' => ['child' => $leaf, '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 = [ + $dataObjectDate = ['id' => 1, 'createdAt' => '2026-09-04 12:34:56']; + $dataDate = ['id' => 1, 'created_at' => '2026-09-04T12:34:56+00:00']; + $dataObjectMixed = [ + 'externalId' => '9', + 'displayName' => 123, + 'status' => 'active', + 'createdAt' => '2026-09-04 12:34:56', + 'child' => ['id' => '1', 'code' => 456, 'enabled' => 1, 'score' => '9.5'], + ]; + $dataMixed = [ 'external_id' => '9', 'display_name' => 123, 'status' => 'active', - 'created_at' => '2026-09-04 12:34:56', + 'created_at' => '2026-09-04T12:34:56+00:00', '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); + $itemRows = array_fill(0, 25, $leaf); try { run(function () use ( @@ -503,69 +523,81 @@ function execute(): void $defaults, $enum, $flat, - $newDate, - $newDeep, - $newMixed, - $newNested, - $oldDeep, - $oldDate, - $oldMixed, - $oldNested, + $itemRows, + $dataDate, + $dataMixed, + $nested, + $deep, + $dataObjectDate, + $dataObjectMixed, + $reverseOrder, $rows, $wide, ): void { - NewFlat::from($flat); - OldFlat::from($flat); + DataFlat::from($flat); + LightweightFlat::from($flat); $construction = [ 'flat-5-scalars' => [ - fn (): int => OldFlat::from($flat)->id, - fn (): int => NewFlat::from($flat)->id, + fn (): int => LightweightFlat::from($flat)->id, + fn (): int => DataFlat::from($flat)->id, ], 'with-defaults' => [ - fn (): int => OldDefaults::from($defaults)->id, - fn (): int => NewDefaults::from($defaults)->id, + fn (): int => LightweightDefaults::from($defaults)->id, + fn (): int => DataDefaults::from($defaults)->id, ], 'wide-20-scalars' => [ - fn (): int => OldWide::from($wide)->twenty, - fn (): int => NewWide::from($wide)->twenty, + fn (): int => LightweightWide::from($wide)->twenty, + fn (): int => DataWide::from($wide)->twenty, ], 'flat-requiring-coercion' => [ - fn (): int => OldFlat::from($coerced)->id, - fn (): int => NewFlat::from($coerced)->id, + fn (): int => LightweightFlat::from($coerced)->id, + fn (): int => DataFlat::from($coerced)->id, ], 'nested-1-level' => [ - fn (): int => OldNested::from($oldNested, true)->child->id, - fn (): int => NewNested::from($newNested)->child->id, + fn (): int => LightweightNested::from($nested)->child->id, + fn (): int => DataNested::from($nested)->child->id, ], 'deep-3-levels' => [ - fn (): int => OldDeep::from($oldDeep, true)->child->child->id, - fn (): int => NewDeep::from($newDeep)->child->child->id, + fn (): int => LightweightDeep::from($deep)->child->child->id, + fn (): int => DataDeep::from($deep)->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, + fn (): int => LightweightEnum::from($enum)->status === BenchStatus::Active ? 1 : 0, + fn (): int => DataEnum::from($enum)->status === BenchStatus::Active ? 1 : 0, ], 'date-time' => [ - fn (): int => OldDate::from($oldDate, true)->id, - fn (): int => NewDate::from($newDate)->id, + fn (): int => LightweightDate::from($dataObjectDate)->id, + fn (): int => DataDate::from($dataDate)->id, ], 'mixed-api-payload' => [ - fn (): int => OldMixed::from($oldMixed, true)->child->id, - fn (): int => NewMixed::from($newMixed)->child->id, + fn (): int => LightweightMixed::from($dataObjectMixed)->child->id, + fn (): int => DataMixed::from($dataMixed)->child->id, + ], + 'array-of-25-data-objects' => [ + function () use ($itemRows): int { + $items = array_map(LightweightLeaf::from(...), $itemRows); + + return LightweightItemList::from(['items' => $items])->items[0]->id; + }, + function () use ($itemRows): int { + $items = array_map(DataLeaf::from(...), $itemRows); + + return DataItemList::from(['items' => $items])->items[0]->id; + }, ], '1000-item-from-loop' => [ function () use ($rows): int { $checksum = 0; foreach ($rows as $row) { - $checksum += OldFlat::from($row)->id; + $checksum += LightweightFlat::from($row)->id; } return $checksum; }, function () use ($rows): int { $checksum = 0; foreach ($rows as $row) { - $checksum += NewFlat::from($row)->id; + $checksum += DataFlat::from($row)->id; } return $checksum; }, @@ -573,147 +605,157 @@ function () use ($rows): int { ]; printf("Construction (nanoseconds per operation)\n"); - printf("%-38s %12s %12s %9s %12s %12s\n", 'scenario', 'old p50', 'data p50', 'ratio', 'old p95', 'data p95'); + printf("%-38s %12s %12s %9s %12s %12s\n", 'scenario', 'object p50', 'data p50', 'ratio', 'object p95', 'data p95'); - foreach ($construction as $name => [$old, $new]) { + foreach ($construction as $name => [$dataObject, $data]) { $divisor = $name === '1000-item-from-loop' ? 1_000 : 1; - $operations = $divisor === 1 ? OPERATIONS : 60; + $operations = $divisor === 1 + ? ($name === 'array-of-25-data-objects' ? 2_000 : 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); + if ($reverseOrder) { + $dataResult = measure($data, $operations, $warmup); + $dataObjectResult = measure($dataObject, $operations, $warmup); + } else { + $dataObjectResult = measure($dataObject, $operations, $warmup); + $dataResult = measure($data, $operations, $warmup); + } + $dataObjectResult = ['p50' => $dataObjectResult['p50'] / $divisor, 'p95' => $dataObjectResult['p95'] / $divisor]; + $dataResult = ['p50' => $dataResult['p50'] / $divisor, 'p95' => $dataResult['p95'] / $divisor]; + printRow($name, $dataObjectResult, $dataResult); } - $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); + $dataObjectFlatObject = LightweightFlat::from($flat); + $dataFlatObject = DataFlat::from($flat); + $dataObjectWideObject = LightweightWide::from($wide); + $dataWideObject = DataWide::from($wide); + $dataObjectNestedObject = LightweightNested::from($nested); + $dataNestedObject = DataNested::from($nested); + $dataObjectDeepObject = LightweightDeep::from($deep); + $dataDeepObject = DataDeep::from($deep); + $dataObjectObjects = array_fill(0, 1_000, null); + $dataObjects = array_fill(0, 1_000, null); + + foreach (array_keys($dataObjectObjects) as $index) { + $dataObjectObjects[$index] = LightweightFlat::from($flat); + $dataObjects[$index] = DataFlat::from($flat); } + $dataObjectItemList = LightweightItemList::from(['items' => array_map(LightweightLeaf::from(...), $itemRows)]); + $dataItemList = DataItemList::from(['items' => array_map(DataLeaf::from(...), $itemRows)]); + $transformation = [ 'flat-uncached' => [ - fn (): int => $oldFlatObject->refresh()->toArray()['id'], - fn (): int => $newFlatObject->toArray()['id'], + fn (): int => $dataObjectFlatObject->toArray()['id'], + fn (): int => $dataFlatObject->toArray()['id'], ], 'wide-uncached' => [ - fn (): int => $oldWideObject->refresh()->toArray()['twenty'], - fn (): int => $newWideObject->toArray()['twenty'], + fn (): int => $dataObjectWideObject->toArray()['twenty'], + fn (): int => $dataWideObject->toArray()['twenty'], ], 'nested-whole-tree' => [ - function () use ($oldNestedObject): int { - $oldNestedObject->child->refresh(); - return $oldNestedObject->refresh()->toArray()['child']['id']; - }, - fn (): int => $newNestedObject->toArray()['child']['id'], + fn (): int => $dataObjectNestedObject->toArray()['child']['id'], + fn (): int => $dataNestedObject->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'], + fn (): int => $dataObjectDeepObject->toArray()['child']['child']['id'], + fn (): int => $dataDeepObject->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)), + fn (): int => strlen((string) json_encode($dataObjectNestedObject, JSON_THROW_ON_ERROR)), + fn (): int => strlen((string) json_encode($dataNestedObject, JSON_THROW_ON_ERROR)), + ], + 'array-of-25-data-objects' => [ + fn (): int => $dataObjectItemList->toArray()['items'][0]['id'], + fn (): int => $dataItemList->toArray()['items'][0]['id'], ], '1000-object-transform' => [ - function () use ($oldObjects): int { + function () use ($dataObjectObjects): int { $checksum = 0; - foreach ($oldObjects as $object) { - $checksum += $object->refresh()->toArray()['id']; + foreach ($dataObjectObjects as $object) { + $checksum += $object->toArray()['id']; } return $checksum; }, - function () use ($newObjects): int { + function () use ($dataObjects): int { $checksum = 0; - foreach ($newObjects as $object) { + foreach ($dataObjects as $object) { $checksum += $object->toArray()['id']; } return $checksum; }, ], 'property-read' => [ - fn (): int => $oldFlatObject->id, - fn (): int => $newFlatObject->id, + fn (): int => $dataObjectFlatObject->id, + fn (): int => $dataFlatObject->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'); + printf("%-38s %12s %12s %9s %12s %12s\n", 'scenario', 'object p50', 'data p50', 'ratio', 'object p95', 'data p95'); - foreach ($transformation as $name => [$old, $new]) { + foreach ($transformation as $name => [$dataObject, $data]) { $divisor = $name === '1000-object-transform' ? 1_000 : 1; - $operations = $divisor === 1 ? OPERATIONS : 80; + $operations = $divisor === 1 + ? ($name === 'array-of-25-data-objects' ? 3_000 : 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); + if ($reverseOrder) { + $dataResult = measure($data, $operations, $warmup); + $dataObjectResult = measure($dataObject, $operations, $warmup); + } else { + $dataObjectResult = measure($dataObject, $operations, $warmup); + $dataResult = measure($data, $operations, $warmup); + } + $dataObjectResult = ['p50' => $dataObjectResult['p50'] / $divisor, 'p95' => $dataObjectResult['p95'] / $divisor]; + $dataResult = ['p50' => $dataResult['p50'] / $divisor, 'p95' => $dataResult['p95'] / $divisor]; + printRow($name, $dataObjectResult, $dataResult); } printf("\nRetained instance bytes\n"); - printf("%-38s %12s %12s\n", 'scenario', 'old', 'data'); - printf("%-38s %12.1f %12.1f\n", 'flat, untransformed', retainedInstanceBytes(fn (int $id): OldFlat => OldFlat::from([...$flat, 'id' => $id])), retainedInstanceBytes(fn (int $id): NewFlat => NewFlat::from([...$flat, 'id' => $id]))); + printf("%-38s %12s %12s\n", 'scenario', 'data object', 'data'); + printf("%-38s %12.1f %12.1f\n", 'flat, untransformed', retainedInstanceBytes(fn (int $id): LightweightFlat => LightweightFlat::from([...$flat, 'id' => $id])), retainedInstanceBytes(fn (int $id): DataFlat => DataFlat::from([...$flat, 'id' => $id]))); printf("%-38s %12.1f %12.1f\n", 'flat, transformed', retainedInstanceBytes( - fn (int $id): OldFlat => OldFlat::from([...$flat, 'id' => $id]), - static function (OldFlat $data): void { + fn (int $id): LightweightFlat => LightweightFlat::from([...$flat, 'id' => $id]), + static function (LightweightFlat $data): void { $data->toArray(); }, ), retainedInstanceBytes( - fn (int $id): NewFlat => NewFlat::from([...$flat, 'id' => $id]), - static function (NewFlat $data): void { + fn (int $id): DataFlat => DataFlat::from([...$flat, 'id' => $id]), + static function (DataFlat $data): void { $data->toArray(); }, )); - printf("%-38s %12.1f %12.1f\n", 'wide, untransformed', retainedInstanceBytes(fn (int $id): OldWide => OldWide::from([...$wide, 'one' => $id])), retainedInstanceBytes(fn (int $id): NewWide => NewWide::from([...$wide, 'one' => $id]))); + printf("%-38s %12.1f %12.1f\n", 'wide, untransformed', retainedInstanceBytes(fn (int $id): LightweightWide => LightweightWide::from([...$wide, 'one' => $id])), retainedInstanceBytes(fn (int $id): DataWide => DataWide::from([...$wide, 'one' => $id]))); printf("%-38s %12.1f %12.1f\n", 'wide, transformed', retainedInstanceBytes( - fn (int $id): OldWide => OldWide::from([...$wide, 'one' => $id]), - static function (OldWide $data): void { + fn (int $id): LightweightWide => LightweightWide::from([...$wide, 'one' => $id]), + static function (LightweightWide $data): void { $data->toArray(); }, ), retainedInstanceBytes( - fn (int $id): NewWide => NewWide::from([...$wide, 'one' => $id]), - static function (NewWide $data): void { + fn (int $id): DataWide => DataWide::from([...$wide, 'one' => $id]), + static function (DataWide $data): void { $data->toArray(); }, )); $repository = $application->make(DataClassRepository::class); - $repository->get(NewWarm::class); + $repository->get(DataWarm::class); gc_collect_cycles(); $before = memory_get_usage(false); - $repository->get(NewCold::class); - $newMetadata = memory_get_usage(false) - $before; + $repository->get(DataCold::class); + $dataMetadata = memory_get_usage(false) - $before; - DataObject::flushState(); + SupportDataObject::flushState(); gc_collect_cycles(); $before = memory_get_usage(false); - $oldObject = OldCold::from(['id' => 1, 'name' => 'cold', 'active' => true]); - unset($oldObject); + $object = LightweightCold::from(['id' => 1, 'name' => 'cold', 'active' => true]); + unset($object); gc_collect_cycles(); - $oldMetadata = memory_get_usage(false) - $before; + $dataObjectMetadata = memory_get_usage(false) - $before; printf("\nRetained metadata bytes (one small class, warm services)\n"); - printf("%-38s %12d %12d\n", 'metadata', $oldMetadata, $newMetadata); + printf("%-38s %12d %12d\n", 'metadata', $dataObjectMetadata, $dataMetadata); }); } finally { $application->terminate(); @@ -721,7 +763,7 @@ static function (NewWide $data): void { printf("\nFresh-process first-use (nanoseconds)\n"); printf("%-38s %12s %12s\n", 'scenario', 'p50', 'p95'); - foreach (['old', 'data-first', 'data-class'] as $mode) { + foreach (['data-object', 'data-first', 'data-class'] as $mode) { $result = measureCold($mode); printf("%-38s %12.1f %12.1f\n", $mode, $result['p50'], $result['p95']); } @@ -738,18 +780,18 @@ function profileDefaultCreation(): void try { run(function () use ($application, $flat): void { - NewFlat::from($flat); - $factory = NewFlat::factory(); + DataFlat::from($flat); + $factory = DataFlat::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, + 'native-constructor' => fn (): int => (new DataFlat(1, 'Taylor', 'taylor@example.com', true, 9.5))->id, + 'base-data-from' => fn (): int => DataFlat::from($flat)->id, 'prepared-factory-from' => fn (): int => $factory->from($flat)->id, - 'creator-with-context' => fn (): int => $creator->create(NewFlat::class, $context, $flat)->id, + 'creator-with-context' => fn (): int => $creator->create(DataFlat::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, + 'base-data-factory' => fn (): int => DataFlat::factory()->dataClass === DataFlat::class ? 1 : 0, ]; printf("%-38s %12s %12s\n", 'layer', 'p50', 'p95'); @@ -795,8 +837,8 @@ function profileTransformation(): void try { run(function () use ($application, $flatPayload, $nestedPayload): void { - $flat = NewFlat::from($flatPayload); - $nested = NewNested::from($nestedPayload); + $flat = DataFlat::from($flatPayload); + $nested = DataNested::from($nestedPayload); $transformer = $application->make(DataTransformer::class); $flatContext = $transformer->defaultContext($flat); $nestedContext = $transformer->defaultContext($nested); From 0114759cc37aa2fd4aedc49c0ae9514d11ed3170 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:48:37 +0000 Subject: [PATCH 05/14] Track typed input contract alignment Record a framework-wide audit of integer, float, and boolean input contracts across InteractsWithData, Support DataObject, and Hypervel Data. Only extract a neutral conversion primitive when public semantics genuinely converge, and keep any future Data behavior decision independent so this lightweight implementation does not create an accidental cross-package contract change. --- docs/todo.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/todo.md b/docs/todo.md index 8974bbc11..d4d23aa48 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -27,6 +27,7 @@ - Convert untyped `$config->get()` calls across `src/` to the typed getters (`string()`, `integer()`, `float()`, `boolean()`, `array()`) without call-site defaults, for every key that isn't genuinely nullable. Defaults live in the merged config files — declare any key currently defaulted only at a call site in its package's config file as part of the conversion. Typed getters throw `InvalidArgumentException` naming the key on misconfiguration instead of letting a wrong type propagate silently, and give phpstan real return types. Bootstrap code that runs before config merging keeps its call-site defaults. Approved modernization per the Porting Packages policy in `AGENTS.md`; new code already follows the rule. - Audit unmatched PHPStan inline ignores and global patterns with `reportUnmatchedIgnoredErrors` enabled — currently 196 unmatched inline ignores across 99 files plus 5 unmatched global patterns. Remove only suppressions that no longer match after tracing the underlying code; do not replace correct source with runtime branches or wider types merely to keep static analysis green. Decide as part of the work whether `phpstan.neon.dist` should then set `reportUnmatchedIgnoredErrors: true` permanently, since leaving it `false` lets the suppressions rot again. - Add PHPStan Eloquent extensions that preserve `Eloquent\Builder` for non-passthrough methods forwarded to `Query\Builder`, and expose model named scopes on Eloquent builders and relations. The query-builder mixin currently gives fluent calls the wrong builder type, while named scopes are treated as nonexistent methods; these gaps force scopes to split mutation from return and leave `HasDatabaseNotifications` with `method.notFound` suppressions. This will be the repository's first PHPStan extension, so use Larastan as prior art and wire the extensions into `phpstan.neon.dist` without maintaining duplicate query-method or scope lists in `@method` annotations. +- Audit typed input accessors across `InteractsWithData`, Support `DataObject`, and Hypervel Data. Define the accepted integer, float, and boolean forms for each public contract. Extract a neutral Support conversion primitive only if `InteractsWithData` and `DataObject` converge on exactly the same strict semantics, and decide separately whether Hypervel Data should adopt those semantics rather than changing its tested permissive casts as a side effect. ## Testing From 0db95fce2ac4448b80a03fbc48c5a0b1ad7b9737 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 06:48:45 +0000 Subject: [PATCH 06/14] Add lightweight data object implementation plan Document the final Support DataObject contract, compiled-recipe design, strict conversion rules, date and nesting behavior, transformation semantics, documentation boundary, benchmark acceptance criteria, and verification coverage. Capture the rejected overlapping or configurable designs so future work preserves the small complementary API without rebuilding a second Hypervel Data engine. --- ...2026-09-05-1421-lightweight-data-object.md | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 docs/plans/2026-09-05-1421-lightweight-data-object.md diff --git a/docs/plans/2026-09-05-1421-lightweight-data-object.md b/docs/plans/2026-09-05-1421-lightweight-data-object.md new file mode 100644 index 000000000..eb44a055f --- /dev/null +++ b/docs/plans/2026-09-05-1421-lightweight-data-object.md @@ -0,0 +1,415 @@ +# Lightweight Support Data Objects + +## Goal + +Add a small `Hypervel\Support\DataObject` for trusted internal message envelopes and per-item typed values where full Hypervel Data behavior is unnecessary. It must retain the useful outcomes of the removed mapper while fixing its correctness, API, worker-state, and maintenance problems. + +This is a complement to `Hypervel\Data`, not a second version of it: + +- `DataObject` maps one array into public promoted constructor properties and converts common PHP value types. +- `Data`, `Dto`, and `Resource` remain the choices for validation, input or output name mapping, contextual values, custom casts and transformers, lazy values, partials, collections, HTTP resources, persistence, FormRequest, Inertia, and Saloon integration. +- There is no public fast mode, feature flag, configuration, container service, provider, or integration layer. + +The API is new for Hypervel 0.4. Do not preserve earlier Hypervel-specific methods or behavior merely for compatibility. + +## Evidence + +The existing `tests/Benchmarks/Data/compare-data-object.php` harness compares current Data with a frozen copy of the removed Support mapper. A fresh internally repeated run on the same machine produced these representative medians: + +| Operation | Removed `DataObject` | Current `Data` | Ratio | +| --- | ---: | ---: | ---: | +| Flat construction, five scalars | 1.735 us | 3.869 us | 2.23x | +| Construction with defaults | 1.137 us | 3.434 us | 3.02x | +| Scalar coercion | 1.853 us | 4.987 us | 2.69x | +| One nested level | 3.788 us | 5.912 us | 1.56x | +| Three nested levels | 5.258 us | 7.390 us | 1.41x | +| Backed enum | 1.387 us | 3.769 us | 2.72x | +| 1,000 constructions | 1.747 ms | 4.041 ms | 2.31x | +| Flat uncached transformation | 0.653 us | 1.831 us | 2.81x | +| 1,000 transformations | 0.633 ms | 1.678 ms | 2.65x | + +Data is highly optimized for its larger contract. These differences still matter in tight loops that need only typed mapping. The new mapper therefore has a separate narrow execution path rather than weakening Data or duplicating its feature engine. + +The removed implementation also demonstrates what not to restore: + +1. Its pre-expanded dependency map suppresses valid recursive types after the same class appears twice. +2. `autoResolve` and worker-global auto-casting switches change both conversion and key conventions, while retained dependency metadata is not invalidated consistently. +3. Int-backed enums reject valid numeric strings under strict types. +4. Raw scalar casts silently turn invalid values into `0`, `false`, truthy booleans, strings, or invented arrays. +5. Per-instance output caching becomes stale after ordinary public-property mutation. +6. `update()` accepts unknown keys unsafely and bypasses construction conversion. +7. Object unions arbitrarily select one arm for array hydration. +8. Numeric date strings are interpreted as Unix timestamps. +9. Nested objects require callers to know about and enable a separate resolution mode. +10. Public caches, global format settings, conversion hooks, and serializer maps expose implementation state and create a second extensible data engine. + +## Public Contract + +Create `src/support/src/DataObject.php`: + +```php + */ +abstract class DataObject implements Arrayable, Jsonable, JsonSerializable, Transient +{ + public static function from(array $data): static; + + public function toArray(): array; + + public function jsonSerialize(): array; + + public function toJson(int $options = 0): string; + + public static function flushState(): void; +} +``` + +The real methods receive the repository's standard concise docblocks and precise PHPStan shapes where native PHP cannot express them. + +The `Transient` marker is inherited by every subclass. A DataObject is a mutable value object rather than a service and must never enter the container's implicit worker-singleton cache if application code resolves one accidentally. The marker is an existing zero-cost lifetime declaration; it does not add container construction as a supported creation path. + +### Construction declarations + +A supported subclass has a public or protected constructor whose parameters are public promoted properties: + +```php +final class MessageEnvelope extends DataObject +{ + public function __construct( + public readonly string $id, + public readonly MessageType $type, + public readonly MessagePayload $payload, + public readonly CarbonImmutable $receivedAt, + public readonly ?string $traceId = null, + ) { + } +} +``` + +Also support a class with no constructor and no public instance properties, inherited public promoted properties, and individual promoted `readonly` properties. + +Compile-time declaration checks are limited to reachable ambiguity or data loss: + +- reject any constructor parameter that is not a public promoted property; +- reject any other public non-static instance property because it would otherwise be visible data omitted from `toArray()`; +- allow protected and private instance properties as internal implementation state; they are never read, written, or serialized by `DataObject`; +- allow static properties; +- do not add a variadic guard because PHP rejects variadic promoted properties when the subclass is declared; +- do not add a readonly-class guard because PHP rejects a readonly child of this non-readonly cached base. Applications may use promoted `public readonly` properties. + +Throw `LogicException` for an invalid subclass declaration and name the class, parameter or property, and required declaration shape. + +A child with no constructor inherits its parent's promoted properties and constructor order. If a child declares its own constructor, every public data property must be promoted by that constructor, including any inherited property that the child redeclares. An unpromoted argument forwarded to `parent::__construct()` is intentionally unsupported because it creates two competing construction shapes. A private constructor remains inaccessible to the base implementation and fails through PHP's native constructor visibility error; do not preflight visibility that PHP already enforces. + +### Input keys and presence + +- Constructor parameter names are the only input keys and output keys. +- Unknown input keys are ignored, matching Data and allowing compatible message-envelope additions. +- Do not perform implicit snake-case conversion. This matches PHP named arguments and Hypervel Data's default. An application-specific named factory may adapt an external shape; full reusable mapping belongs to Data. +- If an input key exists, including with `null`, it is supplied. +- If an input key is missing and the constructor parameter has a default, omit that named argument. PHP must evaluate the default for every construction. Never retain an evaluated default object in the recipe. +- If a missing parameter allows null and has no default, pass `null`. +- Throw `InvalidArgumentException` naming the class and key for any other missing parameter. PHP's argument-count error cannot identify the source key. +- Let explicit `null` bypass conversion and reach the constructor. PHP's native type error enforces non-nullable declarations. + +`from()` is the only base construction method. Do not add the old `make()` alias, `autoResolve` argument, `update()`, `refresh()`, or `ArrayAccess`. Direct public-property access is the normal PHP API, while application classes may define meaningful named factories. + +## Worker-Cached Recipes + +Store one private static recipe map keyed by concrete class: + +```php +/** + * @var array> + */ +private static array $recipes = []; +``` + +Private integer constants identify pass-through, array, boolean, float, integer, string, backed-enum, nested-data-object, and date conversion. A compact list in constructor order is both the construction recipe and the serialized-property list. Do not add recipe, property, repository, resolver, or factory classes. + +Base methods access the private cache and kind constants with `self`, while `static::class` selects the concrete recipe key and `new static` constructs the requested subtype. This keeps the cache owned by DataObject even when an application subclass declares an unrelated static property with the same name. + +The compiler performs one reflection pass for a class and stores only declaration facts. Named class targets are resolved through `Reflector::getParameterClassName()` so `self` and `parent` become declaring-scope class-strings before classification. It must not retain `ReflectionClass`, `ReflectionParameter`, input values, constructed objects, services, callbacks, or evaluated defaults. The key set is naturally bounded by the DataObject classes loaded and used by the application. + +Compilation may autoload a referenced declaration while classifying it. The fully computed immutable recipe is published with one assignment only after classification completes. Concurrent first use may therefore compute the same recipe twice, but cannot observe a partial value or share request state; no lock or coroutine context is needed. + +`flushState()` clears the recipe map as required for framework static caches and has only the standard `Flush all static state.` title docblock. Do not register it in `AfterEachTestSubscriber`: recipes contain immutable declarations and cannot leak application or request behavior between tests. + +## Construction + +`from()` obtains the concrete recipe, builds an associative argument array in constructor order, and invokes: + +```php +return new static(...$arguments); +``` + +Named arguments are intentional. Omitting a missing defaulted parameter lets PHP create a fresh object default on every call; caching `ReflectionParameter::getDefaultValue()` would share one mutable default object between all instances. The measured difference between positional and named construction is about 80 ns for five parameters and does not justify incorrect or reflection-retaining machinery. + +For a present non-null value, dispatch directly on the precompiled integer kind. Do not preprocess the payload, recursively compile dependency trees, allocate a context, resolve the container, or run a pipeline. + +### Built-in types + +Keep strict built-in conversion private to DataObject. Current Data has a separately designed and tested permissive contract, including scalar-to-array conversion. `InteractsWithData` retains Laravel's typed-accessor behavior until its already identified coherent accessor audit is implemented. A shared helper now would either change those public contracts or be a speculative one-consumer abstraction. + +Already-typed values pass through before filtering. Apply these rules: + +| Declared type | Accepted values | Conversion | +| --- | --- | --- | +| `int` | `int`, or a string or whole float accepted by `FILTER_VALIDATE_INT` | validated integer | +| `float` | `float`, `int`, or a string accepted by `FILTER_VALIDATE_FLOAT` | float; widen native integers | +| `bool` | `bool`, or a value accepted by `FILTER_VALIDATE_BOOLEAN` with `FILTER_NULL_ON_FAILURE` | boolean | +| `string` | `string`, another scalar, or native `Stringable` | string cast | +| `array` | `array` | unchanged | + +The boolean vocabulary follows Laravel's request accessor and PHP's filter: `1`, `0`, `true`, `false`, `on`, `off`, `yes`, `no`, and the empty string, including case-insensitive string forms where PHP supports them. + +Boolean values are not accepted for numeric targets. Numeric conversion accepts only the types named in the table, so objects such as numeric `Stringable` values are also rejected. + +Reject all other built-in conversions with `InvalidArgumentException` naming the DataObject class, property, expected type, and supplied value or type. In particular, do not silently turn arbitrary text into zero, accept fractional integers, stringify arrays, or wrap a scalar as an array. This is conversion, not validation: it supports a fixed set of ordinary typed representations and has no rule system, field policy, or configurable behavior. + +Record the intentional difference in the owner-facing summary and user documentation: invalid built-in input throws in Support DataObject, while current Data retains its tested permissive casts. Add a self-contained Framework-wide entry to `docs/todo.md` for the full typed-input accessor audit. When that audit makes `InteractsWithData` and DataObject share exact strict scalar semantics, extract their common int, float, and boolean conversion into a neutral `Hypervel\Support` primitive; separately decide whether Data should adopt it rather than changing Data as a side effect here. + +### Backed enums + +For a concrete backed-enum declaration: + +- return an existing instance of the declared enum unchanged; +- otherwise call the existing neutral `Hypervel\Support\enum_from()` helper; +- let its `ValueError` propagate for an invalid backing value; +- preserve valid numeric-string support for integer-backed enums. + +Unit enums and enum interfaces have no scalar construction rule. They use pass-through behavior and PHP enforces their declared type. + +### Nested DataObjects + +For a DataObject declaration: + +- return an existing instance of the declared type unchanged; +- pass an array to the nested type's `from()` method; +- pass any other value through to PHP's constructor type check rather than inventing another conversion. + +Each object resolves its own immediate parameters. This naturally supports arbitrary depth and recursive declarations such as a nullable linked node without a global visited set, dependency map, double construction, or retained nested input. + +Relative `self` and `parent` declarations are supported and retain PHP's declaration-scope meaning, including when a constructor is inherited by a child class. + +### Dates + +Classify a named type as a date when it is `DateTimeInterface`, `CarbonInterface`, or a compatible implementation. For a non-null value: + +1. Return it unchanged when it already satisfies the declared target. +2. For `DateTimeInterface` input, adapt it directly to a concrete target with `$target::instance($value)` for Carbon or `$target::createFromInterface($value)` for native DateTime classes. Use `Date::instance($value)` only for an interface target, where the configured date factory determines the result. +3. For actual `int` or `float` input, call `Date::createFromTimestamp($value, date_default_timezone_get())`, preserving the removed mapper and Eloquent's timestamp timezone semantics under Carbon 3. Parse string input with `Date::parse()`. +4. Return that configured factory result for the `DateTimeInterface` and `CarbonInterface` declarations. +5. Adapt the parsed or timestamp result with `$target::instance($date)` for a concrete Carbon implementation or subclass, or `$target::createFromInterface($date)` for native `DateTime`, `DateTimeImmutable`, or their subclasses. + +An already-valid subclass may pass through for a base-class declaration; the result satisfies the declared type and follows normal PHP substitution. A conversion into a concrete application subclass returns that subclass. + +Only actual numeric values are Unix timestamps. A numeric string such as `"20240101"` is parsed as a date. Invalid date parsing propagates Carbon's native format exception. Do not add a DataObject date-format setting, custom parser, target table, or exception wrapper. + +### Unions, intersections, and other objects + +Nullable named types use their normal kind after the null bypass. Multi-arm unions and intersections use pass-through behavior only: + +- already valid values reach the constructor unchanged; +- an array is never guessed into one object arm; +- PHP enforces the declared union or intersection. + +Other named object types also pass through. DataObject never constructs them and never resolves the container. Custom conversion belongs in an application named factory or a Data cast. + +## Transformation + +`toArray()` reads the instance through `(array) $this`, then iterates only the promoted names in the compiled recipe. This avoids `get_object_vars()` creating a retained per-object property table, excludes protected and private internal state, ignores deprecated dynamic properties, and preserves constructor order. Keep the scalar fast gate in this outer loop so a plain object does not pay one helper call per already-final property; recursive values continue through the single normalizer. + +Normalize each value with a scalar-first gate: + +1. Return a value immediately when it is neither an array nor an object. +2. Recursively normalize arrays while preserving keys. +3. Convert `DateTimeInterface` to `DATE_ATOM`. +4. Convert `BackedEnum` to its backing value. +5. Convert `DataObject` through `toArray()`. +6. Convert another `Arrayable` through `toArray()`, then normalize that result recursively so collections containing dates, enums, or DataObjects become plain arrays. +7. Leave any other object unchanged for PHP's normal JSON or caller behavior. + +Do not cache transformed output on the instance. Public properties may be mutated, so every result must reflect current state. Do not add cycle detection or depth machinery for unsupported cyclic value graphs. + +`jsonSerialize()` returns `toArray()`. `toJson()` follows current Support convention: + +```php +return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); +``` + +Let `JsonException` propagate. Do not add `__toString()`, pretty-JSON, field exclusions, custom serializers, mapping, partials, or transformers. + +## Documentation + +Update `src/docs/data-objects.md` in Laravel prose: + +- add `Lightweight Data Objects` to the contents and place the section after `Choosing a Base Class`; +- end `Choosing a Base Class` with a link to that section for trusted internal values that need only typed construction and array output; +- show a small internal message-envelope example using `Hypervel\Support\DataObject::from()`; +- explain that exact constructor names are keys, common PHP scalar/enum/date conversions and properties typed as a DataObject subclass are automatic, invalid scalar forms throw, and `toArray()`/JSON recursively normalize supported values; +- state that an `array` property does not infer or hydrate an item type, and show a named factory using `array_map(Item::from(...), $data['items'])` when an envelope needs a one-off list conversion; direct reusable typed collections to Data; +- direct external/request validation, reusable mapping, custom casts, partials, resources, collections, and persistence to `Data`, `Dto`, or `Resource` as appropriate; +- do not document recipe caching, integer kind tags, reflection layout, rejected APIs, benchmarks, or implementation history. + +Do not add a Support README difference or porting-guide entry. This is an additive Hypervel API with canonical user documentation, not an existing Laravel API that porters must adapt. + +Update `docs/todo.md` with the self-contained typed-input accessor work described above. Do not reference scratch-plan numbers or make Data adoption automatic. + +## Tests + +Add `tests/Support/DataObjectTest.php`, using a test-specific namespace for its helper classes and `Hypervel\Tests\TestCase`. Keep fixtures inline because only this test uses them. + +### Public behavior + +- `from()` constructs exact native values and ignores unknown input keys. +- An instance created through its public constructor transforms correctly before `from()` has compiled its recipe. +- Input and output use exact property names; snake-case aliases are not silently accepted. +- Constructor defaults, nullable missing values, missing required values, and explicit null follow the specified precedence. +- Two constructions with a promoted `new` object default receive distinct objects. +- Public promoted `readonly` properties work. +- Every subclass inherits the `Transient` lifetime marker. +- Direct mutation is visible in the next `toArray()` and JSON result. +- Nested DataObjects, arrays of DataObjects, associative keys, dates, enums, and another `Arrayable` normalize recursively. +- An array-typed property retains raw array items during construction rather than guessing an item type. +- `jsonSerialize()` equals `toArray()`; `toJson()` honors flags and throws for an unencodable value. + +### Conversion matrix + +Use data providers to cover every accepted and rejected built-in form, including: + +- integer whitespace, zero, negative values, whole native floats, fractional values, decimal strings, and text; +- float integers, decimals, scientific notation, and text; +- boolean native values, `1`/`0`, true/false, yes/no, on/off, case variants, empty string, `2`, and unrelated text; +- scalar and Stringable strings versus arrays and arbitrary objects; +- arrays versus scalar input. + +Assert failure messages identify the class, property, expected type, and supplied type or value without testing incidental stack details. + +### Object types + +- string- and integer-backed enums accept cases and valid backing values, including numeric strings; invalid values preserve `ValueError`. +- nested values accept arrays and existing instances. +- a nullable recursive node hydrates at least four repeated levels, proving no global visited suppression. +- an inherited constructor keeps a `self`-typed nested property bound to the class that declared the constructor. +- a concrete `parent`-typed nested property accepts an array and hydrates its declaring parent class. +- a non-null object union accepts a valid existing arm but does not hydrate an array into an arbitrary arm. +- an unknown object target is not container-resolved. + +### Dates + +Cover every old useful target category and the generic additions: + +- `DateTimeInterface` and `CarbonInterface` follow the configured Date factory; +- native mutable and immutable classes; +- Hypervel and base Carbon mutable and immutable classes; +- application subclasses of native DateTime and Carbon; +- existing date instances, database-formatted strings, date-only strings, actual integer/float timestamps, and the numeric date string `"20240101"`; +- timestamp conversion under a non-UTC PHP default timezone; +- invalid date strings; +- date serialization retains timezone offset through `DATE_ATOM`. + +Restore the configured Date factory in test cleanup through the repository's existing global cleanup, not a local duplicate reset. +The non-UTC timestamp test changes PHP's process-wide default timezone and must restore its previous value in a `finally` block; `AfterEachTestSubscriber` does not reset it. + +### Declaration boundaries + +- protected constructor supported through `from()`; +- non-promoted constructor parameter rejected; +- protected/private promoted parameter rejected; +- extra public instance property rejected; +- private/protected instance state allowed and absent from output; +- public static state allowed and absent from output; +- a child without its own constructor inherits its parent's public promoted parameters in declaration order. + +Do not test PHP compile-time failures for variadic promotion or readonly inheritance. + +Run `tests/Support/DataObjectTest.php` immediately after creating or changing it. + +## Benchmarks + +Use the frozen legacy fixture only during acceptance: + +1. Extend `tests/Benchmarks/Data/compare-data-object.php` temporarily to report three columns: removed mapper, rebuilt Support DataObject, and current Data. +2. Compare equivalent exact-key input and supported behavior. Do not credit the old implementation for stale cached output or permissive invalid conversion. +3. Measure all existing construction and correct uncached transformation shapes, 1,000-item loops, direct property reads, retained instances before and after transformation, retained metadata for one small class, and fresh-process first use. +4. Use the existing coercion and deep-nesting rows for strict scalar conversion and nested resolution, and add one construction and transformation row for arrays of DataObjects because that is the motivating per-item list shape. Defaults and application date subclasses remain correctness tests rather than benchmark scenarios. +5. Run at least three complete alternating samples while the machine is idle. Record median p50 and p95 results in the implementation summary and PR; describe the PHP, OPcache, and JIT conditions. +6. Retain the harness's existing native-constructor floor and current Data as the richer framework comparison. + +Acceptance requirements: + +- common valid construction and correct uncached transformation meet or improve the removed mapper within normal measurement noise; +- 1,000-item construction and transformation retain the meaningful throughput advantage needed by the motivating hot-loop use case; +- strict conversion does not add enough cost to erase that advantage; +- transformed instances retain no per-object property-table or output-cache allocation; +- recipe memory is naturally bounded per used class and does not materially exceed the removed mapper's metadata; +- first use performs only reflection, ordinary declaration autoloading when necessary, and recipe compilation, with no container, package-owned filesystem discovery, network, or PHPDoc work. + +Remove any specialization that does not earn its complexity or materially regresses p95 or retained memory. + +### Acceptance Results + +Acceptance benchmarks met the requirements above. The historical fixture has been removed, and the retained harness now compares the two supported APIs. + +After recording acceptance results: + +- delete `tests/Benchmarks/Data/Fixtures/DataObject.php` so known-defective production code is not retained as a permanent fixture; +- leave `tests/Benchmarks/Data/compare-data-object.php` as a two-column Support DataObject versus Data harness and update its class names, headings, cold modes, memory labels, and comments; +- update `tests/Benchmarks/Data/README.md` to describe the ongoing supported comparison with no historical-fixture wording. + +Do not add benchmark thresholds to PHPUnit. + +## File Changes + +| File | Change | +| --- | --- | +| `src/support/src/DataObject.php` | Add the complete lightweight mapper, conversion, transformation, recipe compilation, and cache reset. | +| `tests/Support/DataObjectTest.php` | Add supported behavior, failure, declaration, recursion, date, and serialization coverage. | +| `src/docs/data-objects.md` | Document the lightweight choice and its boundary from Hypervel Data. | +| `docs/todo.md` | Record the coherent typed-input accessor audit and conditional future scalar extraction. | +| `tests/Benchmarks/Data/compare-data-object.php` | Measure the new supported mapper against Data after the temporary legacy acceptance comparison. | +| `tests/Benchmarks/Data/README.md` | Describe the supported benchmark. | +| `tests/Benchmarks/Data/Fixtures/DataObject.php` | Delete after the legacy acceptance measurements are recorded. | + +No Composer, provider, alias, facade, contract, Foundation, Database, Saloon, Data package, or test-subscriber change is required. + +## Verification + +1. Run `./vendor/bin/phpunit --no-progress tests/Support/DataObjectTest.php` after every coherent test/source change. +2. Run `composer lint` to check formatting while iterating, and `composer lint:fix` when changed files need formatting. +3. Run targeted PHPStan for source investigation only if needed; tests are excluded from PHPStan. +4. Run the three-column acceptance benchmarks as specified, remove the legacy fixture, then rerun the final two-column harness. +5. Run `composer lint:fix`, `composer analyse`, and the affected Support test file at the completed implementation checkpoint. +6. Review every changed file and trace `from()`, recipe compilation, scalar/enum/date/nested conversion, transformation, and cache reset through all callers and failure paths. Check API naming, PHPDoc types, worker memory, coroutine safety, invalid declarations, defaults, inherited properties, and JSON behavior. +7. Remove dead branches, redundant comments, obsolete imports, temporary probes, legacy fixtures, and unearned optimizations. +8. Request a final code review and address findings with targeted tests. Repeat the full suite only if the review changes warrant it. + +## Rejected Designs + +- **Restore the old class:** retains verified bugs, worker-global switches, stale caches, awkward names, and overlapping extension points. +- **Use Data for the hot loop:** preserves one conceptual model but leaves measured per-item work that the use case does not need. +- **Add a Data fast-mode API:** exposes engine selection and still carries Data's broader public contract and state. +- **Reuse Data's `ValueCaster`:** reverses the Support-to-Data dependency and imports Data-specific metadata, context, exceptions, and permissive semantics. +- **Extract a shared scalar helper now:** has one strict consumer and would require partial, behavior-changing edits to Data or `InteractsWithData`. +- **Cache transformed arrays:** is incorrect for mutable public properties and increases retained instance memory. +- **Cache evaluated defaults or retain reflection:** risks shared mutable default objects or pays avoidable worker memory to reproduce PHP behavior. +- **Generate hydration closures or source code:** adds compile machinery and debugging cost for a compact recipe loop without evidence of a net win. +- **Add mapping, hooks, custom conversion, validation, or integration adapters:** recreates a second Data package rather than the requested internal mapper. +- **Add coroutine state or locking:** recipes are immutable, declaration-derived, bounded, and published by one assignment after classification, so a duplicate compile is harmless. From 65cae95ac6740751ef7f0830e3d621eb8fd076bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:15:33 +0000 Subject: [PATCH 07/14] Make lightweight data objects request castable Implement Foundation's existing RequestCastable contract directly on Support DataObject subclasses. A small Support-owned caster now converts already-validated arrays through the concrete object's from() method, preserves null values, and reports the affected request key for invalid input shapes.\n\nKeep the mapper's construction and transformation hot paths unchanged, reject unsupported cast arguments consistently with Hypervel Data, and cover concrete caster selection, null handling, and the inherited public contract. --- src/support/src/DataObject.php | 21 +++++++++- .../src/Http/DataObjectRequestCast.php | 42 +++++++++++++++++++ tests/Support/DataObjectTest.php | 25 +++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 src/support/src/Http/DataObjectRequestCast.php diff --git a/src/support/src/DataObject.php b/src/support/src/DataObject.php index bdca3d26a..d8605d08d 100644 --- a/src/support/src/DataObject.php +++ b/src/support/src/DataObject.php @@ -8,9 +8,12 @@ use Carbon\CarbonInterface; use DateTimeInterface; use Hypervel\Contracts\Container\Transient; +use Hypervel\Contracts\Http\CastsRequestInput; +use Hypervel\Contracts\Http\RequestCastable; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Http\DataObjectRequestCast; use InvalidArgumentException; use JsonException; use JsonSerializable; @@ -31,7 +34,7 @@ * * @implements Arrayable */ -abstract class DataObject implements Arrayable, Jsonable, JsonSerializable, Transient +abstract class DataObject implements Arrayable, Jsonable, JsonSerializable, RequestCastable, Transient { private const int KIND_PASSTHROUGH = 0; @@ -101,6 +104,22 @@ public static function from(array $data): static return new static(...$arguments); } + /** + * Get the caster to use for validated request input. + * + * @param string[] $arguments + */ + public static function castRequestUsing(array $arguments): CastsRequestInput + { + if ($arguments !== []) { + throw new InvalidArgumentException( + 'Data object request cast [' . static::class . '] does not accept arguments.', + ); + } + + return new DataObjectRequestCast(static::class); + } + /** * Convert the data object to an array. */ diff --git a/src/support/src/Http/DataObjectRequestCast.php b/src/support/src/Http/DataObjectRequestCast.php new file mode 100644 index 000000000..86623bbb7 --- /dev/null +++ b/src/support/src/Http/DataObjectRequestCast.php @@ -0,0 +1,42 @@ + $dataObjectClass + */ + public function __construct(protected readonly string $dataObjectClass) + { + } + + /** + * Transform validated request input into a data object. + */ + public function cast(string $key, mixed $value, array $input): ?DataObject + { + if ($value === null) { + return null; + } + + if (! is_array($value)) { + throw new InvalidArgumentException(sprintf( + 'Cannot cast request input [%s] to data object [%s]: expected array, received %s.', + $key, + $this->dataObjectClass, + get_debug_type($value), + )); + } + + return ($this->dataObjectClass)::from($value); + } +} diff --git a/tests/Support/DataObjectTest.php b/tests/Support/DataObjectTest.php index e0193644e..646cd63e3 100644 --- a/tests/Support/DataObjectTest.php +++ b/tests/Support/DataObjectTest.php @@ -13,11 +13,13 @@ use DateTimeImmutable; use DateTimeInterface; use Hypervel\Contracts\Container\Transient; +use Hypervel\Contracts\Http\RequestCastable; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\DataObject; use Hypervel\Support\DateFactory; +use Hypervel\Support\Http\DataObjectRequestCast; use Hypervel\Tests\TestCase; use InvalidArgumentException; use JsonException; @@ -112,6 +114,29 @@ public function testDataObjectsAreAlwaysTransient(): void $this->assertInstanceOf(Transient::class, RequiredDataObject::from(['name' => 'Taylor'])); } + public function testDataObjectsProvideARequestCasterForTheirConcreteClass(): void + { + $caster = RequiredDataObject::castRequestUsing([]); + + $this->assertInstanceOf(RequestCastable::class, RequiredDataObject::from(['name' => 'Taylor'])); + $this->assertInstanceOf(DataObjectRequestCast::class, $caster); + $this->assertEquals( + RequiredDataObject::from(['name' => 'Taylor']), + $caster->cast('contact', ['name' => 'Taylor'], []), + ); + $this->assertNull($caster->cast('contact', null, [])); + } + + public function testDataObjectRequestCastsRejectArguments(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Data object request cast [' . RequiredDataObject::class . '] does not accept arguments.', + ); + + RequiredDataObject::castRequestUsing(['unsupported']); + } + public function testDirectConstructionTransformsBeforeRecipeCompilation(): void { DirectConstructionDataObject::flushState(); From cccea77429f973896266ad779ab8af736614e834 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:15:41 +0000 Subject: [PATCH 08/14] Cover lightweight FormRequest data object casts Exercise Support DataObject classes through FormRequest's generic cast pipeline. The coverage verifies exact and wildcard declarations, sparse list-key preservation, strict scalar conversion, nullable values, safe extraction, and unchanged raw request input.\n\nAlso pin the clear failure for a validated non-array value so request and cast rule drift identifies the affected input and target class. --- .../Http/FormRequestCastingTest.php | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/Foundation/Http/FormRequestCastingTest.php b/tests/Foundation/Http/FormRequestCastingTest.php index 5d6c8e3c0..35cbc87c4 100644 --- a/tests/Foundation/Http/FormRequestCastingTest.php +++ b/tests/Foundation/Http/FormRequestCastingTest.php @@ -16,6 +16,7 @@ use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; +use Hypervel\Support\DataObject; use Hypervel\Support\Exceptions\MathException; use Hypervel\Support\Facades\Date; use Hypervel\Support\Json; @@ -649,6 +650,48 @@ public function testCastsWithCustomClasses(): void $this->assertEquals(new Money(90, 'GBP', 'object_price'), $validated['object_price']); } + /** + * Test lightweight data objects cast exact and wildcard validated input. + */ + public function testCastsValidatedInputToLightweightDataObjects(): void + { + $request = $this->validateRequest(DataObjectRequest::class, [ + 'contact' => ['name' => 'Taylor', 'age' => '37'], + 'contacts' => [ + 2 => ['name' => 'Abigail', 'age' => 31], + 5 => ['name' => 'Dayle', 'age' => '29'], + ], + 'nullable_contact' => null, + ]); + $validated = $request->validated(); + + $this->assertEquals(new ContactDataObject('Taylor', 37), $validated['contact']); + $this->assertSame([2, 5], array_keys($validated['contacts'])); + $this->assertEquals(new ContactDataObject('Abigail', 31), $validated['contacts'][2]); + $this->assertEquals(new ContactDataObject('Dayle', 29), $validated['contacts'][5]); + $this->assertNull($validated['nullable_contact']); + $this->assertEquals(new ContactDataObject('Taylor', 37), $request->safe()->input('contact')); + $this->assertIsArray($request->input('contact')); + } + + /** + * Test lightweight data object casts require array input. + */ + public function testDataObjectCastsRejectNonArrayInput(): void + { + $request = $this->validateRequest(InvalidDataObjectRequest::class, [ + 'contact' => 'Taylor', + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Cannot cast request input [contact] to data object [' . ContactDataObject::class + . ']: expected array, received string.', + ); + + $request->validated(); + } + /** * Test missing custom inputs do not resolve their caster. */ @@ -1144,6 +1187,41 @@ public function rules(): array } } +class DataObjectRequest extends FormRequest +{ + protected function casts(): array + { + return [ + 'contact' => ContactDataObject::class, + 'contacts.*' => ContactDataObject::class, + 'nullable_contact' => ContactDataObject::class, + ]; + } + + public function rules(): array + { + return [ + 'contact' => ['required', 'array'], + 'contacts' => ['required', 'array'], + 'contacts.*' => ['required', 'array'], + 'nullable_contact' => ['nullable', 'array'], + ]; + } +} + +class InvalidDataObjectRequest extends FormRequest +{ + protected function casts(): array + { + return ['contact' => ContactDataObject::class]; + } + + public function rules(): array + { + return ['contact' => ['required', 'string']]; + } +} + class CountingRequest extends FormRequest { protected function casts(): array @@ -1242,6 +1320,15 @@ public function __construct( } } +class ContactDataObject extends DataObject +{ + public function __construct( + public string $name, + public int $age, + ) { + } +} + class MoneyCast implements CastsRequestInput { public function __construct(protected readonly string $currency) From 492c34af1117d00da9df436692c244d5ffb521ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:15:48 +0000 Subject: [PATCH 09/14] Document FormRequest casting for data objects Show how a FormRequest may return lightweight DataObject instances after it validates the submitted arrays. Document direct casts for one object and wildcard casts for lists, while keeping object-owned validation, mapping, resources, and collection abstractions with the full Data package.\n\nCross-link the data-object and validation guides so developers can choose the appropriate object type without restoring the removed casted() API or Foundation-specific adapters. --- src/docs/data-objects.md | 16 ++++++++++++++++ src/docs/validation.md | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 2fbeb3cf8..014358eb2 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -100,6 +100,20 @@ Constructor property names are the exact input and output keys. Unknown input ke Common integer, float, boolean, and string representations are converted strictly. Backed enums, dates, and properties typed as a concrete `DataObject` are also converted. Invalid scalar values throw an `InvalidArgumentException` instead of being silently coerced. Use an application named factory when an external payload needs different names or custom conversion. +When a form request owns validation, you may declare a lightweight data object directly in its `casts` method. Use a wildcard to convert each member of a validated list: + +```php +protected function casts(): array +{ + return [ + 'contact' => Contact::class, + 'contacts.*' => Contact::class, + ]; +} +``` + +The request returns a `Contact` from `validated('contact')` and an array of `Contact` objects from `validated('contacts')`. The DataObject does not run another validation step. + The `toArray` and `toJson` methods recursively normalize nested data objects, backed enums, dates, and `Arrayable` values. Public properties remain ordinary PHP properties and may be read or changed directly unless they are declared `readonly`. An `array` property retains its input items as-is during construction. Convert a one-off list explicitly: @@ -827,6 +841,8 @@ class StoreUserRequest extends FormRequest The object is built through its normal `from()` pipeline. A present `null` remains `null`, and a missing input is not added to the validated result. Direct `Data`, `Dto`, and `Resource` request casts do not accept cast arguments; Eloquent-only options such as `default` and `encrypted` do not apply here. +A [lightweight data object](#lightweight-data-objects) may also be declared directly when the form request owns validation and you only need typed construction and array or JSON output. + Use `AsDataCollection::of()` when the input contains several objects. It returns a `DataCollection` by default and accepts the same explicit targets as `collect()`, including `'array'` and `Hypervel\Support\Collection::class`: ```php diff --git a/src/docs/validation.md b/src/docs/validation.md index 1171a2e18..0a0f135a7 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -836,6 +836,22 @@ protected function casts(): array Direct `Data`, `Dto`, and `Resource` request casts do not accept cast arguments. Eloquent-only options such as `default` and `encrypted` do not apply to validated request input. +You may also declare a `Hypervel\Support\DataObject` subclass when the form request owns validation and you only need lightweight typed construction: + +```php +use App\Values\Contact; + +protected function casts(): array +{ + return [ + 'contact' => Contact::class, + 'contacts.*' => Contact::class, + ]; +} +``` + +The wildcard applies the cast to each validated list member, so `validated('contacts')` returns an array of `Contact` objects. Lightweight data objects do not accept cast arguments or run another validation step. See the [data object documentation](/docs/{{version}}/data-objects#lightweight-data-objects) for guidance on choosing between these objects and Hypervel Data. + #### Custom Casts A custom request caster implements the `CastsRequestInput` contract. Its `cast` method receives the concrete input key, its validated value, and the complete original validated input array: From fc5396d0dafa4ec546bd0365dc1c682a637e6eb4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:15:55 +0000 Subject: [PATCH 10/14] Update the lightweight data object plan Record the final request-casting design, ownership boundary, public contract, documentation, tests, file changes, and verification steps. Replace the earlier blanket rejection of integration adapters with the narrow use of Foundation's existing generic RequestCastable extension point.\n\nKeep validation and richer object behavior in Hypervel Data while documenting that already-validated arrays may become lightweight DataObject instances without a Foundation special case. --- ...2026-09-05-1421-lightweight-data-object.md | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-09-05-1421-lightweight-data-object.md b/docs/plans/2026-09-05-1421-lightweight-data-object.md index eb44a055f..c231e5094 100644 --- a/docs/plans/2026-09-05-1421-lightweight-data-object.md +++ b/docs/plans/2026-09-05-1421-lightweight-data-object.md @@ -7,8 +7,9 @@ Add a small `Hypervel\Support\DataObject` for trusted internal message envelopes This is a complement to `Hypervel\Data`, not a second version of it: - `DataObject` maps one array into public promoted constructor properties and converts common PHP value types. -- `Data`, `Dto`, and `Resource` remain the choices for validation, input or output name mapping, contextual values, custom casts and transformers, lazy values, partials, collections, HTTP resources, persistence, FormRequest, Inertia, and Saloon integration. -- There is no public fast mode, feature flag, configuration, container service, provider, or integration layer. +- `Data`, `Dto`, and `Resource` remain the choices for object-owned validation, input or output name mapping, contextual values, custom casts and transformers, lazy values, partials, collections, HTTP resources, persistence, Inertia, and Saloon integration. +- `DataObject` may receive input already validated by a FormRequest through Foundation's generic request-cast contract. It does not add validation or a Foundation special case. +- There is no public fast mode, feature flag, configuration, container service, provider, or bespoke integration layer. The API is new for Hypervel 0.4. Do not preserve earlier Hypervel-specific methods or behavior merely for compatibility. @@ -55,15 +56,19 @@ declare(strict_types=1); namespace Hypervel\Support; use Hypervel\Contracts\Container\Transient; +use Hypervel\Contracts\Http\CastsRequestInput; +use Hypervel\Contracts\Http\RequestCastable; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use JsonSerializable; /** @implements Arrayable */ -abstract class DataObject implements Arrayable, Jsonable, JsonSerializable, Transient +abstract class DataObject implements Arrayable, Jsonable, JsonSerializable, RequestCastable, Transient { public static function from(array $data): static; + public static function castRequestUsing(array $arguments): CastsRequestInput; + public function toArray(): array; public function jsonSerialize(): array; @@ -124,6 +129,27 @@ A child with no constructor inherits its parent's promoted properties and constr `from()` is the only base construction method. Do not add the old `make()` alias, `autoResolve` argument, `update()`, `refresh()`, or `ArrayAccess`. Direct public-property access is the normal PHP API, while application classes may define meaningful named factories. +### Form request casting + +Implement Foundation's existing `RequestCastable` extension contract directly on `DataObject`. `castRequestUsing()` rejects declaration arguments and returns a fresh `Hypervel\Support\Http\DataObjectRequestCast` configured for `static::class`. The Support-owned caster implements only `CastsRequestInput`, preserves `null`, passes an array to the concrete class's `from()` method, and fails clearly with the input key when a present value is not an array. It contains no cache or mutable state. + +This keeps Foundation generic and adds no Support dependency on Foundation or Data. A FormRequest can cast one validated object or each member of a validated list through its existing exact and wildcard paths: + +```php +protected function casts(): array +{ + return [ + 'contact' => Contact::class, + 'contacts.*' => Contact::class, + ]; +} + +$contact = $request->validated('contact'); +$contacts = $request->validated('contacts'); +``` + +The first result is a `Contact`; the second is an array whose members are `Contact` objects. Validation still runs on submitted arrays before request casting. Do not restore `casted()`, Foundation DataObject detection, `AsDataObjectArray`, or `AsDataObjectCollection`; the current `validated()` / `safe()` APIs and wildcard cast walker provide the same outcomes through one general extension point. + ## Worker-Cached Recipes Store one private static recipe map keyed by concrete class: @@ -263,9 +289,10 @@ Update `src/docs/data-objects.md` in Laravel prose: - add `Lightweight Data Objects` to the contents and place the section after `Choosing a Base Class`; - end `Choosing a Base Class` with a link to that section for trusted internal values that need only typed construction and array output; - show a small internal message-envelope example using `Hypervel\Support\DataObject::from()`; +- show that a FormRequest may declare a DataObject class directly in `casts()`, including the `items.*` wildcard form for lists, when request rules own validation; - explain that exact constructor names are keys, common PHP scalar/enum/date conversions and properties typed as a DataObject subclass are automatic, invalid scalar forms throw, and `toArray()`/JSON recursively normalize supported values; - state that an `array` property does not infer or hydrate an item type, and show a named factory using `array_map(Item::from(...), $data['items'])` when an envelope needs a one-off list conversion; direct reusable typed collections to Data; -- direct external/request validation, reusable mapping, custom casts, partials, resources, collections, and persistence to `Data`, `Dto`, or `Resource` as appropriate; +- direct object-owned validation, reusable mapping, custom casts, partials, resources, collection abstractions, and persistence to `Data`, `Dto`, or `Resource` as appropriate; - do not document recipe caching, integer kind tags, reflection layout, rejected APIs, benchmarks, or implementation history. Do not add a Support README difference or porting-guide entry. This is an additive Hypervel API with canonical user documentation, not an existing Laravel API that porters must adapt. @@ -285,6 +312,7 @@ Add `tests/Support/DataObjectTest.php`, using a test-specific namespace for its - Two constructions with a promoted `new` object default receive distinct objects. - Public promoted `readonly` properties work. - Every subclass inherits the `Transient` lifetime marker. +- Every subclass is request-castable without arguments and returns a caster for that concrete subclass; cast arguments fail clearly. - Direct mutation is visible in the next `toArray()` and JSON result. - Nested DataObjects, arrays of DataObjects, associative keys, dates, enums, and another `Arrayable` normalize recursively. - An array-typed property retains raw array items during construction rather than guessing an item type. @@ -342,6 +370,8 @@ Do not test PHP compile-time failures for variadic promotion or readonly inherit Run `tests/Support/DataObjectTest.php` immediately after creating or changing it. +Extend `tests/Foundation/Http/FormRequestCastingTest.php` with public-behavior coverage that proves a direct `DataObject` declaration converts one already-validated array, `contacts.*` converts every member while preserving list keys, `null` remains null, invalid non-array input fails clearly, and `validated()` / `safe()` expose the converted values through the existing APIs. Keep the fixtures local to that test file and run it immediately after changing it. No Foundation source change is required. + ## Benchmarks Use the frozen legacy fixture only during acceptance: @@ -381,18 +411,21 @@ Do not add benchmark thresholds to PHPUnit. | File | Change | | --- | --- | | `src/support/src/DataObject.php` | Add the complete lightweight mapper, conversion, transformation, recipe compilation, and cache reset. | +| `src/support/src/Http/DataObjectRequestCast.php` | Adapt one already-validated array to a configured lightweight DataObject class. | | `tests/Support/DataObjectTest.php` | Add supported behavior, failure, declaration, recursion, date, and serialization coverage. | +| `tests/Foundation/Http/FormRequestCastingTest.php` | Cover direct and wildcard lightweight DataObject request casts. | | `src/docs/data-objects.md` | Document the lightweight choice and its boundary from Hypervel Data. | +| `src/docs/validation.md` | Document lightweight DataObject declarations in FormRequest casts. | | `docs/todo.md` | Record the coherent typed-input accessor audit and conditional future scalar extraction. | | `tests/Benchmarks/Data/compare-data-object.php` | Measure the new supported mapper against Data after the temporary legacy acceptance comparison. | | `tests/Benchmarks/Data/README.md` | Describe the supported benchmark. | | `tests/Benchmarks/Data/Fixtures/DataObject.php` | Delete after the legacy acceptance measurements are recorded. | -No Composer, provider, alias, facade, contract, Foundation, Database, Saloon, Data package, or test-subscriber change is required. +No Composer, provider, alias, facade, contract, Foundation source, Database, Saloon, Data package, or test-subscriber change is required. ## Verification -1. Run `./vendor/bin/phpunit --no-progress tests/Support/DataObjectTest.php` after every coherent test/source change. +1. Run `./vendor/bin/phpunit --no-progress tests/Support/DataObjectTest.php` after every coherent Support test/source change, and run `./vendor/bin/phpunit --no-progress tests/Foundation/Http/FormRequestCastingTest.php` immediately after changing that test. 2. Run `composer lint` to check formatting while iterating, and `composer lint:fix` when changed files need formatting. 3. Run targeted PHPStan for source investigation only if needed; tests are excluded from PHPStan. 4. Run the three-column acceptance benchmarks as specified, remove the legacy fixture, then rerun the final two-column harness. @@ -411,5 +444,5 @@ No Composer, provider, alias, facade, contract, Foundation, Database, Saloon, Da - **Cache transformed arrays:** is incorrect for mutable public properties and increases retained instance memory. - **Cache evaluated defaults or retain reflection:** risks shared mutable default objects or pays avoidable worker memory to reproduce PHP behavior. - **Generate hydration closures or source code:** adds compile machinery and debugging cost for a compact recipe loop without evidence of a net win. -- **Add mapping, hooks, custom conversion, validation, or integration adapters:** recreates a second Data package rather than the requested internal mapper. +- **Add mapping, hooks, custom conversion, validation, or bespoke integration adapters:** recreates a second Data package rather than the requested internal mapper. Implementing the existing generic `RequestCastable` contract is deliberately narrower: FormRequest owns validation and the adapter only calls `from()`. - **Add coroutine state or locking:** recipes are immutable, declaration-derived, bounded, and published by one assignment after classification, so a duplicate compile is harmless. From 23af5e770be489db9ecd0d35925acfed3fd6fb57 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:53:42 +0000 Subject: [PATCH 11/14] Reject fractional integer enum values Require float-like inputs for integer-backed enums to be finite, integral, and within the platform integer range before conversion. This prevents fractional request or object values from silently selecting a truncated enum case while retaining integral decimal and exponent forms. Cover the shared helper together with the public DataObject and validation boundaries so downstream consumers inherit the corrected behavior without duplicate test matrices. --- src/collections/src/functions.php | 5 +++-- tests/Support/DataObjectTest.php | 17 +++++++++++++++ tests/Support/SupportEnumFunctionsTest.php | 6 +++++- tests/Validation/ValidationEnumRuleTest.php | 23 +++++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/collections/src/functions.php b/src/collections/src/functions.php index 85cd84ef6..20fb9f9a3 100644 --- a/src/collections/src/functions.php +++ b/src/collections/src/functions.php @@ -53,9 +53,10 @@ function enum_try_from(string $enum, mixed $value): ?BackedEnum return $enum::tryFrom($value); } - // PHP's own coercion accepts the negative boundary because -2^63 is exactly - // representable as a float, and rejects the positive one and non-finite floats. + // PHP_INT_MIN is exactly representable as a float, while PHP_INT_MAX rounds up. + // Reject non-finite, fractional, and positive-boundary values before casting. return is_float($value) && is_finite($value) + && $value === floor($value) && $value >= (float) PHP_INT_MIN && $value < (float) PHP_INT_MAX ? $enum::tryFrom((int) $value) : null; diff --git a/tests/Support/DataObjectTest.php b/tests/Support/DataObjectTest.php index 646cd63e3..e1f67fddc 100644 --- a/tests/Support/DataObjectTest.php +++ b/tests/Support/DataObjectTest.php @@ -337,6 +337,23 @@ public function testBackedEnumsAcceptCasesAndBackingValues(): void $this->assertSame(DataObjectIntegerStatus::Ready, $converted->integerStatus); } + #[DataProvider('fractionalIntegerBackedEnumProvider')] + public function testIntegerBackedEnumsRejectFractionalValues(mixed $value): void + { + $this->expectException(ValueError::class); + + EnumDataObject::from([ + 'stringStatus' => 'ready', + 'integerStatus' => $value, + ]); + } + + public static function fractionalIntegerBackedEnumProvider(): iterable + { + yield 'float' => [1.5]; + yield 'numeric string' => ['1.5']; + } + public function testInvalidBackedEnumValuePreservesValueError(): void { $this->expectException(ValueError::class); diff --git a/tests/Support/SupportEnumFunctionsTest.php b/tests/Support/SupportEnumFunctionsTest.php index fc78207a2..785983c93 100644 --- a/tests/Support/SupportEnumFunctionsTest.php +++ b/tests/Support/SupportEnumFunctionsTest.php @@ -66,7 +66,11 @@ public static function backedEnumDataProvider(): iterable yield 'integer' => [TestBackedEnum::class, 1, TestBackedEnum::A]; yield 'numeric string' => [TestBackedEnum::class, '1', TestBackedEnum::A]; yield 'trimmed numeric string' => [TestBackedEnum::class, ' 2 ', TestBackedEnum::B]; - yield 'float' => [TestBackedEnum::class, 1.5, TestBackedEnum::A]; + yield 'integral float' => [TestBackedEnum::class, 1.0, TestBackedEnum::A]; + yield 'integral decimal string' => [TestBackedEnum::class, '1.0', TestBackedEnum::A]; + yield 'integral exponent string' => [TestBackedEnum::class, '1e0', TestBackedEnum::A]; + yield 'fractional float' => [TestBackedEnum::class, 1.5, null]; + yield 'fractional numeric string' => [TestBackedEnum::class, '1.5', null]; yield 'boolean' => [TestBackedEnum::class, true, TestBackedEnum::A]; yield 'matching instance' => [TestBackedEnum::class, TestBackedEnum::B, TestBackedEnum::B]; yield 'maximum integer string' => [SupportIntegerDomainEnum::class, '9223372036854775807', SupportIntegerDomainEnum::Max]; diff --git a/tests/Validation/ValidationEnumRuleTest.php b/tests/Validation/ValidationEnumRuleTest.php index e2edab53a..e775cad13 100644 --- a/tests/Validation/ValidationEnumRuleTest.php +++ b/tests/Validation/ValidationEnumRuleTest.php @@ -222,6 +222,29 @@ public function testValidationPassesWhenProvidingDifferentTypeThatIsCastableToTh $this->assertFalse($v->fails()); } + #[DataProvider('numericIntegerEnumProvider')] + public function testValidationOnlyAcceptsIntegralNumericValuesForIntegerBackedEnums( + mixed $value, + bool $expected + ): void { + $validator = new Validator( + $this->app->make('translator'), + ['status' => $value], + ['status' => new Enum(IntegerStatus::class)], + ); + + $this->assertSame($expected, $validator->passes()); + } + + public static function numericIntegerEnumProvider(): iterable + { + yield 'integral float' => [1.0, true]; + yield 'integral decimal string' => ['1.0', true]; + yield 'integral exponent string' => ['1e0', true]; + yield 'fractional float' => [1.5, false]; + yield 'fractional numeric string' => ['1.5', false]; + } + public function testValidationFailsWhenProvidingNull(): void { $v = new Validator( From d8f66906c79cb8bd2c009c6f4e67c4d00c72a4b6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:53:47 +0000 Subject: [PATCH 12/14] Document integral enum conversion Clarify the integer-backed enum input contract for lightweight and full Data objects, FormRequest casting, and validation. Integral numeric values remain supported, while fractional values are rejected instead of being truncated to another case. --- src/docs/data-objects.md | 4 ++++ src/docs/validation.md | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/docs/data-objects.md b/src/docs/data-objects.md index 014358eb2..c260a65c0 100644 --- a/src/docs/data-objects.md +++ b/src/docs/data-objects.md @@ -100,6 +100,8 @@ Constructor property names are the exact input and output keys. Unknown input ke Common integer, float, boolean, and string representations are converted strictly. Backed enums, dates, and properties typed as a concrete `DataObject` are also converted. Invalid scalar values throw an `InvalidArgumentException` instead of being silently coerced. Use an application named factory when an external payload needs different names or custom conversion. +Integer-backed enums accept integral numeric values such as `1`, `"1.0"`, and `"1e0"`. Fractional values are rejected instead of being truncated to an enum case. + When a form request owns validation, you may declare a lightweight data object directly in its `casts` method. Use a wildcard to convert each member of a validated list: ```php @@ -549,6 +551,8 @@ $order->status === OrderStatus::Paid; // true ``` +Integer-backed enums accept integral numeric values, including decimal and exponent strings such as `"1.0"` and `"1e0"`. Fractional values are rejected instead of being truncated to an enum case. + ## Casts and Transformers diff --git a/src/docs/validation.md b/src/docs/validation.md index 0a0f135a7..34f293fa9 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -777,7 +777,7 @@ protected function casts(): array } ``` -Integer-backed enums accept the numeric strings normally submitted by forms and JSON clients. Existing matching enum cases are preserved. +Integer-backed enums accept integral numeric values, including decimal and exponent strings such as `"1.0"` and `"1e0"`. Fractional values are rejected instead of being truncated to an enum case. Existing matching enum cases are preserved. Use a wildcard cast for an ordinary enum array. Use `AsEnumCollection::of()` when you want a Support collection instead: @@ -2021,6 +2021,8 @@ $request->validate([ ]); ``` +For integer-backed enums, integral numeric values are valid while fractional values are rejected instead of being truncated to an enum case. + The `Enum` rule's `only` and `except` methods may be used to limit which enum cases should be considered valid: ```php From 73d4aa93fdba3a2aeea7887725b31d0aefaf7cb9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:53:52 +0000 Subject: [PATCH 13/14] Align data object date benchmarks Feed Support DataObject and full Data the same ISO timestamp in the date and mixed-payload scenarios. This keeps the benchmark focused on framework cost instead of comparing different DateTimeImmutable parse inputs. --- tests/Benchmarks/Data/compare-data-object.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Benchmarks/Data/compare-data-object.php b/tests/Benchmarks/Data/compare-data-object.php index 4fe6b5a35..ec7ed4984 100644 --- a/tests/Benchmarks/Data/compare-data-object.php +++ b/tests/Benchmarks/Data/compare-data-object.php @@ -497,13 +497,13 @@ function execute(): void $nested = ['child' => $leaf, 'id' => 2, 'name' => 'nested', 'active' => true, 'note' => null]; $deep = ['child' => ['child' => $leaf, 'id' => 2, 'name' => 'middle'], 'id' => 3, 'name' => 'deep']; $enum = ['id' => 1, 'status' => 'active']; - $dataObjectDate = ['id' => 1, 'createdAt' => '2026-09-04 12:34:56']; + $dataObjectDate = ['id' => 1, 'createdAt' => '2026-09-04T12:34:56+00:00']; $dataDate = ['id' => 1, 'created_at' => '2026-09-04T12:34:56+00:00']; $dataObjectMixed = [ 'externalId' => '9', 'displayName' => 123, 'status' => 'active', - 'createdAt' => '2026-09-04 12:34:56', + 'createdAt' => '2026-09-04T12:34:56+00:00', 'child' => ['id' => '1', 'code' => 456, 'enabled' => 1, 'score' => '9.5'], ]; $dataMixed = [ From 4dfc2e6077e2bf3e99e8cc069d1ece0024ca1d1e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:53:58 +0000 Subject: [PATCH 14/14] Update the lightweight data object plan Record the shared integer-backed enum conversion rule, its tests and documentation, and the identical timestamp requirement for fair date benchmarks. Keep the plan aligned with the final reviewed implementation and verification boundary. --- .../2026-09-05-1421-lightweight-data-object.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-09-05-1421-lightweight-data-object.md b/docs/plans/2026-09-05-1421-lightweight-data-object.md index c231e5094..65b2e4149 100644 --- a/docs/plans/2026-09-05-1421-lightweight-data-object.md +++ b/docs/plans/2026-09-05-1421-lightweight-data-object.md @@ -220,6 +220,8 @@ For a concrete backed-enum declaration: - let its `ValueError` propagate for an invalid backing value; - preserve valid numeric-string support for integer-backed enums. +The shared `enum_try_from()` helper must reject fractional values for integer-backed enums instead of silently truncating them. Its float branch accepts a value only when it is finite, within the platform integer range, and equal to its floored value. This keeps lossless integral forms such as `1.0`, `"1.0"`, and `"1e0"` while rejecting `1.5` and `"1.5"` consistently in DataObject, Validation, FormRequest casts, Eloquent casts, Data casts, collections, and typed Support accessors. Keep this rule at the shared helper rather than adding a DataObject-only check. + Unit enums and enum interfaces have no scalar construction rule. They use pass-through behavior and PHP enforces their declared type. ### Nested DataObjects @@ -293,6 +295,7 @@ Update `src/docs/data-objects.md` in Laravel prose: - explain that exact constructor names are keys, common PHP scalar/enum/date conversions and properties typed as a DataObject subclass are automatic, invalid scalar forms throw, and `toArray()`/JSON recursively normalize supported values; - state that an `array` property does not infer or hydrate an item type, and show a named factory using `array_map(Item::from(...), $data['items'])` when an envelope needs a one-off list conversion; direct reusable typed collections to Data; - direct object-owned validation, reusable mapping, custom casts, partials, resources, collection abstractions, and persistence to `Data`, `Dto`, or `Resource` as appropriate; +- document the shared integer-backed enum rule in the existing Data and validation enum sections; - do not document recipe caching, integer kind tags, reflection layout, rejected APIs, benchmarks, or implementation history. Do not add a Support README difference or porting-guide entry. This is an additive Hypervel API with canonical user documentation, not an existing Laravel API that porters must adapt. @@ -332,7 +335,7 @@ Assert failure messages identify the class, property, expected type, and supplie ### Object types -- string- and integer-backed enums accept cases and valid backing values, including numeric strings; invalid values preserve `ValueError`. +- string- and integer-backed enums accept cases and valid backing values, including integral numeric strings; fractional integer-enum values are rejected without truncation and invalid values preserve `ValueError`. - nested values accept arrays and existing instances. - a nullable recursive node hydrates at least four repeated levels, proving no global visited suppression. - an inherited constructor keeps a `self`-typed nested property bound to the class that declared the constructor. @@ -377,7 +380,7 @@ Extend `tests/Foundation/Http/FormRequestCastingTest.php` with public-behavior c Use the frozen legacy fixture only during acceptance: 1. Extend `tests/Benchmarks/Data/compare-data-object.php` temporarily to report three columns: removed mapper, rebuilt Support DataObject, and current Data. -2. Compare equivalent exact-key input and supported behavior. Do not credit the old implementation for stale cached output or permissive invalid conversion. +2. Compare equivalent exact-key input and supported behavior. Use the same ISO timestamp value for both supported APIs so date parsing rows isolate framework cost. Do not credit the old implementation for stale cached output or permissive invalid conversion. 3. Measure all existing construction and correct uncached transformation shapes, 1,000-item loops, direct property reads, retained instances before and after transformation, retained metadata for one small class, and fresh-process first use. 4. Use the existing coercion and deep-nesting rows for strict scalar conversion and nested resolution, and add one construction and transformation row for arrays of DataObjects because that is the motivating per-item list shape. Defaults and application date subclasses remain correctness tests rather than benchmark scenarios. 5. Run at least three complete alternating samples while the machine is idle. Record median p50 and p95 results in the implementation summary and PR; describe the PHP, OPcache, and JIT conditions. @@ -410,22 +413,25 @@ Do not add benchmark thresholds to PHPUnit. | File | Change | | --- | --- | +| `src/collections/src/functions.php` | Reject fractional values before converting an integer-backed enum while retaining integral numeric forms. | | `src/support/src/DataObject.php` | Add the complete lightweight mapper, conversion, transformation, recipe compilation, and cache reset. | | `src/support/src/Http/DataObjectRequestCast.php` | Adapt one already-validated array to a configured lightweight DataObject class. | | `tests/Support/DataObjectTest.php` | Add supported behavior, failure, declaration, recursion, date, and serialization coverage. | +| `tests/Support/SupportEnumFunctionsTest.php` | Pin lossless integer-backed enum conversion at the shared helper. | | `tests/Foundation/Http/FormRequestCastingTest.php` | Cover direct and wildcard lightweight DataObject request casts. | -| `src/docs/data-objects.md` | Document the lightweight choice and its boundary from Hypervel Data. | -| `src/docs/validation.md` | Document lightweight DataObject declarations in FormRequest casts. | +| `tests/Validation/ValidationEnumRuleTest.php` | Reject fractional integer-backed enum values during validation. | +| `src/docs/data-objects.md` | Document the lightweight choice, its boundary from Hypervel Data, and integral integer-backed enum conversion. | +| `src/docs/validation.md` | Document lightweight DataObject declarations in FormRequest casts and integral integer-backed enum validation. | | `docs/todo.md` | Record the coherent typed-input accessor audit and conditional future scalar extraction. | | `tests/Benchmarks/Data/compare-data-object.php` | Measure the new supported mapper against Data after the temporary legacy acceptance comparison. | | `tests/Benchmarks/Data/README.md` | Describe the supported benchmark. | | `tests/Benchmarks/Data/Fixtures/DataObject.php` | Delete after the legacy acceptance measurements are recorded. | -No Composer, provider, alias, facade, contract, Foundation source, Database, Saloon, Data package, or test-subscriber change is required. +No Composer, provider, alias, facade, contract, Foundation source, Database source, Saloon, Data source, or test-subscriber change is required. The only shared production correction is in the Collections-owned enum helper. ## Verification -1. Run `./vendor/bin/phpunit --no-progress tests/Support/DataObjectTest.php` after every coherent Support test/source change, and run `./vendor/bin/phpunit --no-progress tests/Foundation/Http/FormRequestCastingTest.php` immediately after changing that test. +1. Run each changed test file immediately. Then run the existing enum consumer tests in Support, Collections, Validation, Foundation, Database, and Data together so the shared behavior change is checked at every direct framework boundary without duplicating the same regression assertion in each suite. 2. Run `composer lint` to check formatting while iterating, and `composer lint:fix` when changed files need formatting. 3. Run targeted PHPStan for source investigation only if needed; tests are excluded from PHPStan. 4. Run the three-column acceptance benchmarks as specified, remove the legacy fixture, then rerun the final two-column harness.