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. 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..65b2e4149 --- /dev/null +++ b/docs/plans/2026-09-05-1421-lightweight-data-object.md @@ -0,0 +1,454 @@ +# 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 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. + +## 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, RequestCastable, Transient +{ + public static function from(array $data): static; + + public static function castRequestUsing(array $arguments): CastsRequestInput; + + 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. + +### 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: + +```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. + +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 + +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()`; +- 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 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. + +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. +- 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. +- `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 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. +- 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. + +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: + +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. 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. +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/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. | +| `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 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 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. +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 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. 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 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/src/docs/data-objects.md b/src/docs/data-objects.md index 22982668b..c260a65c0 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,81 @@ 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. + +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 +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: + +```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 @@ -475,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 @@ -767,6 +845,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..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: @@ -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: @@ -2005,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 diff --git a/src/support/src/DataObject.php b/src/support/src/DataObject.php new file mode 100644 index 000000000..d8605d08d --- /dev/null +++ b/src/support/src/DataObject.php @@ -0,0 +1,419 @@ + + */ +abstract class DataObject implements Arrayable, Jsonable, JsonSerializable, RequestCastable, 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); + } + + /** + * 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. + */ + 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/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/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..ec7ed4984 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-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-04T12:34:56+00:00', + '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); 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) diff --git a/tests/Support/DataObjectTest.php b/tests/Support/DataObjectTest.php new file mode 100644 index 000000000..e1f67fddc --- /dev/null +++ b/tests/Support/DataObjectTest.php @@ -0,0 +1,1119 @@ + '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 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(); + $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); + } + + #[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); + + 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'); + } +} 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(